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
This commit is contained in:
		@@ -161,6 +161,18 @@ document.addEventListener("nav", () => {
 | 
			
		||||
})
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								index.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -5,6 +5,7 @@ declare module "*.scss" {
 | 
			
		||||
 | 
			
		||||
// dom custom event
 | 
			
		||||
interface CustomEventMap {
 | 
			
		||||
  prenav: CustomEvent<{}>
 | 
			
		||||
  nav: CustomEvent<{ url: FullSlug }>
 | 
			
		||||
  themechange: CustomEvent<{ theme: "light" | "dark" }>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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": {
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins"
 | 
			
		||||
 */
 | 
			
		||||
const config: QuartzConfig = {
 | 
			
		||||
  configuration: {
 | 
			
		||||
    pageTitle: "🪴 Quartz 4",
 | 
			
		||||
    pageTitle: "Quartz 4",
 | 
			
		||||
    pageTitleSuffix: "",
 | 
			
		||||
    enableSPA: true,
 | 
			
		||||
    enablePopovers: true,
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss"
 | 
			
		||||
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 @@ export default ((opts?: Partial<BacklinksOptions>) => {
 | 
			
		||||
    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 @@ export default ((opts?: Partial<BacklinksOptions>) => {
 | 
			
		||||
          ) : (
 | 
			
		||||
            <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
 | 
			
		||||
          )}
 | 
			
		||||
        </ul>
 | 
			
		||||
        </OverflowList>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Backlinks.css = style
 | 
			
		||||
  Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
 | 
			
		||||
 | 
			
		||||
  return Backlinks
 | 
			
		||||
}) satisfies QuartzComponentConstructor
 | 
			
		||||
 
 | 
			
		||||
@@ -3,22 +3,34 @@ import style from "./styles/explorer.scss"
 | 
			
		||||
 | 
			
		||||
// @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 @@ const defaultOptions = {
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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")}>
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
          id="mobile-explorer"
 | 
			
		||||
          class="collapsed hide-until-loaded"
 | 
			
		||||
      <div
 | 
			
		||||
        class={classNames(displayClass, "explorer")}
 | 
			
		||||
        data-behavior={opts.folderClickBehavior}
 | 
			
		||||
        data-collapsed={opts.folderDefaultState}
 | 
			
		||||
        data-savestate={opts.useSavedState}
 | 
			
		||||
          data-tree={jsonTree}
 | 
			
		||||
        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="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 @@ export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
            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 @@ export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
        <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 @@ export default ((userOpts?: Partial<Options>) => {
 | 
			
		||||
            <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
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								quartz/components/OverflowList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								quartz/components/OverflowList.tsx
									
									
									
									
									
										Normal 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
 | 
			
		||||
@@ -6,6 +6,7 @@ import { classNames } from "../util/lang"
 | 
			
		||||
// @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 @@ const TableOfContents: QuartzComponent = ({
 | 
			
		||||
        </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 @@ const TableOfContents: QuartzComponent = ({
 | 
			
		||||
              </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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
 | 
			
		||||
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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
  const explorers = document.querySelectorAll(".explorer")
 | 
			
		||||
  for (const explorer of explorers) {
 | 
			
		||||
    explorer.classList.toggle("collapsed")
 | 
			
		||||
    explorer.setAttribute(
 | 
			
		||||
      "aria-expanded",
 | 
			
		||||
    this.getAttribute("aria-expanded") === "true" ? "false" : "true",
 | 
			
		||||
      explorer.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")
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toggleFolder(evt: MouseEvent) {
 | 
			
		||||
  evt.stopPropagation()
 | 
			
		||||
 | 
			
		||||
  // Element that was clicked
 | 
			
		||||
  const target = evt.target as MaybeHTMLElement
 | 
			
		||||
  if (!target) return
 | 
			
		||||
 | 
			
		||||
@@ -55,162 +40,240 @@ function toggleFolder(evt: MouseEvent) {
 | 
			
		||||
  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)
 | 
			
		||||
 | 
			
		||||
  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,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Save folder state to localStorage
 | 
			
		||||
  const fullFolderPath = currentFolderParent.dataset.folderpath as string
 | 
			
		||||
  toggleCollapsedByPath(currentExplorerState, fullFolderPath)
 | 
			
		||||
  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")
 | 
			
		||||
    const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
 | 
			
		||||
    const oldIndex = new Map(
 | 
			
		||||
      serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    // Convert to bool
 | 
			
		||||
    const useSavedFolderState = explorer?.dataset.savestate === "true"
 | 
			
		||||
    const data = await fetchData
 | 
			
		||||
    const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
 | 
			
		||||
    const trie = FileTrieNode.fromEntries(entries)
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
    // 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
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
      // 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({
 | 
			
		||||
    // Get folder paths for state management
 | 
			
		||||
    const folderPaths = trie.getFolderPaths()
 | 
			
		||||
    currentExplorerState = folderPaths.map((path) => ({
 | 
			
		||||
      path,
 | 
			
		||||
        collapsed: oldIndex.get(path) ?? collapsed,
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
      collapsed: oldIndex.get(path) === true,
 | 
			
		||||
    }))
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
    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" })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
function toggleExplorerFolders() {
 | 
			
		||||
  const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
 | 
			
		||||
    /\/index$/g,
 | 
			
		||||
    "",
 | 
			
		||||
    // 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)),
 | 
			
		||||
      )
 | 
			
		||||
  const allFolders = document.querySelectorAll(".folder-outer")
 | 
			
		||||
      explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  allFolders.forEach((element) => {
 | 
			
		||||
    const folderUl = Array.from(element.children).find((child) =>
 | 
			
		||||
      child.matches("ul[data-folderul]"),
 | 
			
		||||
    )
 | 
			
		||||
    if (folderUl) {
 | 
			
		||||
      if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
 | 
			
		||||
        if (!element.classList.contains("open")) {
 | 
			
		||||
          element.classList.add("open")
 | 
			
		||||
    // 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)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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())
 | 
			
		||||
})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener("resize", setupExplorer)
 | 
			
		||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
 | 
			
		||||
  const currentSlug = e.detail.url
 | 
			
		||||
  await setupExplorer(currentSlug)
 | 
			
		||||
 | 
			
		||||
document.addEventListener("nav", () => {
 | 
			
		||||
  const explorer = document.querySelector("#mobile-explorer")
 | 
			
		||||
  if (explorer) {
 | 
			
		||||
  // 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")
 | 
			
		||||
    const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
 | 
			
		||||
    if (content) {
 | 
			
		||||
      content.classList.add("collapsed")
 | 
			
		||||
      content.classList.toggle("explorer-viewmode")
 | 
			
		||||
      explorer.setAttribute("aria-expanded", "false")
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  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()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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
 | 
			
		||||
 */
 | 
			
		||||
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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -75,6 +75,10 @@ async function navigate(url: URL, isBack: boolean = false) {
 | 
			
		||||
 | 
			
		||||
  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 @@ async function navigate(url: URL, isBack: boolean = false) {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 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])")
 | 
			
		||||
 
 | 
			
		||||
@@ -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 toggleToc(this: HTMLElement) {
 | 
			
		||||
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)
 | 
			
		||||
 
 | 
			
		||||
@@ -37,6 +37,7 @@ export async function fetchCanonical(url: URL): Promise<Response> {
 | 
			
		||||
  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()
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@
 | 
			
		||||
  height: 20px;
 | 
			
		||||
  margin: 0 10px;
 | 
			
		||||
  text-align: inherit;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
 | 
			
		||||
  & svg {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
  svg {
 | 
			
		||||
    pointer-events: all;
 | 
			
		||||
    transition: transform 0.35s ease;
 | 
			
		||||
 | 
			
		||||
    & > polyline {
 | 
			
		||||
      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));
 | 
			
		||||
  }*/
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
button#mobile-explorer,
 | 
			
		||||
@@ -94,15 +102,28 @@ button#desktop-explorer {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  & .fold {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
    transition: transform 0.3s ease;
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  &.collapsed .fold {
 | 
			
		||||
    transform: rotateZ(-90deg);
 | 
			
		||||
#explorer-content {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  margin-top: 0.5rem;
 | 
			
		||||
 | 
			
		||||
  & ul {
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
 | 
			
		||||
    & li > a {
 | 
			
		||||
      color: var(--dark);
 | 
			
		||||
      opacity: 0.75;
 | 
			
		||||
      pointer-events: all;
 | 
			
		||||
 | 
			
		||||
      &.active {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        color: var(--tertiary);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -118,53 +139,9 @@ button#desktop-explorer {
 | 
			
		||||
 | 
			
		||||
  .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;
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  > #explorer-ul {
 | 
			
		||||
    max-height: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
svg {
 | 
			
		||||
  pointer-events: all;
 | 
			
		||||
 | 
			
		||||
  & > polyline {
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    margin-left: 6px;
 | 
			
		||||
    padding-left: 0.8rem;
 | 
			
		||||
    border-left: 1px solid var(--lightgray);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -227,69 +204,54 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
 | 
			
		||||
  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;
 | 
			
		||||
    &.collapsed {
 | 
			
		||||
      flex: 0 0 34px;
 | 
			
		||||
 | 
			
		||||
      & > #explorer-content {
 | 
			
		||||
        transform: translateX(-100vw);
 | 
			
		||||
        visibility: hidden;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:not(.collapsed) {
 | 
			
		||||
        transform: translateX(100dvw);
 | 
			
		||||
        visibility: visible;
 | 
			
		||||
      }
 | 
			
		||||
      flex: 0 0 34px;
 | 
			
		||||
 | 
			
		||||
      ul.overflow {
 | 
			
		||||
        max-height: 100%;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &.collapsed {
 | 
			
		||||
      & > #explorer-content {
 | 
			
		||||
        transform: translateX(0);
 | 
			
		||||
        visibility: visible;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #mobile-explorer {
 | 
			
		||||
      margin: 5px;
 | 
			
		||||
      z-index: 101;
 | 
			
		||||
 | 
			
		||||
      &:not(.collapsed) .lucide-menu {
 | 
			
		||||
        transform: rotate(-90deg);
 | 
			
		||||
        transition: transform 200ms ease-in-out;
 | 
			
		||||
    #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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #mobile-explorer {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      padding: 5px;
 | 
			
		||||
      z-index: 101;
 | 
			
		||||
 | 
			
		||||
      .lucide-menu {
 | 
			
		||||
        stroke: var(--darkgray);
 | 
			
		||||
        transition: transform 200ms ease;
 | 
			
		||||
 | 
			
		||||
        &:hover {
 | 
			
		||||
          stroke: var(--dark);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -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 @@ button#toc {
 | 
			
		||||
 | 
			
		||||
#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 @@ button#toc {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  > ul.overflow {
 | 
			
		||||
    max-height: none;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @for $i from 0 through 6 {
 | 
			
		||||
    & .depth-#{$i} {
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,7 @@ import DepGraph from "../../depgraph"
 | 
			
		||||
 | 
			
		||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
 | 
			
		||||
export type ContentDetails = {
 | 
			
		||||
  slug: FullSlug
 | 
			
		||||
  title: string
 | 
			
		||||
  links: SimpleSlug[]
 | 
			
		||||
  tags: string[]
 | 
			
		||||
@@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
 | 
			
		||||
        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 ?? [],
 | 
			
		||||
 
 | 
			
		||||
@@ -543,7 +543,6 @@ video {
 | 
			
		||||
 | 
			
		||||
div:has(> .overflow) {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  max-height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -551,6 +550,7 @@ ul.overflow,
 | 
			
		||||
ol.overflow {
 | 
			
		||||
  max-height: 100%;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
 | 
			
		||||
  // clearfix
 | 
			
		||||
  content: "";
 | 
			
		||||
@@ -559,18 +559,15 @@ ol.overflow {
 | 
			
		||||
  & > 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 {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								quartz/util/clone.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								quartz/util/clone.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import rfdc from "rfdc"
 | 
			
		||||
 | 
			
		||||
export const clone = rfdc()
 | 
			
		||||
							
								
								
									
										190
									
								
								quartz/util/fileTrie.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								quartz/util/fileTrie.test.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										128
									
								
								quartz/util/fileTrie.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								quartz/util/fileTrie.ts
									
									
									
									
									
										Normal 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)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user