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:
		
							parent
							
								
									a201105442
								
							
						
					
					
						commit
						5480269d38
					
				| @ -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
									
									
								
							
							
						
						
									
										1
									
								
								index.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -5,6 +5,7 @@ declare module "*.scss" { | ||||
| 
 | ||||
| // dom custom event
 | ||||
| interface CustomEventMap { | ||||
|   prenav: CustomEvent<{}> | ||||
|   nav: CustomEvent<{ url: FullSlug }> | ||||
|   themechange: CustomEvent<{ theme: "light" | "dark" }> | ||||
| } | ||||
|  | ||||
| @ -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": { | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -1,242 +0,0 @@ | ||||
| // @ts-ignore
 | ||||
| import { QuartzPluginData } from "../plugins/vfile" | ||||
| import { | ||||
|   joinSegments, | ||||
|   resolveRelative, | ||||
|   clone, | ||||
|   simplifySlug, | ||||
|   SimpleSlug, | ||||
|   FilePath, | ||||
| } from "../util/path" | ||||
| 
 | ||||
| type OrderEntries = "sort" | "filter" | "map" | ||||
| 
 | ||||
| export interface Options { | ||||
|   title?: string | ||||
|   folderDefaultState: "collapsed" | "open" | ||||
|   folderClickBehavior: "collapse" | "link" | ||||
|   useSavedState: boolean | ||||
|   sortFn: (a: FileNode, b: FileNode) => number | ||||
|   filterFn: (node: FileNode) => boolean | ||||
|   mapFn: (node: FileNode) => void | ||||
|   order: OrderEntries[] | ||||
| } | ||||
| 
 | ||||
| type DataWrapper = { | ||||
|   file: QuartzPluginData | ||||
|   path: string[] | ||||
| } | ||||
| 
 | ||||
| export type FolderState = { | ||||
|   path: string | ||||
|   collapsed: boolean | ||||
| } | ||||
| 
 | ||||
| function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined { | ||||
|   if (!fp) { | ||||
|     return undefined | ||||
|   } | ||||
| 
 | ||||
|   return fp.split("/").at(idx) | ||||
| } | ||||
| 
 | ||||
| // Structure to add all files into a tree
 | ||||
| export class FileNode { | ||||
|   children: Array<FileNode> | ||||
|   name: string // this is the slug segment
 | ||||
|   displayName: string | ||||
|   file: QuartzPluginData | null | ||||
|   depth: number | ||||
| 
 | ||||
|   constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { | ||||
|     this.children = [] | ||||
|     this.name = slugSegment | ||||
|     this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment | ||||
|     this.file = file ? clone(file) : null | ||||
|     this.depth = depth ?? 0 | ||||
|   } | ||||
| 
 | ||||
|   private insert(fileData: DataWrapper) { | ||||
|     if (fileData.path.length === 0) { | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const nextSegment = fileData.path[0] | ||||
| 
 | ||||
|     // base case, insert here
 | ||||
|     if (fileData.path.length === 1) { | ||||
|       if (nextSegment === "") { | ||||
|         // index case (we are the root and we just found index.md), set our data appropriately
 | ||||
|         const title = fileData.file.frontmatter?.title | ||||
|         if (title && title !== "index") { | ||||
|           this.displayName = title | ||||
|         } | ||||
|       } else { | ||||
|         // direct child
 | ||||
|         this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1)) | ||||
|       } | ||||
| 
 | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // find the right child to insert into
 | ||||
|     fileData.path = fileData.path.splice(1) | ||||
|     const child = this.children.find((c) => c.name === nextSegment) | ||||
|     if (child) { | ||||
|       child.insert(fileData) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const newChild = new FileNode( | ||||
|       nextSegment, | ||||
|       getPathSegment(fileData.file.relativePath, this.depth), | ||||
|       undefined, | ||||
|       this.depth + 1, | ||||
|     ) | ||||
|     newChild.insert(fileData) | ||||
|     this.children.push(newChild) | ||||
|   } | ||||
| 
 | ||||
|   // Add new file to tree
 | ||||
|   add(file: QuartzPluginData) { | ||||
|     this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place | ||||
|    * @param filterFn function to filter tree with | ||||
|    */ | ||||
|   filter(filterFn: (node: FileNode) => boolean) { | ||||
|     this.children = this.children.filter(filterFn) | ||||
|     this.children.forEach((child) => child.filter(filterFn)) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place | ||||
|    * @param mapFn function to use for mapping over tree | ||||
|    */ | ||||
|   map(mapFn: (node: FileNode) => void) { | ||||
|     mapFn(this) | ||||
|     this.children.forEach((child) => child.map(mapFn)) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get folder representation with state of tree. | ||||
|    * Intended to only be called on root node before changes to the tree are made | ||||
|    * @param collapsed default state of folders (collapsed by default or not) | ||||
|    * @returns array containing folder state for tree | ||||
|    */ | ||||
|   getFolderPaths(collapsed: boolean): FolderState[] { | ||||
|     const folderPaths: FolderState[] = [] | ||||
| 
 | ||||
|     const traverse = (node: FileNode, currentPath: string) => { | ||||
|       if (!node.file) { | ||||
|         const folderPath = joinSegments(currentPath, node.name) | ||||
|         if (folderPath !== "") { | ||||
|           folderPaths.push({ path: folderPath, collapsed }) | ||||
|         } | ||||
| 
 | ||||
|         node.children.forEach((child) => traverse(child, folderPath)) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     traverse(this, "") | ||||
|     return folderPaths | ||||
|   } | ||||
| 
 | ||||
|   // Sort order: folders first, then files. Sort folders and files alphabetically
 | ||||
|   /** | ||||
|    * Sorts tree according to sort/compare function | ||||
|    * @param sortFn compare function used for `.sort()`, also used recursively for children | ||||
|    */ | ||||
|   sort(sortFn: (a: FileNode, b: FileNode) => number) { | ||||
|     this.children = this.children.sort(sortFn) | ||||
|     this.children.forEach((e) => e.sort(sortFn)) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type ExplorerNodeProps = { | ||||
|   node: FileNode | ||||
|   opts: Options | ||||
|   fileData: QuartzPluginData | ||||
|   fullPath?: string | ||||
| } | ||||
| 
 | ||||
| export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { | ||||
|   // Get options
 | ||||
|   const folderBehavior = opts.folderClickBehavior | ||||
|   const isDefaultOpen = opts.folderDefaultState === "open" | ||||
| 
 | ||||
|   // Calculate current folderPath
 | ||||
|   const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" | ||||
|   const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       {node.file ? ( | ||||
|         // Single file node
 | ||||
|         <li key={node.file.slug}> | ||||
|           <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}> | ||||
|             {node.displayName} | ||||
|           </a> | ||||
|         </li> | ||||
|       ) : ( | ||||
|         <li> | ||||
|           {node.name !== "" && ( | ||||
|             // Node with entire folder
 | ||||
|             // Render svg button + folder name, then children
 | ||||
|             <div class="folder-container"> | ||||
|               <svg | ||||
|                 xmlns="http://www.w3.org/2000/svg" | ||||
|                 width="12" | ||||
|                 height="12" | ||||
|                 viewBox="5 8 14 8" | ||||
|                 fill="none" | ||||
|                 stroke="currentColor" | ||||
|                 stroke-width="2" | ||||
|                 stroke-linecap="round" | ||||
|                 stroke-linejoin="round" | ||||
|                 class="folder-icon" | ||||
|               > | ||||
|                 <polyline points="6 9 12 15 18 9"></polyline> | ||||
|               </svg> | ||||
|               {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */} | ||||
|               <div key={node.name} data-folderpath={folderPath}> | ||||
|                 {folderBehavior === "link" ? ( | ||||
|                   <a href={href} data-for={node.name} class="folder-title"> | ||||
|                     {node.displayName} | ||||
|                   </a> | ||||
|                 ) : ( | ||||
|                   <button class="folder-button"> | ||||
|                     <span class="folder-title">{node.displayName}</span> | ||||
|                   </button> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           )} | ||||
|           {/* Recursively render children of folder */} | ||||
|           <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}> | ||||
|             <ul | ||||
|               // Inline style for left folder paddings
 | ||||
|               style={{ | ||||
|                 paddingLeft: node.name !== "" ? "1.4rem" : "0", | ||||
|               }} | ||||
|               class="content" | ||||
|               data-folderul={folderPath} | ||||
|             > | ||||
|               {node.children.map((childNode, i) => ( | ||||
|                 <ExplorerNode | ||||
|                   node={childNode} | ||||
|                   key={i} | ||||
|                   opts={opts} | ||||
|                   fullPath={folderPath} | ||||
|                   fileData={fileData} | ||||
|                 /> | ||||
|               ))} | ||||
|             </ul> | ||||
|           </div> | ||||
|         </li> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										39
									
								
								quartz/components/OverflowList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								quartz/components/OverflowList.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | ||||
| import { JSX } from "preact" | ||||
| 
 | ||||
| const OverflowList = ({ | ||||
|   children, | ||||
|   ...props | ||||
| }: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => { | ||||
|   return ( | ||||
|     <ul class="overflow" {...props}> | ||||
|       {children} | ||||
|       <li class="overflow-end" /> | ||||
|     </ul> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| OverflowList.afterDOMLoaded = (id: string) => ` | ||||
| document.addEventListener("nav", (e) => { | ||||
|   const observer = new IntersectionObserver((entries) => { | ||||
|     for (const entry of entries) { | ||||
|       const parentUl = entry.target.parentElement | ||||
|       if (entry.isIntersecting) { | ||||
|         parentUl.classList.remove("gradient-active") | ||||
|       } else { | ||||
|         parentUl.classList.add("gradient-active") | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   const ul = document.getElementById("${id}") | ||||
|   if (!ul) return | ||||
| 
 | ||||
|   const end = ul.querySelector(".overflow-end") | ||||
|   if (!end) return | ||||
| 
 | ||||
|   observer.observe(end) | ||||
|   window.addCleanup(() => observer.disconnect()) | ||||
| }) | ||||
| ` | ||||
| 
 | ||||
| export default OverflowList | ||||
| @ -6,6 +6,7 @@ import { classNames } from "../util/lang" | ||||
| // @ts-ignore
 | ||||
| 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) { | ||||
|  | ||||
| @ -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" | ||||
|  | ||||
| @ -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, | ||||
|     })) | ||||
| 
 | ||||
|     const explorerUl = document.getElementById("explorer-ul") | ||||
|     if (!explorerUl) continue | ||||
| 
 | ||||
|     // Create and insert new content
 | ||||
|     const fragment = document.createDocumentFragment() | ||||
|     for (const child of trie.children) { | ||||
|       const node = child.isFolder | ||||
|         ? createFolderNode(currentSlug, child, opts) | ||||
|         : createFileNode(currentSlug, child) | ||||
| 
 | ||||
|       fragment.appendChild(node) | ||||
|     } | ||||
|     explorerUl.insertBefore(fragment, explorerUl.firstChild) | ||||
| 
 | ||||
|     // restore explorer scrollTop position if it exists
 | ||||
|     const scrollTop = sessionStorage.getItem("explorerScrollTop") | ||||
|     if (scrollTop) { | ||||
|       explorerUl.scrollTop = parseInt(scrollTop) | ||||
|     } else { | ||||
|       // try to scroll to the active element if it exists
 | ||||
|       const activeElement = explorerUl.querySelector(".active") | ||||
|       if (activeElement) { | ||||
|         activeElement.scrollIntoView({ behavior: "smooth" }) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     currentExplorerState.map((folderState) => { | ||||
|       const folderLi = document.querySelector( | ||||
|         `[data-folderpath='${folderState.path.replace("'", "-")}']`, | ||||
|       ) as MaybeHTMLElement | ||||
|       const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement | ||||
|       if (folderUl) { | ||||
|         setFolderState(folderUl, folderState.collapsed) | ||||
|     // Set up event handlers
 | ||||
|     const explorerButtons = explorer.querySelectorAll( | ||||
|       "button.explorer-toggle", | ||||
|     ) as NodeListOf<HTMLElement> | ||||
|     if (explorerButtons) { | ||||
|       window.addCleanup(() => | ||||
|         explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)), | ||||
|       ) | ||||
|       explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer)) | ||||
|     } | ||||
| 
 | ||||
|     // Set up folder click handlers
 | ||||
|     if (opts.folderClickBehavior === "collapse") { | ||||
|       const folderButtons = explorer.getElementsByClassName( | ||||
|         "folder-button", | ||||
|       ) as HTMLCollectionOf<HTMLElement> | ||||
|       for (const button of folderButtons) { | ||||
|         window.addCleanup(() => button.removeEventListener("click", toggleFolder)) | ||||
|         button.addEventListener("click", toggleFolder) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const folderIcons = explorer.getElementsByClassName( | ||||
|       "folder-icon", | ||||
|     ) as HTMLCollectionOf<HTMLElement> | ||||
|     for (const icon of folderIcons) { | ||||
|       window.addCleanup(() => icon.removeEventListener("click", toggleFolder)) | ||||
|       icon.addEventListener("click", toggleFolder) | ||||
|     } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 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]"), | ||||
|     ) | ||||
|     if (folderUl) { | ||||
|       if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) { | ||||
|         if (!element.classList.contains("open")) { | ||||
|           element.classList.add("open") | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| window.addEventListener("resize", setupExplorer) | ||||
| 
 | ||||
| document.addEventListener("nav", () => { | ||||
|   const explorer = document.querySelector("#mobile-explorer") | ||||
|   if (explorer) { | ||||
|     explorer.classList.add("collapsed") | ||||
|     const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement | ||||
|     if (content) { | ||||
|       content.classList.add("collapsed") | ||||
|       content.classList.toggle("explorer-viewmode") | ||||
|     } | ||||
|   } | ||||
|   setupExplorer() | ||||
| 
 | ||||
|   observer.disconnect() | ||||
| 
 | ||||
|   // select pseudo element at end of list
 | ||||
|   const lastItem = document.getElementById("explorer-end") | ||||
|   if (lastItem) { | ||||
|     observer.observe(lastItem) | ||||
|   } | ||||
| 
 | ||||
|   // Hide explorer on mobile until it is requested
 | ||||
|   const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer") | ||||
|   hiddenUntilDoneLoading?.classList.remove("hide-until-loaded") | ||||
| 
 | ||||
|   toggleExplorerFolders() | ||||
| 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 <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 | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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])") | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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() | ||||
|  | ||||
| @ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -8,6 +8,7 @@ | ||||
|   height: 20px; | ||||
|   margin: 0 10px; | ||||
|   text-align: inherit; | ||||
|   flex-shrink: 0; | ||||
| 
 | ||||
|   & svg { | ||||
|     position: absolute; | ||||
|  | ||||
| @ -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,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; | ||||
|     &.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); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -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} { | ||||
|  | ||||
| @ -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 ?? [], | ||||
|  | ||||
| @ -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
									
								
							
							
						
						
									
										3
									
								
								quartz/util/clone.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| import rfdc from "rfdc" | ||||
| 
 | ||||
| export const clone = rfdc() | ||||
							
								
								
									
										190
									
								
								quartz/util/fileTrie.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								quartz/util/fileTrie.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,190 @@ | ||||
| import test, { describe, beforeEach } from "node:test" | ||||
| import assert from "node:assert" | ||||
| import { FileTrieNode } from "./fileTrie" | ||||
| 
 | ||||
| interface TestData { | ||||
|   title: string | ||||
|   slug: string | ||||
| } | ||||
| 
 | ||||
| describe("FileTrie", () => { | ||||
|   let trie: FileTrieNode<TestData> | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     trie = new FileTrieNode<TestData>("") | ||||
|   }) | ||||
| 
 | ||||
|   describe("constructor", () => { | ||||
|     test("should create an empty trie", () => { | ||||
|       assert.deepStrictEqual(trie.children, []) | ||||
|       assert.strictEqual(trie.slugSegment, "") | ||||
|       assert.strictEqual(trie.displayName, "") | ||||
|       assert.strictEqual(trie.data, null) | ||||
|       assert.strictEqual(trie.depth, 0) | ||||
|     }) | ||||
| 
 | ||||
|     test("should set displayName from data title", () => { | ||||
|       const data = { | ||||
|         title: "Test Title", | ||||
|         slug: "test", | ||||
|       } | ||||
| 
 | ||||
|       trie.add(data) | ||||
|       assert.strictEqual(trie.children[0].displayName, "Test Title") | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("add", () => { | ||||
|     test("should add a file at root level", () => { | ||||
|       const data = { | ||||
|         title: "Test", | ||||
|         slug: "test", | ||||
|       } | ||||
| 
 | ||||
|       trie.add(data) | ||||
|       assert.strictEqual(trie.children.length, 1) | ||||
|       assert.strictEqual(trie.children[0].slugSegment, "test") | ||||
|       assert.strictEqual(trie.children[0].data, data) | ||||
|     }) | ||||
| 
 | ||||
|     test("should handle index files", () => { | ||||
|       const data = { | ||||
|         title: "Index", | ||||
|         slug: "index", | ||||
|       } | ||||
| 
 | ||||
|       trie.add(data) | ||||
|       assert.strictEqual(trie.data, data) | ||||
|       assert.strictEqual(trie.children.length, 0) | ||||
|     }) | ||||
| 
 | ||||
|     test("should add nested files", () => { | ||||
|       const data1 = { | ||||
|         title: "Nested", | ||||
|         slug: "folder/test", | ||||
|       } | ||||
| 
 | ||||
|       const data2 = { | ||||
|         title: "Really nested index", | ||||
|         slug: "a/b/c/index", | ||||
|       } | ||||
| 
 | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
|       assert.strictEqual(trie.children.length, 2) | ||||
|       assert.strictEqual(trie.children[0].slugSegment, "folder") | ||||
|       assert.strictEqual(trie.children[0].children.length, 1) | ||||
|       assert.strictEqual(trie.children[0].children[0].slugSegment, "test") | ||||
|       assert.strictEqual(trie.children[0].children[0].data, data1) | ||||
| 
 | ||||
|       assert.strictEqual(trie.children[1].slugSegment, "a") | ||||
|       assert.strictEqual(trie.children[1].children.length, 1) | ||||
|       assert.strictEqual(trie.children[1].data, null) | ||||
| 
 | ||||
|       assert.strictEqual(trie.children[1].children[0].slugSegment, "b") | ||||
|       assert.strictEqual(trie.children[1].children[0].children.length, 1) | ||||
|       assert.strictEqual(trie.children[1].children[0].data, null) | ||||
| 
 | ||||
|       assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c") | ||||
|       assert.strictEqual(trie.children[1].children[0].children[0].data, data2) | ||||
|       assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("filter", () => { | ||||
|     test("should filter nodes based on condition", () => { | ||||
|       const data1 = { title: "Test1", slug: "test1" } | ||||
|       const data2 = { title: "Test2", slug: "test2" } | ||||
| 
 | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
| 
 | ||||
|       trie.filter((node) => node.slugSegment !== "test1") | ||||
|       assert.strictEqual(trie.children.length, 1) | ||||
|       assert.strictEqual(trie.children[0].slugSegment, "test2") | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("map", () => { | ||||
|     test("should apply function to all nodes", () => { | ||||
|       const data1 = { title: "Test1", slug: "test1" } | ||||
|       const data2 = { title: "Test2", slug: "test2" } | ||||
| 
 | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
| 
 | ||||
|       trie.map((node) => { | ||||
|         if (node.data) { | ||||
|           node.displayName = "Modified" | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       assert.strictEqual(trie.children[0].displayName, "Modified") | ||||
|       assert.strictEqual(trie.children[1].displayName, "Modified") | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("entries", () => { | ||||
|     test("should return all entries", () => { | ||||
|       const data1 = { title: "Test1", slug: "test1" } | ||||
|       const data2 = { title: "Test2", slug: "a/b/test2" } | ||||
| 
 | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
| 
 | ||||
|       const entries = trie.entries() | ||||
|       assert.deepStrictEqual( | ||||
|         entries.map(([path, node]) => [path, node.data]), | ||||
|         [ | ||||
|           ["", trie.data], | ||||
|           ["test1", data1], | ||||
|           ["a/index", null], | ||||
|           ["a/b/index", null], | ||||
|           ["a/b/test2", data2], | ||||
|         ], | ||||
|       ) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("getFolderPaths", () => { | ||||
|     test("should return all folder paths", () => { | ||||
|       const data1 = { | ||||
|         title: "Root", | ||||
|         slug: "index", | ||||
|       } | ||||
|       const data2 = { | ||||
|         title: "Test", | ||||
|         slug: "folder/subfolder/test", | ||||
|       } | ||||
|       const data3 = { | ||||
|         title: "Folder Index", | ||||
|         slug: "abc/index", | ||||
|       } | ||||
| 
 | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
|       trie.add(data3) | ||||
|       const paths = trie.getFolderPaths() | ||||
| 
 | ||||
|       assert.deepStrictEqual(paths, ["folder/index", "folder/subfolder/index", "abc/index"]) | ||||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   describe("sort", () => { | ||||
|     test("should sort nodes according to sort function", () => { | ||||
|       const data1 = { title: "A", slug: "a" } | ||||
|       const data2 = { title: "B", slug: "b" } | ||||
|       const data3 = { title: "C", slug: "c" } | ||||
| 
 | ||||
|       trie.add(data3) | ||||
|       trie.add(data1) | ||||
|       trie.add(data2) | ||||
| 
 | ||||
|       trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment)) | ||||
|       assert.deepStrictEqual( | ||||
|         trie.children.map((n) => n.slugSegment), | ||||
|         ["a", "b", "c"], | ||||
|       ) | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										128
									
								
								quartz/util/fileTrie.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								quartz/util/fileTrie.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,128 @@ | ||||
| import { ContentDetails } from "../plugins/emitters/contentIndex" | ||||
| import { FullSlug, joinSegments } from "./path" | ||||
| 
 | ||||
| interface FileTrieData { | ||||
|   slug: string | ||||
|   title: string | ||||
| } | ||||
| 
 | ||||
| export class FileTrieNode<T extends FileTrieData = ContentDetails> { | ||||
|   children: Array<FileTrieNode<T>> | ||||
|   slugSegment: string | ||||
|   displayName: string | ||||
|   data: T | null | ||||
|   depth: number | ||||
|   isFolder: boolean | ||||
| 
 | ||||
|   constructor(segment: string, data?: T, depth: number = 0) { | ||||
|     this.children = [] | ||||
|     this.slugSegment = segment | ||||
|     this.displayName = data?.title ?? segment | ||||
|     this.data = data ?? null | ||||
|     this.depth = depth | ||||
|     this.isFolder = segment === "index" | ||||
|   } | ||||
| 
 | ||||
|   private insert(path: string[], file: T) { | ||||
|     if (path.length === 0) return | ||||
| 
 | ||||
|     const nextSegment = path[0] | ||||
| 
 | ||||
|     // base case, insert here
 | ||||
|     if (path.length === 1) { | ||||
|       if (nextSegment === "index") { | ||||
|         // index case (we are the root and we just found index.md)
 | ||||
|         this.data ??= file | ||||
|         const title = file.title | ||||
|         if (title !== "index") { | ||||
|           this.displayName = title | ||||
|         } | ||||
|       } else { | ||||
|         // direct child
 | ||||
|         this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1)) | ||||
|         this.isFolder = true | ||||
|       } | ||||
| 
 | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // find the right child to insert into, creating it if it doesn't exist
 | ||||
|     path = path.splice(1) | ||||
|     let child = this.children.find((c) => c.slugSegment === nextSegment) | ||||
|     if (!child) { | ||||
|       child = new FileTrieNode<T>(nextSegment, undefined, this.depth + 1) | ||||
|       this.children.push(child) | ||||
|       child.isFolder = true | ||||
|     } | ||||
| 
 | ||||
|     child.insert(path, file) | ||||
|   } | ||||
| 
 | ||||
|   // Add new file to trie
 | ||||
|   add(file: T) { | ||||
|     this.insert(file.slug.split("/"), file) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place | ||||
|    */ | ||||
|   filter(filterFn: (node: FileTrieNode<T>) => boolean) { | ||||
|     this.children = this.children.filter(filterFn) | ||||
|     this.children.forEach((child) => child.filter(filterFn)) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place | ||||
|    */ | ||||
|   map(mapFn: (node: FileTrieNode<T>) => void) { | ||||
|     mapFn(this) | ||||
|     this.children.forEach((child) => child.map(mapFn)) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Sort trie nodes according to sort/compare function | ||||
|    */ | ||||
|   sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) { | ||||
|     this.children = this.children.sort(sortFn) | ||||
|     this.children.forEach((e) => e.sort(sortFn)) | ||||
|   } | ||||
| 
 | ||||
|   static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) { | ||||
|     const trie = new FileTrieNode<T>("") | ||||
|     entries.forEach(([, entry]) => trie.add(entry)) | ||||
|     return trie | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get all entries in the trie | ||||
|    * in the a flat array including the full path and the node | ||||
|    */ | ||||
|   entries(): [FullSlug, FileTrieNode<T>][] { | ||||
|     const traverse = ( | ||||
|       node: FileTrieNode<T>, | ||||
|       currentPath: string, | ||||
|     ): [FullSlug, FileTrieNode<T>][] => { | ||||
|       const segments = [currentPath, node.slugSegment] | ||||
|       const fullPath = joinSegments(...segments) as FullSlug | ||||
| 
 | ||||
|       const indexQualifiedPath = | ||||
|         node.isFolder && node.depth > 0 ? (joinSegments(fullPath, "index") as FullSlug) : fullPath | ||||
| 
 | ||||
|       const result: [FullSlug, FileTrieNode<T>][] = [[indexQualifiedPath, node]] | ||||
| 
 | ||||
|       return result.concat(...node.children.map((child) => traverse(child, fullPath))) | ||||
|     } | ||||
| 
 | ||||
|     return traverse(this, "") | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get all folder paths in the trie | ||||
|    * @returns array containing folder state for trie | ||||
|    */ | ||||
|   getFolderPaths() { | ||||
|     return this.entries() | ||||
|       .filter(([_, node]) => node.isFolder) | ||||
|       .map(([path, _]) => path) | ||||
|   } | ||||
| } | ||||
| @ -1,9 +1,6 @@ | ||||
| import { slug as slugAnchor } from "github-slugger" | ||||
| import 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" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user