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:
Jacky Zhao 2025-03-09 14:58:26 -07:00 committed by GitHub
parent a201105442
commit 5480269d38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 797 additions and 674 deletions

View file

@ -161,6 +161,18 @@ document.addEventListener("nav", () => {
})
```
You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event.
```ts
document.addEventListener("prenav", () => {
// executed after an SPA navigation is triggered but
// before the page is replaced
// one usage pattern is to store things in sessionStorage
// in the prenav and then conditionally load then in the consequent
// nav
})
```
It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks.
This will get called on page navigation.

1
index.d.ts vendored
View file

@ -5,6 +5,7 @@ declare module "*.scss" {
// dom custom event
interface CustomEventMap {
prenav: CustomEvent<{}>
nav: CustomEvent<{ url: FullSlug }>
themechange: CustomEvent<{ theme: "light" | "dark" }>
}

View file

@ -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": {

View file

@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins"
*/
const config: QuartzConfig = {
configuration: {
pageTitle: "🪴 Quartz 4",
pageTitle: "Quartz 4",
pageTitleSuffix: "",
enableSPA: true,
enablePopovers: true,

View file

@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
import OverflowList from "./OverflowList"
interface BacklinksOptions {
hideWhenEmpty: boolean
@ -29,7 +30,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
return (
<div class={classNames(displayClass, "backlinks")}>
<h3>{i18n(cfg.locale).components.backlinks.title}</h3>
<ul class="overflow">
<OverflowList id="backlinks-ul">
{backlinkFiles.length > 0 ? (
backlinkFiles.map((f) => (
<li>
@ -41,12 +42,13 @@ export default ((opts?: Partial<BacklinksOptions>) => {
) : (
<li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
)}
</ul>
</OverflowList>
</div>
)
}
Backlinks.css = style
Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
return Backlinks
}) satisfies QuartzComponentConstructor

View file

@ -3,22 +3,34 @@ import style from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie"
import OverflowList from "./OverflowList"
// Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = {
folderClickBehavior: "collapse",
type OrderEntries = "sort" | "filter" | "map"
export interface Options {
title?: string
folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: OrderEntries[]
}
const defaultOptions: Options = {
folderDefaultState: "collapsed",
folderClickBehavior: "collapse",
useSavedState: true,
mapFn: (node) => {
return node
},
sortFn: (a, b) => {
// Sort order: folders first, then files. Sort folders and files alphabetically
if ((!a.file && !b.file) || (a.file && b.file)) {
// Sort order: folders first, then files. Sort folders and files alphabeticall
if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) {
// numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10"
// sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A
return a.displayName.localeCompare(b.displayName, undefined, {
@ -27,75 +39,44 @@ const defaultOptions = {
})
}
if (a.file && !b.file) {
if (!a.isFolder && b.isFolder) {
return 1
} else {
return -1
}
},
filterFn: (node) => node.name !== "tags",
filterFn: (node) => node.slugSegment !== "tags",
order: ["filter", "map", "sort"],
} satisfies Options
}
export type FolderState = {
path: string
collapsed: boolean
}
export default ((userOpts?: Partial<Options>) => {
// Parse config
const opts: Options = { ...defaultOptions, ...userOpts }
// memoized
let fileTree: FileNode
let jsonTree: string
let lastBuildId: string = ""
function constructFileTree(allFiles: QuartzPluginData[]) {
// Construct tree from allFiles
fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file))
// Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied)
if (opts.order) {
// Order is important, use loop with index instead of order.map()
for (let i = 0; i < opts.order.length; i++) {
const functionName = opts.order[i]
if (functionName === "map") {
fileTree.map(opts.mapFn)
} else if (functionName === "sort") {
fileTree.sort(opts.sortFn)
} else if (functionName === "filter") {
fileTree.filter(opts.filterFn)
}
}
}
// Get all folders of tree. Initialize with collapsed state
// Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
jsonTree = JSON.stringify(folders)
}
const Explorer: QuartzComponent = ({
ctx,
cfg,
allFiles,
displayClass,
fileData,
}: QuartzComponentProps) => {
if (ctx.buildId !== lastBuildId) {
lastBuildId = ctx.buildId
constructFileTree(allFiles)
}
const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
return (
<div class={classNames(displayClass, "explorer")}>
<button
type="button"
id="mobile-explorer"
class="collapsed hide-until-loaded"
<div
class={classNames(displayClass, "explorer")}
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
data-data-fns={JSON.stringify({
order: opts.order,
sortFn: opts.sortFn.toString(),
filterFn: opts.filterFn.toString(),
mapFn: opts.mapFn.toString(),
})}
>
<button
type="button"
id="mobile-explorer"
class="explorer-toggle hide-until-loaded"
data-mobile={true}
aria-controls="explorer-content"
aria-expanded={false}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -105,7 +86,7 @@ export default ((userOpts?: Partial<Options>) => {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-menu"
class="lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
@ -115,13 +96,8 @@ export default ((userOpts?: Partial<Options>) => {
<button
type="button"
id="desktop-explorer"
class="title-button"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
class="title-button explorer-toggle"
data-mobile={false}
aria-controls="explorer-content"
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
@ -140,17 +116,47 @@ export default ((userOpts?: Partial<Options>) => {
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="explorer-content">
<ul class="overflow" id="explorer-ul">
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
<li id="explorer-end" />
</ul>
<div id="explorer-content" aria-expanded={false}>
<OverflowList id="explorer-ul" />
</div>
<template id="template-file">
<li>
<a href="#"></a>
</li>
</template>
<template id="template-folder">
<li>
<div class="folder-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="5 8 14 8"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="folder-icon"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<div>
<button class="folder-button">
<span class="folder-title"></span>
</button>
</div>
</div>
<div class="folder-outer">
<ul class="content"></ul>
</div>
</li>
</template>
</div>
)
}
Explorer.css = style
Explorer.afterDOMLoaded = script
Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
return Explorer
}) satisfies QuartzComponentConstructor

View file

@ -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>
)}
</>
)
}

View 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

View file

@ -6,6 +6,7 @@ import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/toc.inline"
import { i18n } from "../i18n"
import OverflowList from "./OverflowList"
interface Options {
layout: "modern" | "legacy"
@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
</svg>
</button>
<div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}>
<ul class="overflow">
<OverflowList id="toc-ul">
{fileData.toc.map((tocEntry) => (
<li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@ -58,13 +59,13 @@ const TableOfContents: QuartzComponent = ({
</a>
</li>
))}
</ul>
</OverflowList>
</div>
</div>
)
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
if (!fileData.toc) {

View file

@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types"
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
import { JSResourceToScriptElement, StaticResources } from "../util/resources"
import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { clone } from "../util/clone"
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
import { GlobalConfiguration } from "../cfg"

View file

@ -1,53 +1,38 @@
import { FolderState } from "../ExplorerNode"
import { FileTrieNode } from "../../util/fileTrie"
import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { ContentDetails } from "../../plugins/emitters/contentIndex"
// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) return
for (const entry of entries) {
if (entry.isIntersecting) {
explorerUl.classList.add("no-background")
} else {
explorerUl.classList.remove("no-background")
interface ParsedOptions {
folderClickBehavior: "collapse" | "link"
folderDefaultState: "collapsed" | "open"
useSavedState: boolean
sortFn: (a: FileTrieNode, b: FileTrieNode) => number
filterFn: (node: FileTrieNode) => boolean
mapFn: (node: FileTrieNode) => void
order: "sort" | "filter" | "map"[]
}
}
})
type FolderState = {
path: string
collapsed: boolean
}
let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
// Toggle collapsed aria state of entire explorer
this.setAttribute(
const explorers = document.querySelectorAll(".explorer")
for (const explorer of explorers) {
explorer.classList.toggle("collapsed")
explorer.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = (
this.nextElementSibling?.nextElementSibling
? this.nextElementSibling.nextElementSibling
: this.nextElementSibling
) as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
content.classList.toggle("explorer-viewmode")
// Prevent scroll under
if (document.querySelector("#mobile-explorer")) {
// Disable scrolling on the page when the explorer is opened on mobile
const bodySelector = document.querySelector("#quartz-body")
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
}
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
// Element that was clicked
const target = evt.target as MaybeHTMLElement
if (!target) return
@ -55,162 +40,240 @@ function toggleFolder(evt: MouseEvent) {
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const childFolderContainer = (
const folderContainer = (
isSvg
? target.parentElement?.nextSibling
: target.parentElement?.parentElement?.nextElementSibling
? // svg -> div.folder-container
target.parentElement
: // button.folder-button -> div -> div.folder-container
target.parentElement?.parentElement
) as MaybeHTMLElement
const currentFolderParent = (
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
if (!folderContainer) return
const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
if (!childFolderContainer) return
childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
const isCollapsed = !childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, isCollapsed)
const currentFolderState = currentExplorerState.find(
(item) => item.path === folderContainer.dataset.folderpath,
)
if (currentFolderState) {
currentFolderState.collapsed = isCollapsed
} else {
currentExplorerState.push({
path: folderContainer.dataset.folderpath as FullSlug,
collapsed: isCollapsed,
})
}
// Save folder state to localStorage
const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
function setupExplorer() {
// Set click handler for collapsing entire explorer
const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
const template = document.getElementById("template-file") as HTMLTemplateElement
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const a = li.querySelector("a") as HTMLAnchorElement
a.href = resolveRelative(currentSlug, node.data?.slug!)
a.dataset.for = node.data?.slug
a.textContent = node.displayName
if (currentSlug === node.data?.slug) {
a.classList.add("active")
}
return li
}
function createFolderNode(
currentSlug: FullSlug,
node: FileTrieNode,
opts: ParsedOptions,
): HTMLLIElement {
const template = document.getElementById("template-folder") as HTMLTemplateElement
const clone = template.content.cloneNode(true) as DocumentFragment
const li = clone.querySelector("li") as HTMLLIElement
const folderContainer = li.querySelector(".folder-container") as HTMLElement
const titleContainer = folderContainer.querySelector("div") as HTMLElement
const folderOuter = li.querySelector(".folder-outer") as HTMLElement
const ul = folderOuter.querySelector("ul") as HTMLUListElement
const folderPath = node.data?.slug!
folderContainer.dataset.folderpath = folderPath
if (opts.folderClickBehavior === "link") {
// Replace button with link for link behavior
const button = titleContainer.querySelector(".folder-button") as HTMLElement
const a = document.createElement("a")
a.href = resolveRelative(currentSlug, folderPath)
a.dataset.for = node.data?.slug
a.className = "folder-title"
a.textContent = node.displayName
button.replaceWith(a)
} else {
const span = titleContainer.querySelector(".folder-title") as HTMLElement
span.textContent = node.displayName
}
// if the saved state is collapsed or the default state is collapsed
const isCollapsed =
currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
opts.folderDefaultState === "collapsed"
// if this folder is a prefix of the current path we
// want to open it anyways
const simpleFolderPath = simplifySlug(folderPath)
const folderIsPrefixOfCurrentSlug =
simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
folderOuter.classList.add("open")
}
for (const child of node.children) {
const childNode = child.data
? createFileNode(currentSlug, child)
: createFolderNode(currentSlug, child, opts)
ul.appendChild(childNode)
}
return li
}
async function setupExplorer(currentSlug: FullSlug) {
const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
const opts: ParsedOptions = {
folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
useSavedState: explorer.dataset.savestate === "true",
order: dataFns.order || ["filter", "map", "sort"],
sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
const oldIndex = new Map(
serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
)
// Convert to bool
const useSavedFolderState = explorer?.dataset.savestate === "true"
const data = await fetchData
const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
const trie = FileTrieNode.fromEntries(entries)
if (explorer) {
// Get config
const collapseBehavior = explorer.dataset.behavior
// Add click handlers for all folders (click handler on folder "label")
if (collapseBehavior === "collapse") {
for (const item of document.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
item.addEventListener("click", toggleFolder)
// Apply functions in order
for (const fn of opts.order) {
switch (fn) {
case "filter":
if (opts.filterFn) trie.filter(opts.filterFn)
break
case "map":
if (opts.mapFn) trie.map(opts.mapFn)
break
case "sort":
if (opts.sortFn) trie.sort(opts.sortFn)
break
}
}
// Add click handler to main explorer
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>) {
item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
}
// Get folder state from local storage
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
const newExplorerState: FolderState[] = explorer.dataset.tree
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({
// Get folder paths for state management
const folderPaths = trie.getFolderPaths()
currentExplorerState = folderPaths.map((path) => ({
path,
collapsed: oldIndex.get(path) ?? collapsed,
})
}
collapsed: oldIndex.get(path) === true,
}))
currentExplorerState.map((folderState) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path.replace("'", "-")}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
setFolderState(folderUl, folderState.collapsed)
const explorerUl = document.getElementById("explorer-ul")
if (!explorerUl) continue
// Create and insert new content
const fragment = document.createDocumentFragment()
for (const child of trie.children) {
const node = child.isFolder
? createFolderNode(currentSlug, child, opts)
: createFileNode(currentSlug, child)
fragment.appendChild(node)
}
})
explorerUl.insertBefore(fragment, explorerUl.firstChild)
// restore explorer scrollTop position if it exists
const scrollTop = sessionStorage.getItem("explorerScrollTop")
if (scrollTop) {
explorerUl.scrollTop = parseInt(scrollTop)
} else {
// try to scroll to the active element if it exists
const activeElement = explorerUl.querySelector(".active")
if (activeElement) {
activeElement.scrollIntoView({ behavior: "smooth" })
}
}
function toggleExplorerFolders() {
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
/\/index$/g,
"",
// Set up event handlers
const explorerButtons = explorer.querySelectorAll(
"button.explorer-toggle",
) as NodeListOf<HTMLElement>
if (explorerButtons) {
window.addCleanup(() =>
explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
)
const allFolders = document.querySelectorAll(".folder-outer")
explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
}
allFolders.forEach((element) => {
const folderUl = Array.from(element.children).find((child) =>
child.matches("ul[data-folderul]"),
)
if (folderUl) {
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
if (!element.classList.contains("open")) {
element.classList.add("open")
// Set up folder click handlers
if (opts.folderClickBehavior === "collapse") {
const folderButtons = explorer.getElementsByClassName(
"folder-button",
) as HTMLCollectionOf<HTMLElement>
for (const button of folderButtons) {
window.addCleanup(() => button.removeEventListener("click", toggleFolder))
button.addEventListener("click", toggleFolder)
}
}
const folderIcons = explorer.getElementsByClassName(
"folder-icon",
) as HTMLCollectionOf<HTMLElement>
for (const icon of folderIcons) {
window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
icon.addEventListener("click", toggleFolder)
}
}
}
document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
// save explorer scrollTop position
const explorer = document.getElementById("explorer-ul")
if (!explorer) return
sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
}
window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url
await setupExplorer(currentSlug)
document.addEventListener("nav", () => {
const explorer = document.querySelector("#mobile-explorer")
if (explorer) {
// if mobile hamburger is visible, collapse by default
const mobileExplorer = document.getElementById("mobile-explorer")
if (mobileExplorer && mobileExplorer.checkVisibility()) {
for (const explorer of document.querySelectorAll(".explorer")) {
explorer.classList.add("collapsed")
const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
if (content) {
content.classList.add("collapsed")
content.classList.toggle("explorer-viewmode")
explorer.setAttribute("aria-expanded", "false")
}
}
setupExplorer()
observer.disconnect()
// select pseudo element at end of list
const lastItem = document.getElementById("explorer-end")
if (lastItem) {
observer.observe(lastItem)
}
// Hide explorer on mobile until it is requested
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
toggleExplorerFolders()
})
/**
* Toggles the state of a given folder
* @param folderElement <div class="folder-outer"> Element of folder (parent)
* @param collapsed if folder should be set to collapsed or not
*/
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
/**
* Toggles visibility of a folder
* @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
* @param path path to folder (e.g. 'advanced/more/more2')
*/
function toggleCollapsedByPath(array: FolderState[], path: string) {
const entry = array.find((item) => item.path === path)
if (entry) {
entry.collapsed = !entry.collapsed
}
}

View file

@ -75,6 +75,10 @@ async function navigate(url: URL, isBack: boolean = false) {
if (!contents) return
// notify about to nav
const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} })
document.dispatchEvent(event)
// cleanup old
cleanupFns.forEach((fn) => fn())
cleanupFns.clear()
@ -108,7 +112,7 @@ async function navigate(url: URL, isBack: boolean = false) {
}
}
// now, patch head
// now, patch head, re-executing scripts
const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])")
elementsToRemove.forEach((el) => el.remove())
const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")

View file

@ -1,4 +1,3 @@
const bufferPx = 150
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const slug = entry.target.id
@ -28,7 +27,6 @@ function toggleToc(this: HTMLElement) {
function setupToc() {
const toc = document.getElementById("toc")
if (toc) {
const collapsed = toc.classList.contains("collapsed")
const content = toc.nextElementSibling as HTMLElement | undefined
if (!content) return
toc.addEventListener("click", toggleToc)

View file

@ -37,6 +37,7 @@ export async function fetchCanonical(url: URL): Promise<Response> {
if (!res.headers.get("content-type")?.startsWith("text/html")) {
return res
}
// reading the body can only be done once, so we need to clone the response
// to allow the caller to read it if it's was not a redirect
const text = await res.clone().text()

View file

@ -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;
}
}
}

View file

@ -8,6 +8,7 @@
height: 20px;
margin: 0 10px;
text-align: inherit;
flex-shrink: 0;
& svg {
position: absolute;

View file

@ -16,10 +16,10 @@
box-sizing: border-box;
position: sticky;
background-color: var(--light);
padding: 1rem 0 1rem 0;
margin: 0;
}
// Hide Explorer on mobile until done loading.
// Prevents ugly animation on page load.
.hide-until-loaded ~ #explorer-content {
display: none;
}
@ -28,9 +28,21 @@
.explorer {
display: flex;
height: 100%;
flex-direction: column;
overflow-y: hidden;
flex: 0 1 auto;
&.collapsed {
flex: 0 1 1.2rem;
& .fold {
transform: rotateZ(-90deg);
}
}
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
@media all and ($mobile) {
order: -1;
@ -64,18 +76,14 @@
}
}
/*&:after {
svg {
pointer-events: all;
transition: transform 0.35s ease;
& > polyline {
pointer-events: none;
content: "";
width: 100%;
height: 50px;
position: absolute;
left: 0;
bottom: 0;
opacity: 1;
transition: opacity 0.3s ease;
background: linear-gradient(transparent 0px, var(--light));
}*/
}
}
}
button#mobile-explorer,
@ -94,15 +102,28 @@ button#desktop-explorer {
display: inline-block;
margin: 0;
}
& .fold {
margin-left: 0.5rem;
transition: transform 0.3s ease;
opacity: 0.8;
}
&.collapsed .fold {
transform: rotateZ(-90deg);
#explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
margin-top: 0.5rem;
& ul {
list-style: none;
margin: 0;
padding: 0;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
&.active {
opacity: 1;
color: var(--tertiary);
}
}
}
@ -118,53 +139,9 @@ button#desktop-explorer {
.folder-outer > ul {
overflow: hidden;
}
#explorer-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
max-height: 0px;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
margin-top: 0.5rem;
visibility: hidden;
&.collapsed {
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
visibility: visible;
}
& ul {
list-style: none;
margin: 0.08rem 0;
padding: 0;
transition:
max-height 0.35s ease,
transform 0.35s ease,
opacity 0.2s ease;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
}
}
> #explorer-ul {
max-height: none;
}
}
svg {
pointer-events: all;
& > polyline {
pointer-events: none;
margin-left: 6px;
padding-left: 0.8rem;
border-left: 1px solid var(--lightgray);
}
}
@ -227,69 +204,54 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
color: var(--tertiary);
}
.no-background::after {
background: none !important;
}
#explorer-end {
// needs height so IntersectionObserver gets triggered
height: 4px;
// remove default margin from li
margin: 0;
}
.explorer {
@media all and ($mobile) {
#explorer-content {
box-sizing: border-box;
overscroll-behavior: none;
z-index: 100;
position: absolute;
top: 0;
background-color: var(--light);
max-width: 100dvw;
left: -100dvw;
width: 100%;
transition: transform 300ms ease-in-out;
overflow: hidden;
padding: $topSpacing 2rem 2rem;
height: 100dvh;
max-height: 100dvh;
margin-top: 0;
&.collapsed {
flex: 0 0 34px;
& > #explorer-content {
transform: translateX(-100vw);
visibility: hidden;
}
}
&:not(.collapsed) {
transform: translateX(100dvw);
visibility: visible;
}
flex: 0 0 34px;
ul.overflow {
max-height: 100%;
width: 100%;
}
&.collapsed {
& > #explorer-content {
transform: translateX(0);
visibility: visible;
}
}
#mobile-explorer {
margin: 5px;
z-index: 101;
&:not(.collapsed) .lucide-menu {
transform: rotate(-90deg);
transition: transform 200ms ease-in-out;
#explorer-content {
box-sizing: border-box;
z-index: 100;
position: absolute;
top: 0;
left: 0;
margin-top: 0;
background-color: var(--light);
max-width: 100vw;
width: 100%;
transform: translateX(-100vw);
transition:
transform 200ms ease,
visibility 200ms ease;
overflow: hidden;
padding: 4rem 0 2rem 0;
height: 100dvh;
max-height: 100dvh;
visibility: hidden;
}
#mobile-explorer {
margin: 0;
padding: 5px;
z-index: 101;
.lucide-menu {
stroke: var(--darkgray);
transition: transform 200ms ease;
&:hover {
stroke: var(--dark);
}
}
}
}

View file

@ -4,8 +4,10 @@
display: flex;
flex-direction: column;
&.desktop-only {
max-height: 40%;
overflow-y: hidden;
flex: 0 1 auto;
&:has(button#toc.collapsed) {
flex: 0 1 1.2rem;
}
}
@ -44,26 +46,7 @@ button#toc {
#toc-content {
list-style: none;
overflow: hidden;
overflow-y: auto;
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
position: relative;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
&.collapsed > .overflow::after {
opacity: 0;
}
& ul {
list-style: none;
@ -80,10 +63,6 @@ button#toc {
}
}
}
> ul.overflow {
max-height: none;
width: 100%;
}
@for $i from 0 through 6 {
& .depth-#{$i} {

View file

@ -11,6 +11,7 @@ import DepGraph from "../../depgraph"
export type ContentIndexMap = Map<FullSlug, ContentDetails>
export type ContentDetails = {
slug: FullSlug
title: string
links: SimpleSlug[]
tags: string[]
@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, {
slug,
title: file.data.frontmatter?.title!,
links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [],

View file

@ -543,7 +543,6 @@ video {
div:has(> .overflow) {
display: flex;
overflow-y: auto;
max-height: 100%;
}
@ -551,6 +550,7 @@ ul.overflow,
ol.overflow {
max-height: 100%;
overflow-y: auto;
width: 100%;
// clearfix
content: "";
@ -559,18 +559,15 @@ ol.overflow {
& > li:last-of-type {
margin-bottom: 30px;
}
/*&:after {
pointer-events: none;
content: "";
width: 100%;
height: 50px;
position: absolute;
left: 0;
bottom: 0;
opacity: 1;
transition: opacity 0.3s ease;
background: linear-gradient(transparent 0px, var(--light));
}*/
& > li.overflow-end {
height: 4px;
margin: 0;
}
&.gradient-active {
mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%);
}
}
.transclude {

3
quartz/util/clone.ts Normal file
View file

@ -0,0 +1,3 @@
import rfdc from "rfdc"
export const clone = rfdc()

View 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
View 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)
}
}

View file

@ -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"