| | |
| | | import { PluggableList } from "unified" |
| | | import { QuartzTransformerPlugin } from "../types" |
| | | import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" |
| | | import { Element, Literal } from "hast" |
| | | import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" |
| | | import { Root, Html, BlockContent, DefinitionContent, Paragraph, Code } from "mdast" |
| | | import { Element, Literal, Root as HtmlRoot } from "hast" |
| | | import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" |
| | | import { slug as slugAnchor } from "github-slugger" |
| | | import rehypeRaw from "rehype-raw" |
| | | import { visit } from "unist-util-visit" |
| | |
| | | import { toHast } from "mdast-util-to-hast" |
| | | import { toHtml } from "hast-util-to-html" |
| | | import { PhrasingContent } from "mdast-util-find-and-replace/lib" |
| | | import { capitalize } from "../../util/lang" |
| | | import { PluggableList } from "unified" |
| | | |
| | | export interface Options { |
| | | comments: boolean |
| | |
| | | return calloutMapping[callout] ?? "note" |
| | | } |
| | | |
| | | const capitalize = (s: string): string => { |
| | | return s.substring(0, 1).toUpperCase() + s.substring(1) |
| | | } |
| | | |
| | | // !? -> 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") |
| | | export const wikilinkRegex = new RegExp( |
| | | /!?\[\[([^\[\]\|\#]+)?(#+[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, |
| | | "g", |
| | | ) |
| | | const highlightRegex = new RegExp(/==([^=]+)==/, "g") |
| | | const commentRegex = new RegExp(/%%(.+)%%/, "g") |
| | | // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts |
| | |
| | | const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") |
| | | // (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line |
| | | // #(...) -> capturing group, tag itself must start with # |
| | | // (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores |
| | | // (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" |
| | | // (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores |
| | | // (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" |
| | | const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") |
| | | const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") |
| | | |
| | |
| | | return toHtml(hast, { allowDangerousHtml: true }) |
| | | } |
| | | |
| | | const findAndReplace = opts.enableInHtmlEmbed |
| | | ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { |
| | | if (replace) { |
| | | visit(tree, "html", (node: HTML) => { |
| | | if (typeof replace === "string") { |
| | | node.value = node.value.replace(regex, replace) |
| | | } else { |
| | | node.value = node.value.replaceAll(regex, (substring: string, ...args) => { |
| | | const replaceValue = replace(substring, ...args) |
| | | if (typeof replaceValue === "string") { |
| | | return replaceValue |
| | | } else if (Array.isArray(replaceValue)) { |
| | | return replaceValue.map(mdastToHtml).join("") |
| | | } else if (typeof replaceValue === "object" && replaceValue !== null) { |
| | | return mdastToHtml(replaceValue) |
| | | } else { |
| | | return substring |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | mdastFindReplace(tree, regex, replace) |
| | | } |
| | | : mdastFindReplace |
| | | |
| | | return { |
| | | name: "ObsidianFlavoredMarkdown", |
| | | textTransform(_ctx, src) { |
| | | // pre-transform blockquotes |
| | | if (opts.callouts) { |
| | | src = src.toString() |
| | | if (src instanceof Buffer) { |
| | | src = src.toString() |
| | | } |
| | | |
| | | src = src.replaceAll(calloutLineRegex, (value) => { |
| | | // force newline after title of callout |
| | | return value + "\n> " |
| | |
| | | |
| | | // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) |
| | | if (opts.wikilinks) { |
| | | src = src.toString() |
| | | if (src instanceof Buffer) { |
| | | src = src.toString() |
| | | } |
| | | |
| | | src = src.replaceAll(wikilinkRegex, (value, ...capture) => { |
| | | const [rawFp, rawHeader, rawAlias] = capture |
| | | const fp = rawFp ?? "" |
| | | const anchor = rawHeader?.trim().slice(1) |
| | | const displayAnchor = anchor ? `#${slugAnchor(anchor)}` : "" |
| | | const anchor = rawHeader?.trim().replace(/^#+/, "") |
| | | const blockRef = Boolean(anchor?.startsWith("^")) ? "^" : "" |
| | | const displayAnchor = anchor ? `#${blockRef}${slugAnchor(anchor)}` : "" |
| | | const displayAlias = rawAlias ?? rawHeader?.replace("#", "|") ?? "" |
| | | const embedDisplay = value.startsWith("!") ? "!" : "" |
| | | return `${embedDisplay}[[${fp}${displayAnchor}${displayAlias}]]` |
| | |
| | | }, |
| | | markdownPlugins() { |
| | | const plugins: PluggableList = [] |
| | | if (opts.wikilinks) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, wikilinkRegex, (value: string, ...capture: string[]) => { |
| | | let [rawFp, rawHeader, rawAlias] = capture |
| | | const fp = rawFp?.trim() ?? "" |
| | | const anchor = rawHeader?.trim() ?? "" |
| | | const alias = rawAlias?.slice(1).trim() |
| | | |
| | | // embed cases |
| | | if (value.startsWith("!")) { |
| | | const ext: string = path.extname(fp).toLowerCase() |
| | | const url = slugifyFilePath(fp as FilePath) |
| | | 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, |
| | | // regex replacements |
| | | plugins.push(() => { |
| | | return (tree: Root, file) => { |
| | | const replacements: [RegExp, string | ReplaceFunction][] = [] |
| | | const base = pathToRoot(file.data.slug!) |
| | | |
| | | if (opts.wikilinks) { |
| | | replacements.push([ |
| | | wikilinkRegex, |
| | | (value: string, ...capture: string[]) => { |
| | | let [rawFp, rawHeader, rawAlias] = capture |
| | | const fp = rawFp?.trim() ?? "" |
| | | const anchor = rawHeader?.trim() ?? "" |
| | | const alias = rawAlias?.slice(1).trim() |
| | | |
| | | // embed cases |
| | | if (value.startsWith("!")) { |
| | | const ext: string = path.extname(fp).toLowerCase() |
| | | const url = slugifyFilePath(fp as FilePath) |
| | | 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 if (ext === "") { |
| | | const block = anchor |
| | | return { |
| | | type: "html", |
| | | data: { hProperties: { transclude: true } }, |
| | | value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${ |
| | | url + anchor |
| | | }" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`, |
| | | } |
| | | } |
| | | } 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 if (ext === "") { |
| | | const block = anchor.slice(1) |
| | | return { |
| | | type: "html", |
| | | data: { hProperties: { transclude: true } }, |
| | | value: `<blockquote class="transclude" data-url="${url}" data-block="${block}"><a href="${ |
| | | url + anchor |
| | | }" class="transclude-inner">Transclude of block ${block}</a></blockquote>`, |
| | | } |
| | | |
| | | // otherwise, fall through to regular link |
| | | } |
| | | |
| | | // otherwise, fall through to regular link |
| | | } |
| | | // internal link |
| | | const url = fp + anchor |
| | | return { |
| | | type: "link", |
| | | url, |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: alias ?? fp, |
| | | }, |
| | | ], |
| | | } |
| | | }, |
| | | ]) |
| | | } |
| | | |
| | | // internal link |
| | | const url = fp + anchor |
| | | return { |
| | | type: "link", |
| | | url, |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: alias ?? fp, |
| | | if (opts.highlight) { |
| | | replacements.push([ |
| | | highlightRegex, |
| | | (_value: string, ...capture: string[]) => { |
| | | const [inner] = capture |
| | | return { |
| | | type: "html", |
| | | value: `<span class="text-highlight">${inner}</span>`, |
| | | } |
| | | }, |
| | | ]) |
| | | } |
| | | |
| | | if (opts.comments) { |
| | | replacements.push([ |
| | | commentRegex, |
| | | (_value: string, ..._capture: string[]) => { |
| | | return { |
| | | type: "text", |
| | | value: "", |
| | | } |
| | | }, |
| | | ]) |
| | | } |
| | | |
| | | if (opts.parseTags) { |
| | | replacements.push([ |
| | | tagRegex, |
| | | (_value: string, tag: string) => { |
| | | // Check if the tag only includes numbers |
| | | if (/^\d+$/.test(tag)) { |
| | | return false |
| | | } |
| | | |
| | | tag = slugTag(tag) |
| | | if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { |
| | | file.data.frontmatter.tags.push(tag) |
| | | } |
| | | |
| | | return { |
| | | type: "link", |
| | | url: base + `/tags/${tag}`, |
| | | data: { |
| | | hProperties: { |
| | | className: ["tag-link"], |
| | | }, |
| | | }, |
| | | ], |
| | | } |
| | | }) |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: `#${tag}`, |
| | | }, |
| | | ], |
| | | } |
| | | }, |
| | | ]) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | if (opts.highlight) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, highlightRegex, (_value: string, ...capture: string[]) => { |
| | | const [inner] = capture |
| | | return { |
| | | type: "html", |
| | | value: `<span class="text-highlight">${inner}</span>`, |
| | | if (opts.enableInHtmlEmbed) { |
| | | visit(tree, "html", (node: Html) => { |
| | | for (const [regex, replace] of replacements) { |
| | | if (typeof replace === "string") { |
| | | node.value = node.value.replace(regex, replace) |
| | | } else { |
| | | node.value = node.value.replaceAll(regex, (substring: string, ...args) => { |
| | | const replaceValue = replace(substring, ...args) |
| | | if (typeof replaceValue === "string") { |
| | | return replaceValue |
| | | } else if (Array.isArray(replaceValue)) { |
| | | return replaceValue.map(mdastToHtml).join("") |
| | | } else if (typeof replaceValue === "object" && replaceValue !== null) { |
| | | return mdastToHtml(replaceValue) |
| | | } else { |
| | | return substring |
| | | } |
| | | }) |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | if (opts.comments) { |
| | | plugins.push(() => { |
| | | return (tree: Root, _file) => { |
| | | findAndReplace(tree, commentRegex, (_value: string, ..._capture: string[]) => { |
| | | return { |
| | | type: "text", |
| | | value: "", |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | mdastFindReplace(tree, replacements) |
| | | } |
| | | }) |
| | | |
| | | if (opts.callouts) { |
| | | plugins.push(() => { |
| | |
| | | <polyline points="6 9 12 15 18 9"></polyline> |
| | | </svg>` |
| | | |
| | | const titleHtml: HTML = { |
| | | const titleHtml: Html = { |
| | | type: "html", |
| | | value: `<div |
| | | class="callout-title" |
| | |
| | | }) |
| | | } |
| | | |
| | | if (opts.parseTags) { |
| | | plugins.push(() => { |
| | | return (tree: Root, file) => { |
| | | const base = pathToRoot(file.data.slug!) |
| | | findAndReplace(tree, tagRegex, (_value: string, tag: string) => { |
| | | // Check if the tag only includes numbers |
| | | if (/^\d+$/.test(tag)) { |
| | | return false |
| | | } |
| | | tag = slugTag(tag) |
| | | if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { |
| | | file.data.frontmatter.tags.push(tag) |
| | | } |
| | | |
| | | return { |
| | | type: "link", |
| | | url: base + `/tags/${tag}`, |
| | | data: { |
| | | hProperties: { |
| | | className: ["tag-link"], |
| | | }, |
| | | }, |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value: `#${tag}`, |
| | | }, |
| | | ], |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | return plugins |
| | | }, |
| | | htmlPlugins() { |
| | | const plugins = [rehypeRaw] |
| | | const plugins: PluggableList = [rehypeRaw] |
| | | |
| | | if (opts.parseBlockReferences) { |
| | | plugins.push(() => { |
| | | const inlineTagTypes = new Set(["p", "li"]) |
| | | const blockTagTypes = new Set(["blockquote"]) |
| | | return (tree, file) => { |
| | | return (tree: HtmlRoot, file) => { |
| | | file.data.blocks = {} |
| | | |
| | | visit(tree, "element", (node, index, parent) => { |
| | |
| | | } |
| | | } |
| | | }) |
| | | |
| | | file.data.htmlAst = tree |
| | | } |
| | | }) |
| | | } |
| | |
| | | declare module "vfile" { |
| | | interface DataMap { |
| | | blocks: Record<string, Element> |
| | | htmlAst: HtmlRoot |
| | | } |
| | | } |