feat: Added ParentBreadcrumbs, which use a property called parent to build a breadcrumbs trail

This commit is contained in:
Stefan Genov 2026-01-07 23:50:04 +02:00
parent 65c5b27041
commit 5432777c51
3 changed files with 209 additions and 110 deletions

View File

@ -1,68 +1,68 @@
import { PageLayout, SharedLayout } from "./quartz/cfg" import { PageLayout, SharedLayout } from "./quartz/cfg";
import * as Component from "./quartz/components" import * as Component from "./quartz/components";
// components shared across all pages // components shared across all pages
export const sharedPageComponents: SharedLayout = { export const sharedPageComponents: SharedLayout = {
head: Component.Head(), head: Component.Head(),
header: [], header: [],
afterBody: [], afterBody: [],
footer: Component.Footer({ footer: Component.Footer({
links: { links: {
GitHub: "https://github.com/jackyzha0/quartz", GitHub: "https://github.com/jackyzha0/quartz",
"Discord Community": "https://discord.gg/cRFFHYye7t", "Discord Community": "https://discord.gg/cRFFHYye7t",
}, },
}), }),
} };
// components for pages that display a single page (e.g. a single note) // components for pages that display a single page (e.g. a single note)
export const defaultContentPageLayout: PageLayout = { export const defaultContentPageLayout: PageLayout = {
beforeBody: [ beforeBody: [
Component.ConditionalRender({ Component.ConditionalRender({
component: Component.Breadcrumbs(), component: Component.ParentBreadcrumbs(),
condition: (page) => page.fileData.slug !== "index", condition: (page) => page.fileData.slug !== "index",
}), }),
Component.ArticleTitle(), Component.ArticleTitle(),
Component.ContentMeta(), Component.ContentMeta(),
Component.TagList(), Component.TagList(),
], ],
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Flex({ Component.Flex({
components: [ components: [
{ {
Component: Component.Search(), Component: Component.Search(),
grow: true, grow: true,
}, },
{ Component: Component.Darkmode() }, { Component: Component.Darkmode() },
{ Component: Component.ReaderMode() }, { Component: Component.ReaderMode() },
], ],
}), }),
Component.Explorer(), Component.Explorer(),
], ],
right: [ right: [
Component.Graph(), Component.Graph(),
Component.DesktopOnly(Component.TableOfContents()), Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(), Component.Backlinks(),
], ],
} };
// components for pages that display lists of pages (e.g. tags or folders) // components for pages that display lists of pages (e.g. tags or folders)
export const defaultListPageLayout: PageLayout = { export const defaultListPageLayout: PageLayout = {
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [ left: [
Component.PageTitle(), Component.PageTitle(),
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Flex({ Component.Flex({
components: [ components: [
{ {
Component: Component.Search(), Component: Component.Search(),
grow: true, grow: true,
}, },
{ Component: Component.Darkmode() }, { Component: Component.Darkmode() },
], ],
}), }),
Component.Explorer(), Component.Explorer(),
], ],
right: [], right: [],
} };

View File

@ -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<string>();
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 (
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => (
<div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a>
{index !== crumbs.length && <p>{options.spacerSymbol}</p>}
</div>
))}
<div class="breadcrumb-element">
<p>{fileData.frontmatter?.title}</p>
</div>
</nav>
);
};
ParentBreadcrumbs.css = style;
return ParentBreadcrumbs;
}) satisfies QuartzComponentConstructor

View File

@ -1,53 +1,55 @@
import Content from "./pages/Content" import Content from "./pages/Content";
import TagContent from "./pages/TagContent" import TagContent from "./pages/TagContent";
import FolderContent from "./pages/FolderContent" import FolderContent from "./pages/FolderContent";
import NotFound from "./pages/404" import NotFound from "./pages/404";
import ArticleTitle from "./ArticleTitle" import ArticleTitle from "./ArticleTitle";
import Darkmode from "./Darkmode" import Darkmode from "./Darkmode";
import ReaderMode from "./ReaderMode" import ReaderMode from "./ReaderMode";
import Head from "./Head" import Head from "./Head";
import PageTitle from "./PageTitle" import PageTitle from "./PageTitle";
import ContentMeta from "./ContentMeta" import ContentMeta from "./ContentMeta";
import Spacer from "./Spacer" import Spacer from "./Spacer";
import TableOfContents from "./TableOfContents" import TableOfContents from "./TableOfContents";
import Explorer from "./Explorer" import Explorer from "./Explorer";
import TagList from "./TagList" import TagList from "./TagList";
import Graph from "./Graph" import Graph from "./Graph";
import Backlinks from "./Backlinks" import Backlinks from "./Backlinks";
import Search from "./Search" import Search from "./Search";
import Footer from "./Footer" import Footer from "./Footer";
import DesktopOnly from "./DesktopOnly" import DesktopOnly from "./DesktopOnly";
import MobileOnly from "./MobileOnly" import MobileOnly from "./MobileOnly";
import RecentNotes from "./RecentNotes" import RecentNotes from "./RecentNotes";
import Breadcrumbs from "./Breadcrumbs" import Breadcrumbs from "./Breadcrumbs";
import Comments from "./Comments" import Comments from "./Comments";
import Flex from "./Flex" import Flex from "./Flex";
import ConditionalRender from "./ConditionalRender" import ConditionalRender from "./ConditionalRender";
import ParentBreadcrumbs from "./ParentBreadcrumbs";
export { export {
ArticleTitle, ParentBreadcrumbs,
Content, ArticleTitle,
TagContent, Content,
FolderContent, TagContent,
Darkmode, FolderContent,
ReaderMode, Darkmode,
Head, ReaderMode,
PageTitle, Head,
ContentMeta, PageTitle,
Spacer, ContentMeta,
TableOfContents, Spacer,
Explorer, TableOfContents,
TagList, Explorer,
Graph, TagList,
Backlinks, Graph,
Search, Backlinks,
Footer, Search,
DesktopOnly, Footer,
MobileOnly, DesktopOnly,
RecentNotes, MobileOnly,
NotFound, RecentNotes,
Breadcrumbs, NotFound,
Comments, Breadcrumbs,
Flex, Comments,
ConditionalRender, Flex,
} ConditionalRender,
};