Ben Schlegel
2023-09-29 0b61f6fbfd20556102ce23444ae7eb9348472952
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
3 files added
4 files modified
193 ■■■■■ changed files
docs/features/breadcrumbs.md 35 ●●●●● patch | view | raw | blame | history
quartz.layout.ts 7 ●●●● patch | view | raw | blame | history
quartz/components/Breadcrumbs.tsx 118 ●●●●● patch | view | raw | blame | history
quartz/components/index.ts 2 ●●●●● patch | view | raw | blame | history
quartz/components/styles/breadcrumbs.scss 22 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 5 ●●●● patch | view | raw | blame | history
quartz/util/lang.ts 4 ●●●● patch | view | raw | blame | history
docs/features/breadcrumbs.md
New file
@@ -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`
quartz.layout.ts
@@ -15,7 +15,12 @@
// 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()),
quartz/components/Breadcrumbs.tsx
New file
@@ -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<BreadcrumbOptions>) => {
  // 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 (
      <nav class="breadcrumb-container" aria-label="breadcrumbs">
        {crumbs.map((crumb, index) => (
          <div class="breadcrumb-element">
            <a href={crumb.path}>{crumb.displayName}</a>
            {index !== crumbs.length - 1 && <p>{` ${options.spacerSymbol} `}</p>}
          </div>
        ))}
      </nav>
    )
  }
  Breadcrumbs.css = breadcrumbsStyle
  return Breadcrumbs
}) satisfies QuartzComponentConstructor
quartz/components/index.ts
@@ -18,6 +18,7 @@
import DesktopOnly from "./DesktopOnly"
import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
export {
  ArticleTitle,
@@ -40,4 +41,5 @@
  MobileOnly,
  RecentNotes,
  NotFound,
  Breadcrumbs,
}
quartz/components/styles/breadcrumbs.scss
New file
@@ -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;
}
quartz/plugins/transformers/ofm.ts
@@ -14,6 +14,7 @@
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 @@
  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)
quartz/util/lang.ts
@@ -5,3 +5,7 @@
    return `${count} ${s}s`
  }
}
export function capitalize(s: string): string {
  return s.substring(0, 1).toUpperCase() + s.substring(1)
}