feat: Added ability to configure frontmatter parent key

This commit is contained in:
Stefan Genov 2026-01-08 21:23:41 +02:00
parent 5432777c51
commit 918286b95f
2 changed files with 104 additions and 20 deletions

View File

@ -33,3 +33,59 @@ Want to customize it even more?
- Component: `quartz/components/Breadcrumbs.tsx` - Component: `quartz/components/Breadcrumbs.tsx`
- Style: `quartz/components/styles/breadcrumbs.scss` - Style: `quartz/components/styles/breadcrumbs.scss`
- Script: inline at `quartz/components/Breadcrumbs.tsx` - 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-basestyle 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.

View File

@ -8,16 +8,19 @@ interface ParentBreadcrumbsOptions {
spacerSymbol?: string; spacerSymbol?: string;
rootName?: string; rootName?: string;
resolveFrontmatterTitle?: boolean; resolveFrontmatterTitle?: boolean;
frontmatterProp?: string,
} }
const defaultOptions: ParentBreadcrumbsOptions = { const defaultOptions: ParentBreadcrumbsOptions = {
spacerSymbol: "", spacerSymbol: "",
rootName: "Home", rootName: "Home",
resolveFrontmatterTitle: true, resolveFrontmatterTitle: true,
frontmatterProp: "parent",
}; };
export default ((opts?: ParentBreadcrumbsOptions) => { export default ((opts?: ParentBreadcrumbsOptions) => {
const options = { ...defaultOptions, ...opts }; const options = { ...defaultOptions, ...opts };
const parentKey = options.frontmatterProp;
const ParentBreadcrumbs: QuartzComponent = ({ const ParentBreadcrumbs: QuartzComponent = ({
fileData, fileData,
@ -34,41 +37,61 @@ export default ((opts?: ParentBreadcrumbsOptions) => {
const findFile = (name: string) => { const findFile = (name: string) => {
const targetSlug = simplifySlug(name as FullSlug); const targetSlug = simplifySlug(name as FullSlug);
return allFiles.find((f: QuartzPluginData) => { return allFiles.find((f: QuartzPluginData) => {
const fSlug = simplifySlug(f.slug!); const fSlug = simplifySlug(f.slug!);
return fSlug === targetSlug || fSlug.endsWith(targetSlug) || f.frontmatter?.title === name; 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<BreadcrumbNode[]> = [];
let current = fileData; let current = fileData;
const visited = new Set<string>(); const visited = new Set<string>();
if (current.slug) visited.add(current.slug); if (current.slug) visited.add(current.slug);
while (current && current.frontmatter?.parent) { while (current && current.frontmatter?.[parentKey!]) {
const parentLink = parseWikiLink(current.frontmatter.parent as string); const rawParent = current.frontmatter[parentKey!];
const parentFile = findFile(parentLink); const parentList = Array.isArray(rawParent) ? rawParent : [rawParent];
if (parentFile && parentFile.slug && !visited.has(parentFile.slug)) { const currentLevelNodes: BreadcrumbNode[] = [];
visited.add(parentFile.slug); let nextParent: QuartzPluginData | undefined = undefined;
crumbs.push({
displayName: options.resolveFrontmatterTitle for (const p of parentList) {
? parentFile.frontmatter?.title ?? parentFile.slug const linkStr = parseWikiLink(p as string);
: parentFile.slug, const parentFile = findFile(linkStr);
path: resolveRelative(fileData.slug!, parentFile.slug!)
}); if (parentFile && parentFile.slug) {
current = parentFile; 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 { } else {
break; break;
} }
} }
if (current.slug !== "index") { if (current.slug !== "index") {
crumbs.push({ crumbs.push([{
displayName: options.rootName!, displayName: options.rootName!,
path: resolveRelative(fileData.slug!, "index" as SimpleSlug) path: resolveRelative(fileData.slug!, "index" as SimpleSlug)
}); }]);
} }
crumbs.reverse(); crumbs.reverse();
@ -79,10 +102,15 @@ export default ((opts?: ParentBreadcrumbsOptions) => {
return ( return (
<nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs"> <nav class={classNames(displayClass, "breadcrumb-container")} aria-label="breadcrumbs">
{crumbs.map((crumb, index) => ( {crumbs.map((crumbLevel, levelIndex) => (
<div class="breadcrumb-element"> <div class="breadcrumb-element">
<a href={crumb.path}>{crumb.displayName}</a> {crumbLevel.map((node, nodeIndex) => (
{index !== crumbs.length && <p>{options.spacerSymbol}</p>} <>
<a href={node.path}>{node.displayName}</a>
{nodeIndex < crumbLevel.length - 1 && <span style={{ opacity: 0.5 }}> / </span>}
</>
))}
{levelIndex !== crumbs.length && <p>{options.spacerSymbol}</p>}
</div> </div>
))} ))}
<div class="breadcrumb-element"> <div class="breadcrumb-element">
@ -94,4 +122,4 @@ export default ((opts?: ParentBreadcrumbsOptions) => {
ParentBreadcrumbs.css = style; ParentBreadcrumbs.css = style;
return ParentBreadcrumbs; return ParentBreadcrumbs;
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor;