* feat: Roam Research flavor markdown * docs: Roam Research transformer * use markdownPlugins * fix roam matching * cleanup: Roam Plugin --------- Co-authored-by: Matt Vogel <>
		
			
				
	
	
		
			225 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			225 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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
 | 
						|
    },
 | 
						|
  }
 | 
						|
}
 |