diff --git a/quartz.config.ts b/quartz.config.ts index e96ee4843fda1..9f7e7779432d9 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -1,5 +1,10 @@ import { QuartzConfig } from "./quartz/cfg" import * as Plugin from "./quartz/plugins" +import * as Text from "./quartz/plugins/transformers/text" +import * as Markdown from "./quartz/plugins/transformers/markdown" +import * as Html from "./quartz/plugins/transformers/html" +import * as Resources from "./quartz/plugins/transformers/resources" +import * as Presets from "./quartz/plugins/transformers/presets" /** * Quartz 4.0 Configuration @@ -54,25 +59,8 @@ const config: QuartzConfig = { }, }, plugins: { - transformers: [ - Plugin.FrontMatter(), - Plugin.CreatedModifiedDate({ - priority: ["frontmatter", "filesystem"], - }), - Plugin.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" }), - ], + //transformers: Presets.DefaultPreset(), + transformers: Presets.ObsidianPreset(), filters: [Plugin.RemoveDrafts()], emitters: [ Plugin.AliasRedirects(), diff --git a/quartz/build.ts b/quartz/build.ts index 67ec0da4da04b..4bc0a3b38cd61 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -54,11 +54,21 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const output = argv.output const pluginCount = Object.values(cfg.plugins).flat().length - const pluginNames = (key: "transformers" | "filters" | "emitters") => - cfg.plugins[key].map((plugin) => plugin.name) + const pluginNames = (key: "filters" | "emitters") => cfg.plugins[key].map((plugin) => plugin.name) if (argv.verbose) { console.log(`Loaded ${pluginCount} plugins`) - console.log(` Transformers: ${pluginNames("transformers").join(", ")}`) + console.log( + ` Text Transformers: ${cfg.plugins["transformers"].textTransformers.map((plugin) => plugin.name).join(", ")}`, + ) + console.log( + ` Markdown Transformers: ${cfg.plugins["transformers"].markdownTransformers.map((plugin) => plugin.name).join(", ")}`, + ) + console.log( + ` Html Transformers: ${cfg.plugins["transformers"].htmlTransformers.map((plugin) => plugin.name).join(", ")}`, + ) + console.log( + ` External Resources: ${cfg.plugins["transformers"].externalResources.map((plugin) => plugin.name).join(", ")}`, + ) console.log(` Filters: ${pluginNames("filters").join(", ")}`) console.log(` Emitters: ${pluginNames("emitters").join(", ")}`) } diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index df9fd1d24c95d..02640a3a836c4 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -8,8 +8,8 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) { js: [], } - for (const transformer of ctx.cfg.plugins.transformers) { - const res = transformer.externalResources ? transformer.externalResources(ctx) : {} + for (const transformer of ctx.cfg.plugins.transformers.externalResources) { + const res = transformer ? transformer.transformation(ctx) : {} if (res?.js) { staticResources.js.push(...res.js) } @@ -38,7 +38,10 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) { return staticResources } -export * from "./transformers" +export * as Text from "./transformers/text" +export * as Markdown from "./transformers/markdown" +export * as Html from "./transformers/html" +export * as Resources from "./transformers/resources" export * from "./filters" export * from "./emitters" diff --git a/quartz/plugins/transformers/citations.ts b/quartz/plugins/transformers/html/citations.ts similarity index 89% rename from quartz/plugins/transformers/citations.ts rename to quartz/plugins/transformers/html/citations.ts index dcac41b2ea50e..63b3d01154788 100644 --- a/quartz/plugins/transformers/citations.ts +++ b/quartz/plugins/transformers/html/citations.ts @@ -1,7 +1,7 @@ import rehypeCitation from "rehype-citation" import { PluggableList } from "unified" import { visit } from "unist-util-visit" -import { QuartzTransformerPlugin } from "../types" +import { HtmlTransformerPlugin } from "../../types" export interface Options { bibliographyFile: string @@ -17,11 +17,11 @@ const defaultOptions: Options = { csl: "apa", } -export const Citations: QuartzTransformerPlugin> = (userOpts) => { +export const Citations: HtmlTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "Citations", - htmlPlugins(ctx) { + transformation(ctx) { const plugins: PluggableList = [] // Add rehype-citation to the list of plugins diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/html/description.ts similarity index 92% rename from quartz/plugins/transformers/description.ts rename to quartz/plugins/transformers/html/description.ts index c7e592ee93304..7b8c8a716ee90 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/html/description.ts @@ -1,7 +1,7 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" -import { QuartzTransformerPlugin } from "../types" -import { escapeHTML } from "../../util/escape" +import { HtmlTransformerPlugin } from "../../types" +import { escapeHTML } from "../../../util/escape" export interface Options { descriptionLength: number @@ -18,11 +18,11 @@ const urlRegex = new RegExp( "g", ) -export const Description: QuartzTransformerPlugin> = (userOpts) => { +export const Description: HtmlTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "Description", - htmlPlugins() { + transformation() { return [ () => { return async (tree: HTMLRoot, file) => { diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/html/gfmLinkHeadings.ts similarity index 80% rename from quartz/plugins/transformers/gfm.ts rename to quartz/plugins/transformers/html/gfmLinkHeadings.ts index eec26f7b9b636..8b73427368e80 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/html/gfmLinkHeadings.ts @@ -1,27 +1,22 @@ -import remarkGfm from "remark-gfm" -import smartypants from "remark-smartypants" -import { QuartzTransformerPlugin } from "../types" +import { HtmlTransformerPlugin } 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) => { +export const GitHubFlavoredMarkdownLinkHeadings: HtmlTransformerPlugin> = ( + userOpts, +) => { const opts = { ...defaultOptions, ...userOpts } return { - name: "GitHubFlavoredMarkdown", - markdownPlugins() { - return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm] - }, - htmlPlugins() { + name: "GitHubFlavoredMarkdownLinkHeadings", + transformation() { if (opts.linkHeadings) { return [ rehypeSlug, diff --git a/quartz/plugins/transformers/html/index.ts b/quartz/plugins/transformers/html/index.ts new file mode 100644 index 0000000000000..527b02a1f2364 --- /dev/null +++ b/quartz/plugins/transformers/html/index.ts @@ -0,0 +1,9 @@ +export { Citations } from "./citations" +export { CrawlLinks } from "./links" +export { Description } from "./description" +export { GitHubFlavoredMarkdownLinkHeadings } from "./gfmLinkHeadings" +export { Latex } from "./latex" +export { SyntaxHighlighting } from "./syntax" +export { ObsidianFlavoredMarkdownBlockReferences } from "./ofmBlockReferences" +export { ObsidianFlavoredMarkdownCheckbox } from "./ofmCheckbox" +export { ObsidianFlavoredMarkdownYouTubeEmbed } from "./ofmYoutubeEmbed" diff --git a/quartz/plugins/transformers/html/latex.ts b/quartz/plugins/transformers/html/latex.ts new file mode 100644 index 0000000000000..e1f0364d9930f --- /dev/null +++ b/quartz/plugins/transformers/html/latex.ts @@ -0,0 +1,27 @@ +import rehypeKatex from "rehype-katex" +import rehypeMathjax from "rehype-mathjax/svg" +import { HtmlTransformerPlugin } from "../../types" + +interface Options { + renderEngine: "katex" | "mathjax" + customMacros: MacroType +} + +interface MacroType { + [key: string]: string +} + +export const Latex: HtmlTransformerPlugin> = (opts) => { + const engine = opts?.renderEngine ?? "katex" + const macros = opts?.customMacros ?? {} + return { + name: "Latex", + transformation() { + if (engine === "katex") { + return [[rehypeKatex, { output: "html", macros }]] + } else { + return [[rehypeMathjax, { macros }]] + } + }, + } +} diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/html/links.ts similarity index 96% rename from quartz/plugins/transformers/links.ts rename to quartz/plugins/transformers/html/links.ts index 3e8dbdede5ba5..0aa67b41f7f15 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/html/links.ts @@ -1,4 +1,4 @@ -import { QuartzTransformerPlugin } from "../types" +import { HtmlTransformerPlugin, QuartzTransformerPlugin } from "../../types" import { FullSlug, RelativeURL, @@ -8,7 +8,7 @@ import { simplifySlug, splitAnchor, transformLink, -} from "../../util/path" +} from "../../../util/path" import path from "path" import { visit } from "unist-util-visit" import isAbsoluteUrl from "is-absolute-url" @@ -32,11 +32,11 @@ const defaultOptions: Options = { externalLinkIcon: true, } -export const CrawlLinks: QuartzTransformerPlugin> = (userOpts) => { +export const CrawlLinks: HtmlTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "LinkProcessing", - htmlPlugins(ctx) { + transformation(ctx) { return [ () => { return (tree: Root, file) => { diff --git a/quartz/plugins/transformers/html/ofmBlockReferences.ts b/quartz/plugins/transformers/html/ofmBlockReferences.ts new file mode 100644 index 0000000000000..bff7e85642583 --- /dev/null +++ b/quartz/plugins/transformers/html/ofmBlockReferences.ts @@ -0,0 +1,99 @@ +import { HtmlTransformerPlugin } from "../../types" +import { Element, Literal, Root as HtmlRoot } from "hast" +import rehypeRaw from "rehype-raw" +import { visit } from "unist-util-visit" +import { PluggableList } from "unified" + +const blockReferenceRegex = new RegExp(/\^([-_A-Za-z0-9]+)$/g) + +export const ObsidianFlavoredMarkdownBlockReferences: HtmlTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownBlockReferences", + transformation() { + const plugins: PluggableList = [rehypeRaw] + + 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 + } + }) + + return plugins + }, + } +} + +declare module "vfile" { + interface DataMap { + blocks: Record + htmlAst: HtmlRoot + } +} diff --git a/quartz/plugins/transformers/html/ofmCheckbox.ts b/quartz/plugins/transformers/html/ofmCheckbox.ts new file mode 100644 index 0000000000000..76710b967c440 --- /dev/null +++ b/quartz/plugins/transformers/html/ofmCheckbox.ts @@ -0,0 +1,32 @@ +import { HtmlTransformerPlugin } from "../../types" +import { Element, Root as HtmlRoot } from "hast" +import rehypeRaw from "rehype-raw" +import { visit } from "unist-util-visit" +import { PluggableList } from "unified" + +export const ObsidianFlavoredMarkdownCheckbox: HtmlTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownCheckbox", + transformation() { + const plugins: PluggableList = [rehypeRaw] + + 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", + } + } + }) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/html/ofmYoutubeEmbed.ts b/quartz/plugins/transformers/html/ofmYoutubeEmbed.ts new file mode 100644 index 0000000000000..bc15808565ae6 --- /dev/null +++ b/quartz/plugins/transformers/html/ofmYoutubeEmbed.ts @@ -0,0 +1,54 @@ +import { HtmlTransformerPlugin } from "../../types" +import { Element, Root as HtmlRoot } from "hast" +import rehypeRaw from "rehype-raw" +import { visit } from "unist-util-visit" +import { PluggableList } from "unified" + +const ytLinkRegex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ +const ytPlaylistLinkRegex = /[?&]list=([^#?&]*)/ + +export const ObsidianFlavoredMarkdownYouTubeEmbed: HtmlTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownYouTubeEmbed", + transformation() { + const plugins: PluggableList = [rehypeRaw] + + 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}`, + } + } + } + }) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/syntax.ts b/quartz/plugins/transformers/html/syntax.ts similarity index 74% rename from quartz/plugins/transformers/syntax.ts rename to quartz/plugins/transformers/html/syntax.ts index 5d3aae0d8d109..8334f02f0e563 100644 --- a/quartz/plugins/transformers/syntax.ts +++ b/quartz/plugins/transformers/html/syntax.ts @@ -1,4 +1,4 @@ -import { QuartzTransformerPlugin } from "../types" +import { HtmlTransformerPlugin, QuartzTransformerPlugin } from "../../types" import rehypePrettyCode, { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code" interface Theme extends Record { @@ -19,12 +19,12 @@ const defaultOptions: Options = { keepBackground: false, } -export const SyntaxHighlighting: QuartzTransformerPlugin> = (userOpts) => { +export const SyntaxHighlighting: HtmlTransformerPlugin> = (userOpts) => { const opts: CodeOptions = { ...defaultOptions, ...userOpts } return { name: "SyntaxHighlighting", - htmlPlugins() { + transformation() { return [[rehypePrettyCode, opts]] }, } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts deleted file mode 100644 index 8e2cd844fec21..0000000000000 --- a/quartz/plugins/transformers/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts deleted file mode 100644 index a8a066fc19529..0000000000000 --- 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/frontmatter.ts b/quartz/plugins/transformers/markdown/frontmatter.ts similarity index 89% rename from quartz/plugins/transformers/frontmatter.ts rename to quartz/plugins/transformers/markdown/frontmatter.ts index 2e599aa0e74bb..119e24e096b57 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/markdown/frontmatter.ts @@ -1,11 +1,11 @@ import matter from "gray-matter" import remarkFrontmatter from "remark-frontmatter" -import { QuartzTransformerPlugin } from "../types" +import { MarkdownTransformerPlugin } from "../../types" import yaml from "js-yaml" import toml from "toml" -import { slugTag } from "../../util/path" -import { QuartzPluginData } from "../vfile" -import { i18n } from "../../i18n" +import { slugTag } from "../../../util/path" +import { QuartzPluginData } from "../../vfile" +import { i18n } from "../../../i18n" export interface Options { delimiters: string | [string, string] @@ -40,11 +40,11 @@ function coerceToArray(input: string | string[]): string[] | undefined { .map((tag: string | number) => tag.toString()) } -export const FrontMatter: QuartzTransformerPlugin> = (userOpts) => { +export const FrontMatter: MarkdownTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "FrontMatter", - markdownPlugins({ cfg }) { + transformation({ cfg }) { return [ [remarkFrontmatter, ["yaml", "toml"]], () => { diff --git a/quartz/plugins/transformers/markdown/gfmRemark.ts b/quartz/plugins/transformers/markdown/gfmRemark.ts new file mode 100644 index 0000000000000..a4687a734ca86 --- /dev/null +++ b/quartz/plugins/transformers/markdown/gfmRemark.ts @@ -0,0 +1,23 @@ +import remarkGfm from "remark-gfm" +import smartypants from "remark-smartypants" +import { MarkdownTransformerPlugin } from "../../types" + +export interface Options { + enableSmartyPants: boolean +} + +const defaultOptions: Options = { + enableSmartyPants: true, +} + +export const GitHubFlavoredMarkdownRemark: MarkdownTransformerPlugin> = ( + userOpts, +) => { + const opts = { ...defaultOptions, ...userOpts } + return { + name: "GitHubFlavoredMarkdownRemark", + transformation() { + return opts.enableSmartyPants ? [remarkGfm, smartypants] : [remarkGfm] + }, + } +} diff --git a/quartz/plugins/transformers/markdown/index.ts b/quartz/plugins/transformers/markdown/index.ts new file mode 100644 index 0000000000000..1be3eda379d6f --- /dev/null +++ b/quartz/plugins/transformers/markdown/index.ts @@ -0,0 +1,21 @@ +export { CreatedModifiedDate } from "./lastmod" +export { FrontMatter } from "./frontmatter" +export { GitHubFlavoredMarkdownRemark } from "./gfmRemark" +export { HardLineBreaks } from "./linebreaks" +export { Latex } from "./latex" +export { ObsidianFlavoredMarkdownArrow } from "./ofmArrow" +export { ObsidianFlavoredMarkdownCallouts } from "./ofmCallouts" +export { ObsidianFlavoredMarkdownDangerousHtml } from "./ofmDangerousHtml" +export { ObsidianFlavoredMarkdownHighlight } from "./ofmHighlight" +export { ObsidianFlavoredMarkdownMermaid } from "./ofmMermaid" +export { ObsidianFlavoredMarkdownTags } from "./ofmTags" +export { ObsidianFlavoredMarkdownVideoEmbed } from "./ofmVideoEmbed" +export { ObsidianFlavoredMarkdownWikilinks } from "./ofmWikilinks" +export { RoamFlavoredMarkdownBlockquote } from "./roamBlockquote" +export { RoamFlavoredMarkdownDONE } from "./roamDONE" +export { RoamFlavoredMarkdownHighlight } from "./roamHighlight" +export { RoamFlavoredMarkdownItalics } from "./roamItalics" +export { RoamFlavoredMarkdownSpecialEmbeds } from "./roamSpecialEmbeds" +export { RoamFlavoredMarkdownTODO } from "./roamTODO" +export { RoamFlavoredMarkdownOrComponent } from "./roamOrComponent" +export { TableOfContents } from "./toc" diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/markdown/lastmod.ts similarity index 94% rename from quartz/plugins/transformers/lastmod.ts rename to quartz/plugins/transformers/markdown/lastmod.ts index fe8c01bcfa7f9..10819706e1fe0 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/markdown/lastmod.ts @@ -1,7 +1,7 @@ import fs from "fs" import path from "path" import { Repository } from "@napi-rs/simple-git" -import { QuartzTransformerPlugin } from "../types" +import { MarkdownTransformerPlugin, QuartzTransformerPlugin } from "../../types" import chalk from "chalk" export interface Options { @@ -27,11 +27,11 @@ function coerceDate(fp: string, d: any): Date { } type MaybeDate = undefined | string | number -export const CreatedModifiedDate: QuartzTransformerPlugin> = (userOpts) => { +export const CreatedModifiedDate: MarkdownTransformerPlugin> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { name: "CreatedModifiedDate", - markdownPlugins() { + transformation() { return [ () => { let repo: Repository | undefined = undefined diff --git a/quartz/plugins/transformers/markdown/latex.ts b/quartz/plugins/transformers/markdown/latex.ts new file mode 100644 index 0000000000000..cc8bf62bde47c --- /dev/null +++ b/quartz/plugins/transformers/markdown/latex.ts @@ -0,0 +1,11 @@ +import remarkMath from "remark-math" +import { MarkdownTransformerPlugin } from "../../types" + +export const Latex: MarkdownTransformerPlugin = () => { + return { + name: "Latex", + transformation() { + return [remarkMath] + }, + } +} diff --git a/quartz/plugins/transformers/markdown/linebreaks.ts b/quartz/plugins/transformers/markdown/linebreaks.ts new file mode 100644 index 0000000000000..c27ff8e2f7f77 --- /dev/null +++ b/quartz/plugins/transformers/markdown/linebreaks.ts @@ -0,0 +1,11 @@ +import { MarkdownTransformerPlugin } from "../../types" +import remarkBreaks from "remark-breaks" + +export const HardLineBreaks: MarkdownTransformerPlugin = () => { + return { + name: "HardLineBreaks", + transformation() { + return [remarkBreaks] + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmArrow.ts b/quartz/plugins/transformers/markdown/ofmArrow.ts new file mode 100644 index 0000000000000..8bbe6f00ee2b0 --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmArrow.ts @@ -0,0 +1,50 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root } from "mdast" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { SKIP } from "unist-util-visit" +import { PluggableList } from "unified" + +const arrowMapping: Record = { + "->": "→", + "-->": "⇒", + "=>": "⇒", + "==>": "⇒", + "<-": "←", + "<--": "⇐", + "<=": "⇐", + "<==": "⇐", +} + +export const arrowRegex = new RegExp(/(-{1,2}>|={1,2}>|<-{1,2}|<={1,2})/g) + +export const ObsidianFlavoredMarkdownArrow: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownArrow", + transformation(_ctx) { + const plugins: PluggableList = [] + + // regex replacements + plugins.push(() => { + return (tree: Root, _file) => { + const replacements: [RegExp, string | ReplaceFunction][] = [] + + replacements.push([ + arrowRegex, + (value: string, ..._capture: string[]) => { + const maybeArrow = arrowMapping[value] + if (maybeArrow === undefined) return SKIP + return { + type: "html", + value: `${maybeArrow}`, + } + }, + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmCallouts.ts b/quartz/plugins/transformers/markdown/ofmCallouts.ts new file mode 100644 index 0000000000000..243e50a7d327b --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmCallouts.ts @@ -0,0 +1,168 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root, Html, BlockContent, DefinitionContent, Paragraph } from "mdast" +import { visit } from "unist-util-visit" +import { toHast } from "mdast-util-to-hast" +import { toHtml } from "hast-util-to-html" +import { PhrasingContent } from "mdast-util-find-and-replace/lib" +import { capitalize } from "../../../util/lang" +import { PluggableList } from "unified" + +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 + +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 +} + +// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts +const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/) + +export const ObsidianFlavoredMarkdownCallouts: MarkdownTransformerPlugin = () => { + const mdastToHtml = (ast: PhrasingContent | Paragraph) => { + const hast = toHast(ast, { allowDangerousHtml: true })! + return toHtml(hast, { allowDangerousHtml: true }) + } + + return { + name: "ObsidianFlavoredMarkdownCallouts", + transformation(_ctx) { + const plugins: PluggableList = [] + + 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) : 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, + }, + ], + }) + } + + // 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, + }, + } + + // Add callout-content class to callout body if it has one. + if (calloutContent.length > 0) { + const contentData: BlockContent | DefinitionContent = { + data: { + hProperties: { + className: "callout-content", + }, + hName: "div", + }, + type: "blockquote", + children: [...calloutContent], + } + node.children = [node.children[0], contentData] + } + } + }) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmDangerousHtml.ts b/quartz/plugins/transformers/markdown/ofmDangerousHtml.ts new file mode 100644 index 0000000000000..f8f9530fd1d2b --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmDangerousHtml.ts @@ -0,0 +1,54 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root, Html, Paragraph } from "mdast" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { visit } from "unist-util-visit" +import { toHast } from "mdast-util-to-hast" +import { toHtml } from "hast-util-to-html" +import { PhrasingContent } from "mdast-util-find-and-replace/lib" +import { PluggableList } from "unified" + +export const ObsidianFlavoredMarkdownDangerousHtml: MarkdownTransformerPlugin = () => { + const mdastToHtml = (ast: PhrasingContent | Paragraph) => { + const hast = toHast(ast, { allowDangerousHtml: true })! + return toHtml(hast, { allowDangerousHtml: true }) + } + + return { + name: "ObsidianFlavoredMarkdownDangerousHtml", + transformation(_ctx) { + const plugins: PluggableList = [] + + // regex replacements + plugins.push(() => { + return (tree: Root, file) => { + const replacements: [RegExp, string | ReplaceFunction][] = [] + + 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) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmHighlight.ts b/quartz/plugins/transformers/markdown/ofmHighlight.ts new file mode 100644 index 0000000000000..a6a1062ad5591 --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmHighlight.ts @@ -0,0 +1,37 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root } from "mdast" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { PluggableList } from "unified" + +const highlightRegex = new RegExp(/==([^=]+)==/g) + +export const ObsidianFlavoredMarkdownHighlight: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownHighlight", + transformation(_ctx) { + const plugins: PluggableList = [] + + // regex replacements + plugins.push(() => { + return (tree: Root, _file) => { + const replacements: [RegExp, string | ReplaceFunction][] = [] + + replacements.push([ + highlightRegex, + (_value: string, ...capture: string[]) => { + const [inner] = capture + return { + type: "html", + value: `${inner}`, + } + }, + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmMermaid.ts b/quartz/plugins/transformers/markdown/ofmMermaid.ts new file mode 100644 index 0000000000000..fbffba88df13c --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmMermaid.ts @@ -0,0 +1,29 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root, Code } from "mdast" +import { visit } from "unist-util-visit" +import { PluggableList } from "unified" + +export const ObsidianFlavoredMarkdownMermaid: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownMermaid", + transformation(_ctx) { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file) => { + visit(tree, "code", (node: Code) => { + if (node.lang === "mermaid") { + node.data = { + hProperties: { + className: ["mermaid"], + }, + } + } + }) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmTags.ts b/quartz/plugins/transformers/markdown/ofmTags.ts new file mode 100644 index 0000000000000..b6586b559b296 --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmTags.ts @@ -0,0 +1,66 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root } from "mdast" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { pathToRoot, slugTag } from "../../../util/path" +import { PluggableList } from "unified" + +// (?:^| ) -> non-capturing group, 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, +) + +export const ObsidianFlavoredMarkdownTags: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownTags", + transformation(_ctx) { + const plugins: PluggableList = [] + + // regex replacements + plugins.push(() => { + return (tree: Root, file) => { + const replacements: [RegExp, string | ReplaceFunction][] = [] + const base = pathToRoot(file.data.slug!) + + 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, + }, + ], + } + }, + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmVideoEmbed.ts b/quartz/plugins/transformers/markdown/ofmVideoEmbed.ts new file mode 100644 index 0000000000000..c3a06e75d6b18 --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmVideoEmbed.ts @@ -0,0 +1,33 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root, Html } from "mdast" +import { SKIP, visit } from "unist-util-visit" +import { PluggableList } from "unified" + +const videoExtensionRegex = new RegExp(/\.(mp4|webm|ogg|avi|mov|flv|wmv|mkv|mpg|mpeg|3gp|m4v)$/) + +export const ObsidianFlavoredMarkdownVideoEmbed: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownVideoEmbed", + transformation(_ctx) { + const plugins: PluggableList = [] + + 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 + } + }) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/ofmWikilinks.ts b/quartz/plugins/transformers/markdown/ofmWikilinks.ts new file mode 100644 index 0000000000000..27cf105ba6094 --- /dev/null +++ b/quartz/plugins/transformers/markdown/ofmWikilinks.ts @@ -0,0 +1,112 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { Root } from "mdast" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import path from "path" +import { FilePath, slugifyFilePath } from "../../../util/path" +import { PluggableList } from "unified" + +// !? -> 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 one or more non-special characters (alias) +export const wikilinkRegex = new RegExp( + /!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]+)?\]\]/g, +) + +const wikilinkImageEmbedRegex = new RegExp( + /^(?(?!^\d*x?\d*$).*?)?(\|?\s*?(?\d+)(x(?\d+))?)?$/, +) + +export const ObsidianFlavoredMarkdownWikilinks: MarkdownTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownWikilinks", + transformation(_ctx) { + const plugins: PluggableList = [] + + // regex replacements + plugins.push(() => { + return (tree: Root, _file) => { + const replacements: [RegExp, string | ReplaceFunction][] = [] + + replacements.push([ + wikilinkRegex, + (value: string, ...capture: string[]) => { + let [rawFp, rawHeader, rawAlias] = capture + const fp = rawFp?.trim() ?? "" + const anchor = rawHeader?.trim() ?? "" + const alias = 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 + } + + // internal link + const url = fp + anchor + return { + type: "link", + url, + children: [ + { + type: "text", + value: alias ?? fp, + }, + ], + } + }, + ]) + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/roamBlockquote.ts b/quartz/plugins/transformers/markdown/roamBlockquote.ts new file mode 100644 index 0000000000000..10857e6836d0b --- /dev/null +++ b/quartz/plugins/transformers/markdown/roamBlockquote.ts @@ -0,0 +1,34 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { PluggableList } from "unified" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root } from "mdast" +import { VFile } from "vfile" + +const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") + +export const RoamFlavoredMarkdownBlockquote: MarkdownTransformerPlugin = () => { + return { + name: "RoamFlavoredMarkdownBlockquote", + transformation() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + 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/markdown/roamDONE.ts b/quartz/plugins/transformers/markdown/roamDONE.ts new file mode 100644 index 0000000000000..093d14e6d7837 --- /dev/null +++ b/quartz/plugins/transformers/markdown/roamDONE.ts @@ -0,0 +1,34 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { PluggableList } from "unified" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root } from "mdast" +import { VFile } from "vfile" + +const DONERegex = new RegExp(/{{.*?\bDONE\b.*?}}/, "g") + +export const RoamFlavoredMarkdownDONE: MarkdownTransformerPlugin = () => { + return { + name: "RoamFlavoredMarkdownDONE", + transformation() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + replacements.push([ + DONERegex, + () => ({ + type: "html", + value: ``, + }), + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/roamHighlight.ts b/quartz/plugins/transformers/markdown/roamHighlight.ts new file mode 100644 index 0000000000000..2ad391aa395f6 --- /dev/null +++ b/quartz/plugins/transformers/markdown/roamHighlight.ts @@ -0,0 +1,35 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { PluggableList } from "unified" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root } from "mdast" +import { VFile } from "vfile" + +const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") + +export const RoamFlavoredMarkdownHighlight: MarkdownTransformerPlugin = () => { + return { + name: "RoamFlavoredMarkdownHighlight", + transformation() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + // Roam highlight syntax + replacements.push([ + roamHighlightRegex, + (_value: string, inner: string) => ({ + type: "html", + value: `${inner}`, + }), + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/roamItalics.ts b/quartz/plugins/transformers/markdown/roamItalics.ts new file mode 100644 index 0000000000000..a3e6a0962d2b6 --- /dev/null +++ b/quartz/plugins/transformers/markdown/roamItalics.ts @@ -0,0 +1,35 @@ +import { MarkdownTransformerPlugin } from "../../types" +import { PluggableList } from "unified" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root } from "mdast" +import { VFile } from "vfile" + +const roamItalicRegex = new RegExp(/__(.+)__/, "g") + +export const RoamFlavoredMarkdownItalics: MarkdownTransformerPlugin = () => { + return { + name: "RoamFlavoredMarkdownItalics", + transformation() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + // Roam italic syntax + replacements.push([ + roamItalicRegex, + (_value: string, match: string) => ({ + type: "emphasis", + children: [{ type: "text", value: match }], + }), + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/markdown/roamOrComponent.ts b/quartz/plugins/transformers/markdown/roamOrComponent.ts new file mode 100644 index 0000000000000..4d801f02e1b92 --- /dev/null +++ b/quartz/plugins/transformers/markdown/roamOrComponent.ts @@ -0,0 +1,41 @@ +import { MarkdownTransformerPlugin, QuartzTransformerPlugin } from "../../types" +import { PluggableList } from "unified" +import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" +import { Root } from "mdast" + +import { VFile } from "vfile" + +const orRegex = new RegExp(/{{or:(.*?)}}/, "g") + +export const RoamFlavoredMarkdownOrComponent: MarkdownTransformerPlugin = () => { + return { + name: "RoamFlavoredMarkdownOrComponent", + transformation() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, _file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + 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 } + }, + ]) + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +} diff --git a/quartz/plugins/transformers/roam.ts b/quartz/plugins/transformers/markdown/roamSpecialEmbeds.ts similarity index 50% rename from quartz/plugins/transformers/roam.ts rename to quartz/plugins/transformers/markdown/roamSpecialEmbeds.ts index b3be8f54284b2..68ebe6485e375 100644 --- a/quartz/plugins/transformers/roam.ts +++ b/quartz/plugins/transformers/markdown/roamSpecialEmbeds.ts @@ -1,55 +1,23 @@ -import { QuartzTransformerPlugin } from "../types" +import { MarkdownTransformerPlugin } from "../../types" import { PluggableList } from "unified" -import { SKIP, visit } from "unist-util-visit" +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 { Node } from "unist" import { VFile } from "vfile" 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 videoRegex = new RegExp(/{{.*?\[\[video\]\].*?\:(.*?)}}/, "g") -const youtubeRegex = new RegExp( - /{{.*?\[\[video\]\].*?(https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?)}}/, - "g", -) - -// const multimediaRegex = new RegExp(/{{.*?\b(video|audio)\b.*?\:(.*?)}}/, "g") - -const audioRegex = new RegExp(/{{.*?\[\[audio\]\].*?\:(.*?)}}/, "g") -const pdfRegex = new RegExp(/{{.*?\[\[pdf\]\].*?\:(.*?)}}/, "g") -const blockquoteRegex = new RegExp(/(\[\[>\]\])\s*(.*)/, "g") -const roamHighlightRegex = new RegExp(/\^\^(.+)\^\^/, "g") -const roamItalicRegex = new RegExp(/__(.+)__/, "g") -const tableRegex = new RegExp(/- {{.*?\btable\b.*?}}/, "g") /* TODO */ -const attributeRegex = new RegExp(/\b\w+(?:\s+\w+)*::/, "g") /* TODO */ - function isSpecialEmbed(node: Paragraph): boolean { if (node.children.length !== 2) return false @@ -93,7 +61,7 @@ function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null { 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 - } - - // 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, - }, - ], - }) - } - - // 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, - }, - } - - // Add callout-content class to callout body if it has one. - if (calloutContent.length > 0) { - const contentData: BlockContent | DefinitionContent = { - data: { - hProperties: { - className: "callout-content", - }, - hName: "div", - }, - type: "blockquote", - children: [...calloutContent], - } - node.children = [node.children[0], contentData] - } - } - }) - } - }) - } - - if (opts.mermaid) { - plugins.push(() => { - return (tree: Root, _file) => { - visit(tree, "code", (node: Code) => { - if (node.lang === "mermaid") { - node.data = { - hProperties: { - className: ["mermaid"], - }, - } - } - }) - } - }) - } - - 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", - } - } - }) - } - }) - } - - return plugins - }, - externalResources() { - const js: JSResource[] = [] - - 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: ` - let mermaidImport = undefined - document.addEventListener('nav', async () => { - if (document.querySelector("code.mermaid")) { - mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs') - const mermaid = mermaidImport.default - const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'loose', - theme: darkMode ? 'dark' : 'default' - }) - - await mermaid.run({ - querySelector: '.mermaid' - }) - } - }); - `, - loadTime: "afterDOMReady", - moduleType: "module", - contentType: "inline", - }) - } - - return { js } - }, - } -} - -declare module "vfile" { - interface DataMap { - blocks: Record - htmlAst: HtmlRoot - } -} diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts deleted file mode 100644 index cdbffcffd1d3e..0000000000000 --- a/quartz/plugins/transformers/oxhugofm.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { QuartzTransformerPlugin } from "../types" - -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 - }, - } -} diff --git a/quartz/plugins/transformers/presets/commonmark.ts b/quartz/plugins/transformers/presets/commonmark.ts new file mode 100644 index 0000000000000..a6a5ed7caf932 --- /dev/null +++ b/quartz/plugins/transformers/presets/commonmark.ts @@ -0,0 +1,55 @@ +import { QuartzTransformerPlugin } from "../../types" +import * as Text from "../text" +import * as Markdown from "../markdown" +import * as Html from "../html" +import * as Resources from "../resources" + +// TODO: commonmark compatibility pass +export const CommonMarkPreset: QuartzTransformerPlugin = () => { + return { + textTransformers: [ + Text.ObsidianFlavoredMarkdownComments(), + Text.ObsidianFlavoredMarkdownCallouts(), + Text.ObsidianFlavoredMarkdownWikilinks(), + ], + markdownTransformers: [ + Markdown.FrontMatter(), + Markdown.CreatedModifiedDate({ + priority: ["frontmatter", "filesystem"], + }), + // TODO: regular markdown links + //Markdown.ObsidianFlavoredMarkdownWikilinks(), + Markdown.ObsidianFlavoredMarkdownHighlight(), + Markdown.ObsidianFlavoredMarkdownArrow(), + Markdown.ObsidianFlavoredMarkdownTags(), + Markdown.ObsidianFlavoredMarkdownVideoEmbed(), + Markdown.ObsidianFlavoredMarkdownCallouts(), + Markdown.ObsidianFlavoredMarkdownMermaid(), + Markdown.GitHubFlavoredMarkdownRemark(), + Markdown.TableOfContents(), + Markdown.Latex(), + ], + htmlTransformers: [ + Html.SyntaxHighlighting({ + theme: { + light: "github-light", + dark: "github-dark", + }, + keepBackground: false, + }), + Html.ObsidianFlavoredMarkdownBlockReferences(), + Html.ObsidianFlavoredMarkdownYouTubeEmbed(), + Html.ObsidianFlavoredMarkdownCheckbox(), + Html.GitHubFlavoredMarkdownLinkHeadings(), + Html.CrawlLinks({ markdownLinkResolution: "shortest" }), + Html.Description(), + Html.Latex({ renderEngine: "katex" }), + ], + externalResources: [ + Resources.ObsidianFlavoredMarkdownCheckbox(), + Resources.ObsidianFlavoredMarkdownCallouts(), + Resources.ObsidianFlavoredMarkdownMermaid(), + Resources.Latex({ renderEngine: "katex" }), + ], + } +} diff --git a/quartz/plugins/transformers/presets/default.ts b/quartz/plugins/transformers/presets/default.ts new file mode 100644 index 0000000000000..4a096d9710236 --- /dev/null +++ b/quartz/plugins/transformers/presets/default.ts @@ -0,0 +1,55 @@ +import { QuartzTransformerPlugin } from "../../types" +import * as Text from "../text" +import * as Markdown from "../markdown" +import * as Html from "../html" +import * as Resources from "../resources" + +export const DefaultPreset: QuartzTransformerPlugin = () => { + return { + textTransformers: [ + Text.HtmlComments(), + Text.ObsidianFlavoredMarkdownCallouts(), + Text.ObsidianFlavoredMarkdownWikilinks(), + ], + markdownTransformers: [ + Markdown.FrontMatter(), + Markdown.CreatedModifiedDate({ + priority: ["frontmatter", "filesystem"], + }), + // TODO: wikilink fixes + Markdown.ObsidianFlavoredMarkdownWikilinks(), + Markdown.ObsidianFlavoredMarkdownHighlight(), + Markdown.ObsidianFlavoredMarkdownArrow(), + Markdown.ObsidianFlavoredMarkdownTags(), + Markdown.ObsidianFlavoredMarkdownVideoEmbed(), + // TODO: callout fixes + Markdown.ObsidianFlavoredMarkdownCallouts(), + Markdown.ObsidianFlavoredMarkdownMermaid(), + Markdown.GitHubFlavoredMarkdownRemark(), + Markdown.TableOfContents(), + Markdown.Latex(), + ], + htmlTransformers: [ + Html.SyntaxHighlighting({ + theme: { + light: "github-light", + dark: "github-dark", + }, + keepBackground: false, + }), + Html.ObsidianFlavoredMarkdownBlockReferences(), + Html.ObsidianFlavoredMarkdownYouTubeEmbed(), + Html.ObsidianFlavoredMarkdownCheckbox(), + Html.GitHubFlavoredMarkdownLinkHeadings(), + Html.CrawlLinks({ markdownLinkResolution: "shortest" }), + Html.Description(), + Html.Latex({ renderEngine: "katex" }), + ], + externalResources: [ + Resources.ObsidianFlavoredMarkdownCheckbox(), + Resources.ObsidianFlavoredMarkdownCallouts(), + Resources.ObsidianFlavoredMarkdownMermaid(), + Resources.Latex({ renderEngine: "katex" }), + ], + } +} diff --git a/quartz/plugins/transformers/presets/index.ts b/quartz/plugins/transformers/presets/index.ts new file mode 100644 index 0000000000000..00f8daf455875 --- /dev/null +++ b/quartz/plugins/transformers/presets/index.ts @@ -0,0 +1,3 @@ +export { CommonMarkPreset } from "./commonmark" +export { DefaultPreset } from "./default" +export { ObsidianPreset } from "./obsidian" diff --git a/quartz/plugins/transformers/presets/obsidian.ts b/quartz/plugins/transformers/presets/obsidian.ts new file mode 100644 index 0000000000000..4ab1d93de59cf --- /dev/null +++ b/quartz/plugins/transformers/presets/obsidian.ts @@ -0,0 +1,52 @@ +import { QuartzTransformerPlugin } from "../../types" +import * as Text from "../text" +import * as Markdown from "../markdown" +import * as Html from "../html" +import * as Resources from "../resources" + +export const ObsidianPreset: QuartzTransformerPlugin = () => { + return { + textTransformers: [ + Text.HtmlComments(), + Text.ObsidianFlavoredMarkdownComments(), + Text.ObsidianFlavoredMarkdownCallouts(), + Text.ObsidianFlavoredMarkdownWikilinks(), + ], + markdownTransformers: [ + Markdown.FrontMatter(), + Markdown.CreatedModifiedDate({ + priority: ["frontmatter", "filesystem"], + }), + Markdown.ObsidianFlavoredMarkdownWikilinks(), + Markdown.ObsidianFlavoredMarkdownHighlight(), + Markdown.ObsidianFlavoredMarkdownArrow(), + Markdown.ObsidianFlavoredMarkdownTags(), + Markdown.ObsidianFlavoredMarkdownVideoEmbed(), + Markdown.ObsidianFlavoredMarkdownCallouts(), + Markdown.ObsidianFlavoredMarkdownMermaid(), + Markdown.TableOfContents(), + Markdown.Latex(), + ], + htmlTransformers: [ + Html.SyntaxHighlighting({ + theme: { + light: "github-light", + dark: "github-dark", + }, + keepBackground: false, + }), + Html.ObsidianFlavoredMarkdownBlockReferences(), + Html.ObsidianFlavoredMarkdownYouTubeEmbed(), + Html.ObsidianFlavoredMarkdownCheckbox(), + Html.CrawlLinks({ markdownLinkResolution: "shortest" }), + Html.Description(), + Html.Latex({ renderEngine: "katex" }), + ], + externalResources: [ + Resources.ObsidianFlavoredMarkdownCheckbox(), + Resources.ObsidianFlavoredMarkdownCallouts(), + Resources.ObsidianFlavoredMarkdownMermaid(), + Resources.Latex({ renderEngine: "katex" }), + ], + } +} diff --git a/quartz/plugins/transformers/resources/index.ts b/quartz/plugins/transformers/resources/index.ts new file mode 100644 index 0000000000000..19d63144ed006 --- /dev/null +++ b/quartz/plugins/transformers/resources/index.ts @@ -0,0 +1,4 @@ +export { Latex } from "./latex" +export { ObsidianFlavoredMarkdownCallouts } from "./ofmCallouts" +export { ObsidianFlavoredMarkdownCheckbox } from "./ofmCheckbox" +export { ObsidianFlavoredMarkdownMermaid } from "./ofmMermaid" diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/resources/latex.ts similarity index 54% rename from quartz/plugins/transformers/latex.ts rename to quartz/plugins/transformers/resources/latex.ts index d323b3ee69036..0dc04ce65a182 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/resources/latex.ts @@ -1,33 +1,14 @@ -import remarkMath from "remark-math" -import rehypeKatex from "rehype-katex" -import rehypeMathjax from "rehype-mathjax/svg" -import { QuartzTransformerPlugin } from "../types" +import { ExternalResourcePlugin } from "../../types" interface Options { renderEngine: "katex" | "mathjax" - customMacros: MacroType } -interface MacroType { - [key: string]: string -} - -export const Latex: QuartzTransformerPlugin> = (opts) => { +export const Latex: ExternalResourcePlugin> = (opts) => { const engine = opts?.renderEngine ?? "katex" - const macros = opts?.customMacros ?? {} return { name: "Latex", - markdownPlugins() { - return [remarkMath] - }, - htmlPlugins() { - if (engine === "katex") { - return [[rehypeKatex, { output: "html", macros }]] - } else { - return [[rehypeMathjax, { macros }]] - } - }, - externalResources() { + transformation() { if (engine === "katex") { return { css: [ diff --git a/quartz/plugins/transformers/resources/ofmCallouts.ts b/quartz/plugins/transformers/resources/ofmCallouts.ts new file mode 100644 index 0000000000000..058bfcf2648bf --- /dev/null +++ b/quartz/plugins/transformers/resources/ofmCallouts.ts @@ -0,0 +1,21 @@ +import { ExternalResourcePlugin } from "../../types" +import { JSResource } from "../../../util/resources" +// @ts-ignore +import calloutScript from "../../../components/scripts/callout.inline.ts" + +export const ObsidianFlavoredMarkdownCallouts: ExternalResourcePlugin = () => { + return { + name: "ObsidianFlavoredMarkdownCallouts", + transformation() { + const js: JSResource[] = [] + + js.push({ + script: calloutScript, + loadTime: "afterDOMReady", + contentType: "inline", + }) + + return { js } + }, + } +} diff --git a/quartz/plugins/transformers/resources/ofmCheckbox.ts b/quartz/plugins/transformers/resources/ofmCheckbox.ts new file mode 100644 index 0000000000000..4e54538b820a2 --- /dev/null +++ b/quartz/plugins/transformers/resources/ofmCheckbox.ts @@ -0,0 +1,21 @@ +import { ExternalResourcePlugin } from "../../types" +import { JSResource } from "../../../util/resources" +// @ts-ignore +import checkboxScript from "../../../components/scripts/checkbox.inline.ts" + +export const ObsidianFlavoredMarkdownCheckbox: ExternalResourcePlugin = () => { + return { + name: "ObsidianFlavoredMarkdownCheckbox", + transformation() { + const js: JSResource[] = [] + + js.push({ + script: checkboxScript, + loadTime: "afterDOMReady", + contentType: "inline", + }) + + return { js } + }, + } +} diff --git a/quartz/plugins/transformers/resources/ofmMermaid.ts b/quartz/plugins/transformers/resources/ofmMermaid.ts new file mode 100644 index 0000000000000..8153036983e4e --- /dev/null +++ b/quartz/plugins/transformers/resources/ofmMermaid.ts @@ -0,0 +1,38 @@ +import { ExternalResourcePlugin } from "../../types" +import { JSResource } from "../../../util/resources" + +export const ObsidianFlavoredMarkdownMermaid: ExternalResourcePlugin = () => { + return { + name: "ObsidianFlavoredMarkdownMermaid", + transformation() { + const js: JSResource[] = [] + + js.push({ + script: ` + let mermaidImport = undefined + document.addEventListener('nav', async () => { + if (document.querySelector("code.mermaid")) { + mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs') + const mermaid = mermaidImport.default + const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + theme: darkMode ? 'dark' : 'default' + }) + + await mermaid.run({ + querySelector: '.mermaid' + }) + } + }); + `, + loadTime: "afterDOMReady", + moduleType: "module", + contentType: "inline", + }) + + return { js } + }, + } +} diff --git a/quartz/plugins/transformers/text/htmlComments.ts b/quartz/plugins/transformers/text/htmlComments.ts new file mode 100644 index 0000000000000..dd3da780a3c65 --- /dev/null +++ b/quartz/plugins/transformers/text/htmlComments.ts @@ -0,0 +1,28 @@ +import { TextTransformerPlugin } from "../../types" + +const commentRegex = new RegExp(//gms) +const codeBlockRegex = new RegExp(/(```.*?```)/gms) + +export const HtmlComments: TextTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownComments", + transformation(_ctx, src) { + // do comments at text level + if (src instanceof Buffer) { + src = src.toString() + } // capture all codeblocks before parsing comments + const codeBlocks = Array.from(src.matchAll(codeBlockRegex), (x) => x[1]) + + src = src.replaceAll(codeBlockRegex, "###codeblockplaceholder###") + + src = src.replaceAll(commentRegex, "") + + // Restore codeblocks + for (const codeblock of codeBlocks) { + src = src.replace("###codeblockplaceholder###", codeblock) + } + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/index.ts b/quartz/plugins/transformers/text/index.ts new file mode 100644 index 0000000000000..97161cb9afba5 --- /dev/null +++ b/quartz/plugins/transformers/text/index.ts @@ -0,0 +1,9 @@ +export { HtmlComments } from "./htmlComments" +export { ObsidianFlavoredMarkdownCallouts } from "./ofmCallouts" +export { ObsidianFlavoredMarkdownComments } from "./ofmComments" +export { ObsidianFlavoredMarkdownWikilinks } from "./ofmWikilinks" +export { OxHugoFlavouredMarkdownPredefinedAnchor } from "./oxhugofmRemovePredefinedAnchor" +export { OxHugoFlavouredMarkdownRemoveHugoShortcode } from "./oxhugofmRemoveHugoShortcode" +export { OxHugoFlavouredMarkdownReplaceFigure } from "./oxhugofmReplaceFigure" +export { OxHugoFlavouredMarkdownReplaceOrgLatex } from "./oxhugofmReplaceOrgLatex" +export { OxHugoFlavouredMarkdownWikilinks } from "./oxhugofmWikilinks" diff --git a/quartz/plugins/transformers/text/ofmCallouts.ts b/quartz/plugins/transformers/text/ofmCallouts.ts new file mode 100644 index 0000000000000..5eefb7af2f2b6 --- /dev/null +++ b/quartz/plugins/transformers/text/ofmCallouts.ts @@ -0,0 +1,22 @@ +import { TextTransformerPlugin } from "../../types" + +const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm) + +export const ObsidianFlavoredMarkdownCallouts: TextTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownCallouts", + transformation(_ctx, src) { + // pre-transform blockquotes + if (src instanceof Buffer) { + src = src.toString() + } + + src = src.replace(calloutLineRegex, (value) => { + // force newline after title of callout + return value + "\n> " + }) + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/ofmComments.ts b/quartz/plugins/transformers/text/ofmComments.ts new file mode 100644 index 0000000000000..82a1e1c95b718 --- /dev/null +++ b/quartz/plugins/transformers/text/ofmComments.ts @@ -0,0 +1,30 @@ +import { TextTransformerPlugin } from "../../types" + +const commentRegex = new RegExp(/%%.*?%%/gms) +const codeBlockRegex = new RegExp(/(```.*?```)/gms) + +export const ObsidianFlavoredMarkdownComments: TextTransformerPlugin = () => { + return { + name: "ObsidianFlavoredMarkdownComments", + transformation(_ctx, src) { + // do comments at text level + if (src instanceof Buffer) { + src = src.toString() + } + + // capture all codeblocks before parsing comments + const codeBlocks = Array.from(src.matchAll(codeBlockRegex), (x) => x[1].toString()) + + src = src.replaceAll(codeBlockRegex, "###codeblockplaceholder###") + + src = src.replaceAll(commentRegex, "") + + // Restore codeblock + for (const codeblock of codeBlocks) { + src = src.replace("###codeblockplaceholder###", codeblock) + } + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/ofmWikilinks.ts b/quartz/plugins/transformers/text/ofmWikilinks.ts new file mode 100644 index 0000000000000..5967764babb91 --- /dev/null +++ b/quartz/plugins/transformers/text/ofmWikilinks.ts @@ -0,0 +1,84 @@ +import { TextTransformerPlugin } from "../../types" +import { Paragraph } from "mdast" +import { Element, Root as HtmlRoot } from "hast" +import { splitAnchor } from "../../../util/path" +import { toHast } from "mdast-util-to-hast" +import { toHtml } from "hast-util-to-html" +import { PhrasingContent } from "mdast-util-find-and-replace/lib" + +export const externalLinkRegex = /^https?:\/\//i + +// !? -> 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 one 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) + +export const ObsidianFlavoredMarkdownWikilinks: TextTransformerPlugin = () => { + const mdastToHtml = (ast: PhrasingContent | Paragraph) => { + const hast = toHast(ast, { allowDangerousHtml: true })! + return toHtml(hast, { allowDangerousHtml: true }) + } + + return { + name: "ObsidianFlavoredMarkdownWikilinks", + transformation(_ctx, src) { + // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) + + if (src instanceof Buffer) { + src = src.toString() + } + + // 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?.match(/^#?\^/)) ? "^" : "" + 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 + }, + } +} + +declare module "vfile" { + interface DataMap { + blocks: Record + htmlAst: HtmlRoot + } +} diff --git a/quartz/plugins/transformers/text/oxhugofmRemoveHugoShortcode.ts b/quartz/plugins/transformers/text/oxhugofmRemoveHugoShortcode.ts new file mode 100644 index 0000000000000..0b557ed1df40f --- /dev/null +++ b/quartz/plugins/transformers/text/oxhugofmRemoveHugoShortcode.ts @@ -0,0 +1,24 @@ +import { TextTransformerPlugin } from "../../types" + +const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "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 OxHugoFlavouredMarkdownRemoveHugoShortcode: TextTransformerPlugin = () => { + return { + name: "OxHugoFlavouredMarkdownRemoveHugoShortcode", + transformation(_ctx, src) { + src = src.toString() + src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { + const [scContent] = capture + return scContent + }) + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/oxhugofmRemovePredefinedAnchor.ts b/quartz/plugins/transformers/text/oxhugofmRemovePredefinedAnchor.ts new file mode 100644 index 0000000000000..e3e8f9f3e2459 --- /dev/null +++ b/quartz/plugins/transformers/text/oxhugofmRemovePredefinedAnchor.ts @@ -0,0 +1,24 @@ +import { TextTransformerPlugin } from "../../types" + +const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "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 OxHugoFlavouredMarkdownPredefinedAnchor: TextTransformerPlugin = () => { + return { + name: "OxHugoFlavouredMarkdownPredefinedAnchor", + transformation(_ctx, src) { + src = src.toString() + src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { + const [headingText] = capture + return headingText + }) + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/oxhugofmReplaceFigure.ts b/quartz/plugins/transformers/text/oxhugofmReplaceFigure.ts new file mode 100644 index 0000000000000..1a18be6a0fafa --- /dev/null +++ b/quartz/plugins/transformers/text/oxhugofmReplaceFigure.ts @@ -0,0 +1,24 @@ +import { TextTransformerPlugin } from "../../types" + +const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "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 OxHugoFlavouredMarkdownReplaceFigure: TextTransformerPlugin = () => { + return { + name: "OxHugoFlavouredMarkdownWikilinks", + transformation(_ctx, src) { + src = src.toString() + src = src.replaceAll(figureTagRegex, (value, ...capture) => { + const [src] = capture + return `![](${src})` + }) + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/oxhugofmReplaceOrgLatex.ts b/quartz/plugins/transformers/text/oxhugofmReplaceOrgLatex.ts new file mode 100644 index 0000000000000..d7cfd71f3429e --- /dev/null +++ b/quartz/plugins/transformers/text/oxhugofmReplaceOrgLatex.ts @@ -0,0 +1,43 @@ +import { TextTransformerPlugin } from "../../types" + +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 OxHugoFlavouredMarkdownReplaceOrgLatex: TextTransformerPlugin = () => { + return { + name: "OxHugoFlavouredMarkdownReplaceOrgLatex", + transformation(_ctx, src) { + src = src.toString() + src = src.replaceAll(inlineLatexRegex, (value, ...capture) => { + const [eqn] = capture + return `$${eqn}$` + }) + src = src.replaceAll(blockLatexRegex, (value, ...capture) => { + const [eqn] = capture + return `$$${eqn}$$` + }) + + // ox-hugo escapes _ as \_ + src = src.replaceAll(quartzLatexRegex, (value) => { + return value.replaceAll("\\_", "_") + }) + + return src + }, + } +} diff --git a/quartz/plugins/transformers/text/oxhugofmWikilinks.ts b/quartz/plugins/transformers/text/oxhugofmWikilinks.ts new file mode 100644 index 0000000000000..4c9be271fdf45 --- /dev/null +++ b/quartz/plugins/transformers/text/oxhugofmWikilinks.ts @@ -0,0 +1,24 @@ +import { TextTransformerPlugin } from "../../types" + +const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "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 OxHugoFlavouredMarkdownWikilinks: TextTransformerPlugin = () => { + return { + name: "OxHugoFlavouredMarkdownWikilinks", + transformation(_ctx, src) { + src = src.toString() + src = src.replaceAll(relrefRegex, (value, ...capture) => { + const [text, link] = capture + return `[${text}](${link})` + }) + + return src + }, + } +} diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index a23f5d6f4630a..85f50a6edd214 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -6,22 +6,57 @@ import { FilePath } from "../util/path" import { BuildCtx } from "../util/ctx" import DepGraph from "../depgraph" +type OptionType = object | undefined export interface PluginTypes { - transformers: QuartzTransformerPluginInstance[] + transformers: QuartzTransformerPluginInstance filters: QuartzFilterPluginInstance[] emitters: QuartzEmitterPluginInstance[] } -type OptionType = object | undefined -export type QuartzTransformerPlugin = ( +export type TextTransformerPlugin = ( + opts?: Options, +) => TextTransformerPluginInstance +export type TextTransformerPluginInstance = { + name: string + transformation: (ctx: BuildCtx, src: string | Buffer) => string | Buffer +} + +export type MarkdownTransformerPlugin = ( + opts?: Options, +) => MarkdownTransformerPluginInstance +export type MarkdownTransformerPluginInstance = { + name: string + transformation: (ctx: BuildCtx) => PluggableList +} + +export type HtmlTransformerPlugin = ( + opts?: Options, +) => HtmlTransformerPluginInstance +export type HtmlTransformerPluginInstance = { + name: string + transformation: (ctx: BuildCtx) => PluggableList +} + +export type ExternalResourcePlugin = ( opts?: Options, +) => ExternalResourcePluginInstance +export type ExternalResourcePluginInstance = { + name: string + transformation: (ctx: BuildCtx) => Partial +} + +export type QuartzTransformerPlugin = ( + textTransformers?: TextTransformerPlugin, + markdownTransformers?: MarkdownTransformerPlugin, + htmlTransformers?: HtmlTransformerPlugin, + externalResources?: ExternalResourcePlugin, ) => QuartzTransformerPluginInstance export type QuartzTransformerPluginInstance = { - name: string - textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer - markdownPlugins?: (ctx: BuildCtx) => PluggableList - htmlPlugins?: (ctx: BuildCtx) => PluggableList - externalResources?: (ctx: BuildCtx) => Partial + //name: string, + textTransformers: TextTransformerPluginInstance[] + markdownTransformers: MarkdownTransformerPluginInstance[] + htmlTransformers: HtmlTransformerPluginInstance[] + externalResources: ExternalResourcePluginInstance[] } export type QuartzFilterPlugin = ( diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index 2bd530c643254..313b7d31f34cd 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -16,22 +16,19 @@ import { BuildCtx } from "../util/ctx" export type QuartzProcessor = Processor export function createProcessor(ctx: BuildCtx): QuartzProcessor { - const transformers = ctx.cfg.plugins.transformers + const markdownTransformers = ctx.cfg.plugins.transformers.markdownTransformers + const htmlTransformers = ctx.cfg.plugins.transformers.htmlTransformers return ( unified() // base Markdown -> MD AST .use(remarkParse) // MD AST -> MD AST transforms - .use( - transformers - .filter((p) => p.markdownPlugins) - .flatMap((plugin) => plugin.markdownPlugins!(ctx)), - ) + .use(markdownTransformers.flatMap((plugin) => plugin.transformation(ctx))) // MD AST -> HTML AST .use(remarkRehype, { allowDangerousHtml: true }) // HTML AST -> HTML AST transforms - .use(transformers.filter((p) => p.htmlPlugins).flatMap((plugin) => plugin.htmlPlugins!(ctx))) + .use(htmlTransformers.flatMap((plugin) => plugin.transformation!(ctx))) ) } @@ -86,8 +83,8 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { file.value = file.value.toString().trim() // Text -> Text transforms - for (const plugin of cfg.plugins.transformers.filter((p) => p.textTransform)) { - file.value = plugin.textTransform!(ctx, file.value.toString()) + for (const plugin of cfg.plugins.transformers.textTransformers) { + file.value = plugin.transformation(ctx, file.value.toString()) } // base data properties that plugins may use