From 1547c8af0d3fa4dcfe6055ff423d60a4d2ddf184 Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Tue, 4 Jul 2023 10:08:32 -0700
Subject: [PATCH] fix indexing causing main thread freeze, various polish

---
 package-lock.json                           |   9 ++
 package.json                                |   1 +
 quartz.config.ts                            |   4 +-
 quartz/components/DesktopOnly.tsx           |  20 +++
 quartz/components/Graph.tsx                 |  18 +--
 quartz/components/Head.tsx                  |   4 +-
 quartz/components/MobileOnly.tsx            |  20 +++
 quartz/components/PageList.tsx              |   2 +-
 quartz/components/ReadingTime.tsx           |   3 +-
 quartz/components/TableOfContents.tsx       |   2 +-
 quartz/components/TagList.tsx               |   1 +
 quartz/components/index.ts                  |   6 +-
 quartz/components/pages/FolderContent.tsx   |  10 +-
 quartz/components/pages/TagContent.tsx      |   9 +-
 quartz/components/renderPage.tsx            |   2 +-
 quartz/components/scripts/graph.inline.ts   |  18 +--
 quartz/components/scripts/popover.inline.ts | 130 ++++++++++----------
 quartz/components/scripts/search.inline.ts  |   8 +-
 quartz/components/styles/darkmode.scss      |   1 +
 quartz/components/styles/graph.scss         |   2 +-
 quartz/components/styles/listPage.scss      |   6 +-
 quartz/components/styles/popover.scss       |   4 +-
 quartz/components/styles/search.scss        |   4 +-
 quartz/path.ts                              |   8 +-
 quartz/plugins/emitters/contentIndex.ts     |   7 +-
 quartz/plugins/emitters/folderPage.tsx      |   3 +-
 quartz/plugins/emitters/tagPage.tsx         |   3 +-
 quartz/plugins/index.ts                     |  26 ++--
 quartz/processors/emit.ts                   |  10 +-
 quartz/resources.tsx                        |   2 +-
 quartz/styles/base.scss                     |  40 +++---
 quartz/styles/variables.scss                |   5 +
 quartz/theme.ts                             |   8 +-
 33 files changed, 255 insertions(+), 141 deletions(-)
 create mode 100644 quartz/components/DesktopOnly.tsx
 create mode 100644 quartz/components/MobileOnly.tsx
 create mode 100644 quartz/styles/variables.scss

diff --git a/package-lock.json b/package-lock.json
index 92e22eb..fb8f23c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
         "mdast-util-find-and-replace": "^2.2.2",
         "mdast-util-to-string": "^3.2.0",
         "micromorph": "^0.4.5",
+        "plausible-tracker": "^0.3.8",
         "preact": "^10.14.1",
         "preact-render-to-string": "^6.0.3",
         "pretty-time": "^1.1.0",
@@ -3619,6 +3620,14 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/plausible-tracker": {
+      "version": "0.3.8",
+      "resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.8.tgz",
+      "integrity": "sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/preact": {
       "version": "10.15.1",
       "resolved": "https://registry.npmjs.org/preact/-/preact-10.15.1.tgz",
diff --git a/package.json b/package.json
index 614bb76..689548d 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
     "mdast-util-find-and-replace": "^2.2.2",
     "mdast-util-to-string": "^3.2.0",
     "micromorph": "^0.4.5",
+    "plausible-tracker": "^0.3.8",
     "preact": "^10.14.1",
     "preact-render-to-string": "^6.0.3",
     "pretty-time": "^1.1.0",
diff --git a/quartz.config.ts b/quartz.config.ts
index 58c1d9c..18f2533 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -23,8 +23,8 @@ const contentPageLayout: PageLayout = {
   left: [
     Component.PageTitle(),
     Component.Search(),
-    Component.TableOfContents(),
-    Component.Darkmode()
+    Component.Darkmode(),
+    Component.DesktopOnly(Component.TableOfContents()),
   ],
   right: [
     Component.Graph(),
diff --git a/quartz/components/DesktopOnly.tsx b/quartz/components/DesktopOnly.tsx
new file mode 100644
index 0000000..a1c5dae
--- /dev/null
+++ b/quartz/components/DesktopOnly.tsx
@@ -0,0 +1,20 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+
+export default ((component?: QuartzComponent) => {
+  if (component) {
+    const Component = component
+    function DesktopOnly(props: QuartzComponentProps) {
+      return <div class="desktop-only">
+        <Component {...props} />
+      </div>
+    }
+
+    DesktopOnly.displayName = component.displayName
+    DesktopOnly.afterDOMLoaded = component?.afterDOMLoaded
+    DesktopOnly.beforeDOMLoaded = component?.beforeDOMLoaded
+    DesktopOnly.css = component?.css
+    return DesktopOnly
+  } else {
+    return () => <></>
+  }
+}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx
index 0146188..e7f1df2 100644
--- a/quartz/components/Graph.tsx
+++ b/quartz/components/Graph.tsx
@@ -25,23 +25,23 @@ const defaultOptions: GraphOptions = {
     drag: true,
     zoom: true,
     depth: 1,
-    scale: 1.2,
-    repelForce: 2,
-    centerForce: 1,
+    scale: 1.1,
+    repelForce: 0.5,
+    centerForce: 0.3,
     linkDistance: 30,
     fontSize: 0.6,
-    opacityScale: 3
+    opacityScale: 1
   },
   globalGraph: {
     drag: true,
     zoom: true,
     depth: -1,
-    scale: 1.2,
-    repelForce: 1,
-    centerForce: 1,
+    scale: 0.9,
+    repelForce: 0.5,
+    centerForce: 0.3,
     linkDistance: 30,
-    fontSize: 0.5,
-    opacityScale: 3
+    fontSize: 0.6,
+    opacityScale: 1
   }
 }
 
diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx
index f8439a0..bfc7bae 100644
--- a/quartz/components/Head.tsx
+++ b/quartz/components/Head.tsx
@@ -1,10 +1,10 @@
-import { resolveToRoot } from "../path"
+import { clientSideSlug, resolveToRoot } from "../path"
 import { JSResourceToScriptElement } from "../resources"
 import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 
 export default (() => {
   function Head({ fileData, externalResources }: QuartzComponentProps) {
-    const slug = fileData.slug!
+    const slug = clientSideSlug(fileData.slug!)
     const title = fileData.frontmatter?.title ?? "Untitled"
     const description = fileData.description ?? "No description provided"
     const { css, js } = externalResources
diff --git a/quartz/components/MobileOnly.tsx b/quartz/components/MobileOnly.tsx
new file mode 100644
index 0000000..b75fd76
--- /dev/null
+++ b/quartz/components/MobileOnly.tsx
@@ -0,0 +1,20 @@
+import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
+
+export default ((component?: QuartzComponent) => {
+  if (component) {
+    const Component = component
+    function MobileOnly(props: QuartzComponentProps) {
+      return <div class="mobile-only">
+        <Component {...props} />
+      </div>
+    }
+
+    MobileOnly.displayName = component.displayName
+    MobileOnly.afterDOMLoaded = component?.afterDOMLoaded
+    MobileOnly.beforeDOMLoaded = component?.beforeDOMLoaded
+    MobileOnly.css = component?.css
+    return MobileOnly
+  } else {
+    return () => <></>
+  }
+}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/PageList.tsx b/quartz/components/PageList.tsx
index 3c39bee..b92720d 100644
--- a/quartz/components/PageList.tsx
+++ b/quartz/components/PageList.tsx
@@ -23,7 +23,7 @@ function byDateAndAlphabetical(f1: QuartzPluginData, f2: QuartzPluginData): numb
 
 export function PageList({ fileData, allFiles }: QuartzComponentProps) {
   const slug = fileData.slug!
-  return <ul class="section-ul popover-hint">
+  return <ul class="section-ul">
     {allFiles.sort(byDateAndAlphabetical).map(page => {
       const title = page.frontmatter?.title
       const pageSlug = page.slug!
diff --git a/quartz/components/ReadingTime.tsx b/quartz/components/ReadingTime.tsx
index cac8711..9c64289 100644
--- a/quartz/components/ReadingTime.tsx
+++ b/quartz/components/ReadingTime.tsx
@@ -3,8 +3,7 @@ import readingTime from "reading-time"
 
 function ReadingTime({ fileData }: QuartzComponentProps) {
   const text = fileData.text
-  const isHomePage = fileData.slug === "index"
-  if (text && !isHomePage) {
+  if (text) {
     const { text: timeTaken, words } = readingTime(text)
     return <p class="reading-time">{words} words, {timeTaken}</p>
   } else {
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
index f3d90bb..a9d7b78 100644
--- a/quartz/components/TableOfContents.tsx
+++ b/quartz/components/TableOfContents.tsx
@@ -18,7 +18,7 @@ function TableOfContents({ fileData }: QuartzComponentProps) {
     return null
   }
 
-  return <div>
+  return <div class="desktop-only">
     <button type="button" id="toc">
       <h3>Table of Contents</h3>
       <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx
index 366889b..6b93719 100644
--- a/quartz/components/TagList.tsx
+++ b/quartz/components/TagList.tsx
@@ -29,6 +29,7 @@ TagList.css = `
   
 .tags > li {
   display: inline-block;
+  white-space: nowrap;
   margin: 0;
   overflow-wrap: normal;
 }
diff --git a/quartz/components/index.ts b/quartz/components/index.ts
index ed0c668..35d5a64 100644
--- a/quartz/components/index.ts
+++ b/quartz/components/index.ts
@@ -13,6 +13,8 @@ import Graph from "./Graph"
 import Backlinks from "./Backlinks"
 import Search from "./Search"
 import Footer from "./Footer"
+import DesktopOnly from "./DesktopOnly"
+import MobileOnly from "./MobileOnly"
 
 export {
   ArticleTitle,
@@ -29,5 +31,7 @@ export {
   Graph,
   Backlinks,
   Search,
-  Footer
+  Footer,
+  DesktopOnly,
+  MobileOnly
 } 
diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx
index 4806843..445074c 100644
--- a/quartz/components/pages/FolderContent.tsx
+++ b/quartz/components/pages/FolderContent.tsx
@@ -6,7 +6,7 @@ import path from "path"
 import style from '../styles/listPage.scss'
 import { PageList } from "../PageList"
 
-function TagContent(props: QuartzComponentProps) {
+function FolderContent(props: QuartzComponentProps) {
   const { tree, fileData, allFiles } = props
   const folderSlug = fileData.slug!
   const allPagesInFolder = allFiles.filter(file => {
@@ -25,13 +25,15 @@ function TagContent(props: QuartzComponentProps) {
 
   // @ts-ignore
   const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
-  return <div>
+  return <div class="popover-hint">
     <article>{content}</article>
+    <hr/>
+    <p>{allPagesInFolder.length} items under this folder.</p>
     <div>
       <PageList {...listProps} /> 
     </div>
   </div>
 }
 
-TagContent.css = style + PageList.css
-export default (() => TagContent) satisfies QuartzComponentConstructor
+FolderContent.css = style + PageList.css
+export default (() => FolderContent) satisfies QuartzComponentConstructor
diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx
index e7e5f6d..b84ce29 100644
--- a/quartz/components/pages/TagContent.tsx
+++ b/quartz/components/pages/TagContent.tsx
@@ -3,13 +3,14 @@ import { Fragment, jsx, jsxs } from 'preact/jsx-runtime'
 import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 import style from '../styles/listPage.scss'
 import { PageList } from "../PageList"
+import { clientSideSlug } from "../../path"
 
 function TagContent(props: QuartzComponentProps) {
   const { tree, fileData, allFiles } = props
   const slug = fileData.slug
-  if (slug?.startsWith("tags/")) {
-    const tag = slug.slice("tags/".length)
 
+  if (slug?.startsWith("tags/")) {
+    const tag = clientSideSlug(slug.slice("tags/".length))
     const allPagesWithTag = allFiles.filter(file => (file.frontmatter?.tags ?? []).includes(tag))
     const listProps = {
       ...props,
@@ -18,8 +19,10 @@ function TagContent(props: QuartzComponentProps) {
 
     // @ts-ignore
     const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
-    return <div>
+    return <div class="popover-hint">
       <article>{content}</article>
+      <hr/>
+      <p>{allPagesWithTag.length} items with this tag.</p>
       <div>
         <PageList {...listProps} />
       </div>
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index c70f092..4da6370 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -25,7 +25,7 @@ export function pageResources(slug: string, staticResources: StaticResources): S
     css: [baseDir + "/index.css", ...staticResources.css],
     js: [
       { src: baseDir + "/prescript.js", loadTime: "beforeDOMReady", contentType: "external" },
-      { loadTime: "afterDOMReady", contentType: "inline", spaPreserve: true, script: contentIndexScript },
+      { loadTime: "beforeDOMReady", contentType: "inline", spaPreserve: true, script: contentIndexScript },
       ...staticResources.js,
       { src: baseDir + "/postscript.js", loadTime: "afterDOMReady", moduleType: 'module', contentType: "external" }
     ]
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index 169b8c4..194a23b 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -110,12 +110,12 @@ async function renderGraph(container: string, slug: string) {
     .join("line")
     .attr("class", "link")
     .attr("stroke", "var(--lightgray)")
-    .attr("stroke-width", 2)
+    .attr("stroke-width", 1)
 
   // svg groups
   const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
 
-  // calculate radius
+  // calculate color
   const color = (d: NodeData) => {
     const isCurrent = d.id === slug
     if (isCurrent) {
@@ -182,7 +182,12 @@ async function renderGraph(container: string, slug: string) {
       neighbourNodes.transition().duration(200).attr("fill", color)
 
       // highlight links
-      linkNodes.transition().duration(200).attr("stroke", "var(--gray)")
+      linkNodes
+        .transition()
+        .duration(200)
+        .attr("stroke", "var(--gray)")
+        .attr("stroke-width", 1)
+
 
       const bigFont = fontSize * 1.5
 
@@ -220,7 +225,7 @@ async function renderGraph(container: string, slug: string) {
   const labels = graphNode
     .append("text")
     .attr("dx", 0)
-    .attr("dy", (d) => nodeRadius(d) + 8 + "px")
+    .attr("dy", (d) => nodeRadius(d) - 8 + "px")
     .attr("text-anchor", "middle")
     .text((d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "))
     .style('opacity', (opacityScale - 1) / 3.75)
@@ -266,12 +271,11 @@ async function renderGraph(container: string, slug: string) {
   })
 }
 
-async function renderGlobalGraph() {
+function renderGlobalGraph() {
   const slug = document.body.dataset["slug"]!
-  await renderGraph("global-graph-container", slug)
   const container = document.getElementById("global-graph-outer")
   container?.classList.add("active")
-
+  renderGraph("global-graph-container", slug)
 
   function hideGlobalGraph() {
     container?.classList.remove("active")
diff --git a/quartz/components/scripts/popover.inline.ts b/quartz/components/scripts/popover.inline.ts
index b388995..666371b 100644
--- a/quartz/components/scripts/popover.inline.ts
+++ b/quartz/components/scripts/popover.inline.ts
@@ -19,69 +19,73 @@ export function normalizeRelativeURLs(
   )
 }
 
-document.addEventListener("nav", () => {
-  const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
-  const p = new DOMParser()
-  for (const link of links) {
-    link.addEventListener("mouseenter", async ({ clientX, clientY }) => {
-      async function setPosition(popoverElement: HTMLElement) {
-        const { x, y } = await computePosition(link, popoverElement, {
-          middleware: [
-            inline({ x: clientX, y: clientY }),
-            shift(),
-            flip()
-          ]
-        })
-        Object.assign(popoverElement.style, {
-          left: `${x}px`,
-          top: `${y}px`,
-        })
-      }
-
-      if (link.dataset.fetchedPopover === "true") {
-        return setPosition(link.lastChild as HTMLElement)
-      }
-
-      const thisUrl = new URL(document.location.href)
-      thisUrl.hash = ""
-      thisUrl.search = ""
-      const targetUrl = new URL(link.href)
-      const hash = targetUrl.hash
-      targetUrl.hash = ""
-      targetUrl.search = ""
-      // prevent hover of the same page
-      if (thisUrl.toString() === targetUrl.toString()) return
-
-      const contents = await fetch(`${targetUrl}`)
-        .then((res) => res.text())
-        .catch((err) => {
-          console.error(err)
-        })
-
-      if (!contents) return
-      const html = p.parseFromString(contents, "text/html")
-      normalizeRelativeURLs(html, targetUrl)
-      const elts = [...html.getElementsByClassName("popover-hint")]
-      if (elts.length === 0) return
-
-      const popoverElement = document.createElement("div")
-      popoverElement.classList.add("popover")
-      const popoverInner = document.createElement("div")
-      popoverInner.classList.add("popover-inner")
-      popoverElement.appendChild(popoverInner)
-      elts.forEach(elt => popoverInner.appendChild(elt))
-
-      setPosition(popoverElement)
-      link.appendChild(popoverElement)
-      link.dataset.fetchedPopover = "true"
-
-      if (hash !== "") {
-        const heading = popoverInner.querySelector(hash) as HTMLElement | null
-        if (heading) {
-          // leave ~12px of buffer when scrolling to a heading
-          popoverInner.scroll({ top: heading.offsetTop - 12, behavior: 'instant' })
-        }
-      }
+const p = new DOMParser()
+async function mouseEnterHandler(this: HTMLLinkElement, { clientX, clientY }: { clientX: number, clientY: number }) {
+  const link = this
+  async function setPosition(popoverElement: HTMLElement) {
+    const { x, y } = await computePosition(link, popoverElement, {
+      middleware: [
+        inline({ x: clientX, y: clientY }),
+        shift(),
+        flip()
+      ]
+    })
+    Object.assign(popoverElement.style, {
+      left: `${x}px`,
+      top: `${y}px`,
     })
   }
+
+  // dont refetch if there's already a popover
+  if ([...link.children].some(child => child.classList.contains("popover"))) {
+    return setPosition(link.lastChild as HTMLElement)
+  }
+
+  const thisUrl = new URL(document.location.href)
+  thisUrl.hash = ""
+  thisUrl.search = ""
+  const targetUrl = new URL(link.href)
+  const hash = targetUrl.hash
+  targetUrl.hash = ""
+  targetUrl.search = ""
+  // prevent hover of the same page
+  if (thisUrl.toString() === targetUrl.toString()) return
+
+  const contents = await fetch(`${targetUrl}`)
+    .then((res) => res.text())
+    .catch((err) => {
+      console.error(err)
+    })
+
+  if (!contents) return
+  const html = p.parseFromString(contents, "text/html")
+  normalizeRelativeURLs(html, targetUrl)
+  const elts = [...html.getElementsByClassName("popover-hint")]
+  if (elts.length === 0) return
+
+  const popoverElement = document.createElement("div")
+  popoverElement.classList.add("popover")
+  const popoverInner = document.createElement("div")
+  popoverInner.classList.add("popover-inner")
+  popoverElement.appendChild(popoverInner)
+  elts.forEach(elt => popoverInner.appendChild(elt))
+
+  setPosition(popoverElement)
+  link.appendChild(popoverElement)
+
+  if (hash !== "") {
+    const heading = popoverInner.querySelector(hash) as HTMLElement | null
+    if (heading) {
+      // leave ~12px of buffer when scrolling to a heading
+      popoverInner.scroll({ top: heading.offsetTop - 12, behavior: 'instant' })
+    }
+  }
+}
+
+document.addEventListener("nav", () => {
+  const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
+  for (const link of links) {
+    link.removeEventListener("mouseenter", mouseEnterHandler)
+    link.addEventListener("mouseenter", mouseEnterHandler)
+  }
 })
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts
index 78517fe..f69d77d 100644
--- a/quartz/components/scripts/search.inline.ts
+++ b/quartz/components/scripts/search.inline.ts
@@ -11,7 +11,8 @@ let index: Document<Item> | undefined = undefined
 
 const contextWindowWords = 30
 function highlight(searchTerm: string, text: string, trim?: boolean) {
-  const tokenizedTerms = searchTerm.split(/\s+/).filter(t => t !== "")
+  // try to highlight longest tokens first
+  const tokenizedTerms = searchTerm.split(/\s+/).filter(t => t !== "").sort((a, b) => b.length - a.length)
   let tokenizedText = text
     .split(/\s+/)
     .filter(t => t !== "")
@@ -42,7 +43,7 @@ function highlight(searchTerm: string, text: string, trim?: boolean) {
     // see if this tok is prefixed by any search terms 
     for (const searchTok of tokenizedTerms) {
       if (tok.toLowerCase().includes(searchTok.toLowerCase())) {
-        const regex = new RegExp(searchTok, "gi")
+        const regex = new RegExp(searchTok.toLowerCase(), "gi")
         return tok.replace(regex, `<span class="highlight">$&</span>`)
       }
     }
@@ -81,7 +82,7 @@ document.addEventListener("nav", async (e: unknown) => {
     })
 
     for (const [slug, fileData] of Object.entries<ContentDetails>(data)) {
-      index.add({
+      await index.addAsync(slug, {
         slug,
         title: fileData.title,
         content: fileData.content
@@ -169,7 +170,6 @@ document.addEventListener("nav", async (e: unknown) => {
     displayResults(finalResults)
   }
 
-
   document.removeEventListener("keydown", shortcutHandler)
   document.addEventListener("keydown", shortcutHandler)
   searchIcon?.removeEventListener("click", showSearch)
diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss
index 730fcd2..0edfebd 100644
--- a/quartz/components/styles/darkmode.scss
+++ b/quartz/components/styles/darkmode.scss
@@ -2,6 +2,7 @@
   position: relative;
   width: 20px;
   height: 20px;
+  margin: 1rem;
 
   & > .toggle {
     display: none;
diff --git a/quartz/components/styles/graph.scss b/quartz/components/styles/graph.scss
index 4533a84..2200282 100644
--- a/quartz/components/styles/graph.scss
+++ b/quartz/components/styles/graph.scss
@@ -40,9 +40,9 @@
     top: 0;
     width: 100vw;
     height: 100%;
-    overflow: scroll;
     backdrop-filter: blur(4px);
     display: none;
+    overflow: hidden;
 
     &.active {
       display: inline-block;
diff --git a/quartz/components/styles/listPage.scss b/quartz/components/styles/listPage.scss
index 1823815..6c8e1b5 100644
--- a/quartz/components/styles/listPage.scss
+++ b/quartz/components/styles/listPage.scss
@@ -1,3 +1,5 @@
+@use "../../styles/variables.scss" as *;
+
 ul.section-ul {
   list-style: none;
   margin-top: 2em;
@@ -11,7 +13,7 @@ li.section-li {
     display: grid;
     grid-template-columns: 6em 3fr 1fr;
 
-    @media all and (max-width: 600px) {
+    @media all and (max-width: $mobileBreakpoint) {
       & > .tags {
         display: none;
       }
@@ -22,7 +24,7 @@ li.section-li {
       margin-left: 1rem;
     }
 
-    & > .desc a {
+    & > .desc > h3 > a {
       background-color: transparent; 
     }
 
diff --git a/quartz/components/styles/popover.scss b/quartz/components/styles/popover.scss
index 80bdfad..05c6dc7 100644
--- a/quartz/components/styles/popover.scss
+++ b/quartz/components/styles/popover.scss
@@ -1,3 +1,5 @@
+@use "../../styles/variables.scss" as *;
+
 @keyframes dropin {
   0% {
     opacity: 0;
@@ -42,7 +44,7 @@
   opacity: 0;
   transition: opacity 0.3s ease, visibility 0.3s ease;
 
-  @media all and (max-width: 600px) {
+  @media all and (max-width: $mobileBreakpoint) {
     display: none !important;
   }
 }
diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss
index cbf982a..1f0a8b5 100644
--- a/quartz/components/styles/search.scss
+++ b/quartz/components/styles/search.scss
@@ -1,3 +1,5 @@
+@use "../../styles/variables.scss" as *;
+
 .search {
   min-width: 5rem;
   max-width: 14rem;
@@ -55,7 +57,7 @@
       margin-left: auto;
       margin-right: auto;
 
-      @media all and (max-width: 1200px) {
+      @media all and (max-width: $tabletBreakpoint) {
         width: 90%;
       }
 
diff --git a/quartz/path.ts b/quartz/path.ts
index 81cdb3a..a0fb223 100644
--- a/quartz/path.ts
+++ b/quartz/path.ts
@@ -7,10 +7,16 @@ function slugSegment(s: string): string {
 
 // on the client, 'index' isn't ever rendered so we should clean it up
 export function clientSideSlug(fp: string): string {
+  // remove index
   if (fp.endsWith("index")) {
     fp = fp.slice(0, -"index".length)
   }
 
+  // remove trailing slash
+  if (fp.endsWith("/")) {
+    fp = fp.slice(0, -1)
+  }
+
   return fp
 }
 
@@ -23,7 +29,7 @@ export function trimPathSuffix(fp: string): string {
 }
 
 export function slugify(s: string): string {
-  const [fp, anchor] = s.split("#", 2)
+  let [fp, anchor] = s.split("#", 2)
   const sluggedAnchor = anchor === undefined ? "" : "#" + slugAnchor(anchor)
   const withoutFileExt = fp.replace(new RegExp(path.extname(fp) + '$'), '')
   const rawSlugSegments = withoutFileExt.split(path.sep)
diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
index cf42756..a1d8648 100644
--- a/quartz/plugins/emitters/contentIndex.ts
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -15,11 +15,13 @@ export type ContentDetails = {
 interface Options {
   enableSiteMap: boolean
   enableRSS: boolean
+  includeEmptyFiles: boolean
 }
 
 const defaultOptions: Options = {
   enableSiteMap: true,
   enableRSS: true,
+  includeEmptyFiles: false,
 }
 
 function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
@@ -57,7 +59,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string {
   </rss>`
 }
 
-export const ContentIndex: QuartzEmitterPlugin<Options> = (opts) => {
+export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
   opts = { ...defaultOptions, ...opts }
   return {
     name: "ContentIndex",
@@ -67,6 +69,7 @@ export const ContentIndex: QuartzEmitterPlugin<Options> = (opts) => {
       for (const [_tree, file] of content) {
         const slug = file.data.slug!
         const date = file.data.dates?.modified ?? new Date()
+        if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
         linkIndex.set(slug, {
           title: file.data.frontmatter?.title!,
           links: file.data.links ?? [],
@@ -75,6 +78,7 @@ export const ContentIndex: QuartzEmitterPlugin<Options> = (opts) => {
           date: date,
           description: file.data.description ?? ""
         })
+        }
       }
 
       if (opts?.enableSiteMap) {
@@ -106,6 +110,7 @@ export const ContentIndex: QuartzEmitterPlugin<Options> = (opts) => {
           return [slug, content]
         })
       )
+
       await emit({
         content: JSON.stringify(simplifiedIndex),
         slug: fp,
diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx
index ee8f0b9..1eed30d 100644
--- a/quartz/plugins/emitters/folderPage.tsx
+++ b/quartz/plugins/emitters/folderPage.tsx
@@ -6,6 +6,7 @@ import { pageResources, renderPage } from "../../components/renderPage"
 import { ProcessedContent, defaultProcessedContent } from "../vfile"
 import { FullPageLayout } from "../../cfg"
 import path from "path"
+import { clientSideSlug } from "../../path"
 
 export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
   if (!opts) {
@@ -36,7 +37,7 @@ export const FolderPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
       ])))
 
       for (const [tree, file] of content) {
-        const slug = file.data.slug!
+        const slug = clientSideSlug(file.data.slug!)
         if (folders.has(slug)) {
           folderDescriptions[slug] = [tree, file]
         }
diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx
index 1f69715..0cdb7c3 100644
--- a/quartz/plugins/emitters/tagPage.tsx
+++ b/quartz/plugins/emitters/tagPage.tsx
@@ -5,6 +5,7 @@ import BodyConstructor from "../../components/Body"
 import { pageResources, renderPage } from "../../components/renderPage"
 import { ProcessedContent, defaultProcessedContent } from "../vfile"
 import { FullPageLayout } from "../../cfg"
+import { clientSideSlug } from "../../path"
 
 export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
   if (!opts) {
@@ -30,7 +31,7 @@ export const TagPage: QuartzEmitterPlugin<FullPageLayout> = (opts) => {
       ])))
 
       for (const [tree, file] of content) {
-        const slug = file.data.slug!
+        const slug = clientSideSlug(file.data.slug!)
         if (slug.startsWith("tags/")) {
           const tag = slug.slice("tags/".length)
           if (tags.has(tag)) {
diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts
index c55e4dd..0de0283 100644
--- a/quartz/plugins/index.ts
+++ b/quartz/plugins/index.ts
@@ -20,26 +20,30 @@ export function getComponentResources(plugins: PluginTypes): ComponentResources
     }
   }
 
-  const componentResources: ComponentResources = {
-    css: [],
-    beforeDOMLoaded: [],
-    afterDOMLoaded: []
+  const componentResources = {
+    css: new Set<string>(),
+    beforeDOMLoaded: new Set<string>(),
+    afterDOMLoaded: new Set<string>()
   }
 
   for (const component of allComponents) {
     const { css, beforeDOMLoaded, afterDOMLoaded } = component
     if (css) {
-      componentResources.css.push(css)
+      componentResources.css.add(css)
     }
     if (beforeDOMLoaded) {
-      componentResources.beforeDOMLoaded.push(beforeDOMLoaded)
+      componentResources.beforeDOMLoaded.add(beforeDOMLoaded)
     }
     if (afterDOMLoaded) {
-      componentResources.afterDOMLoaded.push(afterDOMLoaded)
+      componentResources.afterDOMLoaded.add(afterDOMLoaded)
     }
   }
-
-  return componentResources
+  
+  return {
+    css: [...componentResources.css],
+    beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
+    afterDOMLoaded: [...componentResources.afterDOMLoaded]
+  }
 }
 
 function joinScripts(scripts: string[]): string {
@@ -78,10 +82,10 @@ export function getStaticResourcesFromPlugins(plugins: PluginTypes) {
   for (const transformer of plugins.transformers) {
     const res = transformer.externalResources ? transformer.externalResources() : {}
     if (res?.js) {
-      staticResources.js = staticResources.js.concat(res.js)
+      staticResources.js.push(...res.js)
     }
     if (res?.css) {
-      staticResources.css = staticResources.css.concat(res.css)
+      staticResources.css.push(...res.css)
     }
   }
 
diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts
index 7150f5e..59875f5 100644
--- a/quartz/processors/emit.ts
+++ b/quartz/processors/emit.ts
@@ -8,7 +8,6 @@ import { ProcessedContent } from "../plugins/vfile"
 import { QUARTZ, slugify } from "../path"
 import { globbyStream } from "globby"
 import chalk from "chalk"
-import { googleFontHref } from '../theme'
 
 // @ts-ignore
 import spaRouterScript from '../components/scripts/spa.inline'
@@ -18,9 +17,10 @@ import plausibleScript from '../components/scripts/plausible.inline'
 import popoverScript from '../components/scripts/popover.inline'
 import popoverStyle from '../components/styles/popover.scss'
 import { StaticResources } from "../resources"
+import { QuartzLogger } from "../log"
+import { googleFontHref } from "../theme"
 
 function addGlobalPageResources(cfg: GlobalConfiguration, staticResources: StaticResources, componentResources: ComponentResources) {
-  // font and other resources
   staticResources.css.push(googleFontHref(cfg.theme))
 
   // popovers
@@ -67,6 +67,9 @@ function addGlobalPageResources(cfg: GlobalConfiguration, staticResources: Stati
 
 export async function emitContent(contentFolder: string, output: string, cfg: QuartzConfig, content: ProcessedContent[], verbose: boolean) {
   const perf = new PerfTimer()
+  const log = new QuartzLogger(verbose)
+
+  log.start(`Emitting output files`)
   const emit: EmitCallback = async ({ slug, ext, content }) => {
     const pathToPage = path.join(output, slug + ext)
     const dir = path.dirname(pathToPage)
@@ -80,6 +83,7 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
 
   // component specific scripts and styles
   const componentResources = getComponentResources(cfg.plugins)
+
   // important that this goes *after* component scripts 
   // as the "nav" event gets triggered here and we should make sure 
   // that everyone else had the chance to register a listener for it
@@ -136,5 +140,5 @@ export async function emitContent(contentFolder: string, output: string, cfg: Qu
     }
   }
 
-  console.log(`Emitted ${emittedFiles} files to \`${output}\` in ${perf.timeSince()}`)
+  log.success(`Emitted ${emittedFiles} files to \`${output}\` in ${perf.timeSince()}`)
 }
diff --git a/quartz/resources.tsx b/quartz/resources.tsx
index 3780751..525210b 100644
--- a/quartz/resources.tsx
+++ b/quartz/resources.tsx
@@ -17,7 +17,7 @@ export function JSResourceToScriptElement(resource: JSResource, preserve?: boole
   const scriptType = resource.moduleType ?? 'application/javascript'
   const spaPreserve = preserve ?? resource.spaPreserve
   if (resource.contentType === 'external') {
-    return <script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve} />
+    return <script key={resource.src} src={resource.src} type={scriptType} spa-preserve={spaPreserve}/>
   } else {
     const content = resource.script
     return <script key={randomUUID()} type={scriptType} spa-preserve={spaPreserve}>{content}</script>
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index db0299b..e741755 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -1,5 +1,6 @@
-@import "./syntax.scss";
-@import "./callouts.scss";
+@use "./syntax.scss";
+@use "./callouts.scss";
+@use "./variables.scss" as *;
 
 html {
   scroll-behavior: smooth;
@@ -11,9 +12,6 @@ body {
   box-sizing: border-box;
   background-color: var(--light);
   font-family: var(--bodyFont);
-  --pageWidth: 800px;
-  --sidePanelWidth: 400px;
-  --topSpacing: 6rem;
 }
 
 .text-highlight {
@@ -47,8 +45,8 @@ a {
 
 .page {
   & > .page-header {
-    max-width: var(--pageWidth);
-    margin: var(--topSpacing) auto 0 auto;
+    max-width: $pageWidth;
+    margin: $topSpacing auto 0 auto;
   }
 
   & > #quartz-body {
@@ -57,7 +55,7 @@ a {
 
     & .left, & .right {
       flex: 1;
-      width: calc(calc(100vw - var(--pageWidth)) / 2);
+      width: calc(calc(100vw - $pageWidth) / 2);
     }
 
     & .left-inner, & .right-inner {
@@ -65,30 +63,44 @@ a {
       flex-direction: column;
       gap: 2rem;
       top: 0;
-      width: var(--sidePanelWidth);
-      margin-top: calc(var(--topSpacing));
+      width: $sidePanelWidth;
+      margin-top: $topSpacing;
       box-sizing: border-box;
       padding: 0 4rem;
       position: fixed;
     }
 
     & .left-inner {
-      left: calc(calc(100vw - var(--pageWidth)) / 2 - var(--sidePanelWidth));
+      left: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
     }
 
     & .right-inner {
-      right: calc(calc(100vw - var(--pageWidth)) / 2 - var(--sidePanelWidth));
+      right: calc(calc(100vw - $pageWidth) / 2 - $sidePanelWidth);
     }
 
     & .center {
-      width: var(--pageWidth);
+      width: $pageWidth;
       margin: 0 auto;
     }
   }
 }
 
+.desktop-only {
+  display: initial;
+  @media all and (max-width: ($pageWidth + 2 * $sidePanelWidth)) {
+    display: none;
+  }
+}
+
+.mobile-only {
+  display: none;
+  @media all and (max-width: ($pageWidth + 2 * $sidePanelWidth)) {
+    display: initial;
+  }
+}
+
 .page {
-  @media all and (max-width: 1200px) {
+  @media all and (max-width: $tabletBreakpoint) {
     margin: 25px 5vw;
     & .left, & .right {
       padding: 0;
diff --git a/quartz/styles/variables.scss b/quartz/styles/variables.scss
new file mode 100644
index 0000000..792223b
--- /dev/null
+++ b/quartz/styles/variables.scss
@@ -0,0 +1,5 @@
+$pageWidth: 800px;
+$mobileBreakpoint: 600px;
+$tabletBreakpoint: 1200px;
+$sidePanelWidth: 400px;
+$topSpacing: 6rem;
diff --git a/quartz/theme.ts b/quartz/theme.ts
index 318f5cc..820519f 100644
--- a/quartz/theme.ts
+++ b/quartz/theme.ts
@@ -21,6 +21,8 @@ export interface Theme {
   }
 }
 
+const DEFAULT_SANS_SERIF = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif"
+const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace"
 export function googleFontHref(theme: Theme) {
   const { code, header, body } = theme.typography
   return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap`
@@ -37,9 +39,9 @@ export function joinStyles(theme: Theme, ...stylesheet: string[]) {
   --tertiary: ${theme.colors.lightMode.tertiary};
   --highlight: ${theme.colors.lightMode.highlight};
 
-  --headerFont: ${theme.typography.header};
-  --bodyFont: ${theme.typography.body};
-  --codeFont: ${theme.typography.code};
+  --headerFont: ${theme.typography.header}, ${DEFAULT_SANS_SERIF};
+  --bodyFont: ${theme.typography.body}, ${DEFAULT_SANS_SERIF};
+  --codeFont: ${theme.typography.code}, ${DEFAULT_MONO};
 }
 
 :root[saved-theme="dark"] {