From 5480269d38ffaff7ffd6576d9a9407430429fb2d Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Sun, 09 Mar 2025 21:58:26 +0000
Subject: [PATCH] perf(explorer): client side explorer (#1810)
---
quartz/components/Backlinks.tsx | 6
quartz/components/scripts/util.ts | 1
quartz/styles/base.scss | 23
quartz/util/fileTrie.test.ts | 190 +++++++
docs/advanced/creating components.md | 12
index.d.ts | 1
quartz/components/styles/explorer.scss | 186 ++----
quartz/components/scripts/spa.inline.ts | 6
quartz/components/scripts/explorer.inline.ts | 407 +++++++++------
quartz/components/scripts/toc.inline.ts | 2
/dev/null | 242 ---------
quartz/plugins/emitters/contentIndex.tsx | 2
quartz/components/Explorer.tsx | 152 +++--
quartz/components/OverflowList.tsx | 39 +
quartz/components/styles/backlinks.scss | 22
quartz/util/path.ts | 5
package.json | 2
quartz/components/renderPage.tsx | 3
quartz/util/clone.ts | 3
quartz/components/TableOfContents.tsx | 7
quartz/components/styles/toc.scss | 29 -
quartz/util/fileTrie.ts | 128 +++++
quartz.config.ts | 2
quartz/components/styles/darkmode.scss | 1
24 files changed, 797 insertions(+), 674 deletions(-)
diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md
index 628d5aa..369405b 100644
--- a/docs/advanced/creating components.md
+++ b/docs/advanced/creating components.md
@@ -161,6 +161,18 @@
})
```
+You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event.
+
+```ts
+document.addEventListener("prenav", () => {
+ // executed after an SPA navigation is triggered but
+ // before the page is replaced
+ // one usage pattern is to store things in sessionStorage
+ // in the prenav and then conditionally load then in the consequent
+ // nav
+})
+```
+
It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
This will get called on page navigation.
diff --git a/index.d.ts b/index.d.ts
index a6c594f..8e524af 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -5,6 +5,7 @@
// dom custom event
interface CustomEventMap {
+ prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}
diff --git a/package.json b/package.json
index 92872d7..81e5dbf 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"docs": "npx quartz build --serve -d docs",
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
- "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts",
+ "test": "tsx --test",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
},
"engines": {
diff --git a/quartz.config.ts b/quartz.config.ts
index 0cd7e94..51a7551 100644
--- a/quartz.config.ts
+++ b/quartz.config.ts
@@ -8,7 +8,7 @@
*/
const config: QuartzConfig = {
configuration: {
- pageTitle: "🪴 Quartz 4",
+ pageTitle: "Quartz 4",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,
diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx
index e99055e..735afe7 100644
--- a/quartz/components/Backlinks.tsx
+++ b/quartz/components/Backlinks.tsx
@@ -3,6 +3,7 @@
import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
+import OverflowList from "./OverflowList"
interface BacklinksOptions {
hideWhenEmpty: boolean
@@ -29,7 +30,7 @@
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
- <ul class="overflow">
+ <OverflowList id="backlinks-ul">
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
@@ -41,12 +42,13 @@
) : (
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
)}
- </ul>
+ </OverflowList>
</div>
)
}
Backlinks.css = style
+ Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
return Backlinks
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
index ac276a8..9c1fbdc 100644
--- a/quartz/components/Explorer.tsx
+++ b/quartz/components/Explorer.tsx
@@ -3,22 +3,34 @@
// @ts-ignore
import script from "./scripts/explorer.inline"
-import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
-import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
+import { FileTrieNode } from "../util/fileTrie"
+import OverflowList from "./OverflowList"
-// Options interface defined in `ExplorerNode` to avoid circular dependency
-const defaultOptions = {
- folderClickBehavior: "collapse",
+type OrderEntries = "sort" | "filter" | "map"
+
+export interface Options {
+ title?: string
+ folderDefaultState: "collapsed" | "open"
+ folderClickBehavior: "collapse" | "link"
+ useSavedState: boolean
+ sortFn: (a: FileTrieNode, b: FileTrieNode) => number
+ filterFn: (node: FileTrieNode) => boolean
+ mapFn: (node: FileTrieNode) => void
+ order: OrderEntries[]
+}
+
+const defaultOptions: Options = {
folderDefaultState: "collapsed",
+ folderClickBehavior: "collapse",
useSavedState: true,
mapFn: (node) => {
return node
},
sortFn: (a, b) => {
- // Sort order: folders first, then files. Sort folders and files alphabetically
- if ((!a.file && !b.file) || (a.file && b.file)) {
+ // Sort order: folders first, then files. Sort folders and files alphabeticall
+ if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠b, a = á, a = A
return a.displayName.localeCompare(b.displayName, undefined, {
@@ -27,75 +39,44 @@
})
}
- if (a.file && !b.file) {
+ if (!a.isFolder && b.isFolder) {
return 1
} else {
return -1
}
},
- filterFn: (node) => node.name !== "tags",
+ filterFn: (node) => node.slugSegment !== "tags",
order: ["filter", "map", "sort"],
-} satisfies Options
+}
+
+export type FolderState = {
+ path: string
+ collapsed: boolean
+}
export default ((userOpts?: Partial<Options>) => {
- // Parse config
const opts: Options = { ...defaultOptions, ...userOpts }
- // memoized
- let fileTree: FileNode
- let jsonTree: string
- let lastBuildId: string = ""
-
- function constructFileTree(allFiles: QuartzPluginData[]) {
- // Construct tree from allFiles
- fileTree = new FileNode("")
- allFiles.forEach((file) => fileTree.add(file))
-
- // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
- if (opts.order) {
- // Order is important, use loop with index instead of order.map()
- for (let i = 0; i < opts.order.length; i++) {
- const functionName = opts.order[i]
- if (functionName === "map") {
- fileTree.map(opts.mapFn)
- } else if (functionName === "sort") {
- fileTree.sort(opts.sortFn)
- } else if (functionName === "filter") {
- fileTree.filter(opts.filterFn)
- }
- }
- }
-
- // Get all folders of tree. Initialize with collapsed state
- // Stringify to pass json tree as data attribute ([data-tree])
- const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
- jsonTree = JSON.stringify(folders)
- }
-
- const Explorer: QuartzComponent = ({
- ctx,
- cfg,
- allFiles,
- displayClass,
- fileData,
- }: QuartzComponentProps) => {
- if (ctx.buildId !== lastBuildId) {
- lastBuildId = ctx.buildId
- constructFileTree(allFiles)
- }
+ const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
return (
- <div class={classNames(displayClass, "explorer")}>
+ <div
+ class={classNames(displayClass, "explorer")}
+ data-behavior={opts.folderClickBehavior}
+ data-collapsed={opts.folderDefaultState}
+ data-savestate={opts.useSavedState}
+ data-data-fns={JSON.stringify({
+ order: opts.order,
+ sortFn: opts.sortFn.toString(),
+ filterFn: opts.filterFn.toString(),
+ mapFn: opts.mapFn.toString(),
+ })}
+ >
<button
type="button"
id="mobile-explorer"
- class="collapsed hide-until-loaded"
- data-behavior={opts.folderClickBehavior}
- data-collapsed={opts.folderDefaultState}
- data-savestate={opts.useSavedState}
- data-tree={jsonTree}
+ class="explorer-toggle hide-until-loaded"
data-mobile={true}
aria-controls="explorer-content"
- aria-expanded={false}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -105,7 +86,7 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
- class="lucide lucide-menu"
+ class="lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
@@ -115,13 +96,8 @@
<button
type="button"
id="desktop-explorer"
- class="title-button"
- data-behavior={opts.folderClickBehavior}
- data-collapsed={opts.folderDefaultState}
- data-savestate={opts.useSavedState}
- data-tree={jsonTree}
+ class="title-button explorer-toggle"
data-mobile={false}
- aria-controls="explorer-content"
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
@@ -140,17 +116,47 @@
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
- <div id="explorer-content">
- <ul class="overflow" id="explorer-ul">
- <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
- <li id="explorer-end" />
- </ul>
+ <div id="explorer-content" aria-expanded={false}>
+ <OverflowList id="explorer-ul" />
</div>
+ <template id="template-file">
+ <li>
+ <a href="#"></a>
+ </li>
+ </template>
+ <template id="template-folder">
+ <li>
+ <div class="folder-container">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="12"
+ height="12"
+ viewBox="5 8 14 8"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="folder-icon"
+ >
+ <polyline points="6 9 12 15 18 9"></polyline>
+ </svg>
+ <div>
+ <button class="folder-button">
+ <span class="folder-title"></span>
+ </button>
+ </div>
+ </div>
+ <div class="folder-outer">
+ <ul class="content"></ul>
+ </div>
+ </li>
+ </template>
</div>
)
}
Explorer.css = style
- Explorer.afterDOMLoaded = script
+ Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
return Explorer
}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx
deleted file mode 100644
index e57d677..0000000
--- a/quartz/components/ExplorerNode.tsx
+++ /dev/null
@@ -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>
- )}
- </>
- )
-}
diff --git a/quartz/components/OverflowList.tsx b/quartz/components/OverflowList.tsx
new file mode 100644
index 0000000..d74c5c2
--- /dev/null
+++ b/quartz/components/OverflowList.tsx
@@ -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
diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx
index ec457cf..485f434 100644
--- a/quartz/components/TableOfContents.tsx
+++ b/quartz/components/TableOfContents.tsx
@@ -6,6 +6,7 @@
// @ts-ignore
import script from "./scripts/toc.inline"
import { i18n } from "../i18n"
+import OverflowList from "./OverflowList"
interface Options {
layout: "modern" | "legacy"
@@ -50,7 +51,7 @@
</svg>
</button>
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
- <ul class="overflow">
+ <OverflowList id="toc-ul">
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@@ -58,13 +59,13 @@
</a>
</li>
))}
- </ul>
+ </OverflowList>
</div>
</div>
)
}
TableOfContents.css = modernStyle
-TableOfContents.afterDOMLoaded = script
+TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) {
diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx
index 9cebaa8..75ef82b 100644
--- a/quartz/components/renderPage.tsx
+++ b/quartz/components/renderPage.tsx
@@ -3,7 +3,8 @@
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
-import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
+import { clone } from "../util/clone"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg"
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 9c6c050..15f3a84 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,53 +1,38 @@
-import { FolderState } from "../ExplorerNode"
+import { FileTrieNode } from "../../util/fileTrie"
+import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
+import { ContentDetails } from "../../plugins/emitters/contentIndex"
-// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined
-let currentExplorerState: FolderState[]
-const observer = new IntersectionObserver((entries) => {
- // If last element is observed, remove gradient of "overflow" class so element is visible
- const explorerUl = document.getElementById("explorer-ul")
- if (!explorerUl) return
- for (const entry of entries) {
- if (entry.isIntersecting) {
- explorerUl.classList.add("no-background")
- } else {
- explorerUl.classList.remove("no-background")
- }
- }
-})
+interface ParsedOptions {
+ folderClickBehavior: "collapse" | "link"
+ folderDefaultState: "collapsed" | "open"
+ useSavedState: boolean
+ sortFn: (a: FileTrieNode, b: FileTrieNode) => number
+ filterFn: (node: FileTrieNode) => boolean
+ mapFn: (node: FileTrieNode) => void
+ order: "sort" | "filter" | "map"[]
+}
+type FolderState = {
+ path: string
+ collapsed: boolean
+}
+
+let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
- // Toggle collapsed state of entire explorer
- this.classList.toggle("collapsed")
-
- // Toggle collapsed aria state of entire explorer
- this.setAttribute(
- "aria-expanded",
- this.getAttribute("aria-expanded") === "true" ? "false" : "true",
- )
-
- const content = (
- this.nextElementSibling?.nextElementSibling
- ? this.nextElementSibling.nextElementSibling
- : this.nextElementSibling
- ) as MaybeHTMLElement
- if (!content) return
- content.classList.toggle("collapsed")
- content.classList.toggle("explorer-viewmode")
-
- // Prevent scroll under
- if (document.querySelector("#mobile-explorer")) {
- // Disable scrolling on the page when the explorer is opened on mobile
- const bodySelector = document.querySelector("#quartz-body")
- if (bodySelector) bodySelector.classList.toggle("lock-scroll")
+ const explorers = document.querySelectorAll(".explorer")
+ for (const explorer of explorers) {
+ explorer.classList.toggle("collapsed")
+ explorer.setAttribute(
+ "aria-expanded",
+ explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
+ )
}
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
-
- // Element that was clicked
const target = evt.target as MaybeHTMLElement
if (!target) return
@@ -55,162 +40,240 @@
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
- const childFolderContainer = (
+ const folderContainer = (
isSvg
- ? target.parentElement?.nextSibling
- : target.parentElement?.parentElement?.nextElementSibling
+ ? // svg -> div.folder-container
+ target.parentElement
+ : // button.folder-button -> div -> div.folder-container
+ target.parentElement?.parentElement
) as MaybeHTMLElement
- const currentFolderParent = (
- isSvg ? target.nextElementSibling : target.parentElement
- ) as MaybeHTMLElement
- if (!(childFolderContainer && currentFolderParent)) return
- // <li> element of folder (stores folder-path dataset)
+ if (!folderContainer) return
+ const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
+ if (!childFolderContainer) return
+
childFolderContainer.classList.toggle("open")
// Collapse folder container
- const isCollapsed = childFolderContainer.classList.contains("open")
- setFolderState(childFolderContainer, !isCollapsed)
+ const isCollapsed = !childFolderContainer.classList.contains("open")
+ setFolderState(childFolderContainer, isCollapsed)
- // Save folder state to localStorage
- const fullFolderPath = currentFolderParent.dataset.folderpath as string
- toggleCollapsedByPath(currentExplorerState, fullFolderPath)
+ const currentFolderState = currentExplorerState.find(
+ (item) => item.path === folderContainer.dataset.folderpath,
+ )
+ if (currentFolderState) {
+ currentFolderState.collapsed = isCollapsed
+ } else {
+ currentExplorerState.push({
+ path: folderContainer.dataset.folderpath as FullSlug,
+ collapsed: isCollapsed,
+ })
+ }
+
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
-function setupExplorer() {
- // Set click handler for collapsing entire explorer
- const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
+function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
+ const template = document.getElementById("template-file") as HTMLTemplateElement
+ const clone = template.content.cloneNode(true) as DocumentFragment
+ const li = clone.querySelector("li") as HTMLLIElement
+ const a = li.querySelector("a") as HTMLAnchorElement
+ a.href = resolveRelative(currentSlug, node.data?.slug!)
+ a.dataset.for = node.data?.slug
+ a.textContent = node.displayName
+
+ if (currentSlug === node.data?.slug) {
+ a.classList.add("active")
+ }
+
+ return li
+}
+
+function createFolderNode(
+ currentSlug: FullSlug,
+ node: FileTrieNode,
+ opts: ParsedOptions,
+): HTMLLIElement {
+ const template = document.getElementById("template-folder") as HTMLTemplateElement
+ const clone = template.content.cloneNode(true) as DocumentFragment
+ const li = clone.querySelector("li") as HTMLLIElement
+ const folderContainer = li.querySelector(".folder-container") as HTMLElement
+ const titleContainer = folderContainer.querySelector("div") as HTMLElement
+ const folderOuter = li.querySelector(".folder-outer") as HTMLElement
+ const ul = folderOuter.querySelector("ul") as HTMLUListElement
+
+ const folderPath = node.data?.slug!
+ folderContainer.dataset.folderpath = folderPath
+
+ if (opts.folderClickBehavior === "link") {
+ // Replace button with link for link behavior
+ const button = titleContainer.querySelector(".folder-button") as HTMLElement
+ const a = document.createElement("a")
+ a.href = resolveRelative(currentSlug, folderPath)
+ a.dataset.for = node.data?.slug
+ a.className = "folder-title"
+ a.textContent = node.displayName
+ button.replaceWith(a)
+ } else {
+ const span = titleContainer.querySelector(".folder-title") as HTMLElement
+ span.textContent = node.displayName
+ }
+
+ // if the saved state is collapsed or the default state is collapsed
+ const isCollapsed =
+ currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
+ opts.folderDefaultState === "collapsed"
+
+ // if this folder is a prefix of the current path we
+ // want to open it anyways
+ const simpleFolderPath = simplifySlug(folderPath)
+ const folderIsPrefixOfCurrentSlug =
+ simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
+
+ if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
+ folderOuter.classList.add("open")
+ }
+
+ for (const child of node.children) {
+ const childNode = child.data
+ ? createFileNode(currentSlug, child)
+ : createFolderNode(currentSlug, child, opts)
+ ul.appendChild(childNode)
+ }
+
+ return li
+}
+
+async function setupExplorer(currentSlug: FullSlug) {
+ const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
+ const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
+ const opts: ParsedOptions = {
+ folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
+ folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
+ useSavedState: explorer.dataset.savestate === "true",
+ order: dataFns.order || ["filter", "map", "sort"],
+ sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
+ filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
+ mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
+ }
+
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
-
- // Convert to bool
- const useSavedFolderState = explorer?.dataset.savestate === "true"
-
- if (explorer) {
- // Get config
- const collapseBehavior = explorer.dataset.behavior
-
- // Add click handlers for all folders (click handler on folder "label")
- if (collapseBehavior === "collapse") {
- for (const item of document.getElementsByClassName(
- "folder-button",
- ) as HTMLCollectionOf<HTMLElement>) {
- window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
- item.addEventListener("click", toggleFolder)
- }
- }
-
- // Add click handler to main explorer
- window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
- explorer.addEventListener("click", toggleExplorer)
- }
-
- // Set up click handlers for each folder (click handler on folder "icon")
- for (const item of document.getElementsByClassName(
- "folder-icon",
- ) as HTMLCollectionOf<HTMLElement>) {
- item.addEventListener("click", toggleFolder)
- window.addCleanup(() => item.removeEventListener("click", toggleFolder))
- }
-
- // Get folder state from local storage
- const oldExplorerState: FolderState[] =
- storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
- const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
- const newExplorerState: FolderState[] = explorer.dataset.tree
- ? JSON.parse(explorer.dataset.tree)
- : []
- currentExplorerState = []
-
- for (const { path, collapsed } of newExplorerState) {
- currentExplorerState.push({
- path,
- collapsed: oldIndex.get(path) ?? collapsed,
- })
- }
-
- currentExplorerState.map((folderState) => {
- const folderLi = document.querySelector(
- `[data-folderpath='${folderState.path.replace("'", "-")}']`,
- ) as MaybeHTMLElement
- const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
- if (folderUl) {
- setFolderState(folderUl, folderState.collapsed)
- }
- })
- }
-}
-
-function toggleExplorerFolders() {
- const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
- /\/index$/g,
- "",
- )
- const allFolders = document.querySelectorAll(".folder-outer")
-
- allFolders.forEach((element) => {
- const folderUl = Array.from(element.children).find((child) =>
- child.matches("ul[data-folderul]"),
+ const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
+ const oldIndex = new Map(
+ serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
)
- if (folderUl) {
- if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
- if (!element.classList.contains("open")) {
- element.classList.add("open")
- }
+
+ const data = await fetchData
+ const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
+ const trie = FileTrieNode.fromEntries(entries)
+
+ // Apply functions in order
+ for (const fn of opts.order) {
+ switch (fn) {
+ case "filter":
+ if (opts.filterFn) trie.filter(opts.filterFn)
+ break
+ case "map":
+ if (opts.mapFn) trie.map(opts.mapFn)
+ break
+ case "sort":
+ if (opts.sortFn) trie.sort(opts.sortFn)
+ break
}
}
- })
-}
-window.addEventListener("resize", setupExplorer)
+ // Get folder paths for state management
+ const folderPaths = trie.getFolderPaths()
+ currentExplorerState = folderPaths.map((path) => ({
+ path,
+ collapsed: oldIndex.get(path) === true,
+ }))
-document.addEventListener("nav", () => {
- const explorer = document.querySelector("#mobile-explorer")
- if (explorer) {
- explorer.classList.add("collapsed")
- const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
- if (content) {
- content.classList.add("collapsed")
- content.classList.toggle("explorer-viewmode")
+ const explorerUl = document.getElementById("explorer-ul")
+ if (!explorerUl) continue
+
+ // Create and insert new content
+ const fragment = document.createDocumentFragment()
+ for (const child of trie.children) {
+ const node = child.isFolder
+ ? createFolderNode(currentSlug, child, opts)
+ : createFileNode(currentSlug, child)
+
+ fragment.appendChild(node)
+ }
+ explorerUl.insertBefore(fragment, explorerUl.firstChild)
+
+ // restore explorer scrollTop position if it exists
+ const scrollTop = sessionStorage.getItem("explorerScrollTop")
+ if (scrollTop) {
+ explorerUl.scrollTop = parseInt(scrollTop)
+ } else {
+ // try to scroll to the active element if it exists
+ const activeElement = explorerUl.querySelector(".active")
+ if (activeElement) {
+ activeElement.scrollIntoView({ behavior: "smooth" })
+ }
+ }
+
+ // Set up event handlers
+ const explorerButtons = explorer.querySelectorAll(
+ "button.explorer-toggle",
+ ) as NodeListOf<HTMLElement>
+ if (explorerButtons) {
+ window.addCleanup(() =>
+ explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
+ )
+ explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
+ }
+
+ // Set up folder click handlers
+ if (opts.folderClickBehavior === "collapse") {
+ const folderButtons = explorer.getElementsByClassName(
+ "folder-button",
+ ) as HTMLCollectionOf<HTMLElement>
+ for (const button of folderButtons) {
+ window.addCleanup(() => button.removeEventListener("click", toggleFolder))
+ button.addEventListener("click", toggleFolder)
+ }
+ }
+
+ const folderIcons = explorer.getElementsByClassName(
+ "folder-icon",
+ ) as HTMLCollectionOf<HTMLElement>
+ for (const icon of folderIcons) {
+ window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
+ icon.addEventListener("click", toggleFolder)
}
}
- setupExplorer()
+}
- observer.disconnect()
-
- // select pseudo element at end of list
- const lastItem = document.getElementById("explorer-end")
- if (lastItem) {
- observer.observe(lastItem)
- }
-
- // Hide explorer on mobile until it is requested
- const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
- hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
-
- toggleExplorerFolders()
+document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
+ // save explorer scrollTop position
+ const explorer = document.getElementById("explorer-ul")
+ if (!explorer) return
+ sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
-/**
- * Toggles the state of a given folder
- * @param folderElement <div class="folder-outer"> Element of folder (parent)
- * @param collapsed if folder should be set to collapsed or not
- */
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+ const currentSlug = e.detail.url
+ await setupExplorer(currentSlug)
+
+ // if mobile hamburger is visible, collapse by default
+ const mobileExplorer = document.getElementById("mobile-explorer")
+ if (mobileExplorer && mobileExplorer.checkVisibility()) {
+ for (const explorer of document.querySelectorAll(".explorer")) {
+ explorer.classList.add("collapsed")
+ explorer.setAttribute("aria-expanded", "false")
+ }
+ }
+
+ const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
+ hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
+})
+
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
-
-/**
- * Toggles visibility of a folder
- * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
- * @param path path to folder (e.g. 'advanced/more/more2')
- */
-function toggleCollapsedByPath(array: FolderState[], path: string) {
- const entry = array.find((item) => item.path === path)
- if (entry) {
- entry.collapsed = !entry.collapsed
- }
-}
diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts
index df48f04..77900a6 100644
--- a/quartz/components/scripts/spa.inline.ts
+++ b/quartz/components/scripts/spa.inline.ts
@@ -75,6 +75,10 @@
if (!contents) return
+ // notify about to nav
+ const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
+ document.dispatchEvent(event)
+
// cleanup old
cleanupFns.forEach((fn) => fn())
cleanupFns.clear()
@@ -108,7 +112,7 @@
}
}
- // now, patch head
+ // now, patch head, re-executing scripts
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts
index 2cfb3f9..a518c10 100644
--- a/quartz/components/scripts/toc.inline.ts
+++ b/quartz/components/scripts/toc.inline.ts
@@ -1,4 +1,3 @@
-const bufferPx = 150
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
@@ -28,7 +27,6 @@
function setupToc() {
const toc = document.getElementById("toc")
if (toc) {
- const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return
toc.addEventListener("click", toggleToc)
diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts
index ff486cf..f717901 100644
--- a/quartz/components/scripts/util.ts
+++ b/quartz/components/scripts/util.ts
@@ -37,6 +37,7 @@
if (!res.headers.get("content-type")?.startsWith("text/html")) {
return res
}
+
// reading the body can only be done once, so we need to clone the response
// to allow the caller to read it if it's was not a redirect
const text = await res.clone().text()
diff --git a/quartz/components/styles/backlinks.scss b/quartz/components/styles/backlinks.scss
index 7b3237b..71c13f0 100644
--- a/quartz/components/styles/backlinks.scss
+++ b/quartz/components/styles/backlinks.scss
@@ -2,18 +2,6 @@
.backlinks {
flex-direction: column;
- /*&:after {
- pointer-events: none;
- content: "";
- width: 100%;
- height: 50px;
- position: absolute;
- left: 0;
- bottom: 0;
- opacity: 1;
- transition: opacity 0.3s ease;
- background: linear-gradient(transparent 0px, var(--light));
- }*/
& > h3 {
font-size: 1rem;
@@ -31,14 +19,4 @@
}
}
}
-
- & > .overflow {
- &:after {
- display: none;
- }
- height: auto;
- @media all and not ($desktop) {
- height: 250px;
- }
- }
}
diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss
index edf4e61..4295282 100644
--- a/quartz/components/styles/darkmode.scss
+++ b/quartz/components/styles/darkmode.scss
@@ -8,6 +8,7 @@
height: 20px;
margin: 0 10px;
text-align: inherit;
+ flex-shrink: 0;
& svg {
position: absolute;
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
index fbeb58d..b769726 100644
--- a/quartz/components/styles/explorer.scss
+++ b/quartz/components/styles/explorer.scss
@@ -16,10 +16,10 @@
box-sizing: border-box;
position: sticky;
background-color: var(--light);
+ padding: 1rem 0 1rem 0;
+ margin: 0;
}
- // Hide Explorer on mobile until done loading.
- // Prevents ugly animation on page load.
.hide-until-loaded ~ #explorer-content {
display: none;
}
@@ -28,9 +28,21 @@
.explorer {
display: flex;
- height: 100%;
flex-direction: column;
overflow-y: hidden;
+ flex: 0 1 auto;
+ &.collapsed {
+ flex: 0 1 1.2rem;
+ & .fold {
+ transform: rotateZ(-90deg);
+ }
+ }
+
+ & .fold {
+ margin-left: 0.5rem;
+ transition: transform 0.3s ease;
+ opacity: 0.8;
+ }
@media all and ($mobile) {
order: -1;
@@ -64,18 +76,14 @@
}
}
- /*&:after {
- pointer-events: none;
- content: "";
- width: 100%;
- height: 50px;
- position: absolute;
- left: 0;
- bottom: 0;
- opacity: 1;
- transition: opacity 0.3s ease;
- background: linear-gradient(transparent 0px, var(--light));
- }*/
+ svg {
+ pointer-events: all;
+ transition: transform 0.35s ease;
+
+ & > polyline {
+ pointer-events: none;
+ }
+ }
}
button#mobile-explorer,
@@ -94,77 +102,46 @@
display: inline-block;
margin: 0;
}
-
- & .fold {
- margin-left: 0.5rem;
- transition: transform 0.3s ease;
- opacity: 0.8;
- }
-
- &.collapsed .fold {
- transform: rotateZ(-90deg);
- }
-}
-
-.folder-outer {
- display: grid;
- grid-template-rows: 0fr;
- transition: grid-template-rows 0.3s ease-in-out;
-}
-
-.folder-outer.open {
- grid-template-rows: 1fr;
-}
-
-.folder-outer > ul {
- overflow: hidden;
}
#explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
- max-height: 0px;
- transition:
- max-height 0.35s ease,
- visibility 0s linear 0.35s;
margin-top: 0.5rem;
- visibility: hidden;
-
- &.collapsed {
- max-height: 100%;
- transition:
- max-height 0.35s ease,
- visibility 0s linear 0s;
- visibility: visible;
- }
& ul {
list-style: none;
- margin: 0.08rem 0;
+ margin: 0;
padding: 0;
- transition:
- max-height 0.35s ease,
- transform 0.35s ease,
- opacity 0.2s ease;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
+
+ &.active {
+ opacity: 1;
+ color: var(--tertiary);
+ }
}
}
- > #explorer-ul {
- max-height: none;
+ .folder-outer {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.3s ease-in-out;
}
-}
-svg {
- pointer-events: all;
+ .folder-outer.open {
+ grid-template-rows: 1fr;
+ }
- & > polyline {
- pointer-events: none;
+ .folder-outer > ul {
+ overflow: hidden;
+ margin-left: 6px;
+ padding-left: 0.8rem;
+ border-left: 1px solid var(--lightgray);
}
}
@@ -227,69 +204,54 @@
color: var(--tertiary);
}
-.no-background::after {
- background: none !important;
-}
-
-#explorer-end {
- // needs height so IntersectionObserver gets triggered
- height: 4px;
- // remove default margin from li
- margin: 0;
-}
-
.explorer {
@media all and ($mobile) {
- #explorer-content {
- box-sizing: border-box;
- overscroll-behavior: none;
- z-index: 100;
- position: absolute;
- top: 0;
- background-color: var(--light);
- max-width: 100dvw;
- left: -100dvw;
- width: 100%;
- transition: transform 300ms ease-in-out;
- overflow: hidden;
- padding: $topSpacing 2rem 2rem;
- height: 100dvh;
- max-height: 100dvh;
- margin-top: 0;
- visibility: hidden;
+ &.collapsed {
+ flex: 0 0 34px;
- &:not(.collapsed) {
- transform: translateX(100dvw);
- visibility: visible;
+ & > #explorer-content {
+ transform: translateX(-100vw);
+ visibility: hidden;
}
+ }
- ul.overflow {
- max-height: 100%;
- width: 100%;
- }
+ &:not(.collapsed) {
+ flex: 0 0 34px;
- &.collapsed {
+ & > #explorer-content {
transform: translateX(0);
visibility: visible;
}
}
- #mobile-explorer {
- margin: 5px;
- z-index: 101;
+ #explorer-content {
+ box-sizing: border-box;
+ z-index: 100;
+ position: absolute;
+ top: 0;
+ left: 0;
+ margin-top: 0;
+ background-color: var(--light);
+ max-width: 100vw;
+ width: 100%;
+ transform: translateX(-100vw);
+ transition:
+ transform 200ms ease,
+ visibility 200ms ease;
+ overflow: hidden;
+ padding: 4rem 0 2rem 0;
+ height: 100dvh;
+ max-height: 100dvh;
+ visibility: hidden;
+ }
- &:not(.collapsed) .lucide-menu {
- transform: rotate(-90deg);
- transition: transform 200ms ease-in-out;
- }
+ #mobile-explorer {
+ margin: 0;
+ padding: 5px;
+ z-index: 101;
.lucide-menu {
stroke: var(--darkgray);
- transition: transform 200ms ease;
-
- &:hover {
- stroke: var(--dark);
- }
}
}
}
diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss
index 4988cd8..d1feca6 100644
--- a/quartz/components/styles/toc.scss
+++ b/quartz/components/styles/toc.scss
@@ -4,8 +4,10 @@
display: flex;
flex-direction: column;
- &.desktop-only {
- max-height: 40%;
+ overflow-y: hidden;
+ flex: 0 1 auto;
+ &:has(button#toc.collapsed) {
+ flex: 0 1 1.2rem;
}
}
@@ -44,26 +46,7 @@
#toc-content {
list-style: none;
- overflow: hidden;
- overflow-y: auto;
- max-height: 100%;
- transition:
- max-height 0.35s ease,
- visibility 0s linear 0s;
position: relative;
- visibility: visible;
-
- &.collapsed {
- max-height: 0;
- transition:
- max-height 0.35s ease,
- visibility 0s linear 0.35s;
- visibility: hidden;
- }
-
- &.collapsed > .overflow::after {
- opacity: 0;
- }
& ul {
list-style: none;
@@ -80,10 +63,6 @@
}
}
}
- > ul.overflow {
- max-height: none;
- width: 100%;
- }
@for $i from 0 through 6 {
& .depth-#{$i} {
diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx
index 2810039..0cc70d8 100644
--- a/quartz/plugins/emitters/contentIndex.tsx
+++ b/quartz/plugins/emitters/contentIndex.tsx
@@ -11,6 +11,7 @@
export type ContentIndexMap = Map<FullSlug, ContentDetails>
export type ContentDetails = {
+ slug: FullSlug
title: string
links: SimpleSlug[]
tags: string[]
@@ -124,6 +125,7 @@
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, {
+ slug,
title: file.data.frontmatter?.title!,
links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [],
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index 4389491..e0ab076 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -543,7 +543,6 @@
div:has(> .overflow) {
display: flex;
- overflow-y: auto;
max-height: 100%;
}
@@ -551,6 +550,7 @@
ol.overflow {
max-height: 100%;
overflow-y: auto;
+ width: 100%;
// clearfix
content: "";
@@ -559,18 +559,15 @@
& > li:last-of-type {
margin-bottom: 30px;
}
- /*&:after {
- pointer-events: none;
- content: "";
- width: 100%;
- height: 50px;
- position: absolute;
- left: 0;
- bottom: 0;
- opacity: 1;
- transition: opacity 0.3s ease;
- background: linear-gradient(transparent 0px, var(--light));
- }*/
+
+ & > li.overflow-end {
+ height: 4px;
+ margin: 0;
+ }
+
+ &.gradient-active {
+ mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
+ }
}
.transclude {
diff --git a/quartz/util/clone.ts b/quartz/util/clone.ts
new file mode 100644
index 0000000..37318e2
--- /dev/null
+++ b/quartz/util/clone.ts
@@ -0,0 +1,3 @@
+import rfdc from "rfdc"
+
+export const clone = rfdc()
diff --git a/quartz/util/fileTrie.test.ts b/quartz/util/fileTrie.test.ts
new file mode 100644
index 0000000..3de3d93
--- /dev/null
+++ b/quartz/util/fileTrie.test.ts
@@ -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"],
+ )
+ })
+ })
+})
diff --git a/quartz/util/fileTrie.ts b/quartz/util/fileTrie.ts
new file mode 100644
index 0000000..ed87b4f
--- /dev/null
+++ b/quartz/util/fileTrie.ts
@@ -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)
+ }
+}
diff --git a/quartz/util/path.ts b/quartz/util/path.ts
index 5835f15..8f85029 100644
--- a/quartz/util/path.ts
+++ b/quartz/util/path.ts
@@ -1,9 +1,6 @@
import { slug as slugAnchor } from "github-slugger"
import type { Element as HastElement } from "hast"
-import rfdc from "rfdc"
-
-export const clone = rfdc()
-
+import { clone } from "./clone"
// this file must be isomorphic so it can't use node libs (e.g. path)
export const QUARTZ = "quartz"
--
Gitblit v1.10.0