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 @@ 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. 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 @@ declare module "*.scss" { // 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 @@ import * as Plugin from "./quartz/plugins" */ 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 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) => { return (

{i18n(cfg.locale).components.backlinks.title}

- +
) } 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 @@ 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) => { - // 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 ( -
+
-
-
    - -
  • -
+
+
+ +
) } 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 - 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 -
  • - - {node.displayName} - -
  • - ) : ( -
  • - {node.name !== "" && ( - // Node with entire folder - // Render svg button + folder name, then children - -
  • - )} - {/* Recursively render children of folder */} -
    -
      - {node.children.map((childNode, i) => ( - - ))} -
    -
    - - )} - - ) -} 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 & { id: string }) => { + return ( +
      + {children} +
    • +
    + ) +} + +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 @@ 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 = ({
    - +
    ) } 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 { 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" 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 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 @@ function toggleFolder(evt: MouseEvent) { const isSvg = target.nodeName === "svg" // corresponding
      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 - //
    • 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 +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 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) { - 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) { - 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 + 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 + for (const button of folderButtons) { + window.addCleanup(() => button.removeEventListener("click", toggleFolder)) + button.addEventListener("click", toggleFolder) + } + } + + const folderIcons = explorer.getElementsByClassName( + "folder-icon", + ) as HTMLCollectionOf + 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()) +}) + +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") }) -/** - * Toggles the state of a given folder - * @param folderElement
      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 - } -} 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 @@ 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])") 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 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) 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 @@ export async function fetchCanonical(url: URL): Promise { 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 @@ 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); - } -} - -.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 @@ 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; - 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 @@ 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} { 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 @@ import DepGraph from "../../depgraph" export type ContentIndexMap = Map export type ContentDetails = { + slug: FullSlug title: string links: SimpleSlug[] tags: string[] @@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (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 ?? [], 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 @@ 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 { 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 + + beforeEach(() => { + trie = new FileTrieNode("") + }) + + 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 { + children: Array> + 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(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) => 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) => void) { + mapFn(this) + this.children.forEach((child) => child.map(mapFn)) + } + + /** + * Sort trie nodes according to sort/compare function + */ + sort(sortFn: (a: FileTrieNode, b: FileTrieNode) => number) { + this.children = this.children.sort(sortFn) + this.children.forEach((e) => e.sort(sortFn)) + } + + static fromEntries(entries: [FullSlug, T][]) { + const trie = new FileTrieNode("") + 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][] { + const traverse = ( + node: FileTrieNode, + currentPath: string, + ): [FullSlug, FileTrieNode][] => { + 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][] = [[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"