From 5432777c51f7d9e3c0213842357bde46db263742 Mon Sep 17 00:00:00 2001 From: Stefan Genov Date: Wed, 7 Jan 2026 23:50:04 +0200 Subject: [PATCH 1/6] feat: Added ParentBreadcrumbs, which use a property called `parent` to build a breadcrumbs trail --- quartz.layout.ts | 118 ++++++++++++------------ quartz/components/ParentBreadcrumbs.tsx | 97 +++++++++++++++++++ quartz/components/index.ts | 104 +++++++++++---------- 3 files changed, 209 insertions(+), 110 deletions(-) create mode 100644 quartz/components/ParentBreadcrumbs.tsx diff --git a/quartz.layout.ts b/quartz.layout.ts index 970a5be34..5b54f6569 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -1,68 +1,68 @@ -import { PageLayout, SharedLayout } from "./quartz/cfg" -import * as Component from "./quartz/components" +import { PageLayout, SharedLayout } from "./quartz/cfg"; +import * as Component from "./quartz/components"; // components shared across all pages export const sharedPageComponents: SharedLayout = { - head: Component.Head(), - header: [], - afterBody: [], - footer: Component.Footer({ - links: { - GitHub: "https://github.com/jackyzha0/quartz", - "Discord Community": "https://discord.gg/cRFFHYye7t", - }, - }), -} + head: Component.Head(), + header: [], + afterBody: [], + footer: Component.Footer({ + links: { + GitHub: "https://github.com/jackyzha0/quartz", + "Discord Community": "https://discord.gg/cRFFHYye7t", + }, + }), +}; // components for pages that display a single page (e.g. a single note) export const defaultContentPageLayout: PageLayout = { - beforeBody: [ - Component.ConditionalRender({ - component: Component.Breadcrumbs(), - condition: (page) => page.fileData.slug !== "index", - }), - Component.ArticleTitle(), - Component.ContentMeta(), - Component.TagList(), - ], - left: [ - Component.PageTitle(), - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: Component.Search(), - grow: true, - }, - { Component: Component.Darkmode() }, - { Component: Component.ReaderMode() }, - ], - }), - Component.Explorer(), - ], - right: [ - Component.Graph(), - Component.DesktopOnly(Component.TableOfContents()), - Component.Backlinks(), - ], -} + beforeBody: [ + Component.ConditionalRender({ + component: Component.ParentBreadcrumbs(), + condition: (page) => page.fileData.slug !== "index", + }), + Component.ArticleTitle(), + Component.ContentMeta(), + Component.TagList(), + ], + left: [ + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Flex({ + components: [ + { + Component: Component.Search(), + grow: true, + }, + { Component: Component.Darkmode() }, + { Component: Component.ReaderMode() }, + ], + }), + Component.Explorer(), + ], + right: [ + Component.Graph(), + Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), + ], +}; // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], - left: [ - Component.PageTitle(), - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: Component.Search(), - grow: true, - }, - { Component: Component.Darkmode() }, - ], - }), - Component.Explorer(), - ], - right: [], -} + beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], + left: [ + Component.PageTitle(), + Component.MobileOnly(Component.Spacer()), + Component.Flex({ + components: [ + { + Component: Component.Search(), + grow: true, + }, + { Component: Component.Darkmode() }, + ], + }), + Component.Explorer(), + ], + right: [], +}; diff --git a/quartz/components/ParentBreadcrumbs.tsx b/quartz/components/ParentBreadcrumbs.tsx new file mode 100644 index 000000000..e501f4850 --- /dev/null +++ b/quartz/components/ParentBreadcrumbs.tsx @@ -0,0 +1,97 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"; +import { QuartzPluginData } from "../plugins/vfile"; +import { classNames } from "../util/lang"; +import { resolveRelative, simplifySlug, FullSlug, SimpleSlug } from "../util/path"; +import style from "./styles/breadcrumbs.scss"; + +interface ParentBreadcrumbsOptions { + spacerSymbol?: string; + rootName?: string; + resolveFrontmatterTitle?: boolean; +} + +const defaultOptions: ParentBreadcrumbsOptions = { + spacerSymbol: "❯", + rootName: "Home", + resolveFrontmatterTitle: true, +}; + +export default ((opts?: ParentBreadcrumbsOptions) => { + const options = { ...defaultOptions, ...opts }; + + const ParentBreadcrumbs: QuartzComponent = ({ + fileData, + allFiles, + displayClass, + }: QuartzComponentProps) => { + + const parseWikiLink = (content: string): string => { + if (!content) return ""; + let clean = content.trim().replace(/^["']|["']$/g, ""); + clean = clean.replace(/^\[\[|\]\]$/g, ""); + return clean.split("|")[0]; + }; + + const findFile = (name: string) => { + const targetSlug = simplifySlug(name as FullSlug); + + return allFiles.find((f: QuartzPluginData) => { + const fSlug = simplifySlug(f.slug!); + return fSlug === targetSlug || fSlug.endsWith(targetSlug) || f.frontmatter?.title === name; + }); + }; + + const crumbs: Array<{ displayName: string; path: string; }> = []; + let current = fileData; + const visited = new Set(); + if (current.slug) visited.add(current.slug); + + while (current && current.frontmatter?.parent) { + const parentLink = parseWikiLink(current.frontmatter.parent as string); + const parentFile = findFile(parentLink); + + if (parentFile && parentFile.slug && !visited.has(parentFile.slug)) { + visited.add(parentFile.slug); + crumbs.push({ + displayName: options.resolveFrontmatterTitle + ? parentFile.frontmatter?.title ?? parentFile.slug + : parentFile.slug, + path: resolveRelative(fileData.slug!, parentFile.slug!) + }); + current = parentFile; + } else { + break; + } + } + + if (current.slug !== "index") { + crumbs.push({ + displayName: options.rootName!, + path: resolveRelative(fileData.slug!, "index" as SimpleSlug) + }); + } + + crumbs.reverse(); + + if (crumbs.length === 0 && fileData.slug === "index") { + return <>; + } + + return ( + + ); + }; + + ParentBreadcrumbs.css = style; + return ParentBreadcrumbs; +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index cece8e614..1830cecac 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,53 +1,55 @@ -import Content from "./pages/Content" -import TagContent from "./pages/TagContent" -import FolderContent from "./pages/FolderContent" -import NotFound from "./pages/404" -import ArticleTitle from "./ArticleTitle" -import Darkmode from "./Darkmode" -import ReaderMode from "./ReaderMode" -import Head from "./Head" -import PageTitle from "./PageTitle" -import ContentMeta from "./ContentMeta" -import Spacer from "./Spacer" -import TableOfContents from "./TableOfContents" -import Explorer from "./Explorer" -import TagList from "./TagList" -import Graph from "./Graph" -import Backlinks from "./Backlinks" -import Search from "./Search" -import Footer from "./Footer" -import DesktopOnly from "./DesktopOnly" -import MobileOnly from "./MobileOnly" -import RecentNotes from "./RecentNotes" -import Breadcrumbs from "./Breadcrumbs" -import Comments from "./Comments" -import Flex from "./Flex" -import ConditionalRender from "./ConditionalRender" +import Content from "./pages/Content"; +import TagContent from "./pages/TagContent"; +import FolderContent from "./pages/FolderContent"; +import NotFound from "./pages/404"; +import ArticleTitle from "./ArticleTitle"; +import Darkmode from "./Darkmode"; +import ReaderMode from "./ReaderMode"; +import Head from "./Head"; +import PageTitle from "./PageTitle"; +import ContentMeta from "./ContentMeta"; +import Spacer from "./Spacer"; +import TableOfContents from "./TableOfContents"; +import Explorer from "./Explorer"; +import TagList from "./TagList"; +import Graph from "./Graph"; +import Backlinks from "./Backlinks"; +import Search from "./Search"; +import Footer from "./Footer"; +import DesktopOnly from "./DesktopOnly"; +import MobileOnly from "./MobileOnly"; +import RecentNotes from "./RecentNotes"; +import Breadcrumbs from "./Breadcrumbs"; +import Comments from "./Comments"; +import Flex from "./Flex"; +import ConditionalRender from "./ConditionalRender"; +import ParentBreadcrumbs from "./ParentBreadcrumbs"; export { - ArticleTitle, - Content, - TagContent, - FolderContent, - Darkmode, - ReaderMode, - Head, - PageTitle, - ContentMeta, - Spacer, - TableOfContents, - Explorer, - TagList, - Graph, - Backlinks, - Search, - Footer, - DesktopOnly, - MobileOnly, - RecentNotes, - NotFound, - Breadcrumbs, - Comments, - Flex, - ConditionalRender, -} + ParentBreadcrumbs, + ArticleTitle, + Content, + TagContent, + FolderContent, + Darkmode, + ReaderMode, + Head, + PageTitle, + ContentMeta, + Spacer, + TableOfContents, + Explorer, + TagList, + Graph, + Backlinks, + Search, + Footer, + DesktopOnly, + MobileOnly, + RecentNotes, + NotFound, + Breadcrumbs, + Comments, + Flex, + ConditionalRender, +}; From 918286b95ffc6b2d34a34795a50fdfa8a15acc2f Mon Sep 17 00:00:00 2001 From: Stefan Genov Date: Thu, 8 Jan 2026 21:23:41 +0200 Subject: [PATCH 2/6] feat: Added ability to configure frontmatter parent key --- docs/features/breadcrumbs.md | 56 ++++++++++++++++++++ quartz/components/ParentBreadcrumbs.tsx | 68 +++++++++++++++++-------- 2 files changed, 104 insertions(+), 20 deletions(-) diff --git a/docs/features/breadcrumbs.md b/docs/features/breadcrumbs.md index f3545059d..b8aed34db 100644 --- a/docs/features/breadcrumbs.md +++ b/docs/features/breadcrumbs.md @@ -33,3 +33,59 @@ Want to customize it even more? - Component: `quartz/components/Breadcrumbs.tsx` - Style: `quartz/components/styles/breadcrumbs.scss` - Script: inline at `quartz/components/Breadcrumbs.tsx` + +## Using A Frontmatter Prop + +ParentBreadcrumbs` is an alternative breadcrumbs component that derives its hierarchy **entirely from frontmatter-defined parent relationships**, rather than folder structure. This is useful for knowledge-base–style sites, wikis, or any content where pages may belong to multiple logical hierarchies. + +Unlike the default `Breadcrumbs` component, `ParentBreadcrumbs` supports: + +- Explicit parent chains via frontmatter +- Multiple parents per level +- Wiki-style links (`[[Page Name]]`) +- Customizable frontmatter keys + +### How It Works + +`ParentBreadcrumbs` walks upward through a parent chain starting from the current page, following a configurable frontmatter field. +At each level, **all parents are rendered**, while one unvisited parent is chosen to continue the chain upward. + +Example frontmatter: + +```yaml +--- +title: "Advanced Topics" +parent: Basics +--- +``` + +Wiki links are supported: + +```yaml +parent: [[Basics]] +``` + +Multiple parents: + +```yaml +parent: +- [[Basics]] +- [[Reference]] +``` + +### Configuration + +You can configure `ParentBreadcrumbs` by passing options into `Component.ParentBreadcrumbs()`. + +Default configuration: + +```ts +Component.ParentBreadcrumbs({ + spacerSymbol: "❯", // symbol displayed between breadcrumb levels + rootName: "Home", // label for the root (index) page + resolveFrontmatterTitle: true, // use frontmatter.title instead of slug + parentKey: "parent", // frontmatter key used to resolve parents +}) +``` + +All options are optional; omitted values fall back to the defaults. diff --git a/quartz/components/ParentBreadcrumbs.tsx b/quartz/components/ParentBreadcrumbs.tsx index e501f4850..cb3878e0b 100644 --- a/quartz/components/ParentBreadcrumbs.tsx +++ b/quartz/components/ParentBreadcrumbs.tsx @@ -8,16 +8,19 @@ interface ParentBreadcrumbsOptions { spacerSymbol?: string; rootName?: string; resolveFrontmatterTitle?: boolean; + frontmatterProp?: string, } const defaultOptions: ParentBreadcrumbsOptions = { spacerSymbol: "❯", rootName: "Home", resolveFrontmatterTitle: true, + frontmatterProp: "parent", }; export default ((opts?: ParentBreadcrumbsOptions) => { const options = { ...defaultOptions, ...opts }; + const parentKey = options.frontmatterProp; const ParentBreadcrumbs: QuartzComponent = ({ fileData, @@ -34,41 +37,61 @@ export default ((opts?: ParentBreadcrumbsOptions) => { const findFile = (name: string) => { const targetSlug = simplifySlug(name as FullSlug); - return allFiles.find((f: QuartzPluginData) => { const fSlug = simplifySlug(f.slug!); return fSlug === targetSlug || fSlug.endsWith(targetSlug) || f.frontmatter?.title === name; }); }; - const crumbs: Array<{ displayName: string; path: string; }> = []; + type BreadcrumbNode = { displayName: string; path: string; }; + const crumbs: Array = []; + let current = fileData; const visited = new Set(); if (current.slug) visited.add(current.slug); - while (current && current.frontmatter?.parent) { - const parentLink = parseWikiLink(current.frontmatter.parent as string); - const parentFile = findFile(parentLink); + while (current && current.frontmatter?.[parentKey!]) { + const rawParent = current.frontmatter[parentKey!]; + const parentList = Array.isArray(rawParent) ? rawParent : [rawParent]; - if (parentFile && parentFile.slug && !visited.has(parentFile.slug)) { - visited.add(parentFile.slug); - crumbs.push({ - displayName: options.resolveFrontmatterTitle - ? parentFile.frontmatter?.title ?? parentFile.slug - : parentFile.slug, - path: resolveRelative(fileData.slug!, parentFile.slug!) - }); - current = parentFile; + const currentLevelNodes: BreadcrumbNode[] = []; + let nextParent: QuartzPluginData | undefined = undefined; + + for (const p of parentList) { + const linkStr = parseWikiLink(p as string); + const parentFile = findFile(linkStr); + + if (parentFile && parentFile.slug) { + currentLevelNodes.push({ + displayName: options.resolveFrontmatterTitle + ? parentFile.frontmatter?.title ?? parentFile.slug + : parentFile.slug, + path: resolveRelative(fileData.slug!, parentFile.slug!) + }); + + if (!nextParent && !visited.has(parentFile.slug)) { + nextParent = parentFile; + } + } + } + + if (currentLevelNodes.length > 0) { + crumbs.push(currentLevelNodes); + } + + if (nextParent) { + visited.add(nextParent.slug!); + current = nextParent; } else { break; } } if (current.slug !== "index") { - crumbs.push({ + crumbs.push([{ displayName: options.rootName!, path: resolveRelative(fileData.slug!, "index" as SimpleSlug) - }); + }]); } crumbs.reverse(); @@ -79,10 +102,15 @@ export default ((opts?: ParentBreadcrumbsOptions) => { return (