fix: cleanup a href link construction, global shared trie, breadcrumbs use trie
| | |
| | | import { randomIdNonSecure } from "./util/random" |
| | | import { ChangeEvent } from "./plugins/types" |
| | | import { minimatch } from "minimatch" |
| | | import { FileTrieNode } from "./util/fileTrie" |
| | | |
| | | type ContentMap = Map< |
| | | FilePath, |
| | |
| | | import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import breadcrumbsStyle from "./styles/breadcrumbs.scss" |
| | | import { FullSlug, SimpleSlug, joinSegments, resolveRelative } from "../util/path" |
| | | import { QuartzPluginData } from "../plugins/vfile" |
| | | import { FullSlug, SimpleSlug, resolveRelative, simplifySlug } from "../util/path" |
| | | import { classNames } from "../util/lang" |
| | | import { trieFromAllFiles } from "../util/ctx" |
| | | |
| | | type CrumbData = { |
| | | displayName: string |
| | |
| | | */ |
| | | resolveFrontmatterTitle: boolean |
| | | /** |
| | | * Whether to display breadcrumbs on root `index.md` |
| | | */ |
| | | hideOnRoot: boolean |
| | | /** |
| | | * Whether to display the current page in the breadcrumbs. |
| | | */ |
| | | showCurrentPage: boolean |
| | |
| | | spacerSymbol: "❯", |
| | | rootName: "Home", |
| | | resolveFrontmatterTitle: true, |
| | | hideOnRoot: true, |
| | | showCurrentPage: true, |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | export default ((opts?: Partial<BreadcrumbOptions>) => { |
| | | // Merge options with defaults |
| | | const options: BreadcrumbOptions = { ...defaultOptions, ...opts } |
| | | |
| | | // computed index of folder name to its associated file data |
| | | let folderIndex: Map<string, QuartzPluginData> | undefined |
| | | |
| | | const Breadcrumbs: QuartzComponent = ({ |
| | | fileData, |
| | | allFiles, |
| | | displayClass, |
| | | ctx, |
| | | }: QuartzComponentProps) => { |
| | | // Hide crumbs on root if enabled |
| | | if (options.hideOnRoot && fileData.slug === "index") { |
| | | return <></> |
| | | const trie = (ctx.trie ??= trieFromAllFiles(allFiles)) |
| | | const slugParts = fileData.slug!.split("/") |
| | | const pathNodes = trie.ancestryChain(slugParts) |
| | | |
| | | if (!pathNodes) { |
| | | return null |
| | | } |
| | | |
| | | // Format entry for root element |
| | | const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug) |
| | | const crumbs: CrumbData[] = [firstEntry] |
| | | |
| | | if (!folderIndex && options.resolveFrontmatterTitle) { |
| | | folderIndex = new Map() |
| | | // construct the index for the first time |
| | | for (const file of allFiles) { |
| | | const folderParts = file.slug?.split("/") |
| | | if (folderParts?.at(-1) === "index") { |
| | | folderIndex.set(folderParts.slice(0, -1).join("/"), file) |
| | | } |
| | | } |
| | | const crumbs: CrumbData[] = pathNodes.map((node, idx) => { |
| | | const crumb = formatCrumb(node.displayName, fileData.slug!, simplifySlug(node.slug)) |
| | | if (idx === 0) { |
| | | crumb.displayName = options.rootName |
| | | } |
| | | |
| | | // Split slug into hierarchy/parts |
| | | const slugParts = fileData.slug?.split("/") |
| | | if (slugParts) { |
| | | // is tag breadcrumb? |
| | | const isTagPath = slugParts[0] === "tags" |
| | | |
| | | // full path until current part |
| | | let currentPath = "" |
| | | |
| | | for (let i = 0; i < slugParts.length - 1; i++) { |
| | | let curPathSegment = slugParts[i] |
| | | |
| | | // Try to resolve frontmatter folder title |
| | | const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/")) |
| | | if (currentFile) { |
| | | const title = currentFile.frontmatter!.title |
| | | if (title !== "index") { |
| | | curPathSegment = title |
| | | } |
| | | // For last node (current page), set empty path |
| | | if (idx === pathNodes.length - 1) { |
| | | crumb.path = "" |
| | | } |
| | | |
| | | // Add current slug to full path |
| | | currentPath = joinSegments(currentPath, slugParts[i]) |
| | | const includeTrailingSlash = !isTagPath || i < slugParts.length - 1 |
| | | |
| | | // Format and add current crumb |
| | | const crumb = formatCrumb( |
| | | curPathSegment, |
| | | fileData.slug!, |
| | | (currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug, |
| | | ) |
| | | crumbs.push(crumb) |
| | | } |
| | | |
| | | // Add current file to crumb (can directly use frontmatter title) |
| | | if (options.showCurrentPage && slugParts.at(-1) !== "index") { |
| | | crumbs.push({ |
| | | displayName: fileData.frontmatter!.title, |
| | | path: "", |
| | | return crumb |
| | | }) |
| | | } |
| | | |
| | | if (!options.showCurrentPage) { |
| | | crumbs.pop() |
| | | } |
| | | |
| | | return ( |
| | |
| | | import { pathToRoot, slugTag } from "../util/path" |
| | | import { FullSlug, resolveRelative } from "../util/path" |
| | | import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import { classNames } from "../util/lang" |
| | | |
| | | const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { |
| | | const tags = fileData.frontmatter?.tags |
| | | const baseDir = pathToRoot(fileData.slug!) |
| | | if (tags && tags.length > 0) { |
| | | return ( |
| | | <ul class={classNames(displayClass, "tags")}> |
| | | {tags.map((tag) => { |
| | | const linkDest = baseDir + `/tags/${slugTag(tag)}` |
| | | const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug) |
| | | return ( |
| | | <li> |
| | | <a href={linkDest} class="internal tag-link"> |
| | |
| | | import { QuartzPluginData } from "../../plugins/vfile" |
| | | import { ComponentChildren } from "preact" |
| | | import { concatenateResources } from "../../util/resources" |
| | | import { FileTrieNode } from "../../util/fileTrie" |
| | | import { trieFromAllFiles } from "../../util/ctx" |
| | | |
| | | interface FolderContentOptions { |
| | | /** |
| | | * Whether to display number of folders |
| | |
| | | |
| | | export default ((opts?: Partial<FolderContentOptions>) => { |
| | | const options: FolderContentOptions = { ...defaultOptions, ...opts } |
| | | let trie: FileTrieNode< |
| | | QuartzPluginData & { |
| | | slug: string |
| | | title: string |
| | | filePath: string |
| | | } |
| | | > |
| | | |
| | | const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { |
| | | const { tree, fileData, allFiles, cfg } = props |
| | | |
| | | if (!trie) { |
| | | trie = new FileTrieNode([]) |
| | | allFiles.forEach((file) => { |
| | | if (file.frontmatter) { |
| | | trie.add({ |
| | | ...file, |
| | | slug: file.slug!, |
| | | title: file.frontmatter.title, |
| | | filePath: file.filePath!, |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles)) |
| | | const folder = trie.findNode(fileData.slug!.split("/")) |
| | | if (!folder) { |
| | | return null |
| | |
| | | import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../types" |
| | | import style from "../styles/listPage.scss" |
| | | import { PageList, SortFn } from "../PageList" |
| | | import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" |
| | | import { FullSlug, getAllSegmentPrefixes, resolveRelative, simplifySlug } from "../../util/path" |
| | | import { QuartzPluginData } from "../../plugins/vfile" |
| | | import { Root } from "hast" |
| | | import { htmlToJsx } from "../../util/jsx" |
| | |
| | | ? contentPage?.description |
| | | : htmlToJsx(contentPage.filePath!, root) |
| | | |
| | | const tagListingPage = `/tags/${tag}` as FullSlug |
| | | const href = resolveRelative(fileData.slug!, tagListingPage) |
| | | |
| | | return ( |
| | | <div> |
| | | <h2> |
| | | <a class="internal tag-link" href={`../tags/${tag}`}> |
| | | <a class="internal tag-link" href={href}> |
| | | {tag} |
| | | </a> |
| | | </h2> |
| | |
| | | import { QuartzConfig } from "../cfg" |
| | | import { QuartzPluginData } from "../plugins/vfile" |
| | | import { FileTrieNode } from "./fileTrie" |
| | | import { FilePath, FullSlug } from "./path" |
| | | |
| | | export interface Argv { |
| | |
| | | concurrency?: number |
| | | } |
| | | |
| | | export type BuildTimeTrieData = QuartzPluginData & { |
| | | slug: string |
| | | title: string |
| | | filePath: string |
| | | } |
| | | |
| | | export interface BuildCtx { |
| | | buildId: string |
| | | argv: Argv |
| | | cfg: QuartzConfig |
| | | allSlugs: FullSlug[] |
| | | allFiles: FilePath[] |
| | | trie?: FileTrieNode<BuildTimeTrieData> |
| | | incremental: boolean |
| | | } |
| | | |
| | | export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg"> |
| | | export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> { |
| | | const trie = new FileTrieNode<BuildTimeTrieData>([]) |
| | | allFiles.forEach((file) => { |
| | | if (file.frontmatter) { |
| | | trie.add({ |
| | | ...file, |
| | | slug: file.slug!, |
| | | title: file.frontmatter.title, |
| | | filePath: file.filePath!, |
| | | }) |
| | | } |
| | | }) |
| | | |
| | | return trie |
| | | } |
| | | |
| | | export type WorkerSerializableBuildCtx = Omit<BuildCtx, "cfg" | "trie"> |
| | |
| | | ) |
| | | }) |
| | | }) |
| | | |
| | | describe("pathToNode", () => { |
| | | test("should return root node for empty path", () => { |
| | | const data = { title: "Root", slug: "index", filePath: "index.md" } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain([]) |
| | | assert.deepStrictEqual(path, [trie]) |
| | | }) |
| | | |
| | | test("should return root node for index path", () => { |
| | | const data = { title: "Root", slug: "index", filePath: "index.md" } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain(["index"]) |
| | | assert.deepStrictEqual(path, [trie]) |
| | | }) |
| | | |
| | | test("should return path to first level node", () => { |
| | | const data = { title: "Test", slug: "test", filePath: "test.md" } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain(["test"]) |
| | | assert.deepStrictEqual(path, [trie, trie.children[0]]) |
| | | }) |
| | | |
| | | test("should return path to nested node", () => { |
| | | const data = { |
| | | title: "Nested", |
| | | slug: "folder/subfolder/test", |
| | | filePath: "folder/subfolder/test.md", |
| | | } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain(["folder", "subfolder", "test"]) |
| | | assert.deepStrictEqual(path, [ |
| | | trie, |
| | | trie.children[0], |
| | | trie.children[0].children[0], |
| | | trie.children[0].children[0].children[0], |
| | | ]) |
| | | }) |
| | | |
| | | test("should return undefined for non-existent path", () => { |
| | | const data = { title: "Test", slug: "test", filePath: "test.md" } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain(["nonexistent"]) |
| | | assert.strictEqual(path, undefined) |
| | | }) |
| | | |
| | | test("should return file data for intermediate folders", () => { |
| | | const data1 = { |
| | | title: "Root", |
| | | slug: "index", |
| | | filePath: "index.md", |
| | | } |
| | | const data2 = { |
| | | title: "Test", |
| | | slug: "folder/subfolder/test", |
| | | filePath: "folder/subfolder/test.md", |
| | | } |
| | | const data3 = { |
| | | title: "Folder Index", |
| | | slug: "folder/index", |
| | | filePath: "folder/index.md", |
| | | } |
| | | |
| | | trie.add(data1) |
| | | trie.add(data2) |
| | | trie.add(data3) |
| | | const path = trie.ancestryChain(["folder", "subfolder"]) |
| | | assert.deepStrictEqual(path, [trie, trie.children[0], trie.children[0].children[0]]) |
| | | assert.strictEqual(path[1].data, data3) |
| | | }) |
| | | |
| | | test("should return path for partial path", () => { |
| | | const data = { |
| | | title: "Nested", |
| | | slug: "folder/subfolder/test", |
| | | filePath: "folder/subfolder/test.md", |
| | | } |
| | | trie.add(data) |
| | | const path = trie.ancestryChain(["folder"]) |
| | | assert.deepStrictEqual(path, [trie, trie.children[0]]) |
| | | }) |
| | | }) |
| | | }) |
| | |
| | | return this.children.find((c) => c.slugSegment === path[0])?.findNode(path.slice(1)) |
| | | } |
| | | |
| | | ancestryChain(path: string[]): Array<FileTrieNode<T>> | undefined { |
| | | if (path.length === 0 || (path.length === 1 && path[0] === "index")) { |
| | | return [this] |
| | | } |
| | | |
| | | const child = this.children.find((c) => c.slugSegment === path[0]) |
| | | if (!child) { |
| | | return undefined |
| | | } |
| | | |
| | | const childPath = child.ancestryChain(path.slice(1)) |
| | | if (!childPath) { |
| | | return undefined |
| | | } |
| | | |
| | | return [this, ...childPath] |
| | | } |
| | | |
| | | /** |
| | | * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place |
| | | */ |