Matt Vogel
2024-09-11 d2414b3903c9f1353271ab8157e4be8997916294
feat(markdown): Roam Research flavour (#985)

* feat: Roam Research flavor markdown

* docs: Roam Research transformer

* use markdownPlugins

* fix roam matching

* cleanup: Roam Plugin

---------

Co-authored-by: Matt Vogel <>
3 files added
1 files modified
281 ■■■■■ changed files
docs/features/Roam Research compatibility.md 30 ●●●●● patch | view | raw | blame | history
docs/plugins/RoamFlavoredMarkdown.md 26 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/index.ts 1 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/roam.ts 224 ●●●●● patch | view | raw | blame | history
docs/features/Roam Research compatibility.md
New file
@@ -0,0 +1,30 @@
---
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. Since the markdown exported from Roam Research includes specific `{{[[components]]}}` and formatting that may not be directly compatible with Quartz, we need to transform it. This is achieved with the [[RoamFlavoredMarkdown]] plugin.
```typescript title="quartz.config.ts"
plugins: {
  transformers: [
    // ...
    Plugin.RoamFlavoredMarkdown(),
    Plugin.ObsidianFlavoredMarkdown(),
    // ...
  ],
},
```
## Usage
By default, Quartz does not recognize markdown files exported from `Roam Research` as they contain unique identifiers and components specific to Roam. You are responsible for exporting your `Roam Research` notes as markdown files and then using this transformer to make them compatible with Quartz. This process ensures that your knowledge graph is seamlessly integrated into your static site, maintaining the rich interconnections between your notes.
## Configuration
This functionality is provided by the [[RoamFlavoredMarkdown]] plugin. See the plugin page for customization options.
## Note
As seen above placement of `Plugin.RoamFlavoredMarkdown()` within `quartz.config.ts` is very important. It must come before `Plugin.ObsidianFlavoredMarkdown()`.
docs/plugins/RoamFlavoredMarkdown.md
New file
@@ -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).
quartz/plugins/transformers/index.ts
@@ -10,3 +10,4 @@
export { SyntaxHighlighting } from "./syntax"
export { TableOfContents } from "./toc"
export { HardLineBreaks } from "./linebreaks"
export { RoamFlavoredMarkdown } from "./roam"
quartz/plugins/transformers/roam.ts
New file
@@ -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: `<audio controls>
          <source src="${url}" type="audio/mpeg">
          <source src="${url}" type="audio/ogg">
          Your browser does not support the audio tag.
        </audio>`,
          }
        : null
    case "video":
      if (!opts.videoComponent) return null
      // Check if it's a YouTube video
      const youtubeMatch = url.match(
        /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/,
      )
      if (youtubeMatch) {
        const videoId = youtubeMatch[1].split("&")[0] // Remove additional parameters
        const playlistMatch = url.match(/[?&]list=([^#\&\?]*)/)
        const playlistId = playlistMatch ? playlistMatch[1] : null
        return {
          type: "html",
          value: `<iframe
            class="external-embed youtube"
            width="600px"
            height="350px"
            src="https://www.youtube.com/embed/${videoId}${playlistId ? `?list=${playlistId}` : ""}"
            frameborder="0"
            allow="fullscreen"
          ></iframe>`,
        }
      } else {
        return {
          type: "html",
          value: `<video controls>
            <source src="${url}" type="video/mp4">
            <source src="${url}" type="video/webm">
            Your browser does not support the video tag.
          </video>`,
        }
      }
    case "pdf":
      return opts.pdfComponent
        ? {
            type: "html",
            value: `<embed src="${url}" type="application/pdf" width="100%" height="600px" />`,
          }
        : null
    default:
      return null
  }
}
export const RoamFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "RoamFlavoredMarkdown",
    markdownPlugins() {
      const plugins: PluggableList = []
      plugins.push(() => {
        return (tree: Root, 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<Root, "paragraph">)
          }
          // Roam italic syntax
          replacements.push([
            roamItalicRegex,
            (_value: string, match: string) => ({
              type: "emphasis",
              children: [{ type: "text", value: match }],
            }),
          ])
          // Roam highlight syntax
          replacements.push([
            roamHighlightRegex,
            (_value: string, inner: string) => ({
              type: "html",
              value: `<span class="text-highlight">${inner}</span>`,
            }),
          ])
          if (opts.orComponent) {
            replacements.push([
              orRegex,
              (match: string) => {
                const matchResult = match.match(/{{or:(.*?)}}/)
                if (matchResult === null) {
                  return { type: "html", value: "" }
                }
                const optionsString: string = matchResult[1]
                const options: string[] = optionsString.split("|")
                const selectHtml: string = `<select>${options.map((option: string) => `<option value="${option}">${option}</option>`).join("")}</select>`
                return { type: "html", value: selectHtml }
              },
            ])
          }
          if (opts.TODOComponent) {
            replacements.push([
              TODORegex,
              () => ({
                type: "html",
                value: `<input type="checkbox" disabled>`,
              }),
            ])
          }
          if (opts.DONEComponent) {
            replacements.push([
              DONERegex,
              () => ({
                type: "html",
                value: `<input type="checkbox" checked disabled>`,
              }),
            ])
          }
          if (opts.blockquoteComponent) {
            replacements.push([
              blockquoteRegex,
              (_match: string, _marker: string, content: string) => ({
                type: "html",
                value: `<blockquote>${content.trim()}</blockquote>`,
              }),
            ])
          }
          mdastFindReplace(tree, replacements)
        }
      })
      return plugins
    },
  }
}