diff --git a/package-lock.json b/package-lock.json index 38fe20c6d..ade453ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1916,10 +1916,16 @@ "license": "MIT" }, "node_modules/@quartz-community/types": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/quartz-community/types.git#39fac344ea3909933c9d3f3d388e43765fb5e32c", + "version": "0.2.1", + "resolved": "git+ssh://git@github.com/quartz-community/types.git#307f7393d96e8514c042307e7fbfb47ae7a2b330", "dev": true, "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "unified": "^11.0.5", + "vfile": "^6.0.3" + }, "engines": { "node": ">=22", "npm": ">=10.9.2" @@ -1927,9 +1933,13 @@ }, "node_modules/@quartz-community/utils": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/quartz-community/utils.git#61970ce89f1019a56a95e9a9d7d414de7d6d1ebd", + "resolved": "git+ssh://git@github.com/quartz-community/utils.git#15d75b89e188e937a8de6b8f8a03c328cfd5c830", "dev": true, "license": "MIT", + "dependencies": { + "@quartz-community/types": "github:quartz-community/types", + "github-slugger": "^2.0.0" + }, "engines": { "node": ">=22", "npm": ">=10.9.2" @@ -2292,7 +2302,9 @@ "license": "MIT" }, "node_modules/@types/mdast": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { "@types/unist": "*" diff --git a/quartz.config.ts b/quartz.config.ts index 56456ce8d..19040461b 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -53,22 +53,22 @@ const config: QuartzConfig = { plugins: { transformers: [ Plugin.FrontMatter(), - Plugin.CreatedModifiedDate({ + ExternalPlugin.CreatedModifiedDate({ priority: ["frontmatter", "git", "filesystem"], }), - Plugin.SyntaxHighlighting({ + ExternalPlugin.SyntaxHighlighting({ theme: { light: "github-light", dark: "github-dark", }, keepBackground: false, }), - Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), - Plugin.GitHubFlavoredMarkdown(), - Plugin.TableOfContents(), - Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - Plugin.Description(), - Plugin.Latex({ renderEngine: "katex" }), + ExternalPlugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), + ExternalPlugin.GitHubFlavoredMarkdown(), + ExternalPlugin.TableOfContentsTransformer(), + ExternalPlugin.CrawlLinks({ markdownLinkResolution: "shortest" }), + ExternalPlugin.Description(), + ExternalPlugin.Latex({ renderEngine: "katex" }), ], filters: [Plugin.RemoveDrafts()], emitters: [ @@ -111,6 +111,17 @@ const config: QuartzConfig = { "github:quartz-community/content-page", "github:quartz-community/folder-page", "github:quartz-community/tag-page", + "github:quartz-community/latex", + "github:quartz-community/created-modified-date", + "github:quartz-community/syntax-highlighting", + "github:quartz-community/obsidian-flavored-markdown", + "github:quartz-community/github-flavored-markdown", + "github:quartz-community/crawl-links", + "github:quartz-community/description", + "github:quartz-community/hard-line-breaks", + "github:quartz-community/citations", + "github:quartz-community/ox-hugo", + "github:quartz-community/roam", ], } diff --git a/quartz.lock.json b/quartz.lock.json index 84ba6351a..9ba3c1133 100644 --- a/quartz.lock.json +++ b/quartz.lock.json @@ -4,116 +4,176 @@ "explorer": { "source": "github:quartz-community/explorer", "resolved": "https://github.com/quartz-community/explorer.git", - "commit": "9ba62f0f124d6ffd7d6bede838f8d70c2a3e85da", - "installedAt": "2026-02-13T19:48:10.583Z" + "commit": "a7512a334f2c3842089c3f48d00ab645f818bb55", + "installedAt": "2026-02-13T22:03:10.551Z" }, "graph": { "source": "github:quartz-community/graph", "resolved": "https://github.com/quartz-community/graph.git", - "commit": "26515026b7f2a01029d68ee2cbd8ebdbe35fdd4c", - "installedAt": "2026-02-13T15:36:12.191Z" + "commit": "b7d0660d2ff72266a07f6ed92aabfe6848feffca", + "installedAt": "2026-02-13T22:03:11.177Z" }, "search": { "source": "github:quartz-community/search", "resolved": "https://github.com/quartz-community/search.git", - "commit": "af45aea98cc3e343627f8cf38835b917cc522c08", - "installedAt": "2026-02-13T15:36:12.611Z" + "commit": "ba87563aa638fb710fe1684d2f9af712bbd149b7", + "installedAt": "2026-02-13T22:03:11.789Z" }, "backlinks": { "source": "github:quartz-community/backlinks", "resolved": "https://github.com/quartz-community/backlinks.git", - "commit": "e835cd3f573f647fe126ef6f1915d531e061c43e", - "installedAt": "2026-02-13T15:36:13.044Z" + "commit": "291567d821c8643a7c27adbdf3cf6726a2abd62f", + "installedAt": "2026-02-13T22:03:12.341Z" }, "table-of-contents": { "source": "github:quartz-community/table-of-contents", "resolved": "https://github.com/quartz-community/table-of-contents.git", - "commit": "819b893f53088d5165ea81ba6fe9d5e5cbb55807", - "installedAt": "2026-02-13T15:36:13.526Z" + "commit": "687517e7de18b4ad6b064a3b6cb450a44aa5ed2d", + "installedAt": "2026-02-13T22:03:12.950Z" }, "comments": { "source": "github:quartz-community/comments", "resolved": "https://github.com/quartz-community/comments.git", - "commit": "5e0e6eab927fd5eac1c5b61ab22fb0ff2e05905d", - "installedAt": "2026-02-13T15:36:13.961Z" + "commit": "aaffdbdb5de08ccc89aed62a4e583a7241b9eca7", + "installedAt": "2026-02-13T22:03:13.562Z" }, "breadcrumbs": { "source": "github:quartz-community/breadcrumbs", "resolved": "https://github.com/quartz-community/breadcrumbs.git", - "commit": "27e033fff2f4a8b5188ab737af743b22eca9d4d0", - "installedAt": "2026-02-13T15:36:14.541Z" + "commit": "38f38fd80eedb47ff1e245aae419d5f3fcd4d864", + "installedAt": "2026-02-13T22:03:14.179Z" }, "recent-notes": { "source": "github:quartz-community/recent-notes", "resolved": "https://github.com/quartz-community/recent-notes.git", - "commit": "57d44711d8158ee542dd9a5086535b1f20d6e042", - "installedAt": "2026-02-13T15:36:14.992Z" + "commit": "b85002146e91787cf61ff4e29e56f32bb0ac3ac4", + "installedAt": "2026-02-13T22:03:14.800Z" }, "latex": { "source": "github:quartz-community/latex", "resolved": "https://github.com/quartz-community/latex.git", - "commit": "c9116138d15f702b1f10ebbe8cdaa231b041fd93", - "installedAt": "2026-02-13T15:36:15.428Z" + "commit": "66ff0b50b7d0b4dd46a4e13851a8708993c263e2", + "installedAt": "2026-02-13T22:03:15.405Z" }, "article-title": { "source": "github:quartz-community/article-title", "resolved": "https://github.com/quartz-community/article-title.git", - "commit": "d927a158f04cec6e0b57e5bd22bc6a3d09f3ceb2", - "installedAt": "2026-02-13T17:02:14.634Z" + "commit": "506a637f7fb5feb7cd174be2c66c9d3bc3953d8b", + "installedAt": "2026-02-13T22:03:16.026Z" }, "tag-list": { "source": "github:quartz-community/tag-list", "resolved": "https://github.com/quartz-community/tag-list.git", - "commit": "4136bf0aba9189598d3bc0af20c3bbd6e2617325", - "installedAt": "2026-02-13T17:02:21.825Z" + "commit": "fdd480e261c30936fe6c1e1af8521bfec06c6222", + "installedAt": "2026-02-13T22:03:16.622Z" }, "page-title": { "source": "github:quartz-community/page-title", "resolved": "https://github.com/quartz-community/page-title.git", - "commit": "6aa778f7d83822f7d3f9b781842af7bb090a97a2", - "installedAt": "2026-02-13T17:02:30.858Z" + "commit": "5dc55332126d3115b3df8421720bf01d6539e15c", + "installedAt": "2026-02-13T22:03:17.190Z" }, "darkmode": { "source": "github:quartz-community/darkmode", "resolved": "https://github.com/quartz-community/darkmode.git", - "commit": "2fc5ca7afa22d8162d25a3faea3e66f407978acd", - "installedAt": "2026-02-13T17:02:38.111Z" + "commit": "b5d772075ed376f1877dc823ca4fa05fa07072d9", + "installedAt": "2026-02-13T22:03:17.788Z" }, "reader-mode": { "source": "github:quartz-community/reader-mode", "resolved": "https://github.com/quartz-community/reader-mode.git", - "commit": "cc25e7eaf1297a3e4313d50512d8b0bdf0d45f32", - "installedAt": "2026-02-13T17:02:46.183Z" + "commit": "09fd8bbbba6c02a114a6dd73cf7ad7936005e3e3", + "installedAt": "2026-02-13T22:03:18.376Z" }, "content-meta": { "source": "github:quartz-community/content-meta", "resolved": "https://github.com/quartz-community/content-meta.git", - "commit": "b61471f8305067dde87c9af4be59faa78e334904", - "installedAt": "2026-02-13T17:02:53.623Z" + "commit": "0485da10334e4d1244528a9990f3ecfc948b7373", + "installedAt": "2026-02-13T22:03:18.971Z" }, "footer": { "source": "github:quartz-community/footer", "resolved": "https://github.com/quartz-community/footer.git", - "commit": "78e3750b3df50a36865f9ba362e7409be6a2c0a3", - "installedAt": "2026-02-13T17:03:00.954Z" + "commit": "26a34a9fd593066ebcce13ea2810b39dd4ee642d", + "installedAt": "2026-02-13T22:03:19.539Z" }, "content-page": { "source": "github:quartz-community/content-page", "resolved": "https://github.com/quartz-community/content-page.git", - "commit": "27ae3160f1076e630a2160885515fb81fb67a8e8", - "installedAt": "2026-02-13T18:15:53.092Z" + "commit": "cc03e4eb885dddca4e526c4b7b3d45c1eda31f46", + "installedAt": "2026-02-13T22:03:20.143Z" }, "folder-page": { "source": "github:quartz-community/folder-page", "resolved": "https://github.com/quartz-community/folder-page.git", - "commit": "81f3f27413c6b3af9c65f4f416153ee792503f9e", - "installedAt": "2026-02-13T19:14:08.050Z" + "commit": "c0accc0ee182e0305843b4ba3dc959ca7530be79", + "installedAt": "2026-02-13T22:03:20.748Z" }, "tag-page": { "source": "github:quartz-community/tag-page", "resolved": "https://github.com/quartz-community/tag-page.git", - "commit": "a244ddb1a143654186dbe24e5bc2dc95de343111", - "installedAt": "2026-02-13T18:55:33.209Z" + "commit": "a95e4a5aa7d99eb2fea56dc91e3d044e5fe21455", + "installedAt": "2026-02-13T22:03:21.366Z" + }, + "created-modified-date": { + "source": "github:quartz-community/created-modified-date", + "resolved": "https://github.com/quartz-community/created-modified-date.git", + "commit": "f25330c47c2ac2a9c58db1087adc71c6ee26fe7c", + "installedAt": "2026-02-13T22:03:21.949Z" + }, + "syntax-highlighting": { + "source": "github:quartz-community/syntax-highlighting", + "resolved": "https://github.com/quartz-community/syntax-highlighting.git", + "commit": "7255e37de4d17690eba5508944c232c3a85f74d5", + "installedAt": "2026-02-13T22:03:22.542Z" + }, + "obsidian-flavored-markdown": { + "source": "github:quartz-community/obsidian-flavored-markdown", + "resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git", + "commit": "075e5662c2ae1e8bea35f3a3ffe05f91a81a9c4d", + "installedAt": "2026-02-13T22:03:23.141Z" + }, + "github-flavored-markdown": { + "source": "github:quartz-community/github-flavored-markdown", + "resolved": "https://github.com/quartz-community/github-flavored-markdown.git", + "commit": "64ee10295cd01d0edf6ee17c421c9aed7ba1b552", + "installedAt": "2026-02-13T22:03:23.734Z" + }, + "crawl-links": { + "source": "github:quartz-community/crawl-links", + "resolved": "https://github.com/quartz-community/crawl-links.git", + "commit": "f3fed36a68366465a9d0f2c6d7829fc6a54ae9b1", + "installedAt": "2026-02-13T22:03:24.352Z" + }, + "description": { + "source": "github:quartz-community/description", + "resolved": "https://github.com/quartz-community/description.git", + "commit": "58bba27fccb17a78ed5f190777414295f70939f6", + "installedAt": "2026-02-13T22:03:24.921Z" + }, + "hard-line-breaks": { + "source": "github:quartz-community/hard-line-breaks", + "resolved": "https://github.com/quartz-community/hard-line-breaks.git", + "commit": "ed63b07239b649d9758fc6ffec9e2104765575b1", + "installedAt": "2026-02-13T22:03:25.518Z" + }, + "citations": { + "source": "github:quartz-community/citations", + "resolved": "https://github.com/quartz-community/citations.git", + "commit": "21279407abd796ab8cbf08e306e7f7edbf5ed006", + "installedAt": "2026-02-13T22:03:26.104Z" + }, + "ox-hugo": { + "source": "github:quartz-community/ox-hugo", + "resolved": "https://github.com/quartz-community/ox-hugo.git", + "commit": "ab0c3cf7eaf269f7f29065e99288bf62593c1533", + "installedAt": "2026-02-13T22:03:26.705Z" + }, + "roam": { + "source": "github:quartz-community/roam", + "resolved": "https://github.com/quartz-community/roam.git", + "commit": "b6e6fab11cde1430d396822ebcefd501b486adf1", + "installedAt": "2026-02-13T22:03:27.359Z" } } } diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index c9956836e..8507b71b7 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -1,6 +1,8 @@ import { StaticResources } from "../util/resources" -import { FilePath, FullSlug } from "../util/path" +import { FilePath, FullSlug, SimpleSlug } from "../util/path" import { BuildCtx } from "../util/ctx" +import { Root as HtmlRoot } from "hast" +import { Element } from "hast" export function getStaticResourcesFromPlugins(ctx: BuildCtx) { const staticResources: StaticResources = { @@ -56,5 +58,23 @@ declare module "vfile" { slug: FullSlug filePath: FilePath relativePath: FilePath + // from description transformer + description: string + text: string + // from crawl-links transformer + links: SimpleSlug[] + // from table-of-contents transformer + toc: { depth: number; text: string; slug: string }[] + collapseToc: boolean + // from obsidian-flavored-markdown transformer + blocks: Record + htmlAst: HtmlRoot + hasMermaidDiagram: boolean | undefined + // from created-modified-date transformer + dates: { + created: Date + modified: Date + published: Date + } } } diff --git a/quartz/plugins/transformers/citations.ts b/quartz/plugins/transformers/citations.ts deleted file mode 100644 index 1a3ad8037..000000000 --- a/quartz/plugins/transformers/citations.ts +++ /dev/null @@ -1,63 +0,0 @@ -import rehypeCitation from "rehype-citation" -import { PluggableList } from "unified" -import { visit } from "unist-util-visit" -import { QuartzTransformerPlugin } from "../types" - -export interface Options { - bibliographyFile: string - suppressBibliography: boolean - linkCitations: boolean - csl: string -} - -const defaultOptions: Options = { - bibliographyFile: "./bibliography.bib", - suppressBibliography: false, - linkCitations: false, - csl: "apa", -} - -export const Citations: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "Citations", - htmlPlugins(ctx) { - const plugins: PluggableList = [] - // per default, rehype-citations only supports en-US - // see: https://github.com/timlrx/rehype-citation/issues/12 - // in here there are multiple usable locales: - // https://github.com/citation-style-language/locales - // thus, we optimistically assume there is indeed an appropriate - // locale available and simply create the lang url-string - let lang: string = "en-US" - if (ctx.cfg.configuration.locale !== "en-US") { - lang = `https://raw.githubusercontent.com/citation-stylelanguage/locales/refs/heads/master/locales-${ctx.cfg.configuration.locale}.xml` - } - // Add rehype-citation to the list of plugins - plugins.push([ - rehypeCitation, - { - bibliography: opts.bibliographyFile, - suppressBibliography: opts.suppressBibliography, - linkCitations: opts.linkCitations, - csl: opts.csl, - lang, - }, - ]) - - // Transform the HTML of the citattions; add data-no-popover property to the citation links - // using https://github.com/syntax-tree/unist-util-visit as they're just anochor links - plugins.push(() => { - return (tree, _file) => { - visit(tree, "element", (node, _index, _parent) => { - if (node.tagName === "a" && node.properties?.href?.startsWith("#bib")) { - node.properties["data-no-popover"] = true - } - }) - } - }) - - return plugins - }, - } -} diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts deleted file mode 100644 index 3f8519b32..000000000 --- a/quartz/plugins/transformers/description.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Root as HTMLRoot } from "hast" -import { toString } from "hast-util-to-string" -import { QuartzTransformerPlugin } from "../types" -import { escapeHTML } from "../../util/escape" - -export interface Options { - descriptionLength: number - maxDescriptionLength: number - replaceExternalLinks: boolean -} - -const defaultOptions: Options = { - descriptionLength: 150, - maxDescriptionLength: 300, - replaceExternalLinks: true, -} - -const urlRegex = new RegExp( - /(https?:\/\/)?(?([\da-z\.-]+)\.([a-z\.]{2,6})(:\d+)?)(?[\/\w\.-]*)(\?[\/\w\.=&;-]*)?/, - "g", -) - -export const Description: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "Description", - htmlPlugins() { - return [ - () => { - return async (tree: HTMLRoot, file) => { - let frontMatterDescription = file.data.frontmatter?.description - let text = escapeHTML(toString(tree)) - - if (opts.replaceExternalLinks) { - frontMatterDescription = frontMatterDescription?.replace( - urlRegex, - "$" + "$", - ) - text = text.replace(urlRegex, "$" + "$") - } - - if (frontMatterDescription) { - file.data.description = frontMatterDescription - file.data.text = text - return - } - - // otherwise, use the text content - const desc = text - const sentences = desc.replace(/\s+/g, " ").split(/\.\s/) - let finalDesc = "" - let sentenceIdx = 0 - - // Add full sentences until we exceed the guideline length - while (sentenceIdx < sentences.length) { - const sentence = sentences[sentenceIdx] - if (!sentence) break - - const currentSentence = sentence.endsWith(".") ? sentence : sentence + "." - const nextLength = finalDesc.length + currentSentence.length + (finalDesc ? 1 : 0) - - // Add the sentence if we're under the guideline length - // or if this is the first sentence (always include at least one) - if (nextLength <= opts.descriptionLength || sentenceIdx === 0) { - finalDesc += (finalDesc ? " " : "") + currentSentence - sentenceIdx++ - } else { - break - } - } - - // truncate to max length if necessary - file.data.description = - finalDesc.length > opts.maxDescriptionLength - ? finalDesc.slice(0, opts.maxDescriptionLength) + "..." - : finalDesc - file.data.text = text - } - }, - ] - }, - } -} - -declare module "vfile" { - interface DataMap { - description: string - text: string - } -} diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts deleted file mode 100644 index eec26f7b9..000000000 --- a/quartz/plugins/transformers/gfm.ts +++ /dev/null @@ -1,78 +0,0 @@ -import remarkGfm from "remark-gfm" -import smartypants from "remark-smartypants" -import { QuartzTransformerPlugin } from "../types" -import rehypeSlug from "rehype-slug" -import rehypeAutolinkHeadings from "rehype-autolink-headings" - -export interface Options { - enableSmartyPants: boolean - linkHeadings: boolean -} - -const defaultOptions: Options = { - enableSmartyPants: true, - linkHeadings: true, -} - -export const GitHubFlavoredMarkdown: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "GitHubFlavoredMarkdown", - markdownPlugins() { - return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm] - }, - htmlPlugins() { - if (opts.linkHeadings) { - return [ - rehypeSlug, - [ - rehypeAutolinkHeadings, - { - behavior: "append", - properties: { - role: "anchor", - ariaHidden: true, - tabIndex: -1, - "data-no-popover": true, - }, - content: { - type: "element", - tagName: "svg", - properties: { - width: 18, - height: 18, - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - "stroke-width": "2", - "stroke-linecap": "round", - "stroke-linejoin": "round", - }, - children: [ - { - type: "element", - tagName: "path", - properties: { - d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", - }, - children: [], - }, - { - type: "element", - tagName: "path", - properties: { - d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", - }, - children: [], - }, - ], - }, - }, - ], - ] - } else { - return [] - } - }, - } -} diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..792fad751 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -1,13 +1 @@ export { FrontMatter } from "./frontmatter" -export { GitHubFlavoredMarkdown } from "./gfm" -export { Citations } from "./citations" -export { CreatedModifiedDate } from "./lastmod" -export { Latex } from "./latex" -export { Description } from "./description" -export { CrawlLinks } from "./links" -export { ObsidianFlavoredMarkdown } from "./ofm" -export { OxHugoFlavouredMarkdown } from "./oxhugofm" -export { SyntaxHighlighting } from "./syntax" -export { TableOfContents } from "./toc" -export { HardLineBreaks } from "./linebreaks" -export { RoamFlavoredMarkdown } from "./roam" diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts deleted file mode 100644 index 32a89cc23..000000000 --- a/quartz/plugins/transformers/lastmod.ts +++ /dev/null @@ -1,115 +0,0 @@ -import fs from "fs" -import { Repository } from "@napi-rs/simple-git" -import { QuartzTransformerPlugin } from "../types" -import path from "path" -import { styleText } from "util" - -export interface Options { - priority: ("frontmatter" | "git" | "filesystem")[] -} - -const defaultOptions: Options = { - priority: ["frontmatter", "git", "filesystem"], -} - -// YYYY-MM-DD -const iso8601DateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/ - -function coerceDate(fp: string, d: any): Date { - // check ISO8601 date-only format - // we treat this one as local midnight as the normal - // js date ctor treats YYYY-MM-DD as UTC midnight - if (typeof d === "string" && iso8601DateOnlyRegex.test(d)) { - d = `${d}T00:00:00` - } - - const dt = new Date(d) - const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0 - if (invalidDate && d !== undefined) { - console.log( - styleText( - "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 -export const CreatedModifiedDate: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "CreatedModifiedDate", - markdownPlugins(ctx) { - return [ - () => { - let repo: Repository | undefined = undefined - let repositoryWorkdir: string - if (opts.priority.includes("git")) { - try { - repo = Repository.discover(ctx.argv.directory) - repositoryWorkdir = repo.workdir() ?? ctx.argv.directory - } catch (e) { - console.log( - styleText( - "yellow", - `\nWarning: couldn't find git repository for ${ctx.argv.directory}`, - ), - ) - } - } - - return async (_tree, file) => { - let created: MaybeDate = undefined - let modified: MaybeDate = undefined - let published: MaybeDate = undefined - - const fp = file.data.relativePath! - const fullFp = file.data.filePath! - for (const source of opts.priority) { - if (source === "filesystem") { - const st = await fs.promises.stat(fullFp) - created ||= st.birthtimeMs - modified ||= st.mtimeMs - } else if (source === "frontmatter" && file.data.frontmatter) { - created ||= file.data.frontmatter.created as MaybeDate - modified ||= file.data.frontmatter.modified as MaybeDate - published ||= file.data.frontmatter.published as MaybeDate - } else if (source === "git" && repo) { - try { - const relativePath = path.relative(repositoryWorkdir, fullFp) - modified ||= await repo.getFileLatestModifiedDateAsync(relativePath) - } catch { - console.log( - styleText( - "yellow", - `\nWarning: ${file.data.filePath!} isn't yet tracked by git, dates will be inaccurate`, - ), - ) - } - } - } - - file.data.dates = { - created: coerceDate(fp, created), - modified: coerceDate(fp, modified), - published: coerceDate(fp, published), - } - } - }, - ] - }, - } -} - -declare module "vfile" { - interface DataMap { - dates: { - created: Date - modified: Date - published: Date - } - } -} diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts deleted file mode 100644 index a9f54f319..000000000 --- a/quartz/plugins/transformers/latex.ts +++ /dev/null @@ -1,76 +0,0 @@ -import remarkMath from "remark-math" -import rehypeKatex from "rehype-katex" -import rehypeMathjax from "rehype-mathjax/svg" -//@ts-ignore -import rehypeTypst from "@myriaddreamin/rehype-typst" -import { QuartzTransformerPlugin } from "../types" -import { KatexOptions } from "katex" -import { Options as MathjaxOptions } from "rehype-mathjax/svg" -//@ts-ignore -import { Options as TypstOptions } from "@myriaddreamin/rehype-typst" - -interface Options { - renderEngine: "katex" | "mathjax" | "typst" - customMacros: MacroType - katexOptions: Omit - mathJaxOptions: Omit - typstOptions: TypstOptions -} - -// mathjax macros -export type Args = boolean | number | string | null -interface MacroType { - [key: string]: string | Args[] -} - -export const Latex: QuartzTransformerPlugin> = (opts) => { - const engine = opts?.renderEngine ?? "katex" - const macros = opts?.customMacros ?? {} - return { - name: "Latex", - markdownPlugins() { - return [remarkMath] - }, - htmlPlugins() { - switch (engine) { - case "katex": { - return [[rehypeKatex, { output: "html", macros, ...(opts?.katexOptions ?? {}) }]] - } - case "typst": { - return [[rehypeTypst, opts?.typstOptions ?? {}]] - } - default: - case "mathjax": { - return [ - [ - rehypeMathjax, - { - ...(opts?.mathJaxOptions ?? {}), - tex: { - ...(opts?.mathJaxOptions?.tex ?? {}), - macros, - }, - }, - ], - ] - } - } - }, - externalResources() { - switch (engine) { - case "katex": - return { - css: [{ content: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" }], - js: [ - { - // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md - src: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/copy-tex.min.js", - loadTime: "afterDOMReady", - contentType: "external", - }, - ], - } - } - }, - } -} diff --git a/quartz/plugins/transformers/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts deleted file mode 100644 index a8a066fc1..000000000 --- a/quartz/plugins/transformers/linebreaks.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import remarkBreaks from "remark-breaks" - -export const HardLineBreaks: QuartzTransformerPlugin = () => { - return { - name: "HardLineBreaks", - markdownPlugins() { - return [remarkBreaks] - }, - } -} diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts deleted file mode 100644 index f4451d927..000000000 --- a/quartz/plugins/transformers/links.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import { - FullSlug, - RelativeURL, - SimpleSlug, - TransformOptions, - stripSlashes, - simplifySlug, - splitAnchor, - transformLink, -} from "../../util/path" -import path from "path" -import { visit } from "unist-util-visit" -import isAbsoluteUrl from "is-absolute-url" -import { Root } from "hast" - -interface Options { - /** How to resolve Markdown paths */ - markdownLinkResolution: TransformOptions["strategy"] - /** Strips folders from a link so that it looks nice */ - prettyLinks: boolean - openLinksInNewTab: boolean - lazyLoad: boolean - externalLinkIcon: boolean -} - -const defaultOptions: Options = { - markdownLinkResolution: "absolute", - prettyLinks: true, - openLinksInNewTab: false, - lazyLoad: false, - externalLinkIcon: true, -} - -export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "LinkProcessing", - htmlPlugins(ctx) { - return [ - () => { - return (tree: Root, file) => { - const curSlug = simplifySlug(file.data.slug!) - const outgoing: Set = new Set() - - const transformOptions: TransformOptions = { - strategy: opts.markdownLinkResolution, - allSlugs: ctx.allSlugs, - } - - visit(tree, "element", (node, _index, _parent) => { - // rewrite all links - if ( - node.tagName === "a" && - node.properties && - typeof node.properties.href === "string" - ) { - let dest = node.properties.href as RelativeURL - const classes = (node.properties.className ?? []) as string[] - const isExternal = isAbsoluteUrl(dest, { httpOnly: false }) - classes.push(isExternal ? "external" : "internal") - - if (isExternal && opts.externalLinkIcon) { - node.children.push({ - type: "element", - tagName: "svg", - properties: { - "aria-hidden": "true", - class: "external-icon", - style: "max-width:0.8em;max-height:0.8em", - viewBox: "0 0 512 512", - }, - children: [ - { - type: "element", - tagName: "path", - properties: { - d: "M320 0H288V64h32 82.7L201.4 265.4 178.7 288 224 333.3l22.6-22.6L448 109.3V192v32h64V192 32 0H480 320zM32 32H0V64 480v32H32 456h32V480 352 320H424v32 96H64V96h96 32V32H160 32z", - }, - children: [], - }, - ], - }) - } - - // Check if the link has alias text - if ( - node.children.length === 1 && - node.children[0].type === "text" && - node.children[0].value !== dest - ) { - // Add the 'alias' class if the text content is not the same as the href - classes.push("alias") - } - node.properties.className = classes - - if (isExternal && opts.openLinksInNewTab) { - node.properties.target = "_blank" - } - - // don't process external links or intra-document anchors - const isInternal = !( - isAbsoluteUrl(dest, { httpOnly: false }) || dest.startsWith("#") - ) - if (isInternal) { - dest = node.properties.href = transformLink( - file.data.slug!, - dest, - transformOptions, - ) - - // url.resolve is considered legacy - // WHATWG equivalent https://nodejs.dev/en/api/v18/url/#urlresolvefrom-to - const url = new URL(dest, "https://base.com/" + stripSlashes(curSlug, true)) - const canonicalDest = url.pathname - let [destCanonical, _destAnchor] = splitAnchor(canonicalDest) - if (destCanonical.endsWith("/")) { - destCanonical += "index" - } - - // need to decodeURIComponent here as WHATWG URL percent-encodes everything - const full = decodeURIComponent(stripSlashes(destCanonical, true)) as FullSlug - const simple = simplifySlug(full) - outgoing.add(simple) - node.properties["data-slug"] = full - } - - // rewrite link internals if prettylinks is on - if ( - opts.prettyLinks && - isInternal && - node.children.length === 1 && - node.children[0].type === "text" && - !node.children[0].value.startsWith("#") - ) { - node.children[0].value = path.basename(node.children[0].value) - } - } - - // transform all other resources that may use links - if ( - ["img", "video", "audio", "iframe"].includes(node.tagName) && - node.properties && - typeof node.properties.src === "string" - ) { - if (opts.lazyLoad) { - node.properties.loading = "lazy" - } - - if (!isAbsoluteUrl(node.properties.src, { httpOnly: false })) { - let dest = node.properties.src as RelativeURL - dest = node.properties.src = transformLink( - file.data.slug!, - dest, - transformOptions, - ) - node.properties.src = dest - } - } - }) - - file.data.links = [...outgoing] - } - }, - ] - }, - } -} - -declare module "vfile" { - interface DataMap { - links: SimpleSlug[] - } -} diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts deleted file mode 100644 index 7a523aa59..000000000 --- a/quartz/plugins/transformers/ofm.ts +++ /dev/null @@ -1,793 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import { - Root, - Html, - BlockContent, - PhrasingContent, - DefinitionContent, - Paragraph, - Code, -} from "mdast" -import { Element, Literal, Root as HtmlRoot } from "hast" -import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" -import rehypeRaw from "rehype-raw" -import { SKIP, visit } from "unist-util-visit" -import path from "path" -import { splitAnchor } from "../../util/path" -import { JSResource, CSSResource } from "../../util/resources" -// @ts-ignore -import calloutScript from "../../components/scripts/callout.inline" -// @ts-ignore -import checkboxScript from "../../components/scripts/checkbox.inline" -// @ts-ignore -import mermaidScript from "../../components/scripts/mermaid.inline" -import mermaidStyle from "../../components/styles/mermaid.inline.scss" -import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" -import { toHast } from "mdast-util-to-hast" -import { toHtml } from "hast-util-to-html" -import { capitalize } from "../../util/lang" -import { PluggableList } from "unified" - -export interface Options { - comments: boolean - highlight: boolean - wikilinks: boolean - callouts: boolean - mermaid: boolean - parseTags: boolean - parseArrows: boolean - parseBlockReferences: boolean - enableInHtmlEmbed: boolean - enableYouTubeEmbed: boolean - enableVideoEmbed: boolean - enableCheckbox: boolean - disableBrokenWikilinks: boolean -} - -const defaultOptions: Options = { - comments: true, - highlight: true, - wikilinks: true, - callouts: true, - mermaid: true, - parseTags: true, - parseArrows: true, - parseBlockReferences: true, - enableInHtmlEmbed: false, - enableYouTubeEmbed: true, - enableVideoEmbed: true, - enableCheckbox: false, - disableBrokenWikilinks: false, -} - -const calloutMapping = { - note: "note", - abstract: "abstract", - summary: "abstract", - tldr: "abstract", - info: "info", - todo: "todo", - tip: "tip", - hint: "tip", - important: "tip", - success: "success", - check: "success", - done: "success", - question: "question", - help: "question", - faq: "question", - warning: "warning", - attention: "warning", - caution: "warning", - failure: "failure", - missing: "failure", - fail: "failure", - danger: "danger", - error: "danger", - bug: "bug", - example: "example", - quote: "quote", - cite: "quote", -} as const - -const arrowMapping: Record = { - "->": "→", - "-->": "⇒", - "=>": "⇒", - "==>": "⇒", - "<-": "←", - "<--": "⇐", - "<=": "⇐", - "<==": "⇐", -} - -function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping { - const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping - // if callout is not recognized, make it a custom one - return calloutMapping[normalizedCallout] ?? calloutName -} - -export const externalLinkRegex = /^https?:\/\//i - -export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/g) - -// !? -> optional embedding -// \[\[ -> open brace -// ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) -// (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) -// (\\?\|[^\[\]\#]+)? -> optional escape \ then | then zero or more non-special characters (alias) -export const wikilinkRegex = new RegExp( - /!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g, -) - -// ^\|([^\n])+\|\n(\|) -> matches the header row -// ( ?:?-{3,}:? ?\|)+ -> matches the header row separator -// (\|([^\n])+\|\n)+ -> matches the body rows -export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|([^\n])+\|\n?)+/gm) - -// matches any wikilink, only used for escaping wikilinks inside tables -export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g) - -const highlightRegex = new RegExp(/==([^=]+)==/g) -const commentRegex = new RegExp(/%%[\s\S]*?%%/g) -// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts -const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/) -const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm) -// (?<=^| ) -> a lookbehind assertion, tag should start be separated by a space or be the start of the line -// #(...) -> capturing group, tag itself must start with # -// (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores -// (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" -const tagRegex = new RegExp( - /(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu, -) -const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g) -const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ -const ytPlaylistLinkRegex = /[?&]list=([^#?&]*)/ -const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/) -const wikilinkImageEmbedRegex = new RegExp( - /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/, -) - -export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - - const mdastToHtml = (ast: PhrasingContent | Paragraph) => { - const hast = toHast(ast, { allowDangerousHtml: true })! - return toHtml(hast, { allowDangerousHtml: true }) - } - - return { - name: "ObsidianFlavoredMarkdown", - textTransform(_ctx, src) { - // do comments at text level - if (opts.comments) { - src = src.replace(commentRegex, "") - } - - // pre-transform blockquotes - if (opts.callouts) { - src = src.replace(calloutLineRegex, (value) => { - // force newline after title of callout - return value + "\n> " - }) - } - - // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) - if (opts.wikilinks) { - // replace all wikilinks inside a table first - src = src.replace(tableRegex, (value) => { - // escape all aliases and headers in wikilinks inside a table - return value.replace(tableWikilinkRegex, (_value, raw) => { - // const [raw]: (string | undefined)[] = capture - let escaped = raw ?? "" - escaped = escaped.replace("#", "\\#") - // escape pipe characters if they are not already escaped - escaped = escaped.replace(/((^|[^\\])(\\\\)*)\|/g, "$1\\|") - - return escaped - }) - }) - - // replace all other wikilinks - src = src.replace(wikilinkRegex, (value, ...capture) => { - const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture - - const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) - const blockRef = Boolean(rawHeader?.startsWith("#^")) ? "^" : "" - const displayAnchor = anchor ? `#${blockRef}${anchor.trim().replace(/^#+/, "")}` : "" - const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" - const embedDisplay = value.startsWith("!") ? "!" : "" - - if (rawFp?.match(externalLinkRegex)) { - return `${embedDisplay}[${displayAlias.replace(/^\|/, "")}](${rawFp})` - } - - return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` - }) - } - - return src - }, - markdownPlugins(ctx) { - const plugins: PluggableList = [] - - // regex replacements - plugins.push(() => { - return (tree: Root, file) => { - const replacements: [RegExp, string | ReplaceFunction][] = [] - const base = pathToRoot(file.data.slug!) - - if (opts.wikilinks) { - replacements.push([ - wikilinkRegex, - (value: string, ...capture: string[]) => { - let [rawFp, rawHeader, rawAlias] = capture - const fp = rawFp?.trim() ?? "" - const anchor = rawHeader?.trim() ?? "" - const alias: string | undefined = rawAlias?.slice(1).trim() - - // embed cases - if (value.startsWith("!")) { - const ext: string = path.extname(fp).toLowerCase() - const url = slugifyFilePath(fp as FilePath) - if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp"].includes(ext)) { - const match = wikilinkImageEmbedRegex.exec(alias ?? "") - const alt = match?.groups?.alt ?? "" - const width = match?.groups?.width ?? "auto" - const height = match?.groups?.height ?? "auto" - return { - type: "image", - url, - data: { - hProperties: { - width, - height, - alt, - }, - }, - } - } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else if ( - [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) - ) { - return { - type: "html", - value: ``, - } - } else if ([".pdf"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else { - const block = anchor - return { - type: "html", - data: { hProperties: { transclude: true } }, - value: `
Transclude of ${url}${block}
`, - } - } - - // otherwise, fall through to regular link - } - - // treat as broken link if slug not in ctx.allSlugs - if (opts.disableBrokenWikilinks) { - const slug = slugifyFilePath(fp as FilePath) - const exists = ctx.allSlugs && ctx.allSlugs.includes(slug) - if (!exists) { - return { - type: "html", - value: `${alias ?? fp}`, - } - } - } - - // internal link - const url = fp + anchor - - return { - type: "link", - url, - children: [ - { - type: "text", - value: alias ?? fp, - }, - ], - } - }, - ]) - } - - if (opts.highlight) { - replacements.push([ - highlightRegex, - (_value: string, ...capture: string[]) => { - const [inner] = capture - return { - type: "html", - value: `${inner}`, - } - }, - ]) - } - - if (opts.parseArrows) { - replacements.push([ - arrowRegex, - (value: string, ..._capture: string[]) => { - const maybeArrow = arrowMapping[value] - if (maybeArrow === undefined) return SKIP - return { - type: "html", - value: `${maybeArrow}`, - } - }, - ]) - } - - if (opts.parseTags) { - replacements.push([ - tagRegex, - (_value: string, tag: string) => { - // Check if the tag only includes numbers and slashes - if (/^[\/\d]+$/.test(tag)) { - return false - } - - tag = slugTag(tag) - if (file.data.frontmatter) { - const noteTags = file.data.frontmatter.tags ?? [] - file.data.frontmatter.tags = [...new Set([...noteTags, tag])] - } - - return { - type: "link", - url: base + `/tags/${tag}`, - data: { - hProperties: { - className: ["tag-link"], - }, - }, - children: [ - { - type: "text", - value: tag, - }, - ], - } - }, - ]) - } - - if (opts.enableInHtmlEmbed) { - visit(tree, "html", (node: Html) => { - for (const [regex, replace] of replacements) { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replace(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - } - }) - } - mdastFindReplace(tree, replacements) - } - }) - - if (opts.enableVideoEmbed) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "image", (node, index, parent) => { - if (parent && index != undefined && videoExtensionRegex.test(node.url)) { - const newNode: Html = { - type: "html", - value: ``, - } - - parent.children.splice(index, 1, newNode) - return SKIP - } - }) - } - }) - } - - if (opts.callouts) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "blockquote", (node) => { - if (node.children.length === 0) { - return - } - - // find first line and callout content - const [firstChild, ...calloutContent] = node.children - if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { - return - } - - const text = firstChild.children[0].value - const restOfTitle = firstChild.children.slice(1) - const [firstLine, ...remainingLines] = text.split("\n") - const remainingText = remainingLines.join("\n") - - const match = firstLine.match(calloutRegex) - if (match && match.input) { - const [calloutDirective, typeString, calloutMetaData, collapseChar] = match - const calloutType = canonicalizeCallout(typeString.toLowerCase()) - const collapse = collapseChar === "+" || collapseChar === "-" - const defaultState = collapseChar === "-" ? "collapsed" : "expanded" - const titleContent = match.input.slice(calloutDirective.length).trim() - const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 - const titleNode: Paragraph = { - type: "paragraph", - children: [ - { - type: "text", - value: useDefaultTitle - ? capitalize(typeString).replace(/-/g, " ") - : titleContent + " ", - }, - ...restOfTitle, - ], - } - const title = mdastToHtml(titleNode) - - const toggleIcon = `
` - - const titleHtml: Html = { - type: "html", - value: `
-
-
${title}
- ${collapse ? toggleIcon : ""} -
`, - } - - const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml] - if (remainingText.length > 0) { - blockquoteContent.push({ - type: "paragraph", - children: [ - { - type: "text", - value: remainingText, - }, - ], - }) - } - - // For the rest of the MD callout elements other than the title, wrap them with - // two nested HTML
s (use some hacked mdhast component to achieve this) of - // class `callout-content` and `callout-content-inner` respectively for - // grid-based collapsible animation. - if (calloutContent.length > 0) { - node.children = [ - node.children[0], - { - data: { hProperties: { className: ["callout-content"] }, hName: "div" }, - type: "blockquote", - children: [...calloutContent], - }, - ] - } - - // replace first line of blockquote with title and rest of the paragraph text - node.children.splice(0, 1, ...blockquoteContent) - - const classNames = ["callout", calloutType] - if (collapse) { - classNames.push("is-collapsible") - } - if (defaultState === "collapsed") { - classNames.push("is-collapsed") - } - - // add properties to base blockquote - node.data = { - hProperties: { - ...(node.data?.hProperties ?? {}), - className: classNames.join(" "), - "data-callout": calloutType, - "data-callout-fold": collapse, - "data-callout-metadata": calloutMetaData, - }, - } - } - }) - } - }) - } - - if (opts.mermaid) { - plugins.push(() => { - return (tree: Root, file) => { - visit(tree, "code", (node: Code) => { - if (node.lang === "mermaid") { - file.data.hasMermaidDiagram = true - node.data = { - hProperties: { - className: ["mermaid"], - "data-clipboard": JSON.stringify(node.value), - }, - } - } - }) - } - }) - } - - return plugins - }, - htmlPlugins() { - const plugins: PluggableList = [rehypeRaw] - - if (opts.parseBlockReferences) { - plugins.push(() => { - const inlineTagTypes = new Set(["p", "li"]) - const blockTagTypes = new Set(["blockquote"]) - return (tree: HtmlRoot, file) => { - file.data.blocks = {} - - visit(tree, "element", (node, index, parent) => { - if (blockTagTypes.has(node.tagName)) { - const nextChild = parent?.children.at(index! + 2) as Element - if (nextChild && nextChild.tagName === "p") { - const text = nextChild.children.at(0) as Literal - if (text && text.value && text.type === "text") { - const matches = text.value.match(blockReferenceRegex) - if (matches && matches.length >= 1) { - parent!.children.splice(index! + 2, 1) - const block = matches[0].slice(1) - - if (!Object.keys(file.data.blocks!).includes(block)) { - node.properties = { - ...node.properties, - id: block, - } - file.data.blocks![block] = node - } - } - } - } - } else if (inlineTagTypes.has(node.tagName)) { - const last = node.children.at(-1) as Literal - if (last && last.value && typeof last.value === "string") { - const matches = last.value.match(blockReferenceRegex) - if (matches && matches.length >= 1) { - last.value = last.value.slice(0, -matches[0].length) - const block = matches[0].slice(1) - - if (last.value === "") { - // this is an inline block ref but the actual block - // is the previous element above it - let idx = (index ?? 1) - 1 - while (idx >= 0) { - const element = parent?.children.at(idx) - if (!element) break - if (element.type !== "element") { - idx -= 1 - } else { - if (!Object.keys(file.data.blocks!).includes(block)) { - element.properties = { - ...element.properties, - id: block, - } - file.data.blocks![block] = element - } - return - } - } - } else { - // normal paragraph transclude - if (!Object.keys(file.data.blocks!).includes(block)) { - node.properties = { - ...node.properties, - id: block, - } - file.data.blocks![block] = node - } - } - } - } - } - }) - - file.data.htmlAst = tree - } - }) - } - - if (opts.enableYouTubeEmbed) { - plugins.push(() => { - return (tree: HtmlRoot) => { - visit(tree, "element", (node) => { - if (node.tagName === "img" && typeof node.properties.src === "string") { - const match = node.properties.src.match(ytLinkRegex) - const videoId = match && match[2].length == 11 ? match[2] : null - const playlistId = node.properties.src.match(ytPlaylistLinkRegex)?.[1] - if (videoId) { - // YouTube video (with optional playlist) - node.tagName = "iframe" - node.properties = { - class: "external-embed youtube", - allow: "fullscreen", - frameborder: 0, - width: "600px", - src: playlistId - ? `https://www.youtube.com/embed/${videoId}?list=${playlistId}` - : `https://www.youtube.com/embed/${videoId}`, - } - } else if (playlistId) { - // YouTube playlist only. - node.tagName = "iframe" - node.properties = { - class: "external-embed youtube", - allow: "fullscreen", - frameborder: 0, - width: "600px", - src: `https://www.youtube.com/embed/videoseries?list=${playlistId}`, - } - } - } - }) - } - }) - } - - if (opts.enableCheckbox) { - plugins.push(() => { - return (tree: HtmlRoot, _file) => { - visit(tree, "element", (node) => { - if (node.tagName === "input" && node.properties.type === "checkbox") { - const isChecked = node.properties?.checked ?? false - node.properties = { - type: "checkbox", - disabled: false, - checked: isChecked, - class: "checkbox-toggle", - } - } - }) - } - }) - } - - if (opts.mermaid) { - plugins.push(() => { - return (tree: HtmlRoot, _file) => { - visit(tree, "element", (node: Element, _idx, parent) => { - if ( - node.tagName === "code" && - ((node.properties?.className ?? []) as string[])?.includes("mermaid") - ) { - parent!.children = [ - { - type: "element", - tagName: "button", - properties: { - className: ["expand-button"], - "aria-label": "Expand mermaid diagram", - "data-view-component": true, - }, - children: [ - { - type: "element", - tagName: "svg", - properties: { - width: 16, - height: 16, - viewBox: "0 0 16 16", - fill: "currentColor", - }, - children: [ - { - type: "element", - tagName: "path", - properties: { - fillRule: "evenodd", - d: "M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z", - }, - children: [], - }, - ], - }, - ], - }, - node, - { - type: "element", - tagName: "div", - properties: { id: "mermaid-container", role: "dialog" }, - children: [ - { - type: "element", - tagName: "div", - properties: { id: "mermaid-space" }, - children: [ - { - type: "element", - tagName: "div", - properties: { className: ["mermaid-content"] }, - children: [], - }, - ], - }, - ], - }, - ] - } - }) - } - }) - } - - return plugins - }, - externalResources() { - const js: JSResource[] = [] - const css: CSSResource[] = [] - - if (opts.enableCheckbox) { - js.push({ - script: checkboxScript, - loadTime: "afterDOMReady", - contentType: "inline", - }) - } - - if (opts.callouts) { - js.push({ - script: calloutScript, - loadTime: "afterDOMReady", - contentType: "inline", - }) - } - - if (opts.mermaid) { - js.push({ - script: mermaidScript, - loadTime: "afterDOMReady", - contentType: "inline", - moduleType: "module", - }) - - css.push({ - content: mermaidStyle, - inline: true, - }) - } - - return { js, css } - }, - } -} - -declare module "vfile" { - interface DataMap { - blocks: Record - htmlAst: HtmlRoot - hasMermaidDiagram: boolean | undefined - } -} diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts deleted file mode 100644 index 303566e08..000000000 --- a/quartz/plugins/transformers/oxhugofm.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import rehypeRaw from "rehype-raw" -import { PluggableList } from "unified" - -export interface Options { - /** Replace {{ relref }} with quartz wikilinks []() */ - wikilinks: boolean - /** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */ - removePredefinedAnchor: boolean - /** Remove hugo shortcode syntax */ - removeHugoShortcode: boolean - /** Replace
with ![]() */ - replaceFigureWithMdImg: boolean - - /** Replace org latex fragments with $ and $$ */ - replaceOrgLatex: boolean -} - -const defaultOptions: Options = { - wikilinks: true, - 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 - * markdown in an opinionated way. This plugin adds some tweaks to the generated - * markdown to make it compatible with quartz but the list of changes applied it - * is not exhaustive. - * */ -export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "OxHugoFlavouredMarkdown", - textTransform(_ctx, src) { - if (opts.wikilinks) { - src = src.toString() - src = src.replaceAll(relrefRegex, (_value, ...capture) => { - const [text, link] = capture - return `[${text}](${link})` - }) - } - - if (opts.removePredefinedAnchor) { - src = src.toString() - src = src.replaceAll(predefinedHeadingIdRegex, (_value, ...capture) => { - const [headingText] = capture - return headingText - }) - } - - if (opts.removeHugoShortcode) { - src = src.toString() - src = src.replaceAll(hugoShortcodeRegex, (_value, ...capture) => { - const [scContent] = capture - return scContent - }) - } - - if (opts.replaceFigureWithMdImg) { - src = src.toString() - src = src.replaceAll(figureTagRegex, (_value, ...capture) => { - const [src] = capture - 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 - }, - htmlPlugins() { - const plugins: PluggableList = [rehypeRaw] - return plugins - }, - } -} diff --git a/quartz/plugins/transformers/roam.ts b/quartz/plugins/transformers/roam.ts deleted file mode 100644 index b6df67a8f..000000000 --- a/quartz/plugins/transformers/roam.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import { PluggableList } from "unified" -import { visit } from "unist-util-visit" -import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" -import { Root, Html, Paragraph, Text, Link, Parent } from "mdast" -import { BuildVisitor } from "unist-util-visit" - -export interface Options { - orComponent: boolean - TODOComponent: boolean - DONEComponent: boolean - videoComponent: boolean - audioComponent: boolean - pdfComponent: boolean - blockquoteComponent: boolean - tableComponent: boolean - attributeComponent: boolean -} - -const defaultOptions: Options = { - orComponent: true, - TODOComponent: true, - DONEComponent: true, - videoComponent: true, - audioComponent: true, - pdfComponent: true, - blockquoteComponent: true, - tableComponent: true, - attributeComponent: true, -} - -const orRegex = new RegExp(/{{or:(.*?)}}/, "g") -const TODORegex = new RegExp(/{{.*?\bTODO\b.*?}}/, "g") -const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") - -const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") -const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") -const roamItalicRegex = new RegExp(/__(.+)__/, "g") - -function isSpecialEmbed(node: Paragraph): boolean { - if (node.children.length !== 2) return false - - const [textNode, linkNode] = node.children - return ( - textNode.type === "text" && - textNode.value.startsWith("{{[[") && - linkNode.type === "link" && - linkNode.children[0].type === "text" && - linkNode.children[0].value.endsWith("}}") - ) -} - -function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null { - const [textNode, linkNode] = node.children as [Text, Link] - const embedType = textNode.value.match(/\{\{\[\[(.*?)\]\]:/)?.[1]?.toLowerCase() - const url = linkNode.url.slice(0, -2) // Remove the trailing '}}' - - switch (embedType) { - case "audio": - return opts.audioComponent - ? { - type: "html", - value: ``, - } - : null - case "video": - if (!opts.videoComponent) return null - // Check if it's a YouTube video - const youtubeMatch = url.match( - /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/, - ) - if (youtubeMatch) { - const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters - const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/) - const playlistId = playlistMatch ? playlistMatch[1] : null - - return { - type: "html", - value: ``, - } - } else { - return { - type: "html", - value: ``, - } - } - case "pdf": - return opts.pdfComponent - ? { - type: "html", - value: ``, - } - : null - default: - return null - } -} - -export const RoamFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( - userOpts, -) => { - const opts = { ...defaultOptions, ...userOpts } - - return { - name: "RoamFlavoredMarkdown", - markdownPlugins() { - const plugins: PluggableList = [] - - plugins.push(() => { - return (tree: Root) => { - const replacements: [RegExp, ReplaceFunction][] = [] - - // Handle special embeds (audio, video, PDF) - if (opts.audioComponent || opts.videoComponent || opts.pdfComponent) { - visit(tree, "paragraph", ((node: Paragraph, index: number, parent: Parent | null) => { - if (isSpecialEmbed(node)) { - const transformedNode = transformSpecialEmbed(node, opts) - if (transformedNode && parent) { - parent.children[index] = transformedNode - } - } - }) as BuildVisitor) - } - - // Roam italic syntax - replacements.push([ - roamItalicRegex, - (_value: string, match: string) => ({ - type: "emphasis", - children: [{ type: "text", value: match }], - }), - ]) - - // Roam highlight syntax - replacements.push([ - roamHighlightRegex, - (_value: string, inner: string) => ({ - type: "html", - value: `${inner}`, - }), - ]) - - if (opts.orComponent) { - replacements.push([ - orRegex, - (match: string) => { - const matchResult = match.match(/{{or:(.*?)}}/) - if (matchResult === null) { - return { type: "html", value: "" } - } - const optionsString: string = matchResult[1] - const options: string[] = optionsString.split("|") - const selectHtml: string = `` - return { type: "html", value: selectHtml } - }, - ]) - } - - if (opts.TODOComponent) { - replacements.push([ - TODORegex, - () => ({ - type: "html", - value: ``, - }), - ]) - } - - if (opts.DONEComponent) { - replacements.push([ - DONERegex, - () => ({ - type: "html", - value: ``, - }), - ]) - } - - if (opts.blockquoteComponent) { - replacements.push([ - blockquoteRegex, - (_match: string, _marker: string, content: string) => ({ - type: "html", - value: `
${content.trim()}
`, - }), - ]) - } - - mdastFindReplace(tree, replacements) - } - }) - - return plugins - }, - } -} diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/syntax.ts deleted file mode 100644 index 5d3aae0d8..000000000 --- a/quartz/plugins/transformers/syntax.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code" - -interface Theme extends Record { - light: CodeTheme - dark: CodeTheme -} - -interface Options { - theme?: Theme - keepBackground?: boolean -} - -const defaultOptions: Options = { - theme: { - light: "github-light", - dark: "github-dark", - }, - keepBackground: false, -} - -export const SyntaxHighlighting: QuartzTransformerPlugin> = (userOpts) => { - const opts: CodeOptions = { ...defaultOptions, ...userOpts } - - return { - name: "SyntaxHighlighting", - htmlPlugins() { - return [[rehypePrettyCode, opts]] - }, - } -} diff --git a/quartz/plugins/transformers/toc.ts b/quartz/plugins/transformers/toc.ts deleted file mode 100644 index 791547b6a..000000000 --- a/quartz/plugins/transformers/toc.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" -import { Root } from "mdast" -import { visit } from "unist-util-visit" -import { toString } from "mdast-util-to-string" -import Slugger from "github-slugger" - -export interface Options { - maxDepth: 1 | 2 | 3 | 4 | 5 | 6 - minEntries: number - showByDefault: boolean - collapseByDefault: boolean -} - -const defaultOptions: Options = { - maxDepth: 3, - minEntries: 1, - showByDefault: true, - collapseByDefault: false, -} - -interface TocEntry { - depth: number - text: string - slug: string // this is just the anchor (#some-slug), not the canonical slug -} - -const slugAnchor = new Slugger() -export const TableOfContents: QuartzTransformerPlugin> = (userOpts) => { - const opts = { ...defaultOptions, ...userOpts } - return { - name: "TableOfContents", - markdownPlugins() { - return [ - () => { - return async (tree: Root, file) => { - const display = file.data.frontmatter?.enableToc ?? opts.showByDefault - if (display) { - slugAnchor.reset() - const toc: TocEntry[] = [] - let highestDepth: number = opts.maxDepth - visit(tree, "heading", (node) => { - if (node.depth <= opts.maxDepth) { - const text = toString(node) - highestDepth = Math.min(highestDepth, node.depth) - toc.push({ - depth: node.depth, - text, - slug: slugAnchor.slug(text), - }) - } - }) - - if (toc.length > 0 && toc.length > opts.minEntries) { - file.data.toc = toc.map((entry) => ({ - ...entry, - depth: entry.depth - highestDepth, - })) - file.data.collapseToc = opts.collapseByDefault - } - } - } - }, - ] - }, - } -} - -declare module "vfile" { - interface DataMap { - toc: TocEntry[] - collapseToc: boolean - } -} diff --git a/quartz/util/path.ts b/quartz/util/path.ts index b95770159..bc211c97d 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -7,17 +7,17 @@ import { clone } from "./clone" export const QUARTZ = "quartz" /// Utility type to simulate nominal types in TypeScript -type SlugLike = string & { __brand: T } +type SlugLike = string & { _brand: T } /** Cannot be relative and must have a file extension. */ -export type FilePath = SlugLike<"filepath"> +export type FilePath = SlugLike<"FilePath"> export function isFilePath(s: string): s is FilePath { const validStart = !s.startsWith(".") return validStart && _hasFileExtension(s) } /** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */ -export type FullSlug = SlugLike<"full"> +export type FullSlug = SlugLike<"FullSlug"> export function isFullSlug(s: string): s is FullSlug { const validStart = !(s.startsWith(".") || s.startsWith("/")) const validEnding = !s.endsWith("/") @@ -25,7 +25,7 @@ export function isFullSlug(s: string): s is FullSlug { } /** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */ -export type SimpleSlug = SlugLike<"simple"> +export type SimpleSlug = SlugLike<"SimpleSlug"> export function isSimpleSlug(s: string): s is SimpleSlug { const validStart = !(s.startsWith(".") || (s.length > 1 && s.startsWith("/"))) const validEnding = !endsWith(s, "index") @@ -33,7 +33,7 @@ export function isSimpleSlug(s: string): s is SimpleSlug { } /** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */ -export type RelativeURL = SlugLike<"relative"> +export type RelativeURL = SlugLike<"RelativeURL"> export function isRelativeURL(s: string): s is RelativeURL { const validStart = /^\.{1,2}/.test(s) const validEnding = !endsWith(s, "index")