From 4e74d11b1aee8c0affa0b13ba7b174d175ca3244 Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Mon, 24 Mar 2025 00:24:43 +0000
Subject: [PATCH] fix: cleanup a href link construction, global shared trie, breadcrumbs use trie

---
 quartz/build.ts                           |    1 
 quartz/components/Breadcrumbs.tsx         |   90 ++++-------------
 quartz/util/ctx.ts                        |   27 +++++
 quartz/util/fileTrie.test.ts              |   82 ++++++++++++++++
 quartz/components/TagList.tsx             |    5 
 quartz/components/pages/TagContent.tsx    |    7 +
 quartz/components/pages/FolderContent.tsx |   25 ----
 quartz/util/fileTrie.ts                   |   18 +++
 8 files changed, 159 insertions(+), 96 deletions(-)

diff --git a/quartz/build.ts b/quartz/build.ts
index 7cf4405..032063f 100644
--- a/quartz/build.ts
+++ b/quartz/build.ts
@@ -21,6 +21,7 @@
 import { randomIdNonSecure } from "./util/random"
 import { ChangeEvent } from "./plugins/types"
 import { minimatch } from "minimatch"
+import { FileTrieNode } from "./util/fileTrie"
 
 type ContentMap = Map<
   FilePath,
diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx
index d0faeab..5144a31 100644
--- a/quartz/components/Breadcrumbs.tsx
+++ b/quartz/components/Breadcrumbs.tsx
@@ -1,8 +1,8 @@
 import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
 import breadcrumbsStyle from "./styles/breadcrumbs.scss"
-import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path"
-import { QuartzPluginData } from "../plugins/vfile"
+import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path"
 import { classNames } from "../util/lang"
+import { trieFromAllFiles } from "../util/ctx"
 
 type CrumbData = {
   displayName: string
@@ -23,10 +23,6 @@
    */
   resolveFrontmatterTitle: boolean
   /**
-   * Whether to display breadcrumbs on root `index.md`
-   */
-  hideOnRoot: boolean
-  /**
    * Whether to display the current page in the breadcrumbs.
    */
   showCurrentPage: boolean
@@ -36,7 +32,6 @@
   spacerSymbol: "❯",
   rootName: "Home",
   resolveFrontmatterTitle: true,
-  hideOnRoot: true,
   showCurrentPage: true,
 }
 
@@ -48,78 +43,37 @@
 }
 
 export default ((opts?: Partial<BreadcrumbOptions>) => {
-  // Merge options with defaults
   const options: BreadcrumbOptions = { ...defaultOptions, ...opts }
-
-  // computed index of folder name to its associated file data
-  let folderIndex: Map<string, QuartzPluginData> | undefined
-
   const Breadcrumbs: QuartzComponent = ({
     fileData,
     allFiles,
     displayClass,
+    ctx,
   }: QuartzComponentProps) => {
-    // Hide crumbs on root if enabled
-    if (options.hideOnRoot && fileData.slug === "index") {
-      return <></>
+    const trie = (ctx.trie ??= trieFromAllFiles(allFiles))
+    const slugParts = fileData.slug!.split("/")
+    const pathNodes = trie.ancestryChain(slugParts)
+
+    if (!pathNodes) {
+      return null
     }
 
-    // Format entry for root element
-    const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
-    const crumbs: CrumbData[] = [firstEntry]
-
-    if (!folderIndex && options.resolveFrontmatterTitle) {
-      folderIndex = new Map()
-      // construct the index for the first time
-      for (const file of allFiles) {
-        const folderParts = file.slug?.split("/")
-        if (folderParts?.at(-1) === "index") {
-          folderIndex.set(folderParts.slice(0, -1).join("/"), file)
-        }
-      }
-    }
-
-    // Split slug into hierarchy/parts
-    const slugParts = fileData.slug?.split("/")
-    if (slugParts) {
-      // is tag breadcrumb?
-      const isTagPath = slugParts[0] === "tags"
-
-      // full path until current part
-      let currentPath = ""
-
-      for (let i = 0; i < slugParts.length - 1; i++) {
-        let curPathSegment = slugParts[i]
-
-        // Try to resolve frontmatter folder title
-        const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
-        if (currentFile) {
-          const title = currentFile.frontmatter!.title
-          if (title !== "index") {
-            curPathSegment = title
-          }
-        }
-
-        // Add current slug to full path
-        currentPath = joinSegments(currentPath, slugParts[i])
-        const includeTrailingSlash = !isTagPath || i < slugParts.length - 1
-
-        // Format and add current crumb
-        const crumb = formatCrumb(
-          curPathSegment,
-          fileData.slug!,
-          (currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,
-        )
-        crumbs.push(crumb)
+    const crumbs: CrumbData[] = pathNodes.map((node, idx) => {
+      const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug))
+      if (idx === 0) {
+        crumb.displayName = options.rootName
       }
 
-      // Add current file to crumb (can directly use frontmatter title)
-      if (options.showCurrentPage && slugParts.at(-1) !== "index") {
-        crumbs.push({
-          displayName: fileData.frontmatter!.title,
-          path: "",
-        })
+      // For last node (current page), set empty path
+      if (idx === pathNodes.length - 1) {
+        crumb.path = ""
       }
+
+      return crumb
+    })
+
+    if (!options.showCurrentPage) {
+      crumbs.pop()
     }
 
     return (
diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx
index 4a89fbd..c73ed39 100644
--- a/quartz/components/TagList.tsx
+++ b/quartz/components/TagList.tsx
@@ -1,15 +1,14 @@
-import { pathToRoot, slugTag } from "../util/path"
+import { FullSlug, resolveRelative } from "../util/path"
 import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
 import { classNames } from "../util/lang"
 
 const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
   const tags = fileData.frontmatter?.tags
-  const baseDir = pathToRoot(fileData.slug!)
   if (tags && tags.length > 0) {
     return (
       <ul class={classNames(displayClass, "tags")}>
         {tags.map((tag) => {
-          const linkDest = baseDir + `/tags/${slugTag(tag)}`
+          const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug)
           return (
             <li>
               <a href={linkDest} class="internal tag-link">
diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx
index 9621f4f..afd4f5d 100644
--- a/quartz/components/pages/FolderContent.tsx
+++ b/quartz/components/pages/FolderContent.tsx
@@ -8,7 +8,8 @@
 import { QuartzPluginData } from "../../plugins/vfile"
 import { ComponentChildren } from "preact"
 import { concatenateResources } from "../../util/resources"
-import { FileTrieNode } from "../../util/fileTrie"
+import { trieFromAllFiles } from "../../util/ctx"
+
 interface FolderContentOptions {
   /**
    * Whether to display number of folders
@@ -25,31 +26,11 @@
 
 export default ((opts?: Partial<FolderContentOptions>) => {
   const options: FolderContentOptions = { ...defaultOptions, ...opts }
-  let trie: FileTrieNode<
-    QuartzPluginData & {
-      slug: string
-      title: string
-      filePath: string
-    }
-  >
 
   const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
     const { tree, fileData, allFiles, cfg } = props
 
-    if (!trie) {
-      trie = new FileTrieNode([])
-      allFiles.forEach((file) => {
-        if (file.frontmatter) {
-          trie.add({
-            ...file,
-            slug: file.slug!,
-            title: file.frontmatter.title,
-            filePath: file.filePath!,
-          })
-        }
-      })
-    }
-
+    const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles))
     const folder = trie.findNode(fileData.slug!.split("/"))
     if (!folder) {
       return null
diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx
index 03051d3..5e81901 100644
--- a/quartz/components/pages/TagContent.tsx
+++ b/quartz/components/pages/TagContent.tsx
@@ -1,7 +1,7 @@
 import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types"
 import style from "../styles/listPage.scss"
 import { PageList, SortFn } from "../PageList"
-import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
+import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path"
 import { QuartzPluginData } from "../../plugins/vfile"
 import { Root } from "hast"
 import { htmlToJsx } from "../../util/jsx"
@@ -74,10 +74,13 @@
                   ? contentPage?.description
                   : htmlToJsx(contentPage.filePath!, root)
 
+              const tagListingPage = `/tags/${tag}` as FullSlug
+              const href = resolveRelative(fileData.slug!, tagListingPage)
+
               return (
                 <div>
                   <h2>
-                    <a class="internal tag-link" href={`../tags/${tag}`}>
+                    <a class="internal tag-link" href={href}>
                       {tag}
                     </a>
                   </h2>
diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts
index b3e7a37..80115ec 100644
--- a/quartz/util/ctx.ts
+++ b/quartz/util/ctx.ts
@@ -1,4 +1,6 @@
 import { QuartzConfig } from "../cfg"
+import { QuartzPluginData } from "../plugins/vfile"
+import { FileTrieNode } from "./fileTrie"
 import { FilePath, FullSlug } from "./path"
 
 export interface Argv {
@@ -13,13 +15,36 @@
   concurrency?: number
 }
 
+export type BuildTimeTrieData = QuartzPluginData & {
+  slug: string
+  title: string
+  filePath: string
+}
+
 export interface BuildCtx {
   buildId: string
   argv: Argv
   cfg: QuartzConfig
   allSlugs: FullSlug[]
   allFiles: FilePath[]
+  trie?: FileTrieNode<BuildTimeTrieData>
   incremental: boolean
 }
 
-export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg">
+export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {
+  const trie = new FileTrieNode<BuildTimeTrieData>([])
+  allFiles.forEach((file) => {
+    if (file.frontmatter) {
+      trie.add({
+        ...file,
+        slug: file.slug!,
+        title: file.frontmatter.title,
+        filePath: file.filePath!,
+      })
+    }
+  })
+
+  return trie
+}
+
+export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie">
diff --git a/quartz/util/fileTrie.test.ts b/quartz/util/fileTrie.test.ts
index d66e142..c747f34 100644
--- a/quartz/util/fileTrie.test.ts
+++ b/quartz/util/fileTrie.test.ts
@@ -330,4 +330,86 @@
       )
     })
   })
+
+  describe("pathToNode", () => {
+    test("should return root node for empty path", () => {
+      const data = { title: "Root", slug: "index", filePath: "index.md" }
+      trie.add(data)
+      const path = trie.ancestryChain([])
+      assert.deepStrictEqual(path, [trie])
+    })
+
+    test("should return root node for index path", () => {
+      const data = { title: "Root", slug: "index", filePath: "index.md" }
+      trie.add(data)
+      const path = trie.ancestryChain(["index"])
+      assert.deepStrictEqual(path, [trie])
+    })
+
+    test("should return path to first level node", () => {
+      const data = { title: "Test", slug: "test", filePath: "test.md" }
+      trie.add(data)
+      const path = trie.ancestryChain(["test"])
+      assert.deepStrictEqual(path, [trie, trie.children[0]])
+    })
+
+    test("should return path to nested node", () => {
+      const data = {
+        title: "Nested",
+        slug: "folder/subfolder/test",
+        filePath: "folder/subfolder/test.md",
+      }
+      trie.add(data)
+      const path = trie.ancestryChain(["folder", "subfolder", "test"])
+      assert.deepStrictEqual(path, [
+        trie,
+        trie.children[0],
+        trie.children[0].children[0],
+        trie.children[0].children[0].children[0],
+      ])
+    })
+
+    test("should return undefined for non-existent path", () => {
+      const data = { title: "Test", slug: "test", filePath: "test.md" }
+      trie.add(data)
+      const path = trie.ancestryChain(["nonexistent"])
+      assert.strictEqual(path, undefined)
+    })
+
+    test("should return file data for intermediate folders", () => {
+      const data1 = {
+        title: "Root",
+        slug: "index",
+        filePath: "index.md",
+      }
+      const data2 = {
+        title: "Test",
+        slug: "folder/subfolder/test",
+        filePath: "folder/subfolder/test.md",
+      }
+      const data3 = {
+        title: "Folder Index",
+        slug: "folder/index",
+        filePath: "folder/index.md",
+      }
+
+      trie.add(data1)
+      trie.add(data2)
+      trie.add(data3)
+      const path = trie.ancestryChain(["folder", "subfolder"])
+      assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]])
+      assert.strictEqual(path[1].data, data3)
+    })
+
+    test("should return path for partial path", () => {
+      const data = {
+        title: "Nested",
+        slug: "folder/subfolder/test",
+        filePath: "folder/subfolder/test.md",
+      }
+      trie.add(data)
+      const path = trie.ancestryChain(["folder"])
+      assert.deepStrictEqual(path, [trie, trie.children[0]])
+    })
+  })
 })
diff --git a/quartz/util/fileTrie.ts b/quartz/util/fileTrie.ts
index e3dc2e7..9e6706f 100644
--- a/quartz/util/fileTrie.ts
+++ b/quartz/util/fileTrie.ts
@@ -97,6 +97,24 @@
     return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1))
   }
 
+  ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined {
+    if (path.length === 0 || (path.length === 1 && path[0] === "index")) {
+      return [this]
+    }
+
+    const child = this.children.find((c) => c.slugSegment === path[0])
+    if (!child) {
+      return undefined
+    }
+
+    const childPath = child.ancestryChain(path.slice(1))
+    if (!childPath) {
+      return undefined
+    }
+
+    return [this, ...childPath]
+  }
+
   /**
    * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place
    */

--
Gitblit v1.10.0