| | |
| | | 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 { 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" |
| | | import { JSResource } from "../../util/resources" |
| | | // @ts-ignore |
| | | import calloutScript from "../../components/scripts/callout.inline.ts" |
| | | import { FilePath, canonicalizeServer, pathToRoot, slugTag, slugifyFilePath } from "../../path" |
| | | import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path" |
| | | 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" |
| | | |
| | | export interface Options { |
| | | comments: boolean |
| | |
| | | callouts: boolean |
| | | mermaid: boolean |
| | | parseTags: boolean |
| | | parseBlockReferences: boolean |
| | | enableInHtmlEmbed: boolean |
| | | } |
| | | |
| | |
| | | callouts: true, |
| | | mermaid: true, |
| | | parseTags: true, |
| | | parseBlockReferences: true, |
| | | enableInHtmlEmbed: false, |
| | | } |
| | | |
| | |
| | | const calloutMapping: Record<string, keyof typeof callouts> = { |
| | | note: "note", |
| | | abstract: "abstract", |
| | | summary: "abstract", |
| | | tldr: "abstract", |
| | | info: "info", |
| | | todo: "todo", |
| | | tip: "tip", |
| | |
| | | |
| | | function canonicalizeCallout(calloutName: string): keyof typeof callouts { |
| | | let callout = calloutName.toLowerCase() as keyof typeof calloutMapping |
| | | return calloutMapping[callout] ?? calloutName |
| | | } |
| | | |
| | | const capitalize = (s: string): string => { |
| | | return s.substring(0, 1).toUpperCase() + s.substring(1) |
| | | return calloutMapping[callout] ?? "note" |
| | | } |
| | | |
| | | // !? -> optional embedding |
| | |
| | | // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) |
| | | // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) |
| | | const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") |
| | | const highlightRegex = 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 calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) |
| | | const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") |
| | | // (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line |
| | | // #(\w+) -> tag itself is # followed by a string of alpha-numeric characters |
| | | const tagRegex = new RegExp(/(?:^| )#([\w-_\/]+)/, "g") |
| | | // (?:^| ) -> 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 "/" |
| | | const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") |
| | | const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") |
| | | |
| | | export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options> | undefined> = ( |
| | | userOpts, |
| | |
| | | const hast = toHast(ast, { allowDangerousHtml: true })! |
| | | return toHtml(hast, { allowDangerousHtml: true }) |
| | | } |
| | | |
| | | const findAndReplace = opts.enableInHtmlEmbed |
| | | ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { |
| | | if (replace) { |
| | |
| | | // embed cases |
| | | if (value.startsWith("!")) { |
| | | const ext: string = path.extname(fp).toLowerCase() |
| | | const url = slugifyFilePath(fp as FilePath) + ext |
| | | 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) |
| | |
| | | value: `<iframe src="${url}"></iframe>`, |
| | | } |
| | | } else if (ext === "") { |
| | | // TODO: note embed |
| | | 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 |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | const text = firstChild.children[0].value |
| | | const restChildren = firstChild.children.splice(1) |
| | | const restChildren = firstChild.children.slice(1) |
| | | const [firstLine, ...remainingLines] = text.split("\n") |
| | | const remainingText = remainingLines.join("\n") |
| | | |
| | |
| | | |
| | | const titleHtml: HTML = { |
| | | type: "html", |
| | | value: `<div |
| | | value: `<div |
| | | class="callout-title" |
| | | > |
| | | <div class="callout-icon">${callouts[calloutType]}</div> |
| | |
| | | if (opts.parseTags) { |
| | | plugins.push(() => { |
| | | return (tree: Root, file) => { |
| | | const slug = canonicalizeServer(file.data.slug!) |
| | | const base = pathToRoot(slug) |
| | | findAndReplace(tree, tagRegex, (value: string, tag: string) => { |
| | | if (file.data.frontmatter) { |
| | | 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/${slugTag(tag)}`, |
| | | url: base + `/tags/${tag}`, |
| | | data: { |
| | | hProperties: { |
| | | className: ["tag-link"], |
| | |
| | | children: [ |
| | | { |
| | | type: "text", |
| | | value, |
| | | value: `#${tag}`, |
| | | }, |
| | | ], |
| | | } |
| | |
| | | } |
| | | }) |
| | | } |
| | | |
| | | return plugins |
| | | }, |
| | | htmlPlugins() { |
| | | return [rehypeRaw] |
| | | const plugins = [rehypeRaw] |
| | | |
| | | if (opts.parseBlockReferences) { |
| | | plugins.push(() => { |
| | | const inlineTagTypes = new Set(["p", "li"]) |
| | | const blockTagTypes = new Set(["blockquote"]) |
| | | return (tree, file) => { |
| | | file.data.blocks = {} |
| | | |
| | | visit(tree, "element", (node, index, parent) => { |
| | | if (blockTagTypes.has(node.tagName)) { |
| | | const nextChild = parent?.children.at(index! + 2) as Element |
| | | if (nextChild && nextChild.tagName === "p") { |
| | | const text = nextChild.children.at(0) as Literal |
| | | if (text && text.value && text.type === "text") { |
| | | const matches = text.value.match(blockReferenceRegex) |
| | | if (matches && matches.length >= 1) { |
| | | parent!.children.splice(index! + 2, 1) |
| | | const block = matches[0].slice(1) |
| | | |
| | | if (!Object.keys(file.data.blocks!).includes(block)) { |
| | | node.properties = { |
| | | ...node.properties, |
| | | id: block, |
| | | } |
| | | file.data.blocks![block] = node |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } else if (inlineTagTypes.has(node.tagName)) { |
| | | const last = node.children.at(-1) as Literal |
| | | if (last && last.value && typeof last.value === "string") { |
| | | const matches = last.value.match(blockReferenceRegex) |
| | | if (matches && matches.length >= 1) { |
| | | last.value = last.value.slice(0, -matches[0].length) |
| | | const block = matches[0].slice(1) |
| | | |
| | | if (!Object.keys(file.data.blocks!).includes(block)) { |
| | | node.properties = { |
| | | ...node.properties, |
| | | id: block, |
| | | } |
| | | file.data.blocks![block] = node |
| | | } |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | return plugins |
| | | }, |
| | | externalResources() { |
| | | const js: JSResource[] = [] |
| | |
| | | script: ` |
| | | import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; |
| | | const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' |
| | | mermaid.initialize({ |
| | | mermaid.initialize({ |
| | | startOnLoad: false, |
| | | securityLevel: 'loose', |
| | | theme: darkMode ? 'dark' : 'default' |
| | |
| | | }, |
| | | } |
| | | } |
| | | |
| | | declare module "vfile" { |
| | | interface DataMap { |
| | | blocks: Record<string, Element> |
| | | } |
| | | } |