Jacky Zhao
2025-03-09 5480269d38ffaff7ffd6576d9a9407430429fb2d
perf(explorer): client side explorer (#1810)

* start work on client side explorer

* fix tests

* fmt

* generic test flag

* add prenav hook

* add highlight class

* make flex more consistent, remove transition

* open folders that are prefixes of current path

* make mobile look nice

* more style fixes
1 files deleted
4 files added
19 files modified
1471 ■■■■ changed files
docs/advanced/creating components.md 12 ●●●●● patch | view | raw | blame | history
index.d.ts 1 ●●●● patch | view | raw | blame | history
package.json 2 ●●● patch | view | raw | blame | history
quartz.config.ts 2 ●●● patch | view | raw | blame | history
quartz/components/Backlinks.tsx 6 ●●●●● patch | view | raw | blame | history
quartz/components/Explorer.tsx 152 ●●●● patch | view | raw | blame | history
quartz/components/ExplorerNode.tsx 242 ●●●●● patch | view | raw | blame | history
quartz/components/OverflowList.tsx 39 ●●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 7 ●●●●● patch | view | raw | blame | history
quartz/components/renderPage.tsx 3 ●●●● patch | view | raw | blame | history
quartz/components/scripts/explorer.inline.ts 407 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/spa.inline.ts 6 ●●●● patch | view | raw | blame | history
quartz/components/scripts/toc.inline.ts 2 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/util.ts 1 ●●●● patch | view | raw | blame | history
quartz/components/styles/backlinks.scss 22 ●●●●● patch | view | raw | blame | history
quartz/components/styles/darkmode.scss 1 ●●●● patch | view | raw | blame | history
quartz/components/styles/explorer.scss 186 ●●●●● patch | view | raw | blame | history
quartz/components/styles/toc.scss 29 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentIndex.tsx 2 ●●●●● patch | view | raw | blame | history
quartz/styles/base.scss 23 ●●●●● patch | view | raw | blame | history
quartz/util/clone.ts 3 ●●●●● patch | view | raw | blame | history
quartz/util/fileTrie.test.ts 190 ●●●●● patch | view | raw | blame | history
quartz/util/fileTrie.ts 128 ●●●●● patch | view | raw | blame | history
quartz/util/path.ts 5 ●●●● patch | view | raw | blame | history
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.
index.d.ts
@@ -5,6 +5,7 @@
// dom custom event
interface CustomEventMap {
  prenav: CustomEvent<{}>
  nav: CustomEvent<{ url: FullSlug }>
  themechange: CustomEvent<{ theme: "light" | "dark" }>
}
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": {
quartz.config.ts
@@ -8,7 +8,7 @@
 */
const config: QuartzConfig = {
  configuration: {
    pageTitle: "🪴 Quartz 4",
    pageTitle: "Quartz 4",
    pageTitleSuffix: "",
    enableSPA: true,
    enablePopovers: true,
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
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
quartz/components/ExplorerNode.tsx
File was deleted
quartz/components/OverflowList.tsx
New file
@@ -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
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) {
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"
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
  }
}
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])")
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)
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()
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;
    }
  }
}
quartz/components/styles/darkmode.scss
@@ -8,6 +8,7 @@
  height: 20px;
  margin: 0 10px;
  text-align: inherit;
  flex-shrink: 0;
  & svg {
    position: absolute;
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);
        }
      }
    }
  }
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} {
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 ?? [],
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 {
quartz/util/clone.ts
New file
@@ -0,0 +1,3 @@
import rfdc from "rfdc"
export const clone = rfdc()
quartz/util/fileTrie.test.ts
New file
@@ -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"],
      )
    })
  })
})
quartz/util/fileTrie.ts
New file
@@ -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)
  }
}
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"