Obsidian Parser (Callouts)

This commit is contained in:
Emile Bangma 2024-09-19 12:28:32 +00:00
parent 81d892ffcc
commit 2818bb1c70
6 changed files with 328 additions and 114 deletions

View File

@ -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<Partial<Options>> = (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[]

View File

@ -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<Partial<Options>> = (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: `<span>${maybeArrow}</span>`,
}
},
])
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: `<span>${maybeArrow}</span>`,
}
},
])
mdastFindReplace(tree, replacements)
}
}
return plug
},
htmlPlugins(_ctx) {
return [] as PluggableList
const plug: Pluggable = () => {}
return plug
},
externalResources(_ctx) {
const js = [] as JSResource[]

View File

@ -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<Partial<Options>> = (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 = `<div class="fold-callout-icon"></div>`
const titleHtml: Html = {
type: "html",
value: `<div
class="callout-title"
>
<div class="callout-icon"></div>
<div class="callout-title-inner">${title}</div>
${collapse ? toggleIcon : ""}
</div>`,
}
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 }
},
}
}

View File

@ -1,3 +1,4 @@
export { ObsidianArrow } from "./arrows"
export { ObsidianCallouts } from "./callouts"
export { ObsidianHighlights } from "./highlights"
export { ObsidianWikilinks } from "./wikilinks"

View File

@ -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<Partial<Options>> = (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: `<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}" class="pdf"></iframe>`,
}
} else {
const block = anchor
return {
type: "html",
data: { hProperties: { transclude: true } },
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
url + anchor
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
}
}
// 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: `<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}" class="pdf"></iframe>`,
}
} else {
const block = anchor
return {
type: "html",
data: { hProperties: { transclude: true } },
value: `<blockquote class="transclude" data-url="${url}" data-block="${block}" data-embed-alias="${alias}"><a href="${
url + anchor
}" class="transclude-inner">Transclude of ${url}${block}</a></blockquote>`,
}
}
},
])
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[]

View File

@ -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<Partial<ObsidianO
return {
name: "ObsidianFlavoredMarkdown",
textTransform(ctx, src) {
ObsidianWikilinks({ enabled: opts.wikilinks }).textTransform(ctx, src)
src = ObsidianCallouts({ enabled: opts.callouts }).textTransform(ctx, src)
src = ObsidianWikilinks({ enabled: opts.wikilinks }).textTransform(ctx, src)
return src
},
markdownPlugins(ctx) {
const plugins: PluggableList = []
plugins.push(() => {
/*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<Partial<ObsidianO
//mdastFindReplace(tree, replacements)
}
})
})*/
plugins.push(ObsidianWikilinks({ enabled: opts.wikilinks }).markdownPlugins(ctx))
plugins.push(ObsidianHighlights({ enabled: opts.highlight }).markdownPlugins(ctx))
plugins.push(ObsidianArrow({ enabled: opts.parseArrows }).markdownPlugins(ctx))
plugins.push(ObsidianCallouts({ enabled: opts.callouts }).markdownPlugins(ctx))
return plugins
},