diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md index b25167f8d..3143eb1b5 100644 --- a/docs/features/OxHugo compatibility.md +++ b/docs/features/OxHugo compatibility.md @@ -32,6 +32,7 @@ Quartz by default doesn't understand `org-roam` files as they aren't Markdown. Y - `replaceFigureWithMdImg`: Whether to replace `
` with `![]()` - Formatting - `removeHugoShortcode`: Whether to remove hugo shortcode syntax (`{{}}`) + - `replaceOrgLatex`: Whether to replace org-mode formatting for latex fragments with what `Plugin.Latex` supports. > [!warning] > diff --git a/docs/features/breadcrumbs.md b/docs/features/breadcrumbs.md new file mode 100644 index 000000000..94db66ac0 --- /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 breadcrumbs: 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/docs/features/explorer.md b/docs/features/explorer.md index 91766a999..8937b25c0 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -75,7 +75,12 @@ Every function you can pass is optional. By default, only a `sort` function will Component.Explorer({ sortFn: (a, b) => { if ((!a.file && !b.file) || (a.file && b.file)) { - return a.displayName.localeCompare(b.displayName) + // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A + // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" + return a.displayName.localeCompare(b.displayName, undefined, { + numeric: true, + sensitivity: "base", + }) } if (a.file && !b.file) { return 1 diff --git a/docs/showcase.md b/docs/showcase.md index 9d84a0633..7dbc6e99b 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -17,5 +17,7 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Mike's AI Garden 🤖🪴](https://mwalton.me/) - [Matt Dunn's Second Brain](https://mattdunn.info/) - [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) +- [Vince Imbat's Talahardin](https://vinceimbat.com/) +- [🧠🌳 Chad's Mind Garden](https://www.chadly.net/) If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! diff --git a/quartz.layout.ts b/quartz.layout.ts index 86bd99f3d..5129cefc2 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -16,7 +16,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 000000000..f928a4cf9 --- /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/Explorer.tsx b/quartz/components/Explorer.tsx index e392c2118..d30b5a424 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -15,7 +15,12 @@ const defaultOptions = { sortFn: (a, b) => { // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.file && !b.file) || (a.file && b.file)) { - return a.displayName.localeCompare(b.displayName) + // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" + // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A + return a.displayName.localeCompare(b.displayName, undefined, { + numeric: true, + sensitivity: "base", + }) } if (a.file && !b.file) { return 1 diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx index a4dfac701..36c841843 100644 --- a/quartz/components/TagList.tsx +++ b/quartz/components/TagList.tsx @@ -28,7 +28,8 @@ function TagList({ fileData }: QuartzComponentProps) { TagList.css = ` .tags { list-style: none; - display: flex; + display:flex; + flex-wrap: wrap; padding-left: 0; gap: 0.4rem; margin: 1rem 0; diff --git a/quartz/components/index.ts b/quartz/components/index.ts index d7b6a1c5e..b3db76bed 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 000000000..789808baf --- /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/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 4542446b0..338bfae44 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -7,6 +7,7 @@ import { FullPageLayout } from "../../cfg" import { FilePath, pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" +import chalk from "chalk" export const ContentPage: QuartzEmitterPlugin> = (userOpts) => { const opts: FullPageLayout = { @@ -29,8 +30,14 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp const cfg = ctx.cfg.configuration const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) + + let containsIndex = false for (const [tree, file] of content) { const slug = file.data.slug! + if (slug === "index") { + containsIndex = true + } + const externalResources = pageResources(pathToRoot(slug), resources) const componentData: QuartzComponentProps = { fileData: file.data, @@ -50,6 +57,15 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp fps.push(fp) } + + if (!containsIndex) { + console.log( + chalk.yellow( + `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`, + ), + ) + } + return fps }, } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 571aa04d0..04b1105c3 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -37,6 +37,11 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> data.tags = data.tag } + // coerce title to string + if (data.title) { + data.title = data.title.toString() + } + if (data.tags && !Array.isArray(data.tags)) { data.tags = data.tags .toString() diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 015c350a5..feca4b52f 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -2,6 +2,7 @@ import fs from "fs" import path from "path" import { Repository } from "@napi-rs/simple-git" import { QuartzTransformerPlugin } from "../types" +import chalk from "chalk" export interface Options { priority: ("frontmatter" | "git" | "filesystem")[] @@ -11,9 +12,18 @@ const defaultOptions: Options = { priority: ["frontmatter", "git", "filesystem"], } -function coerceDate(d: any): Date { +function coerceDate(fp: string, d: any): Date { const dt = new Date(d) - return isNaN(dt.getTime()) ? new Date() : dt + const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0 + if (invalidDate && d !== undefined) { + console.log( + chalk.yellow( + `\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`, + ), + ) + } + + return invalidDate ? new Date() : dt } type MaybeDate = undefined | string | number @@ -32,10 +42,11 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und let modified: MaybeDate = undefined let published: MaybeDate = undefined - const fp = path.posix.join(file.cwd, file.data.filePath as string) + const fp = file.data.filePath! + const fullFp = path.posix.join(file.cwd, fp) for (const source of opts.priority) { if (source === "filesystem") { - const st = await fs.promises.stat(fp) + const st = await fs.promises.stat(fullFp) created ||= st.birthtimeMs modified ||= st.mtimeMs } else if (source === "frontmatter" && file.data.frontmatter) { @@ -54,9 +65,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und } file.data.dates = { - created: coerceDate(created), - modified: coerceDate(modified), - published: coerceDate(published), + created: coerceDate(fp, created), + modified: coerceDate(fp, modified), + published: coerceDate(fp, published), } } }, diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 4d55edad8..226e9394e 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/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts index 0d7b9199a..6e70bb190 100644 --- a/quartz/plugins/transformers/oxhugofm.ts +++ b/quartz/plugins/transformers/oxhugofm.ts @@ -9,6 +9,9 @@ export interface Options { removeHugoShortcode: boolean /** Replace
with ![]() */ replaceFigureWithMdImg: boolean + + /** Replace org latex fragments with $ and $$ */ + replaceOrgLatex: boolean } const defaultOptions: Options = { @@ -16,12 +19,27 @@ const defaultOptions: Options = { removePredefinedAnchor: true, removeHugoShortcode: true, replaceFigureWithMdImg: true, + replaceOrgLatex: true, } const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g") const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g") const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g") const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g") +// \\\\\( -> matches \\( +// (.+?) -> Lazy match for capturing the equation +// \\\\\) -> matches \\) +const inlineLatexRegex = new RegExp(/\\\\\((.+?)\\\\\)/, "g") +// (?:\\begin{equation}|\\\\\(|\\\\\[) -> start of equation +// ([\s\S]*?) -> Matches the block equation +// (?:\\\\\]|\\\\\)|\\end{equation}) -> end of equation +const blockLatexRegex = new RegExp( + /(?:\\begin{equation}|\\\\\(|\\\\\[)([\s\S]*?)(?:\\\\\]|\\\\\)|\\end{equation})/, + "g", +) +// \$\$[\s\S]*?\$\$ -> Matches block equations +// \$.*?\$ -> Matches inline equations +const quartzLatexRegex = new RegExp(/\$\$[\s\S]*?\$\$|\$.*?\$/, "g") /** * ox-hugo is an org exporter backend that exports org files to hugo-compatible @@ -67,6 +85,23 @@ export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin | return `![](${src})` }) } + + if (opts.replaceOrgLatex) { + src = src.toString() + src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { + const [eqn] = capture + return `$${eqn}$` + }) + src = src.replaceAll(blockLatexRegex, (value, ...capture) => { + const [eqn] = capture + return `$$${eqn}$$` + }) + + // ox-hugo escapes _ as \_ + src = src.replaceAll(quartzLatexRegex, (value) => { + return value.replaceAll("\\_", "_") + }) + } return src }, } diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 9d553622d..4aff2419a 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -328,6 +328,7 @@ pre { &:has(> code.mermaid) { border: none; + position: relative; } & > code { diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts index eb03a2436..5211b5d9a 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) +}