feat: extract transformers to community plugins and fix type compatibility

- Delete 12 internal transformer files (keep FrontMatter as internal)
- Switch quartz.config.ts to use ExternalPlugin.* for all transformers
- Align branded types with @quartz-community/types (_brand, FullSlug etc.)
- Add vfile DataMap augmentations for fields from extracted transformers
- Update all 29 plugins to @quartz-community/types v0.2.1
This commit is contained in:
saberzero1 2026-02-13 23:07:27 +01:00
parent 737c06d6d2
commit 074951afea
No known key found for this signature in database
18 changed files with 159 additions and 1895 deletions

20
package-lock.json generated
View File

@ -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": "*"

View File

@ -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",
],
}

View File

@ -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"
}
}
}

View File

@ -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<string, Element>
htmlAst: HtmlRoot
hasMermaidDiagram: boolean | undefined
// from created-modified-date transformer
dates: {
created: Date
modified: Date
published: Date
}
}
}

View File

@ -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<Partial<Options>> = (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
},
}
}

View File

@ -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?:\/\/)?(?<domain>([\da-z\.-]+)\.([a-z\.]{2,6})(:\d+)?)(?<path>[\/\w\.-]*)(\?[\/\w\.=&;-]*)?/,
"g",
)
export const Description: QuartzTransformerPlugin<Partial<Options>> = (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,
"$<domain>" + "$<path>",
)
text = text.replace(urlRegex, "$<domain>" + "$<path>")
}
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
}
}

View File

@ -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<Partial<Options>> = (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 []
}
},
}
}

View File

@ -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"

View File

@ -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<Partial<Options>> = (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
}
}
}

View File

@ -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<KatexOptions, "macros" | "output">
mathJaxOptions: Omit<MathjaxOptions, "macros">
typstOptions: TypstOptions
}
// mathjax macros
export type Args = boolean | number | string | null
interface MacroType {
[key: string]: string | Args[]
}
export const Latex: QuartzTransformerPlugin<Partial<Options>> = (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",
},
],
}
}
},
}
}

View File

@ -1,11 +0,0 @@
import { QuartzTransformerPlugin } from "../types"
import remarkBreaks from "remark-breaks"
export const HardLineBreaks: QuartzTransformerPlugin = () => {
return {
name: "HardLineBreaks",
markdownPlugins() {
return [remarkBreaks]
},
}
}

View File

@ -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<Partial<Options>> = (userOpts) => {
const opts = { ...defaultOptions, ...userOpts }
return {
name: "LinkProcessing",
htmlPlugins(ctx) {
return [
() => {
return (tree: Root, file) => {
const curSlug = simplifySlug(file.data.slug!)
const outgoing: Set<SimpleSlug> = 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[]
}
}

View File

@ -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<string, string> = {
"->": "&rarr;",
"-->": "&rArr;",
"=>": "&rArr;",
"==>": "&rArr;",
"<-": "&larr;",
"<--": "&lArr;",
"<=": "&lArr;",
"<==": "&lArr;",
}
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(
/^(?<alt>(?!^\d*x?\d*$).*?)?(\|?\s*?(?<width>\d+)(x(?<height>\d+))?)?$/,
)
export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>> = (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: `<video src="${url}" controls></video>`,
}
} else if (
[".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext)
) {
return {
type: "html",
value: `<audio src="${url}" controls></audio>`,
}
} else if ([".pdf"].includes(ext)) {
return {
type: "html",
value: `<iframe src="${url}" class="pdf"></iframe>`,
}
} else {
const block = anchor
return {
type: "html",
data: { hProperties: { transclude: true } },
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
url + anchor
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
}
}
// 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: `<a class=\"internal broken\">${alias ?? fp}</a>`,
}
}
}
// 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: `<span class="text-highlight">${inner}</span>`,
}
},
])
}
if (opts.parseArrows) {
replacements.push([
arrowRegex,
(value: string, ..._capture: string[]) => {
const maybeArrow = arrowMapping[value]
if (maybeArrow === undefined) return SKIP
return {
type: "html",
value: `<span>${maybeArrow}</span>`,
}
},
])
}
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: `<video controls src="${node.url}"></video>`,
}
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 = `<div class="fold-callout-icon"></div>`
const titleHtml: Html = {
type: "html",
value: `<div
class="callout-title"
>
<div class="callout-icon"></div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`,
}
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 <div>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<string, Element>
htmlAst: HtmlRoot
hasMermaidDiagram: boolean | undefined
}
}

View File

@ -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 <figure/> 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<Partial<Options>> = (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
},
}
}

View File

@ -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: `<audio controls>
<source src="${url}" type="audio/mpeg">
<source src="${url}" type="audio/ogg">
Your browser does not support the audio tag.
</audio>`,
}
: 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: `<iframe
class="external-embed youtube"
width="600px"
height="350px"
src="https://www.youtube.com/embed/${videoId}${playlistId ? `?list=${playlistId}` : ""}"
frameborder="0"
allow="fullscreen"
></iframe>`,
}
} else {
return {
type: "html",
value: `<video controls>
<source src="${url}" type="video/mp4">
<source src="${url}" type="video/webm">
Your browser does not support the video tag.
</video>`,
}
}
case "pdf":
return opts.pdfComponent
? {
type: "html",
value: `<embed src="${url}" type="application/pdf" width="100%" height="600px" />`,
}
: null
default:
return null
}
}
export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | 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<Root, "paragraph">)
}
// 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: `<span class="text-highlight">${inner}</span>`,
}),
])
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 = `<select>${options.map((option: string) => `<option value="${option}">${option}</option>`).join("")}</select>`
return { type: "html", value: selectHtml }
},
])
}
if (opts.TODOComponent) {
replacements.push([
TODORegex,
() => ({
type: "html",
value: `<input type="checkbox" disabled>`,
}),
])
}
if (opts.DONEComponent) {
replacements.push([
DONERegex,
() => ({
type: "html",
value: `<input type="checkbox" checked disabled>`,
}),
])
}
if (opts.blockquoteComponent) {
replacements.push([
blockquoteRegex,
(_match: string, _marker: string, content: string) => ({
type: "html",
value: `<blockquote>${content.trim()}</blockquote>`,
}),
])
}
mdastFindReplace(tree, replacements)
}
})
return plugins
},
}
}

View File

@ -1,31 +0,0 @@
import { QuartzTransformerPlugin } from "../types"
import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code"
interface Theme extends Record<string, CodeTheme> {
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<Partial<Options>> = (userOpts) => {
const opts: CodeOptions = { ...defaultOptions, ...userOpts }
return {
name: "SyntaxHighlighting",
htmlPlugins() {
return [[rehypePrettyCode, opts]]
},
}
}

View File

@ -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<Partial<Options>> = (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
}
}

View File

@ -7,17 +7,17 @@ import { clone } from "./clone"
export const QUARTZ = "quartz"
/// Utility type to simulate nominal types in TypeScript
type SlugLike<T> = string & { __brand: T }
type SlugLike<T extends string> = 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")