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