feat(import/markdown): basic support for admonitions

This commit is contained in:
Elian Doran 2025-03-15 11:58:11 +02:00
parent 6d67e69e2f
commit 535233fec8
No known key found for this signature in database
3 changed files with 61 additions and 13 deletions

View File

@ -5,6 +5,17 @@ import turndownPluginGfm from "@joplin/turndown-plugin-gfm";
let instance: TurndownService | null = null; let instance: TurndownService | null = null;
// TODO: Move this to a dedicated file someday.
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;
const fencedCodeBlockFilter: TurndownService.Rule = { const fencedCodeBlockFilter: TurndownService.Rule = {
filter: function (node, options) { filter: function (node, options) {
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE"; return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE";
@ -102,19 +113,9 @@ function buildImageFilter() {
} }
function buildAdmonitionFilter() { function buildAdmonitionFilter() {
const admonitionTypeMappings: Record<string, string> = {
note: "NOTE",
tip: "TIP",
important: "IMPORTANT",
caution: "CAUTION",
warning: "WARNING"
};
const defaultAdmonitionType = admonitionTypeMappings.note;
function parseAdmonitionType(_node: Node) { function parseAdmonitionType(_node: Node) {
if (!("getAttribute" in _node)) { if (!("getAttribute" in _node)) {
return defaultAdmonitionType; return DEFAULT_ADMONITION_TYPE;
} }
const node = _node as Element; const node = _node as Element;
@ -125,13 +126,13 @@ function buildAdmonitionFilter() {
continue; continue;
} }
const mappedType = admonitionTypeMappings[className]; const mappedType = ADMONITION_TYPE_MAPPINGS[className];
if (mappedType) { if (mappedType) {
return mappedType; return mappedType;
} }
} }
return defaultAdmonitionType; return DEFAULT_ADMONITION_TYPE;
} }
const admonitionFilter: TurndownService.Rule = { const admonitionFilter: TurndownService.Rule = {

View File

@ -74,4 +74,31 @@ second line 2</code></pre>`;
expect(markdownService.renderToHtml(input, "Troubleshooting")).toBe(expected); expect(markdownService.renderToHtml(input, "Troubleshooting")).toBe(expected);
}); });
it("imports admonitions properly", () => {
const space = " "; // editor config trimming space.
const input = trimIndentation`\
Before
> [!NOTE]
> This is a note.
> [!TIP]
> This is a tip.
> [!IMPORTANT]
> This is a very important information.
> [!CAUTION]
> This is a caution.
> [!WARNING]
> ## Title goes here
>${space}
> This is a warning.
After`;
const expected = `<p>Before</p><aside class="admonition note"><p>This is a note.</p></aside><aside class="admonition tip"><p>This is a tip.</p></aside><aside class="admonition important"><p>This is a very important information.</p></aside><aside class="admonition caution"><p>This is a caution.</p></aside><aside class="admonition warning"><h2>Title goes here</h2><p>This is a warning.</p></aside><p>After</p>`;
expect(markdownService.renderToHtml(input, "Title")).toBe(expected);
});
}); });

View File

@ -3,6 +3,7 @@
import { parse, Renderer, type Tokens } from "marked"; import { parse, Renderer, type Tokens } from "marked";
import { minify as minifyHtml } from "html-minifier"; import { minify as minifyHtml } from "html-minifier";
// Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
const renderer = new Renderer({ async: false }); const renderer = new Renderer({ async: false });
renderer.code = ({ text, lang, escaped }: Tokens.Code) => { renderer.code = ({ text, lang, escaped }: Tokens.Code) => {
if (!text) { if (!text) {
@ -12,10 +13,29 @@ renderer.code = ({ text, lang, escaped }: Tokens.Code) => {
const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang); const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang);
return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`; return `<pre><code class="language-${ckEditorLanguage}">${text}</code></pre>`;
}; };
renderer.blockquote = ({ tokens }: Tokens.Blockquote) => {
const body = renderer.parser.parse(tokens);
const admonitionMatch = /^<p>\[\!([A-Z]+)\]/.exec(body);
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
const type = admonitionMatch[1].toLowerCase();
if (ADMONITION_TYPE_MAPPINGS[type]) {
const bodyWithoutHeader = body
.replace(/^<p>\[\!([A-Z]+)\]/, "<p>")
.replace(/^<p><\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove.
return `<aside class="admonition ${type}">\n${bodyWithoutHeader}</aside>\n`;
}
}
return `<blockquote>\n${body}</blockquote>\n`;
};
import htmlSanitizer from "../html_sanitizer.js"; import htmlSanitizer from "../html_sanitizer.js";
import importUtils from "./utils.js"; import importUtils from "./utils.js";
import { getMimeTypeFromHighlightJs, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor } from "./mime_type_definitions.js"; import { getMimeTypeFromHighlightJs, MIME_TYPE_AUTO, normalizeMimeTypeForCKEditor } from "./mime_type_definitions.js";
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
function renderToHtml(content: string, title: string) { function renderToHtml(content: string, title: string) {
let html = parse(content, { let html = parse(content, {