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
+ },
+ }
+}