Changes
6 changed files (+488/-1)
-
-
@@ -15,6 +15,7 @@ import { ofmWikilink } from "./obsidian_markdown/micromark_extension_ofm_wikilink.ts";import { ofmWikilinkFromMarkdown } from "./obsidian_markdown/mdast_util_ofm_wikilink.ts"; import { macanaMarkAssets } from "./obsidian_markdown/mdast_util_macana_mark_assets.ts"; import { macanaMarkDocumentToken } from "./obsidian_markdown/mdast_util_macana_mark_document_token.ts"; import { ofmCalloutFromMarkdown } from "./obsidian_markdown/mdast_util_ofm_callout.ts"; import type { ContentParser,
-
@@ -82,6 +83,7 @@ mdastExtensions: [gfmFromMarkdown(), ofmHighlightFromMarkdown(), ofmWikilinkFromMarkdown(), ofmCalloutFromMarkdown(), ], });
-
-
-
@@ -0,0 +1,163 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 import { assertObjectMatch } from "../../deps/deno.land/std/assert/mod.ts"; import { fromMarkdown } from "../../deps/esm.sh/mdast-util-from-markdown/mod.ts"; import { ofmCalloutFromMarkdown } from "./mdast_util_ofm_callout.ts"; Deno.test("Should parse callout", () => { const mdast = fromMarkdown(`> [!info]\n> Block **content**`, { mdastExtensions: [ofmCalloutFromMarkdown()], }); assertObjectMatch(mdast, { type: "root", children: [ { type: "ofmCallout", calloutType: "info", isFoldable: false, title: [], children: [ { type: "paragraph", children: [ { type: "text", value: "Block ", }, { type: "strong", children: [ { type: "text", value: "content", }, ], }, ], }, ], }, ], }); }); Deno.test("Should parse foldable callout", () => { const mdast = fromMarkdown(`> [!danger]-\n> Foo`, { mdastExtensions: [ofmCalloutFromMarkdown()], }); assertObjectMatch(mdast, { type: "root", children: [ { type: "ofmCallout", calloutType: "danger", isFoldable: true, defaultExpanded: false, title: [], children: [ { type: "paragraph", children: [ { type: "text", value: "Foo", }, ], }, ], }, ], }); }); Deno.test("Should parse default expanded callout", () => { const mdast = fromMarkdown(`> [!oops]+\n> Foo`, { mdastExtensions: [ofmCalloutFromMarkdown()], }); assertObjectMatch(mdast, { type: "root", children: [ { type: "ofmCallout", calloutType: "oops", isFoldable: true, defaultExpanded: true, title: [], children: [ { type: "paragraph", children: [ { type: "text", value: "Foo", }, ], }, ], }, ], }); }); Deno.test("Should parse title", () => { const mdast = fromMarkdown(`> [!todo]- [Foo](https://example.com)\n> Foo`, { mdastExtensions: [ofmCalloutFromMarkdown()], }); assertObjectMatch(mdast, { type: "root", children: [ { type: "ofmCallout", calloutType: "todo", isFoldable: true, defaultExpanded: false, title: [ { type: "link", url: "https://example.com", children: [ { type: "text", value: "Foo", }, ], }, ], children: [ { type: "paragraph", children: [ { type: "text", value: "Foo", }, ], }, ], }, ], }); }); Deno.test("Should not touch non-callout blockquotes", () => { const mdast = fromMarkdown(`> info\n> Block **content**`, { mdastExtensions: [ofmCalloutFromMarkdown()], }); assertObjectMatch(mdast, { type: "root", children: [ { type: "blockquote", }, ], }); });
-
-
-
@@ -0,0 +1,189 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 import type * as Mdast from "../../deps/esm.sh/mdast/types.ts"; import type { Extension } from "../../deps/esm.sh/mdast-util-from-markdown/mod.ts"; import { SKIP, visit } from "../../deps/esm.sh/unist-util-visit/mod.ts"; export interface OfmCallout extends Mdast.Node { type: "ofmCallout"; calloutType: string; isFoldable: boolean; defaultExpanded?: boolean; title: Mdast.PhrasingContent[]; children: (Mdast.BlockContent | Mdast.DefinitionContent)[]; } const PATTERN_REGEXP = /\[!(\S+)]([-+]?)(\s(.*))?$/; function splitNodeAtFirstNewline<Node extends Mdast.Nodes>( node: Node, ): (readonly [Node, Node]) | null { if (node.type === "text") { const idx = node.value.indexOf("\n"); if (idx < 0) { return null; } const left: Mdast.Text = { type: "text", data: node.data, position: node.position && { start: node.position.start, end: { line: node.position.start.line, column: node.position.start.column + idx, }, }, value: node.value.slice(0, idx), }; const right: Mdast.Text = { type: "text", data: node.data, position: node.position && { start: { line: node.position.start.line + 1, column: 0, }, end: node.position.end, }, value: node.value.slice(idx + 1, node.value.length), }; return [ // @ts-expect-error: TypeScript cannot narrow return type when its generic. // https://github.com/microsoft/TypeScript/issues/23132 // https://github.com/microsoft/TypeScript/issues/33912 left, // @ts-expect-error: TypeScript cannot narrow return type when its generic. // https://github.com/microsoft/TypeScript/issues/23132 // https://github.com/microsoft/TypeScript/issues/33912 right, ]; } if (!("children" in node)) { return null; } for (let i = 0, l = node.children.length; i < l; i++) { const result = splitNodeAtFirstNewline(node.children[i]); if (!result) { continue; } return [ { ...node, children: [ ...node.children.slice(0, i), result[0], ], }, { ...node, children: [ result[1], ...node.children.slice(i + 1), ], }, ]; } return null; } function replace(node: Mdast.Blockquote): OfmCallout | null { if (!node.children.length) { return null; } const [head, ...rest] = node.children; if (head.type !== "paragraph" || !head.children.length) { return null; } const splitted = splitNodeAtFirstNewline(head) ?? [head, null]; const [titleHead, ...titleRest] = splitted[0].children; if (titleHead.type !== "text") { return null; } const match = titleHead.value.match(PATTERN_REGEXP); if (!match) { return null; } const [, type, expandSymbol, , titleTextRest] = match; return { type: "ofmCallout", calloutType: type || "", isFoldable: !!expandSymbol, defaultExpanded: typeof expandSymbol === "string" ? expandSymbol === "+" : undefined, title: [ titleTextRest ? { type: "text", value: (titleTextRest || ""), } satisfies Mdast.Text : null, ...titleRest, ].filter((t): t is NonNullable<typeof t> => !!t), children: [ ...(splitted[1] ? [splitted[1]] : []), ...rest, ], }; } /** * Extension for Callout extension from Obsidian Flavored Markdown. * This adds "ofmCallout" node. * * ```markdown * > [!info] * > Callout block * * > [!check]- title * > Default collapsed callout block * ``` */ export function ofmCalloutFromMarkdown(): Extension { return { transforms: [ (root) => { visit( root, (node) => node.type === "blockquote", (node, index, parent) => { if (!parent || typeof index !== "number") { // Root node, unreachable return; } const replaced = replace(node as Mdast.Blockquote); if (!replaced) { return SKIP; } // @ts-expect-error: unist-related libraries heavily relies on ambiend module declarations, // which Deno does not support. APIs also don't accept type parameters. parent.children[index] = replaced; }, ); }, ], }; }
-
-
-
@@ -0,0 +1,122 @@They seem to do find-and-replace, hence many strange behavior. Macana does not do the same parsing as it's quite unintuitive and AST-unfriendly. ## Basics ```markdown > [!info]+ Title > Body ``` > [!info]+ Title > Body ## Escapes ```markdown > \[!info]+ Title > Body ``` > \[!info]+ Title > Body ```markdown > [!info]\+ Title > Body ``` > [!info]\+ Title > Body ```markdown \> [!info]+ Title > Body ``` \> [!info]+ Title > Body ## Edge cases ### No whitespace before the marker ```markdown >[!info]+ Title >Body ``` >[!info]+ Title >Body ### Phrasing semantics continues from title ```markdown > [!info]+ **Bold _Italic > Body, Italic_ Bold** ``` > [!info]+ **Bold _Italic > Body, Italic_ Bold** ### Sequential markers ```markdown > [!info]+ Title > [!info]+ Title > Body ``` > [!info]+ Title > [!info]+ Title > Body ### Whitespace in title ```markdown > [! info ]+ Title > Body ``` > [! info ]+ Title > Body ### Whitespace before exclamation mark ```markdown > [ !info]+ Title > Body ``` > [ !info]+ Title > Body ### Whitespace before foldable sign Seriously? ```markdown > [!info] + Title > Body ``` > [!info] + Title > Body My guess is they incorrectly re-parse the rest of the first paragraph as block content, like this: ```markdown + Title\nBody ``` + Title Body Block quote works fine. ```markdown > + Title > Body ``` > + Title > Body
-
-
-
@@ -5,4 +5,5 @@ However, as neither of format spec nor test cases exist, we need to *see* how it works by actually writing down and displaying it inside Obsidian.Most of the unit tests are picked up from this folder. - [[Highlight extension]] - [[Highlight extension]] - [[Callout extension]]
-
-
-
@@ -27,6 +27,16 @@ ```==Hello, World!== ## Callout extension ```markdown > [!info]+ Title, [[GitHub Flavored Markdown]] > Both title and body can contain Markdown. ``` > [!info]+ Title, [[GitHub Flavored Markdown]] > Both title and body can contain Markdown. ## Image size attribute OFM abuses image `alt` slot for size specifier.
-