docs/advanced/creating components.md
@@ -161,6 +161,18 @@ }) ``` You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event. ```ts document.addEventListener("prenav", () => { // executed after an SPA navigation is triggered but // before the page is replaced // one usage pattern is to store things in sessionStorage // in the prenav and then conditionally load then in the consequent // nav }) ``` It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. This will get called on page navigation. index.d.ts
@@ -5,6 +5,7 @@ // dom custom event interface CustomEventMap { prenav: CustomEvent<{}> nav: CustomEvent<{ url: FullSlug }> themechange: CustomEvent<{ theme: "light" | "dark" }> } 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": { quartz.config.ts
@@ -8,7 +8,7 @@ */ const config: QuartzConfig = { configuration: { pageTitle: "🪴 Quartz 4", pageTitle: "Quartz 4", pageTitleSuffix: "", enableSPA: true, enablePopovers: true, quartz/components/Backlinks.tsx
@@ -3,6 +3,7 @@ import { resolveRelative, simplifySlug } from "../util/path" import { i18n } from "../i18n" import { classNames } from "../util/lang" import OverflowList from "./OverflowList" interface BacklinksOptions { hideWhenEmpty: boolean @@ -29,7 +30,7 @@ return ( <div class={classNames(displayClass, "backlinks")}> <h3>{i18n(cfg.locale).components.backlinks.title}</h3> <ul class="overflow"> <OverflowList id="backlinks-ul"> {backlinkFiles.length > 0 ? ( backlinkFiles.map((f) => ( <li> @@ -41,12 +42,13 @@ ) : ( <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li> )} </ul> </OverflowList> </div> ) } Backlinks.css = style Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul") return Backlinks }) satisfies QuartzComponentConstructor quartz/components/Explorer.tsx
@@ -3,22 +3,34 @@ // @ts-ignore import script from "./scripts/explorer.inline" import { ExplorerNode, FileNode, Options } from "./ExplorerNode" import { QuartzPluginData } from "../plugins/vfile" import { classNames } from "../util/lang" import { i18n } from "../i18n" import { FileTrieNode } from "../util/fileTrie" import OverflowList from "./OverflowList" // Options interface defined in `ExplorerNode` to avoid circular dependency const defaultOptions = { folderClickBehavior: "collapse", type OrderEntries = "sort" | "filter" | "map" export interface Options { title?: string folderDefaultState: "collapsed" | "open" folderClickBehavior: "collapse" | "link" useSavedState: boolean sortFn: (a: FileTrieNode, b: FileTrieNode) => number filterFn: (node: FileTrieNode) => boolean mapFn: (node: FileTrieNode) => void order: OrderEntries[] } const defaultOptions: Options = { folderDefaultState: "collapsed", folderClickBehavior: "collapse", useSavedState: true, mapFn: (node) => { return node }, sortFn: (a, b) => { // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.file && !b.file) || (a.file && b.file)) { // Sort order: folders first, then files. Sort folders and files alphabeticall if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠b, a = á, a = A return a.displayName.localeCompare(b.displayName, undefined, { @@ -27,75 +39,44 @@ }) } if (a.file && !b.file) { if (!a.isFolder && b.isFolder) { return 1 } else { return -1 } }, filterFn: (node) => node.name !== "tags", filterFn: (node) => node.slugSegment !== "tags", order: ["filter", "map", "sort"], } satisfies Options } export type FolderState = { path: string collapsed: boolean } export default ((userOpts?: Partial<Options>) => { // Parse config const opts: Options = { ...defaultOptions, ...userOpts } // memoized let fileTree: FileNode let jsonTree: string let lastBuildId: string = "" function constructFileTree(allFiles: QuartzPluginData[]) { // Construct tree from allFiles fileTree = new FileNode("") allFiles.forEach((file) => fileTree.add(file)) // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) if (opts.order) { // Order is important, use loop with index instead of order.map() for (let i = 0; i < opts.order.length; i++) { const functionName = opts.order[i] if (functionName === "map") { fileTree.map(opts.mapFn) } else if (functionName === "sort") { fileTree.sort(opts.sortFn) } else if (functionName === "filter") { fileTree.filter(opts.filterFn) } } } // Get all folders of tree. Initialize with collapsed state // Stringify to pass json tree as data attribute ([data-tree]) const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") jsonTree = JSON.stringify(folders) } const Explorer: QuartzComponent = ({ ctx, cfg, allFiles, displayClass, fileData, }: QuartzComponentProps) => { if (ctx.buildId !== lastBuildId) { lastBuildId = ctx.buildId constructFileTree(allFiles) } const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => { return ( <div class={classNames(displayClass, "explorer")}> <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 @@ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu" class="lucide-menu" > <line x1="4" x2="20" y1="12" y2="12" /> <line x1="4" x2="20" y1="6" y2="6" /> @@ -115,13 +96,8 @@ <button type="button" id="desktop-explorer" class="title-button" data-behavior={opts.folderClickBehavior} data-collapsed={opts.folderDefaultState} data-savestate={opts.useSavedState} data-tree={jsonTree} class="title-button explorer-toggle" data-mobile={false} aria-controls="explorer-content" aria-expanded={true} > <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2> @@ -140,17 +116,47 @@ <polyline points="6 9 12 15 18 9"></polyline> </svg> </button> <div id="explorer-content"> <ul class="overflow" id="explorer-ul"> <ExplorerNode node={fileTree} opts={opts} fileData={fileData} /> <li id="explorer-end" /> </ul> <div id="explorer-content" aria-expanded={false}> <OverflowList id="explorer-ul" /> </div> <template id="template-file"> <li> <a href="#"></a> </li> </template> <template id="template-folder"> <li> <div class="folder-container"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="5 8 14 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="folder-icon" > <polyline points="6 9 12 15 18 9"></polyline> </svg> <div> <button class="folder-button"> <span class="folder-title"></span> </button> </div> </div> <div class="folder-outer"> <ul class="content"></ul> </div> </li> </template> </div> ) } Explorer.css = style Explorer.afterDOMLoaded = script Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul") return Explorer }) satisfies QuartzComponentConstructor quartz/components/ExplorerNode.tsx
File was deleted quartz/components/OverflowList.tsx
New 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 quartz/components/TableOfContents.tsx
@@ -6,6 +6,7 @@ // @ts-ignore import script from "./scripts/toc.inline" import { i18n } from "../i18n" import OverflowList from "./OverflowList" interface Options { layout: "modern" | "legacy" @@ -50,7 +51,7 @@ </svg> </button> <div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}> <ul class="overflow"> <OverflowList id="toc-ul"> {fileData.toc.map((tocEntry) => ( <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}> @@ -58,13 +59,13 @@ </a> </li> ))} </ul> </OverflowList> </div> </div> ) } TableOfContents.css = modernStyle TableOfContents.afterDOMLoaded = script TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul") const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { if (!fileData.toc) { quartz/components/renderPage.tsx
@@ -3,7 +3,8 @@ import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { clone } from "../util/clone" import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" import { GlobalConfiguration } from "../cfg" quartz/components/scripts/explorer.inline.ts
@@ -1,53 +1,38 @@ import { FolderState } from "../ExplorerNode" import { FileTrieNode } from "../../util/fileTrie" import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" import { ContentDetails } from "../../plugins/emitters/contentIndex" // Current state of folders type MaybeHTMLElement = HTMLElement | undefined let currentExplorerState: FolderState[] const observer = new IntersectionObserver((entries) => { // If last element is observed, remove gradient of "overflow" class so element is visible const explorerUl = document.getElementById("explorer-ul") if (!explorerUl) return for (const entry of entries) { if (entry.isIntersecting) { explorerUl.classList.add("no-background") } else { explorerUl.classList.remove("no-background") interface ParsedOptions { folderClickBehavior: "collapse" | "link" folderDefaultState: "collapsed" | "open" useSavedState: boolean sortFn: (a: FileTrieNode, b: FileTrieNode) => number filterFn: (node: FileTrieNode) => boolean mapFn: (node: FileTrieNode) => void order: "sort" | "filter" | "map"[] } } }) type FolderState = { path: string collapsed: boolean } let currentExplorerState: Array<FolderState> function toggleExplorer(this: HTMLElement) { // Toggle collapsed state of entire explorer this.classList.toggle("collapsed") // Toggle collapsed aria state of entire explorer this.setAttribute( 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 @@ const isSvg = target.nodeName === "svg" // corresponding <ul> element relative to clicked button/folder const childFolderContainer = ( const folderContainer = ( isSvg ? target.parentElement?.nextSibling : target.parentElement?.parentElement?.nextElementSibling ? // svg -> div.folder-container target.parentElement : // button.folder-button -> div -> div.folder-container target.parentElement?.parentElement ) as MaybeHTMLElement const currentFolderParent = ( isSvg ? target.nextElementSibling : target.parentElement ) as MaybeHTMLElement if (!(childFolderContainer && currentFolderParent)) return // <li> element of folder (stores folder-path dataset) if (!folderContainer) return const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement if (!childFolderContainer) return childFolderContainer.classList.toggle("open") // Collapse folder container const isCollapsed = childFolderContainer.classList.contains("open") setFolderState(childFolderContainer, !isCollapsed) const isCollapsed = !childFolderContainer.classList.contains("open") setFolderState(childFolderContainer, isCollapsed) // Save folder state to localStorage const fullFolderPath = currentFolderParent.dataset.folderpath as string toggleCollapsedByPath(currentExplorerState, fullFolderPath) const currentFolderState = currentExplorerState.find( (item) => item.path === folderContainer.dataset.folderpath, ) if (currentFolderState) { currentFolderState.collapsed = isCollapsed } else { currentExplorerState.push({ path: folderContainer.dataset.folderpath as FullSlug, collapsed: isCollapsed, }) } const stringifiedFileTree = JSON.stringify(currentExplorerState) localStorage.setItem("fileTree", stringifiedFileTree) } function setupExplorer() { // Set click handler for collapsing entire explorer const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement> function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement { const template = document.getElementById("template-file") as HTMLTemplateElement const clone = template.content.cloneNode(true) as DocumentFragment const li = clone.querySelector("li") as HTMLLIElement const a = li.querySelector("a") as HTMLAnchorElement a.href = resolveRelative(currentSlug, node.data?.slug!) a.dataset.for = node.data?.slug a.textContent = node.displayName if (currentSlug === node.data?.slug) { a.classList.add("active") } return li } function createFolderNode( currentSlug: FullSlug, node: FileTrieNode, opts: ParsedOptions, ): HTMLLIElement { const template = document.getElementById("template-folder") as HTMLTemplateElement const clone = template.content.cloneNode(true) as DocumentFragment const li = clone.querySelector("li") as HTMLLIElement const folderContainer = li.querySelector(".folder-container") as HTMLElement const titleContainer = folderContainer.querySelector("div") as HTMLElement const folderOuter = li.querySelector(".folder-outer") as HTMLElement const ul = folderOuter.querySelector("ul") as HTMLUListElement const folderPath = node.data?.slug! folderContainer.dataset.folderpath = folderPath if (opts.folderClickBehavior === "link") { // Replace button with link for link behavior const button = titleContainer.querySelector(".folder-button") as HTMLElement const a = document.createElement("a") a.href = resolveRelative(currentSlug, folderPath) a.dataset.for = node.data?.slug a.className = "folder-title" a.textContent = node.displayName button.replaceWith(a) } else { const span = titleContainer.querySelector(".folder-title") as HTMLElement span.textContent = node.displayName } // if the saved state is collapsed or the default state is collapsed const isCollapsed = currentExplorerState.find((item) => item.path === folderPath)?.collapsed ?? opts.folderDefaultState === "collapsed" // if this folder is a prefix of the current path we // want to open it anyways const simpleFolderPath = simplifySlug(folderPath) const folderIsPrefixOfCurrentSlug = simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length) if (!isCollapsed || folderIsPrefixOfCurrentSlug) { folderOuter.classList.add("open") } for (const child of node.children) { const childNode = child.data ? createFileNode(currentSlug, child) : createFolderNode(currentSlug, child, opts) ul.appendChild(childNode) } return li } async function setupExplorer(currentSlug: FullSlug) { const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement> for (const explorer of allExplorers) { const dataFns = JSON.parse(explorer.dataset.dataFns || "{}") const opts: ParsedOptions = { folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link", folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open", useSavedState: explorer.dataset.savestate === "true", order: dataFns.order || ["filter", "map", "sort"], sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(), filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(), mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(), } // Get folder state from local storage const storageTree = localStorage.getItem("fileTree") 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 } } quartz/components/scripts/spa.inline.ts
@@ -75,6 +75,10 @@ if (!contents) return // notify about to nav const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} }) document.dispatchEvent(event) // cleanup old cleanupFns.forEach((fn) => fn()) cleanupFns.clear() @@ -108,7 +112,7 @@ } } // now, patch head // now, patch head, re-executing scripts const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") elementsToRemove.forEach((el) => el.remove()) const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])") quartz/components/scripts/toc.inline.ts
@@ -1,4 +1,3 @@ const bufferPx = 150 const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const slug = entry.target.id @@ -28,7 +27,6 @@ function setupToc() { const toc = document.getElementById("toc") if (toc) { const collapsed = toc.classList.contains("collapsed") const content = toc.nextElementSibling as HTMLElement | undefined if (!content) return toc.addEventListener("click", toggleToc) quartz/components/scripts/util.ts
@@ -37,6 +37,7 @@ if (!res.headers.get("content-type")?.startsWith("text/html")) { return res } // reading the body can only be done once, so we need to clone the response // to allow the caller to read it if it's was not a redirect const text = await res.clone().text() 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; } } } quartz/components/styles/darkmode.scss
@@ -8,6 +8,7 @@ height: 20px; margin: 0 10px; text-align: inherit; flex-shrink: 0; & svg { position: absolute; 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 { 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 @@ 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 @@ .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 @@ 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); } } } } quartz/components/styles/toc.scss
@@ -4,8 +4,10 @@ display: flex; flex-direction: column; &.desktop-only { max-height: 40%; overflow-y: hidden; flex: 0 1 auto; &:has(button#toc.collapsed) { flex: 0 1 1.2rem; } } @@ -44,26 +46,7 @@ #toc-content { list-style: none; overflow: hidden; overflow-y: auto; max-height: 100%; transition: max-height 0.35s ease, visibility 0s linear 0s; position: relative; visibility: visible; &.collapsed { max-height: 0; transition: max-height 0.35s ease, visibility 0s linear 0.35s; visibility: hidden; } &.collapsed > .overflow::after { opacity: 0; } & ul { list-style: none; @@ -80,10 +63,6 @@ } } } > ul.overflow { max-height: none; width: 100%; } @for $i from 0 through 6 { & .depth-#{$i} { quartz/plugins/emitters/contentIndex.tsx
@@ -11,6 +11,7 @@ export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentDetails = { slug: FullSlug title: string links: SimpleSlug[] tags: string[] @@ -124,6 +125,7 @@ const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { slug, title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], quartz/styles/base.scss
@@ -543,7 +543,6 @@ div:has(> .overflow) { display: flex; overflow-y: auto; max-height: 100%; } @@ -551,6 +550,7 @@ ol.overflow { max-height: 100%; overflow-y: auto; width: 100%; // clearfix content: ""; @@ -559,18 +559,15 @@ & > li:last-of-type { margin-bottom: 30px; } /*&:after { pointer-events: none; content: ""; width: 100%; height: 50px; position: absolute; left: 0; bottom: 0; opacity: 1; transition: opacity 0.3s ease; background: linear-gradient(transparent 0px, var(--light)); }*/ & > li.overflow-end { height: 4px; margin: 0; } &.gradient-active { mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%); } } .transclude { quartz/util/clone.ts
New file @@ -0,0 +1,3 @@ import rfdc from "rfdc" export const clone = rfdc() quartz/util/fileTrie.test.ts
New 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"], ) }) }) }) quartz/util/fileTrie.ts
New 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) } } 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"