From 504b44716240bb3fb9a077a1acaa3dc1059e2c1e Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Thu, 28 Dec 2023 00:44:14 +0000
Subject: [PATCH] fix: use slugs instead of title as basis for explorer (#652)

---
 quartz/components/ExplorerNode.tsx           |  114 +++++++++++++++++-----------
 quartz/components/Breadcrumbs.tsx            |    8 +
 quartz/plugins/index.ts                      |    1 
 quartz/components/Explorer.tsx               |   63 +++++++--------
 quartz/util/path.ts                          |    2 
 quartz/components/scripts/explorer.inline.ts |    7 -
 quartz/processors/parse.ts                   |    5 
 7 files changed, 110 insertions(+), 90 deletions(-)

diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx
index 8998c40..a0b8cf5 100644
--- a/quartz/components/Breadcrumbs.tsx
+++ b/quartz/components/Breadcrumbs.tsx
@@ -68,8 +68,9 @@
       // construct the index for the first time
       for (const file of allFiles) {
         if (file.slug?.endsWith("index")) {
-          const folderParts = file.filePath?.split("/")
+          const folderParts = file.slug?.split("/")
           if (folderParts) {
+            // 2nd last to exclude the /index
             const folderName = folderParts[folderParts?.length - 2]
             folderIndex.set(folderName, file)
           }
@@ -88,7 +89,10 @@
         // Try to resolve frontmatter folder title
         const currentFile = folderIndex?.get(curPathSegment)
         if (currentFile) {
-          curPathSegment = currentFile.frontmatter!.title
+          const title = currentFile.frontmatter!.title
+          if (title !== "index") {
+            curPathSegment = title
+          }
         }
 
         // Add current slug to full path
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
index 95eac43..e3ed9b1 100644
--- a/quartz/components/Explorer.tsx
+++ b/quartz/components/Explorer.tsx
@@ -12,6 +12,9 @@
   folderClickBehavior: "collapse",
   folderDefaultState: "collapsed",
   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)) {
@@ -22,6 +25,7 @@
         sensitivity: "base",
       })
     }
+
     if (a.file && !b.file) {
       return 1
     } else {
@@ -41,46 +45,34 @@
   let jsonTree: string
 
   function constructFileTree(allFiles: QuartzPluginData[]) {
-    if (!fileTree) {
-      // Construct tree from allFiles
-      fileTree = new FileNode("")
-      allFiles.forEach((file) => fileTree.add(file, 1))
+    if (fileTree) {
+      return
+    }
 
-      /**
-       * Keys of this object must match corresponding function name of `FileNode`,
-       * while values must be the argument that will be passed to the function.
-       *
-       * e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options)
-       */
-      const functions = {
-        map: opts.mapFn,
-        sort: opts.sortFn,
-        filter: opts.filterFn,
-      }
+    // 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 (functions[functionName]) {
-            // for every entry in order, call matching function in FileNode and pass matching argument
-            // e.g. i = 0; functionName = "filter"
-            // converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn)
-
-            // @ts-ignore
-            // typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning
-            fileTree[functionName].call(fileTree, functions[functionName])
-          }
+    // 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
-      const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
-
-      // Stringify to pass json tree as data attribute ([data-tree])
-      jsonTree = JSON.stringify(folders)
     }
+
+    // Get all folders of tree. Initialize with collapsed state
+    const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
+
+    // Stringify to pass json tree as data attribute ([data-tree])
+    jsonTree = JSON.stringify(folders)
   }
 
   function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
@@ -120,6 +112,7 @@
       </div>
     )
   }
+
   Explorer.css = explorerStyle
   Explorer.afterDOMLoaded = script
   return Explorer
diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx
index e5ceb0b..118f25b 100644
--- a/quartz/components/ExplorerNode.tsx
+++ b/quartz/components/ExplorerNode.tsx
@@ -1,6 +1,13 @@
 // @ts-ignore
 import { QuartzPluginData } from "../plugins/vfile"
-import { resolveRelative } from "../util/path"
+import {
+  joinSegments,
+  resolveRelative,
+  clone,
+  simplifySlug,
+  SimpleSlug,
+  FilePath,
+} from "../util/path"
 
 type OrderEntries = "sort" | "filter" | "map"
 
@@ -10,9 +17,9 @@
   folderClickBehavior: "collapse" | "link"
   useSavedState: boolean
   sortFn: (a: FileNode, b: FileNode) => number
-  filterFn?: (node: FileNode) => boolean
-  mapFn?: (node: FileNode) => void
-  order?: OrderEntries[]
+  filterFn: (node: FileNode) => boolean
+  mapFn: (node: FileNode) => void
+  order: OrderEntries[]
 }
 
 type DataWrapper = {
@@ -25,59 +32,74 @@
   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: FileNode[]
-  name: string
+  children: Array<FileNode>
+  name: string // this is the slug segment
   displayName: string
   file: QuartzPluginData | null
   depth: number
 
-  constructor(name: string, file?: QuartzPluginData, depth?: number) {
+  constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) {
     this.children = []
-    this.name = name
-    this.displayName = name
-    this.file = file ? structuredClone(file) : null
+    this.name = slugSegment
+    this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment
+    this.file = file ? clone(file) : null
     this.depth = depth ?? 0
   }
 
-  private insert(file: DataWrapper) {
-    if (file.path.length === 1) {
-      if (file.path[0] !== "index.md") {
-        this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
-      } else {
-        const title = file.file.frontmatter?.title
-        if (title && title !== "index" && file.path[0] === "index.md") {
+  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 {
-      const next = file.path[0]
-      file.path = file.path.splice(1)
-      for (const child of this.children) {
-        if (child.name === next) {
-          child.insert(file)
-          return
-        }
+      } else {
+        // direct child
+        this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1))
       }
 
-      const newChild = new FileNode(next, undefined, this.depth + 1)
-      newChild.insert(file)
-      this.children.push(newChild)
+      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, splice: number = 0) {
-    this.insert({ file, path: file.filePath!.split("/").splice(splice) })
-  }
-
-  // Print tree structure (for debugging)
-  print(depth: number = 0) {
-    let folderChar = ""
-    if (!this.file) folderChar = "|"
-    console.log("-".repeat(depth), folderChar, this.name, this.depth)
-    this.children.forEach((e) => e.print(depth + 1))
+  add(file: QuartzPluginData) {
+    this.insert({ file: file, path: simplifySlug(file.slug!).split("/") })
   }
 
   /**
@@ -95,7 +117,6 @@
    */
   map(mapFn: (node: FileNode) => void) {
     mapFn(this)
-
     this.children.forEach((child) => child.map(mapFn))
   }
 
@@ -110,16 +131,16 @@
 
     const traverse = (node: FileNode, currentPath: string) => {
       if (!node.file) {
-        const folderPath = currentPath + (currentPath ? "/" : "") + node.name
+        const folderPath = joinSegments(currentPath, node.name)
         if (folderPath !== "") {
           folderPaths.push({ path: folderPath, collapsed })
         }
+
         node.children.forEach((child) => traverse(child, folderPath))
       }
     }
 
     traverse(this, "")
-
     return folderPaths
   }
 
@@ -147,10 +168,9 @@
   const isDefaultOpen = opts.folderDefaultState === "open"
 
   // Calculate current folderPath
-  let pathOld = fullPath ? fullPath : ""
   let folderPath = ""
   if (node.name !== "") {
-    folderPath = `${pathOld}/${node.name}`
+    folderPath = joinSegments(fullPath ?? "", node.name)
   }
 
   return (
@@ -185,7 +205,11 @@
               {/* 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={`${folderPath}`} data-for={node.name} class="folder-title">
+                  <a
+                    href={resolveRelative(fileData.slug!, folderPath as SimpleSlug)}
+                    data-for={node.name}
+                    class="folder-title"
+                  >
                     {node.displayName}
                   </a>
                 ) : (
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 72404ed..8e79d20 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -59,8 +59,7 @@
   // Save folder state to localStorage
   const clickFolderPath = currentFolderParent.dataset.folderpath as string
 
-  // Remove leading "/"
-  const fullFolderPath = clickFolderPath.substring(1)
+  const fullFolderPath = clickFolderPath
   toggleCollapsedByPath(explorerState, fullFolderPath)
 
   const stringifiedFileTree = JSON.stringify(explorerState)
@@ -108,9 +107,7 @@
     explorerState = JSON.parse(storageTree)
     explorerState.map((folderUl) => {
       // grab <li> element for matching folder path
-      const folderLi = document.querySelector(
-        `[data-folderpath='/${folderUl.path}']`,
-      ) as HTMLElement
+      const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement
 
       // Get corresponding content <ul> tag and set state
       if (folderLi) {
diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts
index 9753d2e..f35d053 100644
--- a/quartz/plugins/index.ts
+++ b/quartz/plugins/index.ts
@@ -30,5 +30,6 @@
   interface DataMap {
     slug: FullSlug
     filePath: FilePath
+    relativePath: FilePath
   }
 }
diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts
index fab1795..3950fee 100644
--- a/quartz/processors/parse.ts
+++ b/quartz/processors/parse.ts
@@ -91,8 +91,9 @@
         }
 
         // base data properties that plugins may use
-        file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath)
-        file.data.filePath = fp
+        file.data.filePath = file.path as FilePath
+        file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath
+        file.data.slug = slugifyFilePath(file.data.relativePath)
 
         const ast = processor.parse(file)
         const newAst = await processor.run(ast, file)
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index d399706..6cedffd 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -2,7 +2,7 @@
 import type { Element as HastElement } from "hast"
 import rfdc from "rfdc"
 
-const clone = rfdc()
+export const clone = rfdc()
 
 // this file must be isomorphic so it can't use node libs (e.g. path)
 

--
Gitblit v1.10.0