| | |
| | | import { PluggableList } from "unified" |
| | | import { QuartzTransformerPlugin } from "../types" |
| | | import { Root, HTML, BlockContent, DefinitionContent } from 'mdast' |
| | | import { Root, HTML, BlockContent, DefinitionContent, Code } from "mdast" |
| | | import { findAndReplace } from "mdast-util-find-and-replace" |
| | | import { slugify } from "../../path" |
| | | import { slug as slugAnchor } from "github-slugger" |
| | | import rehypeRaw from "rehype-raw" |
| | | import { visit } from "unist-util-visit" |
| | | import path from "path" |
| | | import { JSResource } from "../../resources" |
| | | // @ts-ignore |
| | | import calloutScript from "../../components/scripts/callout.inline.ts" |
| | | import { FilePath, slugifyFilePath } from "../../path" |
| | | |
| | | export interface Options { |
| | | comments: boolean |
| | | highlight: boolean |
| | | wikilinks: boolean |
| | | callouts: boolean |
| | | mermaid: boolean |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | comments: true, |
| | | highlight: true, |
| | | wikilinks: true, |
| | | callouts: true |
| | | callouts: true, |
| | | mermaid: true, |
| | | } |
| | | |
| | | const icons = { |
| | |
| | | bug: "bug", |
| | | example: "example", |
| | | quote: "quote", |
| | | cite: "quote" |
| | | cite: "quote", |
| | | } |
| | | |
| | | return calloutMapping[callout] |
| | |
| | | } |
| | | |
| | | const capitalize = (s: string): string => { |
| | | return s.substring(0, 1).toUpperCase() + s.substring(1); |
| | | return s.substring(0, 1).toUpperCase() + s.substring(1) |
| | | } |
| | | |
| | | export class ObsidianFlavoredMarkdown extends QuartzTransformerPlugin { |
| | | name = "ObsidianFlavoredMarkdown" |
| | | opts: Options |
| | | // Match wikilinks |
| | | // !? -> optional embedding |
| | | // \[\[ -> open brace |
| | | // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) |
| | | // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) |
| | | // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) |
| | | const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") |
| | | |
| | | constructor(opts?: Options) { |
| | | super() |
| | | this.opts = { ...defaultOptions, ...opts } |
| | | } |
| | | // Match highlights |
| | | const highlightRegex = new RegExp(/==(.+)==/, "g") |
| | | |
| | | markdownPlugins(): PluggableList { |
| | | const plugins: PluggableList = [] |
| | | // Match comments |
| | | const commentRegex = new RegExp(/%%(.+)%%/, "g") |
| | | |
| | | if (this.opts.wikilinks) { |
| | | plugins.push(() => { |
| | | // Match wikilinks |
| | | // !? -> optional embedding |
| | | // \[\[ -> open brace |
| | | // ([^\[\]\|\#]+) -> one or more non-special characters ([,],|, or #) (name) |
| | | // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) |
| | | // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) |
| | | const backlinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, backlinkRegex, (value: string, ...capture: string[]) => { |
| | | if (value.startsWith("!")) { |
| | | // TODO: handle embeds |
| | | } else { |
| | | const [path, rawHeader, rawAlias] = capture |
| | | const anchor = rawHeader?.trim() ?? "" |
| | | const alias = rawAlias?.slice(1).trim() ?? path |
| | | const url = slugify(path.trim() + anchor) |
| | | return { |
| | | type: 'link', |
| | | url, |
| | | children: [{ |
| | | type: 'text', |
| | | value: alias |
| | | }] |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts |
| | | const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) |
| | | |
| | | export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( |
| | | userOpts, |
| | | ) => { |
| | | const opts = { ...defaultOptions, ...userOpts } |
| | | return { |
| | | name: "ObsidianFlavoredMarkdown", |
| | | textTransform(src) { |
| | | // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) |
| | | if (opts.wikilinks) { |
| | | src = src.toString() |
| | | return src.replaceAll(wikilinkRegex, (value, ...capture) => { |
| | | const [fp, rawHeader, rawAlias] = capture |
| | | const anchor = rawHeader?.trim().slice(1) |
| | | const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : "" |
| | | const displayAlias = rawAlias ?? "" |
| | | const embedDisplay = value.startsWith("!") ? "!" : "" |
| | | return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` |
| | | }) |
| | | } |
| | | ) |
| | | } |
| | | return src |
| | | }, |
| | | markdownPlugins() { |
| | | const plugins: PluggableList = [] |
| | | if (opts.wikilinks) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => { |
| | | let [fp, rawHeader, rawAlias] = capture |
| | | fp = fp.trim() |
| | | const anchor = rawHeader?.trim() ?? "" |
| | | const alias = rawAlias?.slice(1).trim() |
| | | |
| | | if (this.opts.highlight) { |
| | | plugins.push(() => { |
| | | // Match highlights |
| | | const highlightRegex = new RegExp(/==(.+)==/, "g") |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => { |
| | | const [inner] = capture |
| | | return { |
| | | type: 'html', |
| | | value: `<span class="text-highlight">${inner}</span>` |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | // embed cases |
| | | if (value.startsWith("!")) { |
| | | const ext: string | undefined = path.extname(fp).toLowerCase() |
| | | const url = slugifyFilePath(fp as FilePath) + ext |
| | | if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) { |
| | | const dims = alias ?? "" |
| | | let [width, height] = dims.split("x", 2) |
| | | width ||= "auto" |
| | | height ||= "auto" |
| | | return { |
| | | type: "image", |
| | | url, |
| | | data: { |
| | | hProperties: { |
| | | width, |
| | | height, |
| | | }, |
| | | }, |
| | | } |
| | | } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { |
| | | return { |
| | | type: "html", |
| | | value: `<video src="${url}" controls></video>`, |
| | | } |
| | | } else if ( |
| | | [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) |
| | | ) { |
| | | return { |
| | | type: "html", |
| | | value: `<audio src="${url}" controls></audio>`, |
| | | } |
| | | } else if ([".pdf"].includes(ext)) { |
| | | return { |
| | | type: "html", |
| | | value: `<iframe src="${url}"></iframe>`, |
| | | } |
| | | } else { |
| | | // TODO: this is the node embed case |
| | | } |
| | | // otherwise, fall through to regular link |
| | | } |
| | | |
| | | if (this.opts.callouts) { |
| | | plugins.push(() => { |
| | | // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts |
| | | const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) |
| | | return (tree: Root, _file) => { |
| | | visit(tree, "blockquote", (node) => { |
| | | if (node.children.length === 0) { |
| | | return |
| | | } |
| | | // internal link |
| | | // const url = transformInternalLink(fp + anchor) |
| | | const url = fp + anchor |
| | | return { |
| | | type: "link", |
| | | url, |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: alias ?? fp, |
| | | }, |
| | | ], |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | // find first line |
| | | const firstChild = node.children[0] |
| | | if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { |
| | | return |
| | | } |
| | | |
| | | const text = firstChild.children[0].value |
| | | const [firstLine, ...remainingLines] = text.split("\n") |
| | | const remainingText = remainingLines.join("\n") |
| | | |
| | | const match = firstLine.match(calloutRegex) |
| | | if (match && match.input) { |
| | | const [calloutDirective, typeString, collapseChar] = match |
| | | const calloutType = typeString.toLowerCase() as keyof typeof callouts |
| | | const collapse = collapseChar === "+" || collapseChar === "-" |
| | | const defaultState = collapseChar === "-" ? "collapsed" : "expanded" |
| | | const title = match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) |
| | | |
| | | const titleNode: HTML = { |
| | | if (opts.highlight) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => { |
| | | const [inner] = capture |
| | | return { |
| | | type: "html", |
| | | value: `<div |
| | | value: `<span class="text-highlight">${inner}</span>`, |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | if (opts.comments) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => { |
| | | return { |
| | | type: "text", |
| | | value: "", |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | if (opts.callouts) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | visit(tree, "blockquote", (node) => { |
| | | if (node.children.length === 0) { |
| | | return |
| | | } |
| | | |
| | | // find first line |
| | | const firstChild = node.children[0] |
| | | if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { |
| | | return |
| | | } |
| | | |
| | | const text = firstChild.children[0].value |
| | | const restChildren = firstChild.children.splice(1) |
| | | const [firstLine, ...remainingLines] = text.split("\n") |
| | | const remainingText = remainingLines.join("\n") |
| | | |
| | | const match = firstLine.match(calloutRegex) |
| | | if (match && match.input) { |
| | | const [calloutDirective, typeString, collapseChar] = match |
| | | const calloutType = typeString.toLowerCase() as keyof typeof callouts |
| | | const collapse = collapseChar === "+" || collapseChar === "-" |
| | | const defaultState = collapseChar === "-" ? "collapsed" : "expanded" |
| | | const title = |
| | | match.input.slice(calloutDirective.length).trim() || capitalize(calloutType) |
| | | |
| | | const toggleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold"> |
| | | <polyline points="6 9 12 15 18 9"></polyline> |
| | | </svg>` |
| | | |
| | | const titleNode: HTML = { |
| | | type: "html", |
| | | value: `<div |
| | | class="callout-title" |
| | | > |
| | | <div class="callout-icon">${callouts[canonicalizeCallout(calloutType)]}</div> |
| | | <div class="callout-title-inner">${title}</div> |
| | | </div>` |
| | | } |
| | | ${collapse ? toggleIcon : ""} |
| | | </div>`, |
| | | } |
| | | |
| | | const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode] |
| | | if (remainingText.length > 0) { |
| | | blockquoteContent.push({ |
| | | type: 'paragraph', |
| | | children: [{ |
| | | type: 'text', |
| | | value: remainingText, |
| | | }] |
| | | const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleNode] |
| | | if (remainingText.length > 0) { |
| | | blockquoteContent.push({ |
| | | type: "paragraph", |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: remainingText, |
| | | }, |
| | | ...restChildren, |
| | | ], |
| | | }) |
| | | } |
| | | |
| | | }) |
| | | } |
| | | // replace first line of blockquote with title and rest of the paragraph text |
| | | node.children.splice(0, 1, ...blockquoteContent) |
| | | |
| | | // replace first line of blockquote with title and rest of the paragraph text |
| | | node.children.splice(0, 1, ...blockquoteContent) |
| | | |
| | | // add properties to base blockquote |
| | | node.data = { |
| | | hProperties: { |
| | | ...(node.data?.hProperties ?? {}), |
| | | className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : ""}`, |
| | | "data-callout": calloutType, |
| | | "data-callout-fold": collapse, |
| | | // add properties to base blockquote |
| | | node.data = { |
| | | hProperties: { |
| | | ...(node.data?.hProperties ?? {}), |
| | | className: `callout ${collapse ? "is-collapsible" : ""} ${ |
| | | defaultState === "collapsed" ? "is-collapsed" : "" |
| | | }`, |
| | | "data-callout": calloutType, |
| | | "data-callout-fold": collapse, |
| | | }, |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | return plugins |
| | | } |
| | | if (opts.mermaid) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | visit(tree, "code", (node: Code) => { |
| | | if (node.lang === "mermaid") { |
| | | node.data = { |
| | | hProperties: { |
| | | className: "mermaid", |
| | | }, |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | htmlPlugins(): PluggableList { |
| | | return [rehypeRaw] |
| | | return plugins |
| | | }, |
| | | htmlPlugins() { |
| | | return [rehypeRaw] |
| | | }, |
| | | externalResources() { |
| | | const js: JSResource[] = [] |
| | | |
| | | if (opts.callouts) { |
| | | js.push({ |
| | | script: calloutScript, |
| | | loadTime: "afterDOMReady", |
| | | contentType: "inline", |
| | | }) |
| | | } |
| | | |
| | | if (opts.mermaid) { |
| | | js.push({ |
| | | script: ` |
| | | import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; |
| | | mermaid.initialize({ startOnLoad: true }); |
| | | `, |
| | | loadTime: "afterDOMReady", |
| | | moduleType: "module", |
| | | contentType: "inline", |
| | | }) |
| | | } |
| | | |
| | | return { js } |
| | | }, |
| | | } |
| | | } |