From 535233fec8ba40cd34e7d18e0735e3d2aa3cd3f8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 15 Mar 2025 11:58:11 +0200 Subject: [PATCH] feat(import/markdown): basic support for admonitions --- src/services/export/markdown.ts | 27 ++++++++++++++------------- src/services/import/markdown.spec.ts | 27 +++++++++++++++++++++++++++ src/services/import/markdown.ts | 20 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/services/export/markdown.ts b/src/services/export/markdown.ts index da0e104a7..a52517873 100644 --- a/src/services/export/markdown.ts +++ b/src/services/export/markdown.ts @@ -5,6 +5,17 @@ import turndownPluginGfm from "@joplin/turndown-plugin-gfm"; let instance: TurndownService | null = null; +// TODO: Move this to a dedicated file someday. +export const ADMONITION_TYPE_MAPPINGS: Record = { + note: "NOTE", + tip: "TIP", + important: "IMPORTANT", + caution: "CAUTION", + warning: "WARNING" +}; + +export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note; + const fencedCodeBlockFilter: TurndownService.Rule = { filter: function (node, options) { return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE"; @@ -102,19 +113,9 @@ function buildImageFilter() { } function buildAdmonitionFilter() { - const admonitionTypeMappings: Record = { - note: "NOTE", - tip: "TIP", - important: "IMPORTANT", - caution: "CAUTION", - warning: "WARNING" - }; - - const defaultAdmonitionType = admonitionTypeMappings.note; - function parseAdmonitionType(_node: Node) { if (!("getAttribute" in _node)) { - return defaultAdmonitionType; + return DEFAULT_ADMONITION_TYPE; } const node = _node as Element; @@ -125,13 +126,13 @@ function buildAdmonitionFilter() { continue; } - const mappedType = admonitionTypeMappings[className]; + const mappedType = ADMONITION_TYPE_MAPPINGS[className]; if (mappedType) { return mappedType; } } - return defaultAdmonitionType; + return DEFAULT_ADMONITION_TYPE; } const admonitionFilter: TurndownService.Rule = { diff --git a/src/services/import/markdown.spec.ts b/src/services/import/markdown.spec.ts index 9c22667cd..dc382b896 100644 --- a/src/services/import/markdown.spec.ts +++ b/src/services/import/markdown.spec.ts @@ -74,4 +74,31 @@ second line 2`; 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 = `

Before

After

`; + expect(markdownService.renderToHtml(input, "Title")).toBe(expected); + }); + }); diff --git a/src/services/import/markdown.ts b/src/services/import/markdown.ts index f64ce295a..e42846404 100644 --- a/src/services/import/markdown.ts +++ b/src/services/import/markdown.ts @@ -3,6 +3,7 @@ import { parse, Renderer, type Tokens } from "marked"; 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 }); renderer.code = ({ text, lang, escaped }: Tokens.Code) => { if (!text) { @@ -12,10 +13,29 @@ renderer.code = ({ text, lang, escaped }: Tokens.Code) => { const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang); return `
${text}
`; }; +renderer.blockquote = ({ tokens }: Tokens.Blockquote) => { + const body = renderer.parser.parse(tokens); + + const admonitionMatch = /^

\[\!([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(/^

\[\!([A-Z]+)\]/, "

") + .replace(/^

<\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove. + + return `

\n`; + } + } + + return `
\n${body}
\n`; +}; import htmlSanitizer from "../html_sanitizer.js"; import importUtils from "./utils.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) { let html = parse(content, {