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