diff --git a/quartz/plugins/parsers/custom/default.ts b/quartz/plugins/parsers/custom/default.ts index b7a0cfdb0..6a05b6bba 100644 --- a/quartz/plugins/parsers/custom/default.ts +++ b/quartz/plugins/parsers/custom/default.ts @@ -2,7 +2,7 @@ import { QuartzParserPlugin } from "../../types" import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { JSResource } from "../../../util/resources" import { Root } from "mdast" -import { PluggableList } from "unified" +import { Pluggable } from "unified" interface Options { enabled: Boolean @@ -23,17 +23,17 @@ export const CustomDefault: QuartzParserPlugin> = (userOpts) => return src }, markdownPlugins(_ctx) { - return [ - (tree: Root) => { - if (opts.enabled) { - const replacements: [RegExp, string | ReplaceFunction][] = [] - mdastFindReplace(tree, replacements) - } - }, - ] as PluggableList + const plug: Pluggable = (tree: Root, _file) => { + if (opts.enabled) { + const replacements: [RegExp, string | ReplaceFunction][] = [] + mdastFindReplace(tree, replacements) + } + } + return plug }, htmlPlugins(_ctx) { - return [] as PluggableList + const plug: Pluggable = () => {} + return plug }, externalResources(_ctx) { const js = [] as JSResource[] diff --git a/quartz/plugins/parsers/obsidian/arrows.ts b/quartz/plugins/parsers/obsidian/arrows.ts index e61bfe677..93cd48668 100644 --- a/quartz/plugins/parsers/obsidian/arrows.ts +++ b/quartz/plugins/parsers/obsidian/arrows.ts @@ -3,7 +3,7 @@ import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util- import { JSResource } from "../../../util/resources" import { SKIP } from "unist-util-visit" import { Root } from "mdast" -import { PluggableList } from "unified" +import { Pluggable } from "unified" interface Options { enabled: Boolean @@ -37,28 +37,28 @@ export const ObsidianArrow: QuartzParserPlugin> = (userOpts) => return src }, markdownPlugins(_ctx) { - return [ - (tree: Root) => { - if (opts.enabled) { - const replacements: [RegExp, string | ReplaceFunction][] = [] - replacements.push([ - arrowRegex, - (value: string, ..._capture: string[]) => { - const maybeArrow = arrowMapping[value] - if (maybeArrow === undefined) return SKIP - return { - type: "html", - value: `${maybeArrow}`, - } - }, - ]) - mdastFindReplace(tree, replacements) - } - }, - ] as PluggableList + const plug: Pluggable = (tree: Root, _path) => { + if (opts.enabled) { + const replacements: [RegExp, string | ReplaceFunction][] = [] + replacements.push([ + arrowRegex, + (value: string, ..._capture: string[]) => { + const maybeArrow = arrowMapping[value] + if (maybeArrow === undefined) return SKIP + return { + type: "html", + value: `${maybeArrow}`, + } + }, + ]) + mdastFindReplace(tree, replacements) + } + } + return plug }, htmlPlugins(_ctx) { - return [] as PluggableList + const plug: Pluggable = () => {} + return plug }, externalResources(_ctx) { const js = [] as JSResource[] diff --git a/quartz/plugins/parsers/obsidian/callouts.ts b/quartz/plugins/parsers/obsidian/callouts.ts index e69de29bb..3b80ec729 100644 --- a/quartz/plugins/parsers/obsidian/callouts.ts +++ b/quartz/plugins/parsers/obsidian/callouts.ts @@ -0,0 +1,203 @@ +import { QuartzParserPlugin } from "../../types" +import { JSResource } from "../../../util/resources" +import { Root, BlockContent, DefinitionContent, Paragraph, Html } from "mdast" +import { visit } from "unist-util-visit" +import { Pluggable } from "unified" +// @ts-ignore +import calloutScript from "../../components/scripts/callout.inline.ts" +import { PhrasingContent } from "mdast-util-find-and-replace/lib" +import { capitalize } from "../../../util/lang" +import { toHast } from "mdast-util-to-hast" +import { toHtml } from "hast-util-to-html" + +interface Options { + enabled: Boolean +} + +const defaultOptions: Options = { + enabled: true, +} + +// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts +const calloutRegex = new RegExp(/^\[\!(\w+)\|?(.+?)?\]([+-]?)/) +const calloutLineRegex = new RegExp(/^> *\[\!\w+\|?.*?\][+-]?.*$/gm) + +const calloutMapping = { + note: "note", + abstract: "abstract", + summary: "abstract", + tldr: "abstract", + info: "info", + todo: "todo", + tip: "tip", + hint: "tip", + important: "tip", + success: "success", + check: "success", + done: "success", + question: "question", + help: "question", + faq: "question", + warning: "warning", + attention: "warning", + caution: "warning", + failure: "failure", + missing: "failure", + fail: "failure", + danger: "danger", + error: "danger", + bug: "bug", + example: "example", + quote: "quote", + cite: "quote", +} as const + +function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping { + const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping + // if callout is not recognized, make it a custom one + return calloutMapping[normalizedCallout] ?? calloutName +} + +const mdastToHtml = (ast: PhrasingContent | Paragraph) => { + const hast = toHast(ast, { allowDangerousHtml: true })! + return toHtml(hast, { allowDangerousHtml: true }) +} + +export const ObsidianCallouts: QuartzParserPlugin> = (userOpts) => { + const opts: Options = { ...defaultOptions, ...userOpts } + return { + name: "ObsidianCallouts", + textTransform(_ctx, src: string | Buffer) { + if (src instanceof Buffer) { + src = src.toString() + } + + src = src.replace(calloutLineRegex, (value) => { + // force newline after title of callout + return value + "\n> " + }) + + return src + }, + markdownPlugins(_ctx) { + const plug: Pluggable = (tree: Root, _path) => { + if (opts.enabled) { + visit(tree, "blockquote", (node) => { + if (node.children.length === 0) { + return + } + + // find first line and callout content + const [firstChild, ...calloutContent] = node.children + if (firstChild.type !== "paragraph" || firstChild.children[0]?.type !== "text") { + return + } + + const text = firstChild.children[0].value + const restOfTitle = firstChild.children.slice(1) + const [firstLine, ...remainingLines] = text.split("\n") + const remainingText = remainingLines.join("\n") + + const match = firstLine.match(calloutRegex) + if (match && match.input) { + const [calloutDirective, typeString, calloutMetaData, collapseChar] = match + const calloutType = canonicalizeCallout(typeString.toLowerCase()) + const collapse = collapseChar === "+" || collapseChar === "-" + const defaultState = collapseChar === "-" ? "collapsed" : "expanded" + const titleContent = match.input.slice(calloutDirective.length).trim() + const useDefaultTitle = titleContent === "" && restOfTitle.length === 0 + const titleNode: Paragraph = { + type: "paragraph", + children: [ + { + type: "text", + value: useDefaultTitle ? capitalize(typeString) : titleContent + " ", + }, + ...restOfTitle, + ], + } + const title = mdastToHtml(titleNode) + + const toggleIcon = `
` + + const titleHtml: Html = { + type: "html", + value: `
+
+
${title}
+ ${collapse ? toggleIcon : ""} +
`, + } + + const blockquoteContent: (BlockContent | DefinitionContent)[] = [titleHtml] + if (remainingText.length > 0) { + blockquoteContent.push({ + type: "paragraph", + children: [ + { + type: "text", + value: remainingText, + }, + ], + }) + } + + // replace first line of blockquote with title and rest of the paragraph text + node.children.splice(0, 1, ...blockquoteContent) + + const classNames = ["callout", calloutType] + if (collapse) { + classNames.push("is-collapsible") + } + if (defaultState === "collapsed") { + classNames.push("is-collapsed") + } + + // add properties to base blockquote + node.data = { + hProperties: { + ...(node.data?.hProperties ?? {}), + className: classNames.join(" "), + "data-callout": calloutType, + "data-callout-fold": collapse, + "data-callout-metadata": calloutMetaData, + }, + } + + // Add callout-content class to callout body if it has one. + if (calloutContent.length > 0) { + const contentData: BlockContent | DefinitionContent = { + data: { + hProperties: { + className: "callout-content", + }, + hName: "div", + }, + type: "blockquote", + children: [...calloutContent], + } + node.children = [node.children[0], contentData] + } + } + }) + } + } + return plug + }, + htmlPlugins(_ctx) { + const plug: Pluggable = () => {} + return plug + }, + externalResources(_ctx) { + const js = [] as JSResource[] + js.push({ + script: calloutScript, + loadTime: "afterDOMReady", + contentType: "inline", + }) + return { js } + }, + } +} diff --git a/quartz/plugins/parsers/obsidian/index.ts b/quartz/plugins/parsers/obsidian/index.ts index f8c383be1..7df56c7e6 100644 --- a/quartz/plugins/parsers/obsidian/index.ts +++ b/quartz/plugins/parsers/obsidian/index.ts @@ -1,3 +1,4 @@ export { ObsidianArrow } from "./arrows" +export { ObsidianCallouts } from "./callouts" export { ObsidianHighlights } from "./highlights" export { ObsidianWikilinks } from "./wikilinks" diff --git a/quartz/plugins/parsers/obsidian/wikilinks.ts b/quartz/plugins/parsers/obsidian/wikilinks.ts index c44f34c33..f6b427ac1 100644 --- a/quartz/plugins/parsers/obsidian/wikilinks.ts +++ b/quartz/plugins/parsers/obsidian/wikilinks.ts @@ -3,7 +3,7 @@ import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util- import { FilePath, splitAnchor, slugifyFilePath } from "../../../util/path" import { JSResource } from "../../../util/resources" import { Root } from "mdast" -import { PluggableList } from "unified" +import { Pluggable } from "unified" interface Options { enabled: Boolean @@ -79,90 +79,90 @@ export const ObsidianWikilinks: QuartzParserPlugin> = (userOpts return src }, markdownPlugins(_ctx) { - return [ - (tree: Root, path) => { - if (opts.enabled) { - const replacements: [RegExp, string | ReplaceFunction][] = [] - 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() + const plug: Pluggable = (tree: Root, path) => { + if (opts.enabled) { + const replacements: [RegExp, string | ReplaceFunction][] = [] + 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", ".webp"].includes(ext)) { - const match = wikilinkImageEmbedRegex.exec(alias ?? "") - const alt = match?.groups?.alt ?? "" - const width = match?.groups?.width ?? "auto" - const height = match?.groups?.height ?? "auto" - return { - type: "image", - url, - data: { - hProperties: { - width, - height, - alt, - }, + // 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", ".webp"].includes(ext)) { + const match = wikilinkImageEmbedRegex.exec(alias ?? "") + const alt = match?.groups?.alt ?? "" + const width = match?.groups?.width ?? "auto" + const height = match?.groups?.height ?? "auto" + return { + type: "image", + url, + data: { + hProperties: { + width, + height, + alt, }, - } - } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else if ( - [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) - ) { - return { - type: "html", - value: ``, - } - } else if ([".pdf"].includes(ext)) { - return { - type: "html", - value: ``, - } - } else { - const block = anchor - return { - type: "html", - data: { hProperties: { transclude: true } }, - value: `
Transclude of ${url}${block}
`, - } - } - - // otherwise, fall through to regular link - } - - // internal link - const url = fp + anchor - return { - type: "link", - url, - children: [ - { - type: "text", - value: alias ?? fp, }, - ], + } + } else if ([".mp4", ".webm", ".ogv", ".mov", ".mkv"].includes(ext)) { + return { + type: "html", + value: ``, + } + } else if ( + [".mp3", ".webm", ".wav", ".m4a", ".ogg", ".3gp", ".flac"].includes(ext) + ) { + return { + type: "html", + value: ``, + } + } else if ([".pdf"].includes(ext)) { + return { + type: "html", + value: ``, + } + } else { + const block = anchor + return { + type: "html", + data: { hProperties: { transclude: true } }, + value: `
Transclude of ${url}${block}
`, + } } - }, - ]) - mdastFindReplace(tree, replacements) - } - }, - ] as PluggableList + + // otherwise, fall through to regular link + } + + // internal link + const url = fp + anchor + return { + type: "link", + url, + children: [ + { + type: "text", + value: alias ?? fp, + }, + ], + } + }, + ]) + mdastFindReplace(tree, replacements) + } + } + return plug }, htmlPlugins(_ctx) { - return [] as PluggableList + const plug: Pluggable = () => {} + return plug }, externalResources(_ctx) { const js = [] as JSResource[] diff --git a/quartz/plugins/transformers/markdown.ts b/quartz/plugins/transformers/markdown.ts index da0006303..942580e0c 100644 --- a/quartz/plugins/transformers/markdown.ts +++ b/quartz/plugins/transformers/markdown.ts @@ -35,7 +35,12 @@ import smartypants from "remark-smartypants" import rehypeSlug from "rehype-slug" import rehypeAutolinkHeadings from "rehype-autolink-headings" -import { ObsidianArrow, ObsidianHighlights, ObsidianWikilinks } from "../parsers/obsidian" +import { + ObsidianArrow, + ObsidianCallouts, + ObsidianHighlights, + ObsidianWikilinks, +} from "../parsers/obsidian" export interface CommonMarkOptions { option1: Boolean @@ -173,14 +178,15 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin { + /*plugins.push(() => { return (tree: Root, file) => { //const replacements: [RegExp, string | ReplaceFunction][] = [] //const base = pathToRoot(file.data.slug!) @@ -193,7 +199,11 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin