diff --git a/docs/features/Roam Research compatibility.md b/docs/features/Roam Research compatibility.md new file mode 100644 index 0000000..41ea885 --- /dev/null +++ b/docs/features/Roam Research compatibility.md @@ -0,0 +1,28 @@ +--- +title: "Roam Research Compatibility" +tags: + - feature/transformer +--- + +[Roam Research](https://roamresearch.com) is a note-taking tool that organizes your knowledge graph in a unique and interconnected way. + +Quartz supports transforming the special Markdown syntax from Roam Research (like `{{[[components]]}}` and other formatting) into +regular Markdown via the [[RoamFlavoredMarkdown]] plugin. + +```typescript title="quartz.config.ts" +plugins: { + transformers: [ + // ... + Plugin.RoamFlavoredMarkdown(), + Plugin.ObsidianFlavoredMarkdown(), + // ... + ], +}, +``` + +> [!warning] +> As seen above placement of `Plugin.RoamFlavoredMarkdown()` within `quartz.config.ts` is very important. It must come before `Plugin.ObsidianFlavoredMarkdown()`. + +## Customization + +This functionality is provided by the [[RoamFlavoredMarkdown]] plugin. See the plugin page for customization options. diff --git a/docs/plugins/RoamFlavoredMarkdown.md b/docs/plugins/RoamFlavoredMarkdown.md new file mode 100644 index 0000000..9d89477 --- /dev/null +++ b/docs/plugins/RoamFlavoredMarkdown.md @@ -0,0 +1,26 @@ +--- +title: RoamFlavoredMarkdown +tags: + - plugin/transformer +--- + +This plugin provides support for [Roam Research](https://roamresearch.com) compatibility. See [[Roam Research Compatibility]] for more information. + +> [!note] +> For information on how to add, remove or configure plugins, see the [[Configuration#Plugins|Configuration]] page. + +This plugin accepts the following configuration options: + +- `orComponent`: If `true` (default), converts Roam `{{ or:ONE|TWO|THREE }}` shortcodes into HTML Dropdown options. +- `TODOComponent`: If `true` (default), converts Roam `{{[[TODO]]}}` shortcodes into HTML check boxes. +- `DONEComponent`: If `true` (default), converts Roam `{{[[DONE]]}}` shortcodes into checked HTML check boxes. +- `videoComponent`: If `true` (default), converts Roam `{{[[video]]:URL}}` shortcodes into embeded HTML video. +- `audioComponent`: If `true` (default), converts Roam `{{[[audio]]:URL}}` shortcodes into embeded HTML audio. +- `pdfComponent`: If `true` (default), converts Roam `{{[[pdf]]:URL}}` shortcodes into embeded HTML PDF viewer. +- `blockquoteComponent`: If `true` (default), converts Roam `{{[[>]]}}` shortcodes into Quartz blockquotes. + +## API + +- Category: Transformer +- Function name: `Plugin.RoamFlavoredMarkdown()`. +- Source: [`quartz/plugins/transformers/roam.ts`](https://github.com/jackyzha0/quartz/blob/v4/quartz/plugins/transformers/roam.ts). diff --git a/package-lock.json b/package-lock.json index e373615..c2a746f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "github-slugger": "^2.0.0", "globby": "^14.0.2", "gray-matter": "^4.0.3", - "hast-util-to-html": "^9.0.1", + "hast-util-to-html": "^9.0.2", "hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", @@ -82,7 +82,7 @@ "@types/yargs": "^17.0.33", "esbuild": "^0.19.9", "prettier": "^3.3.3", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4" }, "engines": { @@ -2785,15 +2785,14 @@ "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, "node_modules/hast-util-to-html": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.1.tgz", - "integrity": "sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.2.tgz", + "integrity": "sha512-RP5wNpj5nm1Z8cloDv4Sl4RS8jH5HYa0v93YB6Wb4poEzgMo/dAAL0KcT4974dCjcNG5pkLqTImeFHHCwwfY3g==", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", - "hast-util-raw": "^9.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", @@ -5836,9 +5835,9 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/tsx": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.18.0.tgz", - "integrity": "sha512-a1jaKBSVQkd6yEc1/NI7G6yHFfefIcuf3QJST7ZEyn4oQnxLYrZR5uZAM8UrwUa3Ge8suiZHcNS1gNrEvmobqg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.0.tgz", + "integrity": "sha512-bV30kM7bsLZKZIOCHeMNVMJ32/LuJzLVajkQI/qf92J2Qr08ueLQvW00PUZGiuLPP760UINwupgUj8qrSCPUKg==", "dev": true, "dependencies": { "esbuild": "~0.23.0", diff --git a/package.json b/package.json index 88065ca..26921d6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "github-slugger": "^2.0.0", "globby": "^14.0.2", "gray-matter": "^4.0.3", - "hast-util-to-html": "^9.0.1", + "hast-util-to-html": "^9.0.2", "hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-string": "^3.0.0", "is-absolute-url": "^4.0.1", @@ -105,7 +105,7 @@ "@types/yargs": "^17.0.33", "esbuild": "^0.19.9", "prettier": "^3.3.3", - "tsx": "^4.18.0", + "tsx": "^4.19.0", "typescript": "^5.5.4" } } diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 7908c86..8e2cd84 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -10,3 +10,4 @@ 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/roam.ts b/quartz/plugins/transformers/roam.ts new file mode 100644 index 0000000..b3be8f5 --- /dev/null +++ b/quartz/plugins/transformers/roam.ts @@ -0,0 +1,224 @@ +import { QuartzTransformerPlugin } from "../types" +import { PluggableList } from "unified" +import { SKIP, 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 + + const [textNode, linkNode] = node.children + return ( + textNode.type === "text" && + textNode.value.startsWith("{{[[") && + linkNode.type === "link" && + linkNode.children[0].type === "text" && + linkNode.children[0].value.endsWith("}}") + ) +} + +function transformSpecialEmbed(node: Paragraph, opts: Options): Html | null { + const [textNode, linkNode] = node.children as [Text, Link] + const embedType = textNode.value.match(/\{\{\[\[(.*?)\]\]:/)?.[1]?.toLowerCase() + const url = linkNode.url.slice(0, -2) // Remove the trailing '}}' + + switch (embedType) { + case "audio": + return opts.audioComponent + ? { + type: "html", + value: ``, + } + : null + case "video": + if (!opts.videoComponent) return null + // Check if it's a YouTube video + const youtubeMatch = url.match( + /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/, + ) + if (youtubeMatch) { + const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters + const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/) + const playlistId = playlistMatch ? playlistMatch[1] : null + + return { + type: "html", + value: ``, + } + } else { + return { + type: "html", + value: ``, + } + } + case "pdf": + return opts.pdfComponent + ? { + type: "html", + value: ``, + } + : null + default: + return null + } +} + +export const RoamFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( + userOpts, +) => { + const opts = { ...defaultOptions, ...userOpts } + + return { + name: "RoamFlavoredMarkdown", + markdownPlugins() { + const plugins: PluggableList = [] + + plugins.push(() => { + return (tree: Root, file: VFile) => { + const replacements: [RegExp, ReplaceFunction][] = [] + + // Handle special embeds (audio, video, PDF) + if (opts.audioComponent || opts.videoComponent || opts.pdfComponent) { + visit(tree, "paragraph", ((node: Paragraph, index: number, parent: Parent | null) => { + if (isSpecialEmbed(node)) { + const transformedNode = transformSpecialEmbed(node, opts) + if (transformedNode && parent) { + parent.children[index] = transformedNode + } + } + }) as BuildVisitor) + } + + // Roam italic syntax + replacements.push([ + roamItalicRegex, + (_value: string, match: string) => ({ + type: "emphasis", + children: [{ type: "text", value: match }], + }), + ]) + + // Roam highlight syntax + replacements.push([ + roamHighlightRegex, + (_value: string, inner: string) => ({ + type: "html", + value: `${inner}`, + }), + ]) + + if (opts.orComponent) { + replacements.push([ + orRegex, + (match: string) => { + const matchResult = match.match(/{{or:(.*?)}}/) + if (matchResult === null) { + return { type: "html", value: "" } + } + const optionsString: string = matchResult[1] + const options: string[] = optionsString.split("|") + const selectHtml: string = `` + return { type: "html", value: selectHtml } + }, + ]) + } + + if (opts.TODOComponent) { + replacements.push([ + TODORegex, + () => ({ + type: "html", + value: ``, + }), + ]) + } + + if (opts.DONEComponent) { + replacements.push([ + DONERegex, + () => ({ + type: "html", + value: ``, + }), + ]) + } + + if (opts.blockquoteComponent) { + replacements.push([ + blockquoteRegex, + (_match: string, _marker: string, content: string) => ({ + type: "html", + value: `
${content.trim()}
`, + }), + ]) + } + + mdastFindReplace(tree, replacements) + } + }) + + return plugins + }, + } +}