From 5432777c51f7d9e3c0213842357bde46db263742 Mon Sep 17 00:00:00 2001 From: Stefan Genov Date: Wed, 7 Jan 2026 23:50:04 +0200 Subject: [PATCH] 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, +};