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.
 | 
					It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
 | 
				
			||||||
This will get called on page navigation.
 | 
					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
 | 
					// dom custom event
 | 
				
			||||||
interface CustomEventMap {
 | 
					interface CustomEventMap {
 | 
				
			||||||
 | 
					  prenav: CustomEvent<{}>
 | 
				
			||||||
  nav: CustomEvent<{ url: FullSlug }>
 | 
					  nav: CustomEvent<{ url: FullSlug }>
 | 
				
			||||||
  themechange: CustomEvent<{ theme: "light" | "dark" }>
 | 
					  themechange: CustomEvent<{ theme: "light" | "dark" }>
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@
 | 
				
			|||||||
    "docs": "npx quartz build --serve -d docs",
 | 
					    "docs": "npx quartz build --serve -d docs",
 | 
				
			||||||
    "check": "tsc --noEmit && npx prettier . --check",
 | 
					    "check": "tsc --noEmit && npx prettier . --check",
 | 
				
			||||||
    "format": "npx prettier . --write",
 | 
					    "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"
 | 
					    "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins"
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
const config: QuartzConfig = {
 | 
					const config: QuartzConfig = {
 | 
				
			||||||
  configuration: {
 | 
					  configuration: {
 | 
				
			||||||
    pageTitle: "🪴 Quartz 4",
 | 
					    pageTitle: "Quartz 4",
 | 
				
			||||||
    pageTitleSuffix: "",
 | 
					    pageTitleSuffix: "",
 | 
				
			||||||
    enableSPA: true,
 | 
					    enableSPA: true,
 | 
				
			||||||
    enablePopovers: true,
 | 
					    enablePopovers: true,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss"
 | 
				
			|||||||
import { resolveRelative, simplifySlug } from "../util/path"
 | 
					import { resolveRelative, simplifySlug } from "../util/path"
 | 
				
			||||||
import { i18n } from "../i18n"
 | 
					import { i18n } from "../i18n"
 | 
				
			||||||
import { classNames } from "../util/lang"
 | 
					import { classNames } from "../util/lang"
 | 
				
			||||||
 | 
					import OverflowList from "./OverflowList"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface BacklinksOptions {
 | 
					interface BacklinksOptions {
 | 
				
			||||||
  hideWhenEmpty: boolean
 | 
					  hideWhenEmpty: boolean
 | 
				
			||||||
@@ -29,7 +30,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
 | 
				
			|||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div class={classNames(displayClass, "backlinks")}>
 | 
					      <div class={classNames(displayClass, "backlinks")}>
 | 
				
			||||||
        <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
 | 
					        <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
 | 
				
			||||||
        <ul class="overflow">
 | 
					        <OverflowList id="backlinks-ul">
 | 
				
			||||||
          {backlinkFiles.length > 0 ? (
 | 
					          {backlinkFiles.length > 0 ? (
 | 
				
			||||||
            backlinkFiles.map((f) => (
 | 
					            backlinkFiles.map((f) => (
 | 
				
			||||||
              <li>
 | 
					              <li>
 | 
				
			||||||
@@ -41,12 +42,13 @@ export default ((opts?: Partial<BacklinksOptions>) => {
 | 
				
			|||||||
          ) : (
 | 
					          ) : (
 | 
				
			||||||
            <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
 | 
					            <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
 | 
				
			||||||
          )}
 | 
					          )}
 | 
				
			||||||
        </ul>
 | 
					        </OverflowList>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Backlinks.css = style
 | 
					  Backlinks.css = style
 | 
				
			||||||
 | 
					  Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return Backlinks
 | 
					  return Backlinks
 | 
				
			||||||
}) satisfies QuartzComponentConstructor
 | 
					}) satisfies QuartzComponentConstructor
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,22 +3,34 @@ import style from "./styles/explorer.scss"
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// @ts-ignore
 | 
					// @ts-ignore
 | 
				
			||||||
import script from "./scripts/explorer.inline"
 | 
					import script from "./scripts/explorer.inline"
 | 
				
			||||||
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
 | 
					 | 
				
			||||||
import { QuartzPluginData } from "../plugins/vfile"
 | 
					 | 
				
			||||||
import { classNames } from "../util/lang"
 | 
					import { classNames } from "../util/lang"
 | 
				
			||||||
import { i18n } from "../i18n"
 | 
					import { i18n } from "../i18n"
 | 
				
			||||||
 | 
					import { FileTrieNode } from "../util/fileTrie"
 | 
				
			||||||
 | 
					import OverflowList from "./OverflowList"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Options interface defined in `ExplorerNode` to avoid circular dependency
 | 
					type OrderEntries = "sort" | "filter" | "map"
 | 
				
			||||||
const defaultOptions = {
 | 
					
 | 
				
			||||||
  folderClickBehavior: "collapse",
 | 
					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",
 | 
					  folderDefaultState: "collapsed",
 | 
				
			||||||
 | 
					  folderClickBehavior: "collapse",
 | 
				
			||||||
  useSavedState: true,
 | 
					  useSavedState: true,
 | 
				
			||||||
  mapFn: (node) => {
 | 
					  mapFn: (node) => {
 | 
				
			||||||
    return node
 | 
					    return node
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  sortFn: (a, b) => {
 | 
					  sortFn: (a, b) => {
 | 
				
			||||||
    // Sort order: folders first, then files. Sort folders and files alphabetically
 | 
					    // Sort order: folders first, then files. Sort folders and files alphabeticall
 | 
				
			||||||
    if ((!a.file && !b.file) || (a.file && b.file)) {
 | 
					    if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
 | 
				
			||||||
      // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
 | 
					      // 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
 | 
					      // 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, {
 | 
					      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
 | 
					      return 1
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      return -1
 | 
					      return -1
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  filterFn: (node) => node.name !== "tags",
 | 
					  filterFn: (node) => node.slugSegment !== "tags",
 | 
				
			||||||
  order: ["filter", "map", "sort"],
 | 
					  order: ["filter", "map", "sort"],
 | 
				
			||||||
} satisfies Options
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type FolderState = {
 | 
				
			||||||
 | 
					  path: string
 | 
				
			||||||
 | 
					  collapsed: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default ((userOpts?: Partial<Options>) => {
 | 
					export default ((userOpts?: Partial<Options>) => {
 | 
				
			||||||
  // Parse config
 | 
					 | 
				
			||||||
  const opts: Options = { ...defaultOptions, ...userOpts }
 | 
					  const opts: Options = { ...defaultOptions, ...userOpts }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // memoized
 | 
					  const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
 | 
				
			||||||
  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)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div class={classNames(displayClass, "explorer")}>
 | 
					      <div
 | 
				
			||||||
        <button
 | 
					        class={classNames(displayClass, "explorer")}
 | 
				
			||||||
          type="button"
 | 
					 | 
				
			||||||
          id="mobile-explorer"
 | 
					 | 
				
			||||||
          class="collapsed hide-until-loaded"
 | 
					 | 
				
			||||||
        data-behavior={opts.folderClickBehavior}
 | 
					        data-behavior={opts.folderClickBehavior}
 | 
				
			||||||
        data-collapsed={opts.folderDefaultState}
 | 
					        data-collapsed={opts.folderDefaultState}
 | 
				
			||||||
        data-savestate={opts.useSavedState}
 | 
					        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}
 | 
					          data-mobile={true}
 | 
				
			||||||
          aria-controls="explorer-content"
 | 
					          aria-controls="explorer-content"
 | 
				
			||||||
          aria-expanded={false}
 | 
					 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <svg
 | 
					          <svg
 | 
				
			||||||
            xmlns="http://www.w3.org/2000/svg"
 | 
					            xmlns="http://www.w3.org/2000/svg"
 | 
				
			||||||
@@ -105,7 +86,7 @@ export default ((userOpts?: Partial<Options>) => {
 | 
				
			|||||||
            stroke-width="2"
 | 
					            stroke-width="2"
 | 
				
			||||||
            stroke-linecap="round"
 | 
					            stroke-linecap="round"
 | 
				
			||||||
            stroke-linejoin="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="12" y2="12" />
 | 
				
			||||||
            <line x1="4" x2="20" y1="6" y2="6" />
 | 
					            <line x1="4" x2="20" y1="6" y2="6" />
 | 
				
			||||||
@@ -115,13 +96,8 @@ export default ((userOpts?: Partial<Options>) => {
 | 
				
			|||||||
        <button
 | 
					        <button
 | 
				
			||||||
          type="button"
 | 
					          type="button"
 | 
				
			||||||
          id="desktop-explorer"
 | 
					          id="desktop-explorer"
 | 
				
			||||||
          class="title-button"
 | 
					          class="title-button explorer-toggle"
 | 
				
			||||||
          data-behavior={opts.folderClickBehavior}
 | 
					 | 
				
			||||||
          data-collapsed={opts.folderDefaultState}
 | 
					 | 
				
			||||||
          data-savestate={opts.useSavedState}
 | 
					 | 
				
			||||||
          data-tree={jsonTree}
 | 
					 | 
				
			||||||
          data-mobile={false}
 | 
					          data-mobile={false}
 | 
				
			||||||
          aria-controls="explorer-content"
 | 
					 | 
				
			||||||
          aria-expanded={true}
 | 
					          aria-expanded={true}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
 | 
					          <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>
 | 
					            <polyline points="6 9 12 15 18 9"></polyline>
 | 
				
			||||||
          </svg>
 | 
					          </svg>
 | 
				
			||||||
        </button>
 | 
					        </button>
 | 
				
			||||||
        <div id="explorer-content">
 | 
					        <div id="explorer-content" aria-expanded={false}>
 | 
				
			||||||
          <ul class="overflow" id="explorer-ul">
 | 
					          <OverflowList id="explorer-ul" />
 | 
				
			||||||
            <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
 | 
					 | 
				
			||||||
            <li id="explorer-end" />
 | 
					 | 
				
			||||||
          </ul>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </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>
 | 
					      </div>
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Explorer.css = style
 | 
					  Explorer.css = style
 | 
				
			||||||
  Explorer.afterDOMLoaded = script
 | 
					  Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
 | 
				
			||||||
  return Explorer
 | 
					  return Explorer
 | 
				
			||||||
}) satisfies QuartzComponentConstructor
 | 
					}) 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
 | 
					// @ts-ignore
 | 
				
			||||||
import script from "./scripts/toc.inline"
 | 
					import script from "./scripts/toc.inline"
 | 
				
			||||||
import { i18n } from "../i18n"
 | 
					import { i18n } from "../i18n"
 | 
				
			||||||
 | 
					import OverflowList from "./OverflowList"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Options {
 | 
					interface Options {
 | 
				
			||||||
  layout: "modern" | "legacy"
 | 
					  layout: "modern" | "legacy"
 | 
				
			||||||
@@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
 | 
				
			|||||||
        </svg>
 | 
					        </svg>
 | 
				
			||||||
      </button>
 | 
					      </button>
 | 
				
			||||||
      <div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
 | 
					      <div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
 | 
				
			||||||
        <ul class="overflow">
 | 
					        <OverflowList id="toc-ul">
 | 
				
			||||||
          {fileData.toc.map((tocEntry) => (
 | 
					          {fileData.toc.map((tocEntry) => (
 | 
				
			||||||
            <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
 | 
					            <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
 | 
				
			||||||
              <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
 | 
					              <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
 | 
				
			||||||
@@ -58,13 +59,13 @@ const TableOfContents: QuartzComponent = ({
 | 
				
			|||||||
              </a>
 | 
					              </a>
 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
          ))}
 | 
					          ))}
 | 
				
			||||||
        </ul>
 | 
					        </OverflowList>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
TableOfContents.css = modernStyle
 | 
					TableOfContents.css = modernStyle
 | 
				
			||||||
TableOfContents.afterDOMLoaded = script
 | 
					TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
 | 
					const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
 | 
				
			||||||
  if (!fileData.toc) {
 | 
					  if (!fileData.toc) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
 | 
				
			|||||||
import HeaderConstructor from "./Header"
 | 
					import HeaderConstructor from "./Header"
 | 
				
			||||||
import BodyConstructor from "./Body"
 | 
					import BodyConstructor from "./Body"
 | 
				
			||||||
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
 | 
					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 { visit } from "unist-util-visit"
 | 
				
			||||||
import { Root, Element, ElementContent } from "hast"
 | 
					import { Root, Element, ElementContent } from "hast"
 | 
				
			||||||
import { GlobalConfiguration } from "../cfg"
 | 
					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
 | 
					type MaybeHTMLElement = HTMLElement | undefined
 | 
				
			||||||
let currentExplorerState: FolderState[]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const observer = new IntersectionObserver((entries) => {
 | 
					interface ParsedOptions {
 | 
				
			||||||
  // If last element is observed, remove gradient of "overflow" class so element is visible
 | 
					  folderClickBehavior: "collapse" | "link"
 | 
				
			||||||
  const explorerUl = document.getElementById("explorer-ul")
 | 
					  folderDefaultState: "collapsed" | "open"
 | 
				
			||||||
  if (!explorerUl) return
 | 
					  useSavedState: boolean
 | 
				
			||||||
  for (const entry of entries) {
 | 
					  sortFn: (a: FileTrieNode, b: FileTrieNode) => number
 | 
				
			||||||
    if (entry.isIntersecting) {
 | 
					  filterFn: (node: FileTrieNode) => boolean
 | 
				
			||||||
      explorerUl.classList.add("no-background")
 | 
					  mapFn: (node: FileTrieNode) => void
 | 
				
			||||||
    } else {
 | 
					  order: "sort" | "filter" | "map"[]
 | 
				
			||||||
      explorerUl.classList.remove("no-background")
 | 
					}
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FolderState = {
 | 
				
			||||||
 | 
					  path: string
 | 
				
			||||||
 | 
					  collapsed: boolean
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let currentExplorerState: Array<FolderState>
 | 
				
			||||||
function toggleExplorer(this: HTMLElement) {
 | 
					function toggleExplorer(this: HTMLElement) {
 | 
				
			||||||
  // Toggle collapsed state of entire explorer
 | 
					  const explorers = document.querySelectorAll(".explorer")
 | 
				
			||||||
  this.classList.toggle("collapsed")
 | 
					  for (const explorer of explorers) {
 | 
				
			||||||
 | 
					    explorer.classList.toggle("collapsed")
 | 
				
			||||||
  // Toggle collapsed aria state of entire explorer
 | 
					    explorer.setAttribute(
 | 
				
			||||||
  this.setAttribute(
 | 
					 | 
				
			||||||
      "aria-expanded",
 | 
					      "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) {
 | 
					function toggleFolder(evt: MouseEvent) {
 | 
				
			||||||
  evt.stopPropagation()
 | 
					  evt.stopPropagation()
 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Element that was clicked
 | 
					 | 
				
			||||||
  const target = evt.target as MaybeHTMLElement
 | 
					  const target = evt.target as MaybeHTMLElement
 | 
				
			||||||
  if (!target) return
 | 
					  if (!target) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,162 +40,240 @@ function toggleFolder(evt: MouseEvent) {
 | 
				
			|||||||
  const isSvg = target.nodeName === "svg"
 | 
					  const isSvg = target.nodeName === "svg"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // corresponding <ul> element relative to clicked button/folder
 | 
					  // corresponding <ul> element relative to clicked button/folder
 | 
				
			||||||
  const childFolderContainer = (
 | 
					  const folderContainer = (
 | 
				
			||||||
    isSvg
 | 
					    isSvg
 | 
				
			||||||
      ? target.parentElement?.nextSibling
 | 
					      ? // svg -> div.folder-container
 | 
				
			||||||
      : target.parentElement?.parentElement?.nextElementSibling
 | 
					        target.parentElement
 | 
				
			||||||
 | 
					      : // button.folder-button -> div -> div.folder-container
 | 
				
			||||||
 | 
					        target.parentElement?.parentElement
 | 
				
			||||||
  ) as MaybeHTMLElement
 | 
					  ) as MaybeHTMLElement
 | 
				
			||||||
  const currentFolderParent = (
 | 
					  if (!folderContainer) return
 | 
				
			||||||
    isSvg ? target.nextElementSibling : target.parentElement
 | 
					  const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
 | 
				
			||||||
  ) as MaybeHTMLElement
 | 
					  if (!childFolderContainer) return
 | 
				
			||||||
  if (!(childFolderContainer && currentFolderParent)) return
 | 
					
 | 
				
			||||||
  // <li> element of folder (stores folder-path dataset)
 | 
					 | 
				
			||||||
  childFolderContainer.classList.toggle("open")
 | 
					  childFolderContainer.classList.toggle("open")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Collapse folder container
 | 
					  // Collapse folder container
 | 
				
			||||||
  const isCollapsed = childFolderContainer.classList.contains("open")
 | 
					  const isCollapsed = !childFolderContainer.classList.contains("open")
 | 
				
			||||||
  setFolderState(childFolderContainer, !isCollapsed)
 | 
					  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)
 | 
					  const stringifiedFileTree = JSON.stringify(currentExplorerState)
 | 
				
			||||||
  localStorage.setItem("fileTree", stringifiedFileTree)
 | 
					  localStorage.setItem("fileTree", stringifiedFileTree)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function setupExplorer() {
 | 
					function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
 | 
				
			||||||
  // Set click handler for collapsing entire explorer
 | 
					  const template = document.getElementById("template-file") as HTMLTemplateElement
 | 
				
			||||||
  const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
 | 
					  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) {
 | 
					  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
 | 
					    // Get folder state from local storage
 | 
				
			||||||
    const storageTree = localStorage.getItem("fileTree")
 | 
					    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 data = await fetchData
 | 
				
			||||||
    const useSavedFolderState = explorer?.dataset.savestate === "true"
 | 
					    const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
 | 
				
			||||||
 | 
					    const trie = FileTrieNode.fromEntries(entries)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (explorer) {
 | 
					    // Apply functions in order
 | 
				
			||||||
      // Get config
 | 
					    for (const fn of opts.order) {
 | 
				
			||||||
      const collapseBehavior = explorer.dataset.behavior
 | 
					      switch (fn) {
 | 
				
			||||||
 | 
					        case "filter":
 | 
				
			||||||
      // Add click handlers for all folders (click handler on folder "label")
 | 
					          if (opts.filterFn) trie.filter(opts.filterFn)
 | 
				
			||||||
      if (collapseBehavior === "collapse") {
 | 
					          break
 | 
				
			||||||
        for (const item of document.getElementsByClassName(
 | 
					        case "map":
 | 
				
			||||||
          "folder-button",
 | 
					          if (opts.mapFn) trie.map(opts.mapFn)
 | 
				
			||||||
        ) as HTMLCollectionOf<HTMLElement>) {
 | 
					          break
 | 
				
			||||||
          window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
 | 
					        case "sort":
 | 
				
			||||||
          item.addEventListener("click", toggleFolder)
 | 
					          if (opts.sortFn) trie.sort(opts.sortFn)
 | 
				
			||||||
 | 
					          break
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Add click handler to main explorer
 | 
					    // Get folder paths for state management
 | 
				
			||||||
      window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
 | 
					    const folderPaths = trie.getFolderPaths()
 | 
				
			||||||
      explorer.addEventListener("click", toggleExplorer)
 | 
					    currentExplorerState = folderPaths.map((path) => ({
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // 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,
 | 
					      path,
 | 
				
			||||||
        collapsed: oldIndex.get(path) ?? collapsed,
 | 
					      collapsed: oldIndex.get(path) === true,
 | 
				
			||||||
      })
 | 
					    }))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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" })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    currentExplorerState.map((folderState) => {
 | 
					    // Set up event handlers
 | 
				
			||||||
      const folderLi = document.querySelector(
 | 
					    const explorerButtons = explorer.querySelectorAll(
 | 
				
			||||||
        `[data-folderpath='${folderState.path.replace("'", "-")}']`,
 | 
					      "button.explorer-toggle",
 | 
				
			||||||
      ) as MaybeHTMLElement
 | 
					    ) as NodeListOf<HTMLElement>
 | 
				
			||||||
      const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
 | 
					    if (explorerButtons) {
 | 
				
			||||||
      if (folderUl) {
 | 
					      window.addCleanup(() =>
 | 
				
			||||||
        setFolderState(folderUl, folderState.collapsed)
 | 
					        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)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function toggleExplorerFolders() {
 | 
					document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
 | 
				
			||||||
  const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
 | 
					  // save explorer scrollTop position
 | 
				
			||||||
    /\/index$/g,
 | 
					  const explorer = document.getElementById("explorer-ul")
 | 
				
			||||||
    "",
 | 
					  if (!explorer) return
 | 
				
			||||||
  )
 | 
					  sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
 | 
				
			||||||
  const allFolders = document.querySelectorAll(".folder-outer")
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  allFolders.forEach((element) => {
 | 
					document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
 | 
				
			||||||
    const folderUl = Array.from(element.children).find((child) =>
 | 
					  const currentSlug = e.detail.url
 | 
				
			||||||
      child.matches("ul[data-folderul]"),
 | 
					  await setupExplorer(currentSlug)
 | 
				
			||||||
    )
 | 
					
 | 
				
			||||||
    if (folderUl) {
 | 
					  // if mobile hamburger is visible, collapse by default
 | 
				
			||||||
      if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
 | 
					  const mobileExplorer = document.getElementById("mobile-explorer")
 | 
				
			||||||
        if (!element.classList.contains("open")) {
 | 
					  if (mobileExplorer && mobileExplorer.checkVisibility()) {
 | 
				
			||||||
          element.classList.add("open")
 | 
					    for (const explorer of document.querySelectorAll(".explorer")) {
 | 
				
			||||||
        }
 | 
					      explorer.classList.add("collapsed")
 | 
				
			||||||
      }
 | 
					      explorer.setAttribute("aria-expanded", "false")
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  }
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
 | 
					  const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
 | 
				
			||||||
window.addEventListener("resize", setupExplorer)
 | 
					  hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
 | 
				
			||||||
 | 
					 | 
				
			||||||
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")
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  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) {
 | 
					function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
 | 
				
			||||||
  return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
 | 
					  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
 | 
					  if (!contents) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // notify about to nav
 | 
				
			||||||
 | 
					  const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
 | 
				
			||||||
 | 
					  document.dispatchEvent(event)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // cleanup old
 | 
					  // cleanup old
 | 
				
			||||||
  cleanupFns.forEach((fn) => fn())
 | 
					  cleanupFns.forEach((fn) => fn())
 | 
				
			||||||
  cleanupFns.clear()
 | 
					  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])")
 | 
					  const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
 | 
				
			||||||
  elementsToRemove.forEach((el) => el.remove())
 | 
					  elementsToRemove.forEach((el) => el.remove())
 | 
				
			||||||
  const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
 | 
					  const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,3 @@
 | 
				
			|||||||
const bufferPx = 150
 | 
					 | 
				
			||||||
const observer = new IntersectionObserver((entries) => {
 | 
					const observer = new IntersectionObserver((entries) => {
 | 
				
			||||||
  for (const entry of entries) {
 | 
					  for (const entry of entries) {
 | 
				
			||||||
    const slug = entry.target.id
 | 
					    const slug = entry.target.id
 | 
				
			||||||
@@ -28,7 +27,6 @@ function toggleToc(this: HTMLElement) {
 | 
				
			|||||||
function setupToc() {
 | 
					function setupToc() {
 | 
				
			||||||
  const toc = document.getElementById("toc")
 | 
					  const toc = document.getElementById("toc")
 | 
				
			||||||
  if (toc) {
 | 
					  if (toc) {
 | 
				
			||||||
    const collapsed = toc.classList.contains("collapsed")
 | 
					 | 
				
			||||||
    const content = toc.nextElementSibling as HTMLElement | undefined
 | 
					    const content = toc.nextElementSibling as HTMLElement | undefined
 | 
				
			||||||
    if (!content) return
 | 
					    if (!content) return
 | 
				
			||||||
    toc.addEventListener("click", toggleToc)
 | 
					    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")) {
 | 
					  if (!res.headers.get("content-type")?.startsWith("text/html")) {
 | 
				
			||||||
    return res
 | 
					    return res
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // reading the body can only be done once, so we need to clone the response
 | 
					  // 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
 | 
					  // to allow the caller to read it if it's was not a redirect
 | 
				
			||||||
  const text = await res.clone().text()
 | 
					  const text = await res.clone().text()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,18 +2,6 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
.backlinks {
 | 
					.backlinks {
 | 
				
			||||||
  flex-direction: column;
 | 
					  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 {
 | 
					  & > h3 {
 | 
				
			||||||
    font-size: 1rem;
 | 
					    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;
 | 
					  height: 20px;
 | 
				
			||||||
  margin: 0 10px;
 | 
					  margin: 0 10px;
 | 
				
			||||||
  text-align: inherit;
 | 
					  text-align: inherit;
 | 
				
			||||||
 | 
					  flex-shrink: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & svg {
 | 
					  & svg {
 | 
				
			||||||
    position: absolute;
 | 
					    position: absolute;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,10 +16,10 @@
 | 
				
			|||||||
      box-sizing: border-box;
 | 
					      box-sizing: border-box;
 | 
				
			||||||
      position: sticky;
 | 
					      position: sticky;
 | 
				
			||||||
      background-color: var(--light);
 | 
					      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 {
 | 
					    .hide-until-loaded ~ #explorer-content {
 | 
				
			||||||
      display: none;
 | 
					      display: none;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -28,9 +28,21 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
.explorer {
 | 
					.explorer {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  height: 100%;
 | 
					 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
  overflow-y: hidden;
 | 
					  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) {
 | 
					  @media all and ($mobile) {
 | 
				
			||||||
    order: -1;
 | 
					    order: -1;
 | 
				
			||||||
@@ -64,18 +76,14 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /*&:after {
 | 
					  svg {
 | 
				
			||||||
 | 
					    pointer-events: all;
 | 
				
			||||||
 | 
					    transition: transform 0.35s ease;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    & > polyline {
 | 
				
			||||||
      pointer-events: none;
 | 
					      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,
 | 
					button#mobile-explorer,
 | 
				
			||||||
@@ -94,77 +102,46 @@ button#desktop-explorer {
 | 
				
			|||||||
    display: inline-block;
 | 
					    display: inline-block;
 | 
				
			||||||
    margin: 0;
 | 
					    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 {
 | 
					#explorer-content {
 | 
				
			||||||
  list-style: none;
 | 
					  list-style: none;
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
  max-height: 0px;
 | 
					 | 
				
			||||||
  transition:
 | 
					 | 
				
			||||||
    max-height 0.35s ease,
 | 
					 | 
				
			||||||
    visibility 0s linear 0.35s;
 | 
					 | 
				
			||||||
  margin-top: 0.5rem;
 | 
					  margin-top: 0.5rem;
 | 
				
			||||||
  visibility: hidden;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  &.collapsed {
 | 
					 | 
				
			||||||
    max-height: 100%;
 | 
					 | 
				
			||||||
    transition:
 | 
					 | 
				
			||||||
      max-height 0.35s ease,
 | 
					 | 
				
			||||||
      visibility 0s linear 0s;
 | 
					 | 
				
			||||||
    visibility: visible;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & ul {
 | 
					  & ul {
 | 
				
			||||||
    list-style: none;
 | 
					    list-style: none;
 | 
				
			||||||
    margin: 0.08rem 0;
 | 
					    margin: 0;
 | 
				
			||||||
    padding: 0;
 | 
					    padding: 0;
 | 
				
			||||||
    transition:
 | 
					 | 
				
			||||||
      max-height 0.35s ease,
 | 
					 | 
				
			||||||
      transform 0.35s ease,
 | 
					 | 
				
			||||||
      opacity 0.2s ease;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    & li > a {
 | 
					    & li > a {
 | 
				
			||||||
      color: var(--dark);
 | 
					      color: var(--dark);
 | 
				
			||||||
      opacity: 0.75;
 | 
					      opacity: 0.75;
 | 
				
			||||||
      pointer-events: all;
 | 
					      pointer-events: all;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &.active {
 | 
				
			||||||
 | 
					        opacity: 1;
 | 
				
			||||||
 | 
					        color: var(--tertiary);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  > #explorer-ul {
 | 
					  .folder-outer {
 | 
				
			||||||
    max-height: none;
 | 
					    display: grid;
 | 
				
			||||||
 | 
					    grid-template-rows: 0fr;
 | 
				
			||||||
 | 
					    transition: grid-template-rows 0.3s ease-in-out;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
svg {
 | 
					  .folder-outer.open {
 | 
				
			||||||
  pointer-events: all;
 | 
					    grid-template-rows: 1fr;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  & > polyline {
 | 
					  .folder-outer > ul {
 | 
				
			||||||
    pointer-events: none;
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    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);
 | 
					  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 {
 | 
					.explorer {
 | 
				
			||||||
  @media all and ($mobile) {
 | 
					  @media all and ($mobile) {
 | 
				
			||||||
    #explorer-content {
 | 
					    &.collapsed {
 | 
				
			||||||
      box-sizing: border-box;
 | 
					      flex: 0 0 34px;
 | 
				
			||||||
      overscroll-behavior: none;
 | 
					
 | 
				
			||||||
      z-index: 100;
 | 
					      & > #explorer-content {
 | 
				
			||||||
      position: absolute;
 | 
					        transform: translateX(-100vw);
 | 
				
			||||||
      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;
 | 
					        visibility: hidden;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    &:not(.collapsed) {
 | 
					    &:not(.collapsed) {
 | 
				
			||||||
        transform: translateX(100dvw);
 | 
					      flex: 0 0 34px;
 | 
				
			||||||
        visibility: visible;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      ul.overflow {
 | 
					      & > #explorer-content {
 | 
				
			||||||
        max-height: 100%;
 | 
					 | 
				
			||||||
        width: 100%;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      &.collapsed {
 | 
					 | 
				
			||||||
        transform: translateX(0);
 | 
					        transform: translateX(0);
 | 
				
			||||||
        visibility: visible;
 | 
					        visibility: visible;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    #mobile-explorer {
 | 
					    #explorer-content {
 | 
				
			||||||
      margin: 5px;
 | 
					      box-sizing: border-box;
 | 
				
			||||||
      z-index: 101;
 | 
					      z-index: 100;
 | 
				
			||||||
 | 
					      position: absolute;
 | 
				
			||||||
      &:not(.collapsed) .lucide-menu {
 | 
					      top: 0;
 | 
				
			||||||
        transform: rotate(-90deg);
 | 
					      left: 0;
 | 
				
			||||||
        transition: transform 200ms ease-in-out;
 | 
					      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 {
 | 
					      .lucide-menu {
 | 
				
			||||||
        stroke: var(--darkgray);
 | 
					        stroke: var(--darkgray);
 | 
				
			||||||
        transition: transform 200ms ease;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        &:hover {
 | 
					 | 
				
			||||||
          stroke: var(--dark);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,8 +4,10 @@
 | 
				
			|||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &.desktop-only {
 | 
					  overflow-y: hidden;
 | 
				
			||||||
    max-height: 40%;
 | 
					  flex: 0 1 auto;
 | 
				
			||||||
 | 
					  &:has(button#toc.collapsed) {
 | 
				
			||||||
 | 
					    flex: 0 1 1.2rem;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -44,26 +46,7 @@ button#toc {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
#toc-content {
 | 
					#toc-content {
 | 
				
			||||||
  list-style: none;
 | 
					  list-style: none;
 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
  overflow-y: auto;
 | 
					 | 
				
			||||||
  max-height: 100%;
 | 
					 | 
				
			||||||
  transition:
 | 
					 | 
				
			||||||
    max-height 0.35s ease,
 | 
					 | 
				
			||||||
    visibility 0s linear 0s;
 | 
					 | 
				
			||||||
  position: relative;
 | 
					  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 {
 | 
					  & ul {
 | 
				
			||||||
    list-style: none;
 | 
					    list-style: none;
 | 
				
			||||||
@@ -80,10 +63,6 @@ button#toc {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  > ul.overflow {
 | 
					 | 
				
			||||||
    max-height: none;
 | 
					 | 
				
			||||||
    width: 100%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @for $i from 0 through 6 {
 | 
					  @for $i from 0 through 6 {
 | 
				
			||||||
    & .depth-#{$i} {
 | 
					    & .depth-#{$i} {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ import DepGraph from "../../depgraph"
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export type ContentIndexMap = Map<FullSlug, ContentDetails>
 | 
					export type ContentIndexMap = Map<FullSlug, ContentDetails>
 | 
				
			||||||
export type ContentDetails = {
 | 
					export type ContentDetails = {
 | 
				
			||||||
 | 
					  slug: FullSlug
 | 
				
			||||||
  title: string
 | 
					  title: string
 | 
				
			||||||
  links: SimpleSlug[]
 | 
					  links: SimpleSlug[]
 | 
				
			||||||
  tags: string[]
 | 
					  tags: string[]
 | 
				
			||||||
@@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
 | 
				
			|||||||
        const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
 | 
					        const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
 | 
				
			||||||
        if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
 | 
					        if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
 | 
				
			||||||
          linkIndex.set(slug, {
 | 
					          linkIndex.set(slug, {
 | 
				
			||||||
 | 
					            slug,
 | 
				
			||||||
            title: file.data.frontmatter?.title!,
 | 
					            title: file.data.frontmatter?.title!,
 | 
				
			||||||
            links: file.data.links ?? [],
 | 
					            links: file.data.links ?? [],
 | 
				
			||||||
            tags: file.data.frontmatter?.tags ?? [],
 | 
					            tags: file.data.frontmatter?.tags ?? [],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -543,7 +543,6 @@ video {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
div:has(> .overflow) {
 | 
					div:has(> .overflow) {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  overflow-y: auto;
 | 
					 | 
				
			||||||
  max-height: 100%;
 | 
					  max-height: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -551,6 +550,7 @@ ul.overflow,
 | 
				
			|||||||
ol.overflow {
 | 
					ol.overflow {
 | 
				
			||||||
  max-height: 100%;
 | 
					  max-height: 100%;
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // clearfix
 | 
					  // clearfix
 | 
				
			||||||
  content: "";
 | 
					  content: "";
 | 
				
			||||||
@@ -559,18 +559,15 @@ ol.overflow {
 | 
				
			|||||||
  & > li:last-of-type {
 | 
					  & > li:last-of-type {
 | 
				
			||||||
    margin-bottom: 30px;
 | 
					    margin-bottom: 30px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  /*&:after {
 | 
					
 | 
				
			||||||
    pointer-events: none;
 | 
					  & > li.overflow-end {
 | 
				
			||||||
    content: "";
 | 
					    height: 4px;
 | 
				
			||||||
    width: 100%;
 | 
					    margin: 0;
 | 
				
			||||||
    height: 50px;
 | 
					  }
 | 
				
			||||||
    position: absolute;
 | 
					
 | 
				
			||||||
    left: 0;
 | 
					  &.gradient-active {
 | 
				
			||||||
    bottom: 0;
 | 
					    mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
 | 
				
			||||||
    opacity: 1;
 | 
					  }
 | 
				
			||||||
    transition: opacity 0.3s ease;
 | 
					 | 
				
			||||||
    background: linear-gradient(transparent 0px, var(--light));
 | 
					 | 
				
			||||||
  }*/
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.transclude {
 | 
					.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 { slug as slugAnchor } from "github-slugger"
 | 
				
			||||||
import type { Element as HastElement } from "hast"
 | 
					import type { Element as HastElement } from "hast"
 | 
				
			||||||
import rfdc from "rfdc"
 | 
					import { clone } from "./clone"
 | 
				
			||||||
 | 
					 | 
				
			||||||
export const clone = rfdc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// this file must be isomorphic so it can't use node libs (e.g. path)
 | 
					// this file must be isomorphic so it can't use node libs (e.g. path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const QUARTZ = "quartz"
 | 
					export const QUARTZ = "quartz"
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user