From 12664ae596731b923d398cdc19c0d55ec5427e59 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 29 Sep 2023 10:26:15 +0200 Subject: [PATCH] feat: implement breadcrumb component (#508) * feat: implement breadcrumbs * style: fix styling, move breadcrumbs to top * refactor: move `capitalize to `lang.ts`` * refactor: clean breadcrumb generation * feat: add options to breadcrumbs * feat: implement `resolveFrontmatterTitle` * feat: add `hideOnRoot` option * feat(consistency): capitalize every crumb * style: add `flex-wrap` to parent container * refactor: clean `Breadcrumbs.tsx` * feat(accessibility): use `nav`, add aria label * style: improve look in popovers by adding margin * docs: write docs for breadcrumb component * refactor: collapse `if` condition for hideOnRoot * chore: add todo for perf optimization * docs: update introduction --- docs/features/breadcrumbs.md | 35 +++++++ quartz.layout.ts | 7 +- quartz/components/Breadcrumbs.tsx | 118 ++++++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/styles/breadcrumbs.scss | 22 ++++ quartz/plugins/transformers/ofm.ts | 5 +- quartz/util/lang.ts | 4 + 7 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 docs/features/breadcrumbs.md create mode 100644 quartz/components/Breadcrumbs.tsx create mode 100644 quartz/components/styles/breadcrumbs.scss diff --git a/docs/features/breadcrumbs.md b/docs/features/breadcrumbs.md new file mode 100644 index 00000000..9f6b6455 --- /dev/null +++ b/docs/features/breadcrumbs.md @@ -0,0 +1,35 @@ +--- +title: "Breadcrumbs" +tags: + - component +--- + +Breadcrumbs provide a way to navigate a hierarchy of pages within your site using a list of its parent folders. + +By default, the element at the very top of your page is the breadcrumb navigation bar (can also be seen at the top on this page!). + +## Customization + +Most configuration can be done by passing in options to `Component.Breadcrumbs()`. + +For example, here's what the default configuration looks like: + +```typescript title="quartz.layout.ts" +Component.Breadcrumbs({ + spacerSymbol: ">", // symbol between crumbs + rootName: "Home", // name of first/root element + resolveFrontmatterTitle: false, // wether to resolve folder names through frontmatter titles (more computationally expensive) + hideOnRoot: true, // wether to hide breadcrumbs on root `index.md` page +}) +``` + +When passing in your own options, you can omit any or all of these fields if you'd like to keep the default value for that field. + +You can also adjust where the breadcrumbs will be displayed by adjusting the [[layout]] (moving `Component.Breadcrumbs()` up or down) + +Want to customize it even more? + +- Removing graph view: delete all usages of `Component.Breadcrumbs()` from `quartz.layout.ts`. +- Component: `quartz/components/Breadcrumbs.tsx` +- Style: `quartz/components/styles/breadcrumbs.scss` +- Script: inline at `quartz/components/Breadcrumbs.tsx` diff --git a/quartz.layout.ts b/quartz.layout.ts index 8c1c6c11..8b6edd8f 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -15,7 +15,12 @@ export const sharedPageComponents: SharedLayout = { // components for pages that display a single page (e.g. a single note) export const defaultContentPageLayout: PageLayout = { - beforeBody: [Component.ArticleTitle(), Component.ContentMeta(), Component.TagList()], + beforeBody: [ + Component.Breadcrumbs(), + Component.ArticleTitle(), + Component.ContentMeta(), + Component.TagList(), + ], left: [ Component.PageTitle(), Component.MobileOnly(Component.Spacer()), diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx new file mode 100644 index 00000000..f928a4cf --- /dev/null +++ b/quartz/components/Breadcrumbs.tsx @@ -0,0 +1,118 @@ +import { QuartzComponentConstructor, QuartzComponentProps } from "./types" +import breadcrumbsStyle from "./styles/breadcrumbs.scss" +import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" +import { capitalize } from "../util/lang" +import { QuartzPluginData } from "../plugins/vfile" + +type CrumbData = { + displayName: string + path: string +} + +interface BreadcrumbOptions { + /** + * Symbol between crumbs + */ + spacerSymbol: string + /** + * Name of first crumb + */ + rootName: string + /** + * wether to look up frontmatter title for folders (could cause performance problems with big vaults) + */ + resolveFrontmatterTitle: boolean + /** + * Wether to display breadcrumbs on root `index.md` + */ + hideOnRoot: boolean +} + +const defaultOptions: BreadcrumbOptions = { + spacerSymbol: ">", + rootName: "Home", + resolveFrontmatterTitle: false, + hideOnRoot: true, +} + +function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData { + return { displayName, path: resolveRelative(baseSlug, currentSlug) } +} + +// given a folderName (e.g. "features"), search for the corresponding `index.md` file +function findCurrentFile(allFiles: QuartzPluginData[], folderName: string) { + return allFiles.find((file) => { + if (file.slug?.endsWith("index")) { + const folderParts = file.filePath?.split("/") + if (folderParts) { + const name = folderParts[folderParts?.length - 2] + if (name === folderName) { + return true + } + } + } + }) +} + +export default ((opts?: Partial) => { + // Merge options with defaults + const options: BreadcrumbOptions = { ...defaultOptions, ...opts } + + function Breadcrumbs({ fileData, allFiles }: QuartzComponentProps) { + // Hide crumbs on root if enabled + if (options.hideOnRoot && fileData.slug === "index") { + return <> + } + + // Format entry for root element + const firstEntry = formatCrumb(capitalize(options.rootName), fileData.slug!, "/" as SimpleSlug) + const crumbs: CrumbData[] = [firstEntry] + + // Get parts of filePath (every folder) + const parts = fileData.filePath?.split("/")?.splice(1) + if (parts) { + // full path until current part + let current = "" + for (let i = 0; i < parts.length - 1; i++) { + const folderName = parts[i] + let currentTitle = folderName + + // TODO: performance optimizations/memoizing + // Try to resolve frontmatter folder title + if (options?.resolveFrontmatterTitle) { + // try to find file for current path + const currentFile = findCurrentFile(allFiles, folderName) + if (currentFile) { + currentTitle = currentFile.frontmatter!.title + } + } + // Add current path to full path + current += folderName + "/" + + // Format and add current crumb + const crumb = formatCrumb(capitalize(currentTitle), fileData.slug!, current as SimpleSlug) + crumbs.push(crumb) + } + + // Add current file to crumb (can directly use frontmatter title) + if (parts.length > 0) { + crumbs.push({ + displayName: capitalize(fileData.frontmatter!.title), + path: "", + }) + } + } + return ( + + ) + } + Breadcrumbs.css = breadcrumbsStyle + return Breadcrumbs +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index d7b6a1c5..b3db76be 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -18,6 +18,7 @@ import Footer from "./Footer" import DesktopOnly from "./DesktopOnly" import MobileOnly from "./MobileOnly" import RecentNotes from "./RecentNotes" +import Breadcrumbs from "./Breadcrumbs" export { ArticleTitle, @@ -40,4 +41,5 @@ export { MobileOnly, RecentNotes, NotFound, + Breadcrumbs, } diff --git a/quartz/components/styles/breadcrumbs.scss b/quartz/components/styles/breadcrumbs.scss new file mode 100644 index 00000000..789808ba --- /dev/null +++ b/quartz/components/styles/breadcrumbs.scss @@ -0,0 +1,22 @@ +.breadcrumb-container { + margin: 0; + margin-top: 0.75rem; + padding: 0; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; +} + +.breadcrumb-element { + p { + margin: 0; + margin-left: 0.5rem; + padding: 0; + line-height: normal; + } + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 4d55edad..226e9394 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -14,6 +14,7 @@ import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" import { toHast } from "mdast-util-to-hast" import { toHtml } from "hast-util-to-html" import { PhrasingContent } from "mdast-util-find-and-replace/lib" +import { capitalize } from "../../util/lang" export interface Options { comments: boolean @@ -104,10 +105,6 @@ function canonicalizeCallout(calloutName: string): keyof typeof callouts { return calloutMapping[callout] ?? "note" } -const capitalize = (s: string): string => { - return s.substring(0, 1).toUpperCase() + s.substring(1) -} - // !? -> optional embedding // \[\[ -> open brace // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts index eb03a243..5211b5d9 100644 --- a/quartz/util/lang.ts +++ b/quartz/util/lang.ts @@ -5,3 +5,7 @@ export function pluralize(count: number, s: string): string { return `${count} ${s}s` } } + +export function capitalize(s: string): string { + return s.substring(0, 1).toUpperCase() + s.substring(1) +}