From 5480269d38ffaff7ffd6576d9a9407430429fb2d Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Sun, 09 Mar 2025 21:58:26 +0000
Subject: [PATCH] perf(explorer): client side explorer (#1810)

---
 quartz/components/Backlinks.tsx              |    6 
 quartz/components/scripts/util.ts            |    1 
 quartz/styles/base.scss                      |   23 
 quartz/util/fileTrie.test.ts                 |  190 +++++++
 docs/advanced/creating components.md         |   12 
 index.d.ts                                   |    1 
 quartz/components/styles/explorer.scss       |  186 ++----
 quartz/components/scripts/spa.inline.ts      |    6 
 quartz/components/scripts/explorer.inline.ts |  407 +++++++++------
 quartz/components/scripts/toc.inline.ts      |    2 
 /dev/null                                    |  242 ---------
 quartz/plugins/emitters/contentIndex.tsx     |    2 
 quartz/components/Explorer.tsx               |  152 +++--
 quartz/components/OverflowList.tsx           |   39 +
 quartz/components/styles/backlinks.scss      |   22 
 quartz/util/path.ts                          |    5 
 package.json                                 |    2 
 quartz/components/renderPage.tsx             |    3 
 quartz/util/clone.ts                         |    3 
 quartz/components/TableOfContents.tsx        |    7 
 quartz/components/styles/toc.scss            |   29 -
 quartz/util/fileTrie.ts                      |  128 +++++
 quartz.config.ts                             |    2 
 quartz/components/styles/darkmode.scss       |    1 
 24 files changed, 797 insertions(+), 674 deletions(-)

diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md
index 628d5aa..369405b 100644
--- a/docs/advanced/creating components.md
+++ b/docs/advanced/creating components.md
@@ -161,6 +161,18 @@
 })
 ```
 
+You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event.
+
+```ts
+document.addEventListener("prenav", () => {
+  // executed after an SPA navigation is triggered but
+  // before the page is replaced
+  // one usage pattern is to store things in sessionStorage
+  // in the prenav and then conditionally load then in the consequent
+  // nav
+})
+```
+
 It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
 This will get called on page navigation.
 
diff --git a/index.d.ts b/index.d.ts
index a6c594f..8e524af 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -5,6 +5,7 @@
 
 // dom custom event
 interface CustomEventMap {
+  prenav: CustomEvent<{}>
   nav: CustomEvent<{ url: FullSlug }>
   themechange: CustomEvent<{ theme: "light" | "dark" }>
 }
diff --git a/package.json b/package.json
index 92872d7..81e5dbf 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
     "docs": "npx quartz build --serve -d docs",
     "check": "tsc --noEmit && npx prettier . --check",
     "format": "npx prettier . --write",
-    "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
+    "test": "tsx --test",
     "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
   },
   "engines": {
diff --git a/quartz.config.ts b/quartz.config.ts
index 0cd7e94..51a7551 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -8,7 +8,7 @@
  */
 const config: QuartzConfig = {
   configuration: {
-    pageTitle: "🪴 Quartz 4",
+    pageTitle: "Quartz 4",
     pageTitleSuffix: "",
     enableSPA: true,
     enablePopovers: true,
diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx
index e99055e..735afe7 100644
--- a/quartz/components/Backlinks.tsx
+++ b/quartz/components/Backlinks.tsx
@@ -3,6 +3,7 @@
 import { resolveRelative, simplifySlug } from "../util/path"
 import { i18n } from "../i18n"
 import { classNames } from "../util/lang"
+import OverflowList from "./OverflowList"
 
 interface BacklinksOptions {
   hideWhenEmpty: boolean
@@ -29,7 +30,7 @@
     return (
       <div class={classNames(displayClass, "backlinks")}>
         <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
-        <ul class="overflow">
+        <OverflowList id="backlinks-ul">
           {backlinkFiles.length > 0 ? (
             backlinkFiles.map((f) => (
               <li>
@@ -41,12 +42,13 @@
           ) : (
             <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
           )}
-        </ul>
+        </OverflowList>
       </div>
     )
   }
 
   Backlinks.css = style
+  Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
 
   return Backlinks
 }) satisfies QuartzComponentConstructor
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
index ac276a8..9c1fbdc 100644
--- a/quartz/components/Explorer.tsx
+++ b/quartz/components/Explorer.tsx
@@ -3,22 +3,34 @@
 
 // @ts-ignore
 import script from "./scripts/explorer.inline"
-import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
-import { QuartzPluginData } from "../plugins/vfile"
 import { classNames } from "../util/lang"
 import { i18n } from "../i18n"
+import { FileTrieNode } from "../util/fileTrie"
+import OverflowList from "./OverflowList"
 
-// Options interface defined in `ExplorerNode` to avoid circular dependency
-const defaultOptions = {
-  folderClickBehavior: "collapse",
+type OrderEntries = "sort" | "filter" | "map"
+
+export interface Options {
+  title?: string
+  folderDefaultState: "collapsed" | "open"
+  folderClickBehavior: "collapse" | "link"
+  useSavedState: boolean
+  sortFn: (a: FileTrieNode, b: FileTrieNode) => number
+  filterFn: (node: FileTrieNode) => boolean
+  mapFn: (node: FileTrieNode) => void
+  order: OrderEntries[]
+}
+
+const defaultOptions: Options = {
   folderDefaultState: "collapsed",
+  folderClickBehavior: "collapse",
   useSavedState: true,
   mapFn: (node) => {
     return node
   },
   sortFn: (a, b) => {
-    // Sort order: folders first, then files. Sort folders and files alphabetically
-    if ((!a.file && !b.file) || (a.file && b.file)) {
+    // Sort order: folders first, then files. Sort folders and files alphabeticall
+    if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
       // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
       // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
       return a.displayName.localeCompare(b.displayName, undefined, {
@@ -27,75 +39,44 @@
       })
     }
 
-    if (a.file && !b.file) {
+    if (!a.isFolder && b.isFolder) {
       return 1
     } else {
       return -1
     }
   },
-  filterFn: (node) => node.name !== "tags",
+  filterFn: (node) => node.slugSegment !== "tags",
   order: ["filter", "map", "sort"],
-} satisfies Options
+}
+
+export type FolderState = {
+  path: string
+  collapsed: boolean
+}
 
 export default ((userOpts?: Partial<Options>) => {
-  // Parse config
   const opts: Options = { ...defaultOptions, ...userOpts }
 
-  // memoized
-  let fileTree: FileNode
-  let jsonTree: string
-  let lastBuildId: string = ""
-
-  function constructFileTree(allFiles: QuartzPluginData[]) {
-    // Construct tree from allFiles
-    fileTree = new FileNode("")
-    allFiles.forEach((file) => fileTree.add(file))
-
-    // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
-    if (opts.order) {
-      // Order is important, use loop with index instead of order.map()
-      for (let i = 0; i < opts.order.length; i++) {
-        const functionName = opts.order[i]
-        if (functionName === "map") {
-          fileTree.map(opts.mapFn)
-        } else if (functionName === "sort") {
-          fileTree.sort(opts.sortFn)
-        } else if (functionName === "filter") {
-          fileTree.filter(opts.filterFn)
-        }
-      }
-    }
-
-    // Get all folders of tree. Initialize with collapsed state
-    // Stringify to pass json tree as data attribute ([data-tree])
-    const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
-    jsonTree = JSON.stringify(folders)
-  }
-
-  const Explorer: QuartzComponent = ({
-    ctx,
-    cfg,
-    allFiles,
-    displayClass,
-    fileData,
-  }: QuartzComponentProps) => {
-    if (ctx.buildId !== lastBuildId) {
-      lastBuildId = ctx.buildId
-      constructFileTree(allFiles)
-    }
+  const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
     return (
-      <div class={classNames(displayClass, "explorer")}>
+      <div
+        class={classNames(displayClass, "explorer")}
+        data-behavior={opts.folderClickBehavior}
+        data-collapsed={opts.folderDefaultState}
+        data-savestate={opts.useSavedState}
+        data-data-fns={JSON.stringify({
+          order: opts.order,
+          sortFn: opts.sortFn.toString(),
+          filterFn: opts.filterFn.toString(),
+          mapFn: opts.mapFn.toString(),
+        })}
+      >
         <button
           type="button"
           id="mobile-explorer"
-          class="collapsed hide-until-loaded"
-          data-behavior={opts.folderClickBehavior}
-          data-collapsed={opts.folderDefaultState}
-          data-savestate={opts.useSavedState}
-          data-tree={jsonTree}
+          class="explorer-toggle hide-until-loaded"
           data-mobile={true}
           aria-controls="explorer-content"
-          aria-expanded={false}
         >
           <svg
             xmlns="http://www.w3.org/2000/svg"
@@ -105,7 +86,7 @@
             stroke-width="2"
             stroke-linecap="round"
             stroke-linejoin="round"
-            class="lucide lucide-menu"
+            class="lucide-menu"
           >
             <line x1="4" x2="20" y1="12" y2="12" />
             <line x1="4" x2="20" y1="6" y2="6" />
@@ -115,13 +96,8 @@
         <button
           type="button"
           id="desktop-explorer"
-          class="title-button"
-          data-behavior={opts.folderClickBehavior}
-          data-collapsed={opts.folderDefaultState}
-          data-savestate={opts.useSavedState}
-          data-tree={jsonTree}
+          class="title-button explorer-toggle"
           data-mobile={false}
-          aria-controls="explorer-content"
           aria-expanded={true}
         >
           <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
@@ -140,17 +116,47 @@
             <polyline points="6 9 12 15 18 9"></polyline>
           </svg>
         </button>
-        <div id="explorer-content">
-          <ul class="overflow" id="explorer-ul">
-            <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
-            <li id="explorer-end" />
-          </ul>
+        <div id="explorer-content" aria-expanded={false}>
+          <OverflowList id="explorer-ul" />
         </div>
+        <template id="template-file">
+          <li>
+            <a href="#"></a>
+          </li>
+        </template>
+        <template id="template-folder">
+          <li>
+            <div class="folder-container">
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="12"
+                height="12"
+                viewBox="5 8 14 8"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="2"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+                class="folder-icon"
+              >
+                <polyline points="6 9 12 15 18 9"></polyline>
+              </svg>
+              <div>
+                <button class="folder-button">
+                  <span class="folder-title"></span>
+                </button>
+              </div>
+            </div>
+            <div class="folder-outer">
+              <ul class="content"></ul>
+            </div>
+          </li>
+        </template>
       </div>
     )
   }
 
   Explorer.css = style
-  Explorer.afterDOMLoaded = script
+  Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
   return Explorer
 }) satisfies QuartzComponentConstructor
diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx
deleted file mode 100644
index e57d677..0000000
--- a/quartz/components/ExplorerNode.tsx
+++ /dev/null
@@ -1,242 +0,0 @@
-// @ts-ignore
-import { QuartzPluginData } from "../plugins/vfile"
-import {
-  joinSegments,
-  resolveRelative,
-  clone,
-  simplifySlug,
-  SimpleSlug,
-  FilePath,
-} from "../util/path"
-
-type OrderEntries = "sort" | "filter" | "map"
-
-export interface Options {
-  title?: string
-  folderDefaultState: "collapsed" | "open"
-  folderClickBehavior: "collapse" | "link"
-  useSavedState: boolean
-  sortFn: (a: FileNode, b: FileNode) => number
-  filterFn: (node: FileNode) => boolean
-  mapFn: (node: FileNode) => void
-  order: OrderEntries[]
-}
-
-type DataWrapper = {
-  file: QuartzPluginData
-  path: string[]
-}
-
-export type FolderState = {
-  path: string
-  collapsed: boolean
-}
-
-function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined {
-  if (!fp) {
-    return undefined
-  }
-
-  return fp.split("/").at(idx)
-}
-
-// Structure to add all files into a tree
-export class FileNode {
-  children: Array<FileNode>
-  name: string // this is the slug segment
-  displayName: string
-  file: QuartzPluginData | null
-  depth: number
-
-  constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
-    this.children = []
-    this.name = slugSegment
-    this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
-    this.file = file ? clone(file) : null
-    this.depth = depth ?? 0
-  }
-
-  private insert(fileData: DataWrapper) {
-    if (fileData.path.length === 0) {
-      return
-    }
-
-    const nextSegment = fileData.path[0]
-
-    // base case, insert here
-    if (fileData.path.length === 1) {
-      if (nextSegment === "") {
-        // index case (we are the root and we just found index.md), set our data appropriately
-        const title = fileData.file.frontmatter?.title
-        if (title && title !== "index") {
-          this.displayName = title
-        }
-      } else {
-        // direct child
-        this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
-      }
-
-      return
-    }
-
-    // find the right child to insert into
-    fileData.path = fileData.path.splice(1)
-    const child = this.children.find((c) => c.name === nextSegment)
-    if (child) {
-      child.insert(fileData)
-      return
-    }
-
-    const newChild = new FileNode(
-      nextSegment,
-      getPathSegment(fileData.file.relativePath, this.depth),
-      undefined,
-      this.depth + 1,
-    )
-    newChild.insert(fileData)
-    this.children.push(newChild)
-  }
-
-  // Add new file to tree
-  add(file: QuartzPluginData) {
-    this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
-  }
-
-  /**
-   * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
-   * @param filterFn function to filter tree with
-   */
-  filter(filterFn: (node: FileNode) => boolean) {
-    this.children = this.children.filter(filterFn)
-    this.children.forEach((child) => child.filter(filterFn))
-  }
-
-  /**
-   * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place
-   * @param mapFn function to use for mapping over tree
-   */
-  map(mapFn: (node: FileNode) => void) {
-    mapFn(this)
-    this.children.forEach((child) => child.map(mapFn))
-  }
-
-  /**
-   * Get folder representation with state of tree.
-   * Intended to only be called on root node before changes to the tree are made
-   * @param collapsed default state of folders (collapsed by default or not)
-   * @returns array containing folder state for tree
-   */
-  getFolderPaths(collapsed: boolean): FolderState[] {
-    const folderPaths: FolderState[] = []
-
-    const traverse = (node: FileNode, currentPath: string) => {
-      if (!node.file) {
-        const folderPath = joinSegments(currentPath, node.name)
-        if (folderPath !== "") {
-          folderPaths.push({ path: folderPath, collapsed })
-        }
-
-        node.children.forEach((child) => traverse(child, folderPath))
-      }
-    }
-
-    traverse(this, "")
-    return folderPaths
-  }
-
-  // Sort order: folders first, then files. Sort folders and files alphabetically
-  /**
-   * Sorts tree according to sort/compare function
-   * @param sortFn compare function used for `.sort()`, also used recursively for children
-   */
-  sort(sortFn: (a: FileNode, b: FileNode) => number) {
-    this.children = this.children.sort(sortFn)
-    this.children.forEach((e) => e.sort(sortFn))
-  }
-}
-
-type ExplorerNodeProps = {
-  node: FileNode
-  opts: Options
-  fileData: QuartzPluginData
-  fullPath?: string
-}
-
-export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
-  // Get options
-  const folderBehavior = opts.folderClickBehavior
-  const isDefaultOpen = opts.folderDefaultState === "open"
-
-  // Calculate current folderPath
-  const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : ""
-  const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/"
-
-  return (
-    <>
-      {node.file ? (
-        // Single file node
-        <li key={node.file.slug}>
-          <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
-            {node.displayName}
-          </a>
-        </li>
-      ) : (
-        <li>
-          {node.name !== "" && (
-            // Node with entire folder
-            // Render svg button + folder name, then children
-            <div class="folder-container">
-              <svg
-                xmlns="http://www.w3.org/2000/svg"
-                width="12"
-                height="12"
-                viewBox="5 8 14 8"
-                fill="none"
-                stroke="currentColor"
-                stroke-width="2"
-                stroke-linecap="round"
-                stroke-linejoin="round"
-                class="folder-icon"
-              >
-                <polyline points="6 9 12 15 18 9"></polyline>
-              </svg>
-              {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
-              <div key={node.name} data-folderpath={folderPath}>
-                {folderBehavior === "link" ? (
-                  <a href={href} data-for={node.name} class="folder-title">
-                    {node.displayName}
-                  </a>
-                ) : (
-                  <button class="folder-button">
-                    <span class="folder-title">{node.displayName}</span>
-                  </button>
-                )}
-              </div>
-            </div>
-          )}
-          {/* Recursively render children of folder */}
-          <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
-            <ul
-              // Inline style for left folder paddings
-              style={{
-                paddingLeft: node.name !== "" ? "1.4rem" : "0",
-              }}
-              class="content"
-              data-folderul={folderPath}
-            >
-              {node.children.map((childNode, i) => (
-                <ExplorerNode
-                  node={childNode}
-                  key={i}
-                  opts={opts}
-                  fullPath={folderPath}
-                  fileData={fileData}
-                />
-              ))}
-            </ul>
-          </div>
-        </li>
-      )}
-    </>
-  )
-}
diff --git a/quartz/components/OverflowList.tsx b/quartz/components/OverflowList.tsx
new file mode 100644
index 0000000..d74c5c2
--- /dev/null
+++ b/quartz/components/OverflowList.tsx
@@ -0,0 +1,39 @@
+import { JSX } from "preact"
+
+const OverflowList = ({
+  children,
+  ...props
+}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
+  return (
+    <ul class="overflow" {...props}>
+      {children}
+      <li class="overflow-end" />
+    </ul>
+  )
+}
+
+OverflowList.afterDOMLoaded = (id: string) => `
+document.addEventListener("nav", (e) => {
+  const observer = new IntersectionObserver((entries) => {
+    for (const entry of entries) {
+      const parentUl = entry.target.parentElement
+      if (entry.isIntersecting) {
+        parentUl.classList.remove("gradient-active")
+      } else {
+        parentUl.classList.add("gradient-active")
+      }
+    }
+  })
+
+  const ul = document.getElementById("${id}")
+  if (!ul) return
+
+  const end = ul.querySelector(".overflow-end")
+  if (!end) return
+
+  observer.observe(end)
+  window.addCleanup(() => observer.disconnect())
+})
+`
+
+export default OverflowList
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
index ec457cf..485f434 100644
--- a/quartz/components/TableOfContents.tsx
+++ b/quartz/components/TableOfContents.tsx
@@ -6,6 +6,7 @@
 // @ts-ignore
 import script from "./scripts/toc.inline"
 import { i18n } from "../i18n"
+import OverflowList from "./OverflowList"
 
 interface Options {
   layout: "modern" | "legacy"
@@ -50,7 +51,7 @@
         </svg>
       </button>
       <div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
-        <ul class="overflow">
+        <OverflowList id="toc-ul">
           {fileData.toc.map((tocEntry) => (
             <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
               <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@@ -58,13 +59,13 @@
               </a>
             </li>
           ))}
-        </ul>
+        </OverflowList>
       </div>
     </div>
   )
 }
 TableOfContents.css = modernStyle
-TableOfContents.afterDOMLoaded = script
+TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
 
 const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
   if (!fileData.toc) {
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index 9cebaa8..75ef82b 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -3,7 +3,8 @@
 import HeaderConstructor from "./Header"
 import BodyConstructor from "./Body"
 import { JSResourceToScriptElement, StaticResources } from "../util/resources"
-import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { clone } from "../util/clone"
 import { visit } from "unist-util-visit"
 import { Root, Element, ElementContent } from "hast"
 import { GlobalConfiguration } from "../cfg"
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 9c6c050..15f3a84 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,53 +1,38 @@
-import { FolderState } from "../ExplorerNode"
+import { FileTrieNode } from "../../util/fileTrie"
+import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
+import { ContentDetails } from "../../plugins/emitters/contentIndex"
 
-// Current state of folders
 type MaybeHTMLElement = HTMLElement | undefined
-let currentExplorerState: FolderState[]
 
-const observer = new IntersectionObserver((entries) => {
-  // If last element is observed, remove gradient of "overflow" class so element is visible
-  const explorerUl = document.getElementById("explorer-ul")
-  if (!explorerUl) return
-  for (const entry of entries) {
-    if (entry.isIntersecting) {
-      explorerUl.classList.add("no-background")
-    } else {
-      explorerUl.classList.remove("no-background")
-    }
-  }
-})
+interface ParsedOptions {
+  folderClickBehavior: "collapse" | "link"
+  folderDefaultState: "collapsed" | "open"
+  useSavedState: boolean
+  sortFn: (a: FileTrieNode, b: FileTrieNode) => number
+  filterFn: (node: FileTrieNode) => boolean
+  mapFn: (node: FileTrieNode) => void
+  order: "sort" | "filter" | "map"[]
+}
 
+type FolderState = {
+  path: string
+  collapsed: boolean
+}
+
+let currentExplorerState: Array<FolderState>
 function toggleExplorer(this: HTMLElement) {
-  // Toggle collapsed state of entire explorer
-  this.classList.toggle("collapsed")
-
-  // Toggle collapsed aria state of entire explorer
-  this.setAttribute(
-    "aria-expanded",
-    this.getAttribute("aria-expanded") === "true" ? "false" : "true",
-  )
-
-  const content = (
-    this.nextElementSibling?.nextElementSibling
-      ? this.nextElementSibling.nextElementSibling
-      : this.nextElementSibling
-  ) as MaybeHTMLElement
-  if (!content) return
-  content.classList.toggle("collapsed")
-  content.classList.toggle("explorer-viewmode")
-
-  // Prevent scroll under
-  if (document.querySelector("#mobile-explorer")) {
-    // Disable scrolling on the page when the explorer is opened on mobile
-    const bodySelector = document.querySelector("#quartz-body")
-    if (bodySelector) bodySelector.classList.toggle("lock-scroll")
+  const explorers = document.querySelectorAll(".explorer")
+  for (const explorer of explorers) {
+    explorer.classList.toggle("collapsed")
+    explorer.setAttribute(
+      "aria-expanded",
+      explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
+    )
   }
 }
 
 function toggleFolder(evt: MouseEvent) {
   evt.stopPropagation()
-
-  // Element that was clicked
   const target = evt.target as MaybeHTMLElement
   if (!target) return
 
@@ -55,162 +40,240 @@
   const isSvg = target.nodeName === "svg"
 
   // corresponding <ul> element relative to clicked button/folder
-  const childFolderContainer = (
+  const folderContainer = (
     isSvg
-      ? target.parentElement?.nextSibling
-      : target.parentElement?.parentElement?.nextElementSibling
+      ? // svg -> div.folder-container
+        target.parentElement
+      : // button.folder-button -> div -> div.folder-container
+        target.parentElement?.parentElement
   ) as MaybeHTMLElement
-  const currentFolderParent = (
-    isSvg ? target.nextElementSibling : target.parentElement
-  ) as MaybeHTMLElement
-  if (!(childFolderContainer && currentFolderParent)) return
-  // <li> element of folder (stores folder-path dataset)
+  if (!folderContainer) return
+  const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
+  if (!childFolderContainer) return
+
   childFolderContainer.classList.toggle("open")
 
   // Collapse folder container
-  const isCollapsed = childFolderContainer.classList.contains("open")
-  setFolderState(childFolderContainer, !isCollapsed)
+  const isCollapsed = !childFolderContainer.classList.contains("open")
+  setFolderState(childFolderContainer, isCollapsed)
 
-  // Save folder state to localStorage
-  const fullFolderPath = currentFolderParent.dataset.folderpath as string
-  toggleCollapsedByPath(currentExplorerState, fullFolderPath)
+  const currentFolderState = currentExplorerState.find(
+    (item) => item.path === folderContainer.dataset.folderpath,
+  )
+  if (currentFolderState) {
+    currentFolderState.collapsed = isCollapsed
+  } else {
+    currentExplorerState.push({
+      path: folderContainer.dataset.folderpath as FullSlug,
+      collapsed: isCollapsed,
+    })
+  }
+
   const stringifiedFileTree = JSON.stringify(currentExplorerState)
   localStorage.setItem("fileTree", stringifiedFileTree)
 }
 
-function setupExplorer() {
-  // Set click handler for collapsing entire explorer
-  const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
+function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
+  const template = document.getElementById("template-file") as HTMLTemplateElement
+  const clone = template.content.cloneNode(true) as DocumentFragment
+  const li = clone.querySelector("li") as HTMLLIElement
+  const a = li.querySelector("a") as HTMLAnchorElement
+  a.href = resolveRelative(currentSlug, node.data?.slug!)
+  a.dataset.for = node.data?.slug
+  a.textContent = node.displayName
+
+  if (currentSlug === node.data?.slug) {
+    a.classList.add("active")
+  }
+
+  return li
+}
+
+function createFolderNode(
+  currentSlug: FullSlug,
+  node: FileTrieNode,
+  opts: ParsedOptions,
+): HTMLLIElement {
+  const template = document.getElementById("template-folder") as HTMLTemplateElement
+  const clone = template.content.cloneNode(true) as DocumentFragment
+  const li = clone.querySelector("li") as HTMLLIElement
+  const folderContainer = li.querySelector(".folder-container") as HTMLElement
+  const titleContainer = folderContainer.querySelector("div") as HTMLElement
+  const folderOuter = li.querySelector(".folder-outer") as HTMLElement
+  const ul = folderOuter.querySelector("ul") as HTMLUListElement
+
+  const folderPath = node.data?.slug!
+  folderContainer.dataset.folderpath = folderPath
+
+  if (opts.folderClickBehavior === "link") {
+    // Replace button with link for link behavior
+    const button = titleContainer.querySelector(".folder-button") as HTMLElement
+    const a = document.createElement("a")
+    a.href = resolveRelative(currentSlug, folderPath)
+    a.dataset.for = node.data?.slug
+    a.className = "folder-title"
+    a.textContent = node.displayName
+    button.replaceWith(a)
+  } else {
+    const span = titleContainer.querySelector(".folder-title") as HTMLElement
+    span.textContent = node.displayName
+  }
+
+  // if the saved state is collapsed or the default state is collapsed
+  const isCollapsed =
+    currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
+    opts.folderDefaultState === "collapsed"
+
+  // if this folder is a prefix of the current path we
+  // want to open it anyways
+  const simpleFolderPath = simplifySlug(folderPath)
+  const folderIsPrefixOfCurrentSlug =
+    simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
+
+  if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
+    folderOuter.classList.add("open")
+  }
+
+  for (const child of node.children) {
+    const childNode = child.data
+      ? createFileNode(currentSlug, child)
+      : createFolderNode(currentSlug, child, opts)
+    ul.appendChild(childNode)
+  }
+
+  return li
+}
+
+async function setupExplorer(currentSlug: FullSlug) {
+  const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
 
   for (const explorer of allExplorers) {
+    const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
+    const opts: ParsedOptions = {
+      folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
+      folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
+      useSavedState: explorer.dataset.savestate === "true",
+      order: dataFns.order || ["filter", "map", "sort"],
+      sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
+      filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
+      mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
+    }
+
     // Get folder state from local storage
     const storageTree = localStorage.getItem("fileTree")
-
-    // Convert to bool
-    const useSavedFolderState = explorer?.dataset.savestate === "true"
-
-    if (explorer) {
-      // Get config
-      const collapseBehavior = explorer.dataset.behavior
-
-      // Add click handlers for all folders (click handler on folder "label")
-      if (collapseBehavior === "collapse") {
-        for (const item of document.getElementsByClassName(
-          "folder-button",
-        ) as HTMLCollectionOf<HTMLElement>) {
-          window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
-          item.addEventListener("click", toggleFolder)
-        }
-      }
-
-      // Add click handler to main explorer
-      window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
-      explorer.addEventListener("click", toggleExplorer)
-    }
-
-    // Set up click handlers for each folder (click handler on folder "icon")
-    for (const item of document.getElementsByClassName(
-      "folder-icon",
-    ) as HTMLCollectionOf<HTMLElement>) {
-      item.addEventListener("click", toggleFolder)
-      window.addCleanup(() => item.removeEventListener("click", toggleFolder))
-    }
-
-    // Get folder state from local storage
-    const oldExplorerState: FolderState[] =
-      storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
-    const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
-    const newExplorerState: FolderState[] = explorer.dataset.tree
-      ? JSON.parse(explorer.dataset.tree)
-      : []
-    currentExplorerState = []
-
-    for (const { path, collapsed } of newExplorerState) {
-      currentExplorerState.push({
-        path,
-        collapsed: oldIndex.get(path) ?? collapsed,
-      })
-    }
-
-    currentExplorerState.map((folderState) => {
-      const folderLi = document.querySelector(
-        `[data-folderpath='${folderState.path.replace("'", "-")}']`,
-      ) as MaybeHTMLElement
-      const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
-      if (folderUl) {
-        setFolderState(folderUl, folderState.collapsed)
-      }
-    })
-  }
-}
-
-function toggleExplorerFolders() {
-  const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
-    /\/index$/g,
-    "",
-  )
-  const allFolders = document.querySelectorAll(".folder-outer")
-
-  allFolders.forEach((element) => {
-    const folderUl = Array.from(element.children).find((child) =>
-      child.matches("ul[data-folderul]"),
+    const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
+    const oldIndex = new Map(
+      serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
     )
-    if (folderUl) {
-      if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
-        if (!element.classList.contains("open")) {
-          element.classList.add("open")
-        }
+
+    const data = await fetchData
+    const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
+    const trie = FileTrieNode.fromEntries(entries)
+
+    // Apply functions in order
+    for (const fn of opts.order) {
+      switch (fn) {
+        case "filter":
+          if (opts.filterFn) trie.filter(opts.filterFn)
+          break
+        case "map":
+          if (opts.mapFn) trie.map(opts.mapFn)
+          break
+        case "sort":
+          if (opts.sortFn) trie.sort(opts.sortFn)
+          break
       }
     }
-  })
-}
 
-window.addEventListener("resize", setupExplorer)
+    // Get folder paths for state management
+    const folderPaths = trie.getFolderPaths()
+    currentExplorerState = folderPaths.map((path) => ({
+      path,
+      collapsed: oldIndex.get(path) === true,
+    }))
 
-document.addEventListener("nav", () => {
-  const explorer = document.querySelector("#mobile-explorer")
-  if (explorer) {
-    explorer.classList.add("collapsed")
-    const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
-    if (content) {
-      content.classList.add("collapsed")
-      content.classList.toggle("explorer-viewmode")
+    const explorerUl = document.getElementById("explorer-ul")
+    if (!explorerUl) continue
+
+    // Create and insert new content
+    const fragment = document.createDocumentFragment()
+    for (const child of trie.children) {
+      const node = child.isFolder
+        ? createFolderNode(currentSlug, child, opts)
+        : createFileNode(currentSlug, child)
+
+      fragment.appendChild(node)
+    }
+    explorerUl.insertBefore(fragment, explorerUl.firstChild)
+
+    // restore explorer scrollTop position if it exists
+    const scrollTop = sessionStorage.getItem("explorerScrollTop")
+    if (scrollTop) {
+      explorerUl.scrollTop = parseInt(scrollTop)
+    } else {
+      // try to scroll to the active element if it exists
+      const activeElement = explorerUl.querySelector(".active")
+      if (activeElement) {
+        activeElement.scrollIntoView({ behavior: "smooth" })
+      }
+    }
+
+    // Set up event handlers
+    const explorerButtons = explorer.querySelectorAll(
+      "button.explorer-toggle",
+    ) as NodeListOf<HTMLElement>
+    if (explorerButtons) {
+      window.addCleanup(() =>
+        explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
+      )
+      explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
+    }
+
+    // Set up folder click handlers
+    if (opts.folderClickBehavior === "collapse") {
+      const folderButtons = explorer.getElementsByClassName(
+        "folder-button",
+      ) as HTMLCollectionOf<HTMLElement>
+      for (const button of folderButtons) {
+        window.addCleanup(() => button.removeEventListener("click", toggleFolder))
+        button.addEventListener("click", toggleFolder)
+      }
+    }
+
+    const folderIcons = explorer.getElementsByClassName(
+      "folder-icon",
+    ) as HTMLCollectionOf<HTMLElement>
+    for (const icon of folderIcons) {
+      window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
+      icon.addEventListener("click", toggleFolder)
     }
   }
-  setupExplorer()
+}
 
-  observer.disconnect()
-
-  // select pseudo element at end of list
-  const lastItem = document.getElementById("explorer-end")
-  if (lastItem) {
-    observer.observe(lastItem)
-  }
-
-  // Hide explorer on mobile until it is requested
-  const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
-  hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
-
-  toggleExplorerFolders()
+document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
+  // save explorer scrollTop position
+  const explorer = document.getElementById("explorer-ul")
+  if (!explorer) return
+  sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
 })
 
-/**
- * Toggles the state of a given folder
- * @param folderElement <div class="folder-outer"> Element of folder (parent)
- * @param collapsed if folder should be set to collapsed or not
- */
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+  const currentSlug = e.detail.url
+  await setupExplorer(currentSlug)
+
+  // if mobile hamburger is visible, collapse by default
+  const mobileExplorer = document.getElementById("mobile-explorer")
+  if (mobileExplorer && mobileExplorer.checkVisibility()) {
+    for (const explorer of document.querySelectorAll(".explorer")) {
+      explorer.classList.add("collapsed")
+      explorer.setAttribute("aria-expanded", "false")
+    }
+  }
+
+  const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
+  hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
+})
+
 function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
   return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
 }
-
-/**
- * Toggles visibility of a folder
- * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
- * @param path path to folder (e.g. 'advanced/more/more2')
- */
-function toggleCollapsedByPath(array: FolderState[], path: string) {
-  const entry = array.find((item) => item.path === path)
-  if (entry) {
-    entry.collapsed = !entry.collapsed
-  }
-}
diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts
index df48f04..77900a6 100644
--- a/quartz/components/scripts/spa.inline.ts
+++ b/quartz/components/scripts/spa.inline.ts
@@ -75,6 +75,10 @@
 
   if (!contents) return
 
+  // notify about to nav
+  const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
+  document.dispatchEvent(event)
+
   // cleanup old
   cleanupFns.forEach((fn) => fn())
   cleanupFns.clear()
@@ -108,7 +112,7 @@
     }
   }
 
-  // now, patch head
+  // now, patch head, re-executing scripts
   const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
   elementsToRemove.forEach((el) => el.remove())
   const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts
index 2cfb3f9..a518c10 100644
--- a/quartz/components/scripts/toc.inline.ts
+++ b/quartz/components/scripts/toc.inline.ts
@@ -1,4 +1,3 @@
-const bufferPx = 150
 const observer = new IntersectionObserver((entries) => {
   for (const entry of entries) {
     const slug = entry.target.id
@@ -28,7 +27,6 @@
 function setupToc() {
   const toc = document.getElementById("toc")
   if (toc) {
-    const collapsed = toc.classList.contains("collapsed")
     const content = toc.nextElementSibling as HTMLElement | undefined
     if (!content) return
     toc.addEventListener("click", toggleToc)
diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts
index ff486cf..f717901 100644
--- a/quartz/components/scripts/util.ts
+++ b/quartz/components/scripts/util.ts
@@ -37,6 +37,7 @@
   if (!res.headers.get("content-type")?.startsWith("text/html")) {
     return res
   }
+
   // reading the body can only be done once, so we need to clone the response
   // to allow the caller to read it if it's was not a redirect
   const text = await res.clone().text()
diff --git a/quartz/components/styles/backlinks.scss b/quartz/components/styles/backlinks.scss
index 7b3237b..71c13f0 100644
--- a/quartz/components/styles/backlinks.scss
+++ b/quartz/components/styles/backlinks.scss
@@ -2,18 +2,6 @@
 
 .backlinks {
   flex-direction: column;
-  /*&:after {
-      pointer-events: none;
-      content: "";
-      width: 100%;
-      height: 50px;
-      position: absolute;
-      left: 0;
-      bottom: 0;
-      opacity: 1;
-      transition: opacity 0.3s ease;
-      background: linear-gradient(transparent 0px, var(--light));
-    }*/
 
   & > h3 {
     font-size: 1rem;
@@ -31,14 +19,4 @@
       }
     }
   }
-
-  & > .overflow {
-    &:after {
-      display: none;
-    }
-    height: auto;
-    @media all and not ($desktop) {
-      height: 250px;
-    }
-  }
 }
diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss
index edf4e61..4295282 100644
--- a/quartz/components/styles/darkmode.scss
+++ b/quartz/components/styles/darkmode.scss
@@ -8,6 +8,7 @@
   height: 20px;
   margin: 0 10px;
   text-align: inherit;
+  flex-shrink: 0;
 
   & svg {
     position: absolute;
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index fbeb58d..b769726 100644
--- a/quartz/components/styles/explorer.scss
+++ b/quartz/components/styles/explorer.scss
@@ -16,10 +16,10 @@
       box-sizing: border-box;
       position: sticky;
       background-color: var(--light);
+      padding: 1rem 0 1rem 0;
+      margin: 0;
     }
 
-    // Hide Explorer on mobile until done loading.
-    // Prevents ugly animation on page load.
     .hide-until-loaded ~ #explorer-content {
       display: none;
     }
@@ -28,9 +28,21 @@
 
 .explorer {
   display: flex;
-  height: 100%;
   flex-direction: column;
   overflow-y: hidden;
+  flex: 0 1 auto;
+  &.collapsed {
+    flex: 0 1 1.2rem;
+    & .fold {
+      transform: rotateZ(-90deg);
+    }
+  }
+
+  & .fold {
+    margin-left: 0.5rem;
+    transition: transform 0.3s ease;
+    opacity: 0.8;
+  }
 
   @media all and ($mobile) {
     order: -1;
@@ -64,18 +76,14 @@
     }
   }
 
-  /*&:after {
-    pointer-events: none;
-    content: "";
-    width: 100%;
-    height: 50px;
-    position: absolute;
-    left: 0;
-    bottom: 0;
-    opacity: 1;
-    transition: opacity 0.3s ease;
-    background: linear-gradient(transparent 0px, var(--light));
-  }*/
+  svg {
+    pointer-events: all;
+    transition: transform 0.35s ease;
+
+    & > polyline {
+      pointer-events: none;
+    }
+  }
 }
 
 button#mobile-explorer,
@@ -94,77 +102,46 @@
     display: inline-block;
     margin: 0;
   }
-
-  & .fold {
-    margin-left: 0.5rem;
-    transition: transform 0.3s ease;
-    opacity: 0.8;
-  }
-
-  &.collapsed .fold {
-    transform: rotateZ(-90deg);
-  }
-}
-
-.folder-outer {
-  display: grid;
-  grid-template-rows: 0fr;
-  transition: grid-template-rows 0.3s ease-in-out;
-}
-
-.folder-outer.open {
-  grid-template-rows: 1fr;
-}
-
-.folder-outer > ul {
-  overflow: hidden;
 }
 
 #explorer-content {
   list-style: none;
   overflow: hidden;
   overflow-y: auto;
-  max-height: 0px;
-  transition:
-    max-height 0.35s ease,
-    visibility 0s linear 0.35s;
   margin-top: 0.5rem;
-  visibility: hidden;
-
-  &.collapsed {
-    max-height: 100%;
-    transition:
-      max-height 0.35s ease,
-      visibility 0s linear 0s;
-    visibility: visible;
-  }
 
   & ul {
     list-style: none;
-    margin: 0.08rem 0;
+    margin: 0;
     padding: 0;
-    transition:
-      max-height 0.35s ease,
-      transform 0.35s ease,
-      opacity 0.2s ease;
 
     & li > a {
       color: var(--dark);
       opacity: 0.75;
       pointer-events: all;
+
+      &.active {
+        opacity: 1;
+        color: var(--tertiary);
+      }
     }
   }
 
-  > #explorer-ul {
-    max-height: none;
+  .folder-outer {
+    display: grid;
+    grid-template-rows: 0fr;
+    transition: grid-template-rows 0.3s ease-in-out;
   }
-}
 
-svg {
-  pointer-events: all;
+  .folder-outer.open {
+    grid-template-rows: 1fr;
+  }
 
-  & > polyline {
-    pointer-events: none;
+  .folder-outer > ul {
+    overflow: hidden;
+    margin-left: 6px;
+    padding-left: 0.8rem;
+    border-left: 1px solid var(--lightgray);
   }
 }
 
@@ -227,69 +204,54 @@
   color: var(--tertiary);
 }
 
-.no-background::after {
-  background: none !important;
-}
-
-#explorer-end {
-  // needs height so IntersectionObserver gets triggered
-  height: 4px;
-  // remove default margin from li
-  margin: 0;
-}
-
 .explorer {
   @media all and ($mobile) {
-    #explorer-content {
-      box-sizing: border-box;
-      overscroll-behavior: none;
-      z-index: 100;
-      position: absolute;
-      top: 0;
-      background-color: var(--light);
-      max-width: 100dvw;
-      left: -100dvw;
-      width: 100%;
-      transition: transform 300ms ease-in-out;
-      overflow: hidden;
-      padding: $topSpacing 2rem 2rem;
-      height: 100dvh;
-      max-height: 100dvh;
-      margin-top: 0;
-      visibility: hidden;
+    &.collapsed {
+      flex: 0 0 34px;
 
-      &:not(.collapsed) {
-        transform: translateX(100dvw);
-        visibility: visible;
+      & > #explorer-content {
+        transform: translateX(-100vw);
+        visibility: hidden;
       }
+    }
 
-      ul.overflow {
-        max-height: 100%;
-        width: 100%;
-      }
+    &:not(.collapsed) {
+      flex: 0 0 34px;
 
-      &.collapsed {
+      & > #explorer-content {
         transform: translateX(0);
         visibility: visible;
       }
     }
 
-    #mobile-explorer {
-      margin: 5px;
-      z-index: 101;
+    #explorer-content {
+      box-sizing: border-box;
+      z-index: 100;
+      position: absolute;
+      top: 0;
+      left: 0;
+      margin-top: 0;
+      background-color: var(--light);
+      max-width: 100vw;
+      width: 100%;
+      transform: translateX(-100vw);
+      transition:
+        transform 200ms ease,
+        visibility 200ms ease;
+      overflow: hidden;
+      padding: 4rem 0 2rem 0;
+      height: 100dvh;
+      max-height: 100dvh;
+      visibility: hidden;
+    }
 
-      &:not(.collapsed) .lucide-menu {
-        transform: rotate(-90deg);
-        transition: transform 200ms ease-in-out;
-      }
+    #mobile-explorer {
+      margin: 0;
+      padding: 5px;
+      z-index: 101;
 
       .lucide-menu {
         stroke: var(--darkgray);
-        transition: transform 200ms ease;
-
-        &:hover {
-          stroke: var(--dark);
-        }
       }
     }
   }
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
index 4988cd8..d1feca6 100644
--- a/quartz/components/styles/toc.scss
+++ b/quartz/components/styles/toc.scss
@@ -4,8 +4,10 @@
   display: flex;
   flex-direction: column;
 
-  &.desktop-only {
-    max-height: 40%;
+  overflow-y: hidden;
+  flex: 0 1 auto;
+  &:has(button#toc.collapsed) {
+    flex: 0 1 1.2rem;
   }
 }
 
@@ -44,26 +46,7 @@
 
 #toc-content {
   list-style: none;
-  overflow: hidden;
-  overflow-y: auto;
-  max-height: 100%;
-  transition:
-    max-height 0.35s ease,
-    visibility 0s linear 0s;
   position: relative;
-  visibility: visible;
-
-  &.collapsed {
-    max-height: 0;
-    transition:
-      max-height 0.35s ease,
-      visibility 0s linear 0.35s;
-    visibility: hidden;
-  }
-
-  &.collapsed > .overflow::after {
-    opacity: 0;
-  }
 
   & ul {
     list-style: none;
@@ -80,10 +63,6 @@
       }
     }
   }
-  > ul.overflow {
-    max-height: none;
-    width: 100%;
-  }
 
   @for $i from 0 through 6 {
     & .depth-#{$i} {
diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx
index 2810039..0cc70d8 100644
--- a/quartz/plugins/emitters/contentIndex.tsx
+++ b/quartz/plugins/emitters/contentIndex.tsx
@@ -11,6 +11,7 @@
 
 export type ContentIndexMap = Map<FullSlug, ContentDetails>
 export type ContentDetails = {
+  slug: FullSlug
   title: string
   links: SimpleSlug[]
   tags: string[]
@@ -124,6 +125,7 @@
         const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
         if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
           linkIndex.set(slug, {
+            slug,
             title: file.data.frontmatter?.title!,
             links: file.data.links ?? [],
             tags: file.data.frontmatter?.tags ?? [],
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index 4389491..e0ab076 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -543,7 +543,6 @@
 
 div:has(> .overflow) {
   display: flex;
-  overflow-y: auto;
   max-height: 100%;
 }
 
@@ -551,6 +550,7 @@
 ol.overflow {
   max-height: 100%;
   overflow-y: auto;
+  width: 100%;
 
   // clearfix
   content: "";
@@ -559,18 +559,15 @@
   & > li:last-of-type {
     margin-bottom: 30px;
   }
-  /*&:after {
-    pointer-events: none;
-    content: "";
-    width: 100%;
-    height: 50px;
-    position: absolute;
-    left: 0;
-    bottom: 0;
-    opacity: 1;
-    transition: opacity 0.3s ease;
-    background: linear-gradient(transparent 0px, var(--light));
-  }*/
+
+  & > li.overflow-end {
+    height: 4px;
+    margin: 0;
+  }
+
+  &.gradient-active {
+    mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
+  }
 }
 
 .transclude {
diff --git a/quartz/util/clone.ts b/quartz/util/clone.ts
new file mode 100644
index 0000000..37318e2
--- /dev/null
+++ b/quartz/util/clone.ts
@@ -0,0 +1,3 @@
+import rfdc from "rfdc"
+
+export const clone = rfdc()
diff --git a/quartz/util/fileTrie.test.ts b/quartz/util/fileTrie.test.ts
new file mode 100644
index 0000000..3de3d93
--- /dev/null
+++ b/quartz/util/fileTrie.test.ts
@@ -0,0 +1,190 @@
+import test, { describe, beforeEach } from "node:test"
+import assert from "node:assert"
+import { FileTrieNode } from "./fileTrie"
+
+interface TestData {
+  title: string
+  slug: string
+}
+
+describe("FileTrie", () => {
+  let trie: FileTrieNode<TestData>
+
+  beforeEach(() => {
+    trie = new FileTrieNode<TestData>("")
+  })
+
+  describe("constructor", () => {
+    test("should create an empty trie", () => {
+      assert.deepStrictEqual(trie.children, [])
+      assert.strictEqual(trie.slugSegment, "")
+      assert.strictEqual(trie.displayName, "")
+      assert.strictEqual(trie.data, null)
+      assert.strictEqual(trie.depth, 0)
+    })
+
+    test("should set displayName from data title", () => {
+      const data = {
+        title: "Test Title",
+        slug: "test",
+      }
+
+      trie.add(data)
+      assert.strictEqual(trie.children[0].displayName, "Test Title")
+    })
+  })
+
+  describe("add", () => {
+    test("should add a file at root level", () => {
+      const data = {
+        title: "Test",
+        slug: "test",
+      }
+
+      trie.add(data)
+      assert.strictEqual(trie.children.length, 1)
+      assert.strictEqual(trie.children[0].slugSegment, "test")
+      assert.strictEqual(trie.children[0].data, data)
+    })
+
+    test("should handle index files", () => {
+      const data = {
+        title: "Index",
+        slug: "index",
+      }
+
+      trie.add(data)
+      assert.strictEqual(trie.data, data)
+      assert.strictEqual(trie.children.length, 0)
+    })
+
+    test("should add nested files", () => {
+      const data1 = {
+        title: "Nested",
+        slug: "folder/test",
+      }
+
+      const data2 = {
+        title: "Really nested index",
+        slug: "a/b/c/index",
+      }
+
+      trie.add(data1)
+      trie.add(data2)
+      assert.strictEqual(trie.children.length, 2)
+      assert.strictEqual(trie.children[0].slugSegment, "folder")
+      assert.strictEqual(trie.children[0].children.length, 1)
+      assert.strictEqual(trie.children[0].children[0].slugSegment, "test")
+      assert.strictEqual(trie.children[0].children[0].data, data1)
+
+      assert.strictEqual(trie.children[1].slugSegment, "a")
+      assert.strictEqual(trie.children[1].children.length, 1)
+      assert.strictEqual(trie.children[1].data, null)
+
+      assert.strictEqual(trie.children[1].children[0].slugSegment, "b")
+      assert.strictEqual(trie.children[1].children[0].children.length, 1)
+      assert.strictEqual(trie.children[1].children[0].data, null)
+
+      assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c")
+      assert.strictEqual(trie.children[1].children[0].children[0].data, data2)
+      assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0)
+    })
+  })
+
+  describe("filter", () => {
+    test("should filter nodes based on condition", () => {
+      const data1 = { title: "Test1", slug: "test1" }
+      const data2 = { title: "Test2", slug: "test2" }
+
+      trie.add(data1)
+      trie.add(data2)
+
+      trie.filter((node) => node.slugSegment !== "test1")
+      assert.strictEqual(trie.children.length, 1)
+      assert.strictEqual(trie.children[0].slugSegment, "test2")
+    })
+  })
+
+  describe("map", () => {
+    test("should apply function to all nodes", () => {
+      const data1 = { title: "Test1", slug: "test1" }
+      const data2 = { title: "Test2", slug: "test2" }
+
+      trie.add(data1)
+      trie.add(data2)
+
+      trie.map((node) => {
+        if (node.data) {
+          node.displayName = "Modified"
+        }
+      })
+
+      assert.strictEqual(trie.children[0].displayName, "Modified")
+      assert.strictEqual(trie.children[1].displayName, "Modified")
+    })
+  })
+
+  describe("entries", () => {
+    test("should return all entries", () => {
+      const data1 = { title: "Test1", slug: "test1" }
+      const data2 = { title: "Test2", slug: "a/b/test2" }
+
+      trie.add(data1)
+      trie.add(data2)
+
+      const entries = trie.entries()
+      assert.deepStrictEqual(
+        entries.map(([path, node]) => [path, node.data]),
+        [
+          ["", trie.data],
+          ["test1", data1],
+          ["a/index", null],
+          ["a/b/index", null],
+          ["a/b/test2", data2],
+        ],
+      )
+    })
+  })
+
+  describe("getFolderPaths", () => {
+    test("should return all folder paths", () => {
+      const data1 = {
+        title: "Root",
+        slug: "index",
+      }
+      const data2 = {
+        title: "Test",
+        slug: "folder/subfolder/test",
+      }
+      const data3 = {
+        title: "Folder Index",
+        slug: "abc/index",
+      }
+
+      trie.add(data1)
+      trie.add(data2)
+      trie.add(data3)
+      const paths = trie.getFolderPaths()
+
+      assert.deepStrictEqual(paths, ["folder/index", "folder/subfolder/index", "abc/index"])
+    })
+  })
+
+  describe("sort", () => {
+    test("should sort nodes according to sort function", () => {
+      const data1 = { title: "A", slug: "a" }
+      const data2 = { title: "B", slug: "b" }
+      const data3 = { title: "C", slug: "c" }
+
+      trie.add(data3)
+      trie.add(data1)
+      trie.add(data2)
+
+      trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment))
+      assert.deepStrictEqual(
+        trie.children.map((n) => n.slugSegment),
+        ["a", "b", "c"],
+      )
+    })
+  })
+})
diff --git a/quartz/util/fileTrie.ts b/quartz/util/fileTrie.ts
new file mode 100644
index 0000000..ed87b4f
--- /dev/null
+++ b/quartz/util/fileTrie.ts
@@ -0,0 +1,128 @@
+import { ContentDetails } from "../plugins/emitters/contentIndex"
+import { FullSlug, joinSegments } from "./path"
+
+interface FileTrieData {
+  slug: string
+  title: string
+}
+
+export class FileTrieNode<T extends FileTrieData = ContentDetails> {
+  children: Array<FileTrieNode<T>>
+  slugSegment: string
+  displayName: string
+  data: T | null
+  depth: number
+  isFolder: boolean
+
+  constructor(segment: string, data?: T, depth: number = 0) {
+    this.children = []
+    this.slugSegment = segment
+    this.displayName = data?.title ?? segment
+    this.data = data ?? null
+    this.depth = depth
+    this.isFolder = segment === "index"
+  }
+
+  private insert(path: string[], file: T) {
+    if (path.length === 0) return
+
+    const nextSegment = path[0]
+
+    // base case, insert here
+    if (path.length === 1) {
+      if (nextSegment === "index") {
+        // index case (we are the root and we just found index.md)
+        this.data ??= file
+        const title = file.title
+        if (title !== "index") {
+          this.displayName = title
+        }
+      } else {
+        // direct child
+        this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1))
+        this.isFolder = true
+      }
+
+      return
+    }
+
+    // find the right child to insert into, creating it if it doesn't exist
+    path = path.splice(1)
+    let child = this.children.find((c) => c.slugSegment === nextSegment)
+    if (!child) {
+      child = new FileTrieNode<T>(nextSegment, undefined, this.depth + 1)
+      this.children.push(child)
+      child.isFolder = true
+    }
+
+    child.insert(path, file)
+  }
+
+  // Add new file to trie
+  add(file: T) {
+    this.insert(file.slug.split("/"), file)
+  }
+
+  /**
+   * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
+   */
+  filter(filterFn: (node: FileTrieNode<T>) => boolean) {
+    this.children = this.children.filter(filterFn)
+    this.children.forEach((child) => child.filter(filterFn))
+  }
+
+  /**
+   * Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place
+   */
+  map(mapFn: (node: FileTrieNode<T>) => void) {
+    mapFn(this)
+    this.children.forEach((child) => child.map(mapFn))
+  }
+
+  /**
+   * Sort trie nodes according to sort/compare function
+   */
+  sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) {
+    this.children = this.children.sort(sortFn)
+    this.children.forEach((e) => e.sort(sortFn))
+  }
+
+  static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) {
+    const trie = new FileTrieNode<T>("")
+    entries.forEach(([, entry]) => trie.add(entry))
+    return trie
+  }
+
+  /**
+   * Get all entries in the trie
+   * in the a flat array including the full path and the node
+   */
+  entries(): [FullSlug, FileTrieNode<T>][] {
+    const traverse = (
+      node: FileTrieNode<T>,
+      currentPath: string,
+    ): [FullSlug, FileTrieNode<T>][] => {
+      const segments = [currentPath, node.slugSegment]
+      const fullPath = joinSegments(...segments) as FullSlug
+
+      const indexQualifiedPath =
+        node.isFolder && node.depth > 0 ? (joinSegments(fullPath, "index") as FullSlug) : fullPath
+
+      const result: [FullSlug, FileTrieNode<T>][] = [[indexQualifiedPath, node]]
+
+      return result.concat(...node.children.map((child) => traverse(child, fullPath)))
+    }
+
+    return traverse(this, "")
+  }
+
+  /**
+   * Get all folder paths in the trie
+   * @returns array containing folder state for trie
+   */
+  getFolderPaths() {
+    return this.entries()
+      .filter(([_, node]) => node.isFolder)
+      .map(([path, _]) => path)
+  }
+}
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index 5835f15..8f85029 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -1,9 +1,6 @@
 import { slug as slugAnchor } from "github-slugger"
 import type { Element as HastElement } from "hast"
-import rfdc from "rfdc"
-
-export const clone = rfdc()
-
+import { clone } from "./clone"
 // this file must be isomorphic so it can't use node libs (e.g. path)
 
 export const QUARTZ = "quartz"

--
Gitblit v1.10.0