From aa01bc1457e120b9baf214b3a20e3627a243e554 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 12:44:30 +0200 Subject: [PATCH 1/8] feat(markdown): switch to turnish instead of turndown --- apps/server/package.json | 2 +- .../src/services/export/markdown.spec.ts | 19 ++++ apps/server/src/services/export/markdown.ts | 89 +++++++++---------- .../src/services/import/markdown.spec.ts | 5 ++ pnpm-lock.yaml | 75 +++++++--------- 5 files changed, 101 insertions(+), 89 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index bb099d98b..6b2c56ca7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -126,7 +126,7 @@ "swagger-jsdoc": "6.2.8", "time2fa": "1.4.2", "tmp": "0.2.5", - "turndown": "7.2.2", + "turnish": "1.7.1", "unescape": "1.0.1", "vite": "7.3.1", "ws": "8.19.0", diff --git a/apps/server/src/services/export/markdown.spec.ts b/apps/server/src/services/export/markdown.spec.ts index 4fa2913fc..c7370a84a 100644 --- a/apps/server/src/services/export/markdown.spec.ts +++ b/apps/server/src/services/export/markdown.spec.ts @@ -387,4 +387,23 @@ describe("Markdown export", () => { expect(markdownExportService.toMarkdown(html)).toBe(expected); }); + it("maintains escaped HTML tags", () => { + const html = /*html*/`

<div>Hello World</div>

`; + const expected = `\\Hello World\\`; + expect(markdownExportService.toMarkdown(html)).toBe(expected); + }); + + it("escapes HTML tags inside list", () => { + const html = trimIndentation/*html*/`\ + + `; + const expected = trimIndentation`\ + * \\ is note.`; + expect(markdownExportService.toMarkdown(html)).toBe(expected); + }); + }); diff --git a/apps/server/src/services/export/markdown.ts b/apps/server/src/services/export/markdown.ts index ed16c6b30..0d7a8deb4 100644 --- a/apps/server/src/services/export/markdown.ts +++ b/apps/server/src/services/export/markdown.ts @@ -1,9 +1,7 @@ -"use strict"; - -import TurndownService, { type Rule } from "turndown"; import { gfm } from "@triliumnext/turndown-plugin-gfm"; +import Turnish, { type Rule } from "turnish"; -let instance: TurndownService | null = null; +let instance: Turnish | null = null; // TODO: Move this to a dedicated file someday. export const ADMONITION_TYPE_MAPPINGS: Record = { @@ -16,12 +14,12 @@ export const ADMONITION_TYPE_MAPPINGS: Record = { export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note; -const fencedCodeBlockFilter: TurndownService.Rule = { - filter: function (node, options) { +const fencedCodeBlockFilter: Turnish.Rule = { + filter (node, options) { return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE"; }, - replacement: function (content, node, options) { + replacement (content, node, options) { if (!node.firstChild || !("getAttribute" in node.firstChild) || typeof node.firstChild.getAttribute !== "function") { return content; } @@ -29,14 +27,15 @@ const fencedCodeBlockFilter: TurndownService.Rule = { const className = node.firstChild.getAttribute("class") || ""; const language = rewriteLanguageTag((className.match(/language-(\S+)/) || [null, ""])[1]); - return "\n\n" + options.fence + language + "\n" + node.firstChild.textContent + "\n" + options.fence + "\n\n"; + return `\n\n${ options.fence }${language }\n${ node.firstChild.textContent }\n${ options.fence }\n\n`; } }; function toMarkdown(content: string) { if (instance === null) { - instance = new TurndownService({ + instance = new Turnish({ headingStyle: "atx", + bulletListMarker: "*", codeBlockStyle: "fenced", blankReplacement(content, node, options) { if (node.nodeName === "SECTION" && (node as HTMLElement).classList.contains("include-note")) { @@ -44,7 +43,7 @@ function toMarkdown(content: string) { } // Original implementation as per https://github.com/mixmark-io/turndown/blob/master/src/turndown.js. - return ("isBlock" in node && node.isBlock) ? '\n\n' : '' + return ("isBlock" in node && node.isBlock) ? '\n\n' : ''; } }); // Filter is heavily based on: https://github.com/mixmark-io/turndown/issues/274#issuecomment-458730974 @@ -59,7 +58,7 @@ function toMarkdown(content: string) { instance.keep([ "kbd", "sup", "sub" ]); } - return instance.turndown(content); + return instance.render(content); } function rewriteLanguageTag(source: string) { @@ -85,14 +84,14 @@ function buildImageFilter() { const ESCAPE_PATTERNS = { before: /([\\*`[\]_]|(?:^[-+>])|(?:^~~~)|(?:^#{1-6}))/g, after: /((?:^\d+(?=\.)))/ - } + }; - const escapePattern = new RegExp('(?:' + ESCAPE_PATTERNS.before.source + '|' + ESCAPE_PATTERNS.after.source + ')', 'g'); + const escapePattern = new RegExp(`(?:${ ESCAPE_PATTERNS.before.source }|${ ESCAPE_PATTERNS.after.source })`, 'g'); function escapeMarkdown (content: string) { - return content.replace(escapePattern, function (match, before, after) { - return before ? '\\' + before : after + '\\' - }) + return content.replace(escapePattern, (match, before, after) => { + return before ? `\\${ before}` : `${after }\\`; + }); } function escapeLinkDestination(destination: string) { @@ -102,10 +101,10 @@ function buildImageFilter() { } function escapeLinkTitle (title: string) { - return title.replace(/"/g, '\\"') + return title.replace(/"/g, '\\"'); } - const imageFilter: TurndownService.Rule = { + const imageFilter: Turnish.Rule = { filter: "img", replacement(content, _node) { const node = _node as HTMLElement; @@ -117,12 +116,12 @@ function buildImageFilter() { // TODO: Deduplicate with upstream. const untypedNode = (node as any); - const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt'))) - const src = escapeLinkDestination(untypedNode.getAttribute('src') || '') - const title = cleanAttribute(untypedNode.getAttribute('title')) - const titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : '' + const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt'))); + const src = escapeLinkDestination(untypedNode.getAttribute('src') || ''); + const title = cleanAttribute(untypedNode.getAttribute('title')); + const titlePart = title ? ` "${ escapeLinkTitle(title) }"` : ''; - return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '' + return src ? `![${ alt }]` + `(${ src }${titlePart })` : ''; } }; return imageFilter; @@ -151,7 +150,7 @@ function buildAdmonitionFilter() { return DEFAULT_ADMONITION_TYPE; } - const admonitionFilter: TurndownService.Rule = { + const admonitionFilter: Turnish.Rule = { filter(node, options) { return node.nodeName === "ASIDE" && node.classList.contains("admonition"); }, @@ -161,11 +160,11 @@ function buildAdmonitionFilter() { content = content.replace(/^\n+|\n+$/g, ''); content = content.replace(/^/gm, '> '); - content = `> [!${admonitionType}]\n` + content; + content = `> [!${admonitionType}]\n${ content}`; - return "\n\n" + content + "\n\n"; + return `\n\n${ content }\n\n`; } - } + }; return admonitionFilter; } @@ -178,15 +177,15 @@ function buildAdmonitionFilter() { */ function buildInlineLinkFilter(): Rule { return { - filter: function (node, options) { + filter (node, options) { return ( options.linkStyle === 'inlined' && node.nodeName === 'A' && !!node.getAttribute('href') - ) + ); }, - replacement: function (content, _node) { + replacement (content, _node) { const node = _node as HTMLElement; // Return reference links verbatim. @@ -196,13 +195,13 @@ function buildInlineLinkFilter(): Rule { // Otherwise treat as normal. // TODO: Call super() somehow instead of duplicating the implementation. - let href = node.getAttribute('href') - if (href) href = href.replace(/([()])/g, '\\$1') - let title = cleanAttribute(node.getAttribute('title')) - if (title) title = ' "' + title.replace(/"/g, '\\"') + '"' - return '[' + content + '](' + href + title + ')' + let href = node.getAttribute('href'); + if (href) href = href.replace(/([()])/g, '\\$1'); + let title = cleanAttribute(node.getAttribute('title')); + if (title) title = ` "${ title.replace(/"/g, '\\"') }"`; + return `[${ content }](${ href }${title })`; } - } + }; } function buildFigureFilter(): Rule { @@ -214,7 +213,7 @@ function buildFigureFilter(): Rule { replacement(content, node) { return (node as HTMLElement).outerHTML; } - } + }; } // Keep in line with https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js. @@ -224,13 +223,13 @@ function buildListItemFilter(): Rule { replacement(content, node, options) { content = content .trim() - .replace(/\n/gm, '\n ') // indent - let prefix = options.bulletListMarker + ' ' + .replace(/\n/gm, '\n '); // indent + let prefix = `${options.bulletListMarker } `; const parent = node.parentNode as HTMLElement; if (parent.nodeName === 'OL') { - var start = parent.getAttribute('start') - var index = Array.prototype.indexOf.call(parent.children, node) - prefix = (start ? Number(start) + index : index + 1) + '. ' + const start = parent.getAttribute('start'); + const index = Array.prototype.indexOf.call(parent.children, node); + prefix = `${start ? Number(start) + index : index + 1 }. `; } else if (parent.classList.contains("todo-list")) { const isChecked = node.querySelector("input[type=checkbox]:checked"); prefix = (isChecked ? "- [x] " : "- [ ] "); @@ -239,7 +238,7 @@ function buildListItemFilter(): Rule { const result = prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); return result; } - } + }; } function buildMathFilter(): Rule { @@ -270,13 +269,13 @@ function buildMathFilter(): Rule { // Unknown. return content; } - } + }; } // Taken from upstream since it's not exposed. // https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js function cleanAttribute(attribute: string | null | undefined) { - return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '' + return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''; } export default { diff --git a/apps/server/src/services/import/markdown.spec.ts b/apps/server/src/services/import/markdown.spec.ts index 1ac49f613..453db33b8 100644 --- a/apps/server/src/services/import/markdown.spec.ts +++ b/apps/server/src/services/import/markdown.spec.ts @@ -314,4 +314,9 @@ $$`; expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected); }); + it("doesn't unescape HTML in list", () => { + const input = `* <note> is note.`; + const expected = /*html*/`
  • <note> is note.
`; + expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be475fc6d..1c748bf83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -792,9 +792,9 @@ importers: tmp: specifier: 0.2.5 version: 0.2.5 - turndown: - specifier: 7.2.2 - version: 7.2.2 + turnish: + specifier: 1.7.1 + version: 1.7.1 unescape: specifier: 1.0.1 version: 1.0.1 @@ -1461,6 +1461,9 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -13466,6 +13469,9 @@ packages: turndown@7.2.2: resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} + turnish@1.7.1: + resolution: {integrity: sha512-NgyY7pIDABjKyg2isRgZyFPav6tOyvmqpTx3HROsKrOaE3JccP4C1P2IhAtkAZ8DkQb/O1R7HOFAkxY8uaJmcQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -14407,6 +14413,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -15078,6 +15086,8 @@ snapshots: '@ckeditor/ckeditor5-core': 47.4.0 '@ckeditor/ckeditor5-upload': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-ai@47.4.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -15218,12 +15228,16 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 '@ckeditor/ckeditor5-widget': 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-cloud-services@47.4.0': dependencies: '@ckeditor/ckeditor5-core': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -15416,6 +15430,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-classic@47.4.0': dependencies: @@ -15425,6 +15441,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-decoupled@47.4.0': dependencies: @@ -15434,6 +15452,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-inline@47.4.0': dependencies: @@ -15467,8 +15487,6 @@ snapshots: '@ckeditor/ckeditor5-table': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-emoji@47.4.0': dependencies: @@ -15525,8 +15543,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-export-word@47.4.0': dependencies: @@ -15551,6 +15567,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-font@47.4.0': dependencies: @@ -15625,6 +15643,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-html-embed@47.4.0': dependencies: @@ -15670,8 +15690,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-import-word@47.4.0': dependencies: @@ -15684,8 +15702,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-indent@47.4.0': dependencies: @@ -15697,8 +15713,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-inspector@5.0.0': {} @@ -15708,8 +15722,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-line-height@47.4.0': dependencies: @@ -15734,8 +15746,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-list-multi-level@47.4.0': dependencies: @@ -15759,8 +15769,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-markdown-gfm@47.4.0': dependencies: @@ -15798,8 +15806,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-mention@47.4.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': dependencies: @@ -15809,8 +15815,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-merge-fields@47.4.0': dependencies: @@ -15823,8 +15827,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-minimap@47.4.0': dependencies: @@ -15833,8 +15835,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-operations-compressor@47.4.0': dependencies: @@ -15889,8 +15889,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-pagination@47.4.0': dependencies: @@ -15998,8 +15996,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-slash-command@47.4.0': dependencies: @@ -16012,8 +16008,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-source-editing-enhanced@47.4.0': dependencies: @@ -16061,8 +16055,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-table@47.4.0': dependencies: @@ -16075,8 +16067,6 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-template@47.4.0': dependencies: @@ -16187,8 +16177,6 @@ snapshots: '@ckeditor/ckeditor5-engine': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-widget@47.4.0': dependencies: @@ -16208,8 +16196,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@codemirror/autocomplete@6.18.6': dependencies: @@ -21676,8 +21662,6 @@ snapshots: ckeditor5-collaboration@47.4.0: dependencies: '@ckeditor/ckeditor5-collaboration-core': 47.4.0 - transitivePeerDependencies: - - supports-color ckeditor5-premium-features@47.4.0(bufferutil@4.0.9)(ckeditor5@47.4.0)(utf-8-validate@6.0.5): dependencies: @@ -30073,6 +30057,11 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 + turnish@1.7.1: + dependencies: + '@adobe/css-tools': 4.4.4 + '@mixmark-io/domino': 2.2.0 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From bfb6d975ff0130670e2509be954508ca72712e35 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 12:46:55 +0200 Subject: [PATCH 2/8] fix(export/markdown): type error due to blankReplacement signature change --- apps/server/src/services/export/markdown.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/export/markdown.ts b/apps/server/src/services/export/markdown.ts index 0d7a8deb4..93adb594e 100644 --- a/apps/server/src/services/export/markdown.ts +++ b/apps/server/src/services/export/markdown.ts @@ -37,14 +37,14 @@ function toMarkdown(content: string) { headingStyle: "atx", bulletListMarker: "*", codeBlockStyle: "fenced", - blankReplacement(content, node, options) { - if (node.nodeName === "SECTION" && (node as HTMLElement).classList.contains("include-note")) { - return (node as HTMLElement).outerHTML; + blankReplacement(_content, node) { + if (node.nodeName === "SECTION" && node.classList.contains("include-note")) { + return node.outerHTML; } // Original implementation as per https://github.com/mixmark-io/turndown/blob/master/src/turndown.js. return ("isBlock" in node && node.isBlock) ? '\n\n' : ''; - } + }, }); // Filter is heavily based on: https://github.com/mixmark-io/turndown/issues/274#issuecomment-458730974 instance.addRule("fencedCodeBlock", fencedCodeBlockFilter); From 51157e19791a894bc8e2d74562457f79d82a2d9e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 12:47:34 +0200 Subject: [PATCH 3/8] fix(export/markdown): error due to namespace usage --- apps/server/src/services/export/markdown.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/export/markdown.ts b/apps/server/src/services/export/markdown.ts index 93adb594e..4803de21d 100644 --- a/apps/server/src/services/export/markdown.ts +++ b/apps/server/src/services/export/markdown.ts @@ -14,7 +14,7 @@ export const ADMONITION_TYPE_MAPPINGS: Record = { export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note; -const fencedCodeBlockFilter: Turnish.Rule = { +const fencedCodeBlockFilter: Rule = { filter (node, options) { return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE"; }, @@ -104,7 +104,7 @@ function buildImageFilter() { return title.replace(/"/g, '\\"'); } - const imageFilter: Turnish.Rule = { + const imageFilter: Rule = { filter: "img", replacement(content, _node) { const node = _node as HTMLElement; @@ -150,7 +150,7 @@ function buildAdmonitionFilter() { return DEFAULT_ADMONITION_TYPE; } - const admonitionFilter: Turnish.Rule = { + const admonitionFilter: Rule = { filter(node, options) { return node.nodeName === "ASIDE" && node.classList.contains("admonition"); }, From 3aacd255f4abeb5443e2ac567e602d126b4c4118 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 12:58:24 +0200 Subject: [PATCH 4/8] chore(export/markdown): add test for jQuery-like text inside table --- .../src/services/export/markdown.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/server/src/services/export/markdown.spec.ts b/apps/server/src/services/export/markdown.spec.ts index c7370a84a..de8224c5a 100644 --- a/apps/server/src/services/export/markdown.spec.ts +++ b/apps/server/src/services/export/markdown.spec.ts @@ -406,4 +406,33 @@ describe("Markdown export", () => { expect(markdownExportService.toMarkdown(html)).toBe(expected); }); + it("exports jQuery code in table properly", () => { + const html = trimIndentation`\ +
+ + + + + + + + + + + +
+ Code +
+
+            this.$widget = $("<div>");
+                                
+
+
+ `; + const expected = trimIndentation`\ +
Code
this.$widget = $("<div>");
+                                
`; + expect(markdownExportService.toMarkdown(html)).toBe(expected); + }); + }); From 67cc1113b14a1efc5cb531ef1c803ab77a12f060 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 13:05:29 +0200 Subject: [PATCH 5/8] chore(export/markdown): render emphasis with underscore --- apps/server/src/services/export/markdown.spec.ts | 6 ++++++ apps/server/src/services/export/markdown.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/apps/server/src/services/export/markdown.spec.ts b/apps/server/src/services/export/markdown.spec.ts index de8224c5a..80827c2b1 100644 --- a/apps/server/src/services/export/markdown.spec.ts +++ b/apps/server/src/services/export/markdown.spec.ts @@ -435,4 +435,10 @@ describe("Markdown export", () => { expect(markdownExportService.toMarkdown(html)).toBe(expected); }); + it("renders underline with underscore", () => { + const html = /*html*/`

This is underlined text.

`; + const expected = `This is _underlined_ text.`; + expect(markdownExportService.toMarkdown(html)).toBe(expected); + }); + }); diff --git a/apps/server/src/services/export/markdown.ts b/apps/server/src/services/export/markdown.ts index 4803de21d..7c90143e7 100644 --- a/apps/server/src/services/export/markdown.ts +++ b/apps/server/src/services/export/markdown.ts @@ -36,6 +36,7 @@ function toMarkdown(content: string) { instance = new Turnish({ headingStyle: "atx", bulletListMarker: "*", + emDelimiter: "_", codeBlockStyle: "fenced", blankReplacement(_content, node) { if (node.nodeName === "SECTION" && node.classList.contains("include-note")) { From 0c9c20c0c5900c0568a465a5d5e1a6e3049cbf3a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 13:11:53 +0200 Subject: [PATCH 6/8] docs(user): fix escapes --- .../Import & Export/Evernote.html | 66 +++++++++---------- .../Developer Guide/Documentation.md | 2 +- docs/Release Notes/Release Notes/v0.101.2.md | 2 +- docs/User Guide/!!!meta.json | 28 ++++---- .../Notes/Sorting Notes.md | 2 +- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote.html index 439cdab93..30a170a9c 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Basic Concepts and Features/Import & Export/Evernote.html @@ -1,55 +1,53 @@ -

- Trilium can import ENEX files, which are used by Evernote for backup/export. +

Trilium can import ENEX files, which are used by Evernote for backup/export. One ENEX file represents the content (notes and resources) of one notebook.

Export ENEX from Evernote

To export ENEX files from Evernote, you can use:

    -
  • Evernote desktop application. See Evernote documentation. Note that +
  • Evernote desktop application. See Evernote documentation. Note that the limitation of this method is that you can only export 100 notes at a time or one notebook at a time.
  • -
  • A third-party evernote-backup CLI tool. This tool can export all +
  • A third-party evernote-backup CLI tool. This tool can export all of your notebooks in bulk.

Import ENEX in Trilium

Once you have your ENEX files, do the following to import them in Trilium:

    -
  1. In the Trilium note tree, right-click the note under which you want to +
  2. In the Trilium note tree, right-click the note under which you want to import one or more of your ENEX files. The notes in the files will be imported as child notes of the selected note.
  3. -
  4. Click Import into note.
  5. -
  6. Choose your ENEX file or files and click Import.
  7. -
  8. During the import, you will see "Import in progress" message. If the import +
  9. Click Import into note.
  10. +
  11. Choose your ENEX file or files and click Import.
  12. +
  13. During the import, you will see "Import in progress" message. If the import is successful, the message will change to “Import finished successfully” and then disappear.
  14. -
  15. We recommend you to check the imported notes and their attachments to +
  16. We recommend you to check the imported notes and their attachments to verify that you haven’t lost any data.

A non-exhaustive list of what the importer preserves:

    -
  • Attachments
  • -
  • The hierarchy of headings (these are shifted to start with H2 because +
  • Attachments
  • +
  • The hierarchy of headings (these are shifted to start with H2 because H1 is reserved for note title, see Headings)
  • -
  • Tables
  • -
  • Bulleted lists
  • -
  • Numbered lists
  • -
  • Bold
  • -
  • Italics
  • -
  • Strikethrough
  • -
  • Highlights
  • -
  • Font colors
  • -
  • Soft line breaks
  • -
  • External links
  • +
  • Tables
  • +
  • Bulleted lists
  • +
  • Numbered lists
  • +
  • Bold
  • +
  • Italics
  • +
  • Strikethrough
  • +
  • Highlights
  • +
  • Font colors
  • +
  • Soft line breaks
  • +
  • External links

However, we do not guarantee that all of your formatting will be imported 100% correctly.

Limitations

    -
  • The size limit of one import is 250Mb. If the total size of your files +
  • The size limit of one import is 250Mb. If the total size of your files is larger, you can increase the upload limit, or divide your files, and run the import as many times as necessary.
  • -
  • All resources (except for images) are created as notes’ attachments.
  • -
  • If you have HTML inside ENEX files, the HTML formatting may be broken +
  • All resources (except for images) are created as notes’ attachments.
  • +
  • If you have HTML inside ENEX files, the HTML formatting may be broken or lost after import in Trilium. You can report major problems at Trilium issue tracker.
@@ -59,24 +57,24 @@

If you want to restore the internal links in Trilium after you import all of your ENEX files, you can use or adapt this custom script:  Process internal links by title + class="reference-link" href="#root/_help_dj3j8dG4th4l">Process internal links by title

The script does the following:

    -
  1. It finds all Evernote internal links.
  2. -
  3. For each one, it checks if its link text matches a note title, and if +
  4. It finds all Evernote internal links.
  5. +
  6. For each one, it checks if its link text matches a note title, and if yes, it replaces the Evernote link with an internal Trilium link. If not, it leaves the Evernote link in place.
  7. -
  8. If it finds more than one note with a matching note title, it leaves the +
  9. If it finds more than one note with a matching note title, it leaves the Evernote link in place.
  10. -
  11. It outputs the results in a log that you can see in the respective code - note in Trilium. 
  12. +
  13. It outputs the results in a log that you can see in the respective code + note in Trilium.

The script has the following limitations:

    -
  • It will not fix links to anchors and links to notes that you renamed in +
  • It will not fix links to anchors and links to notes that you renamed in Evernote after you created the links.
  • -
  • Some note titles might not be well identified, even if they exist. This +
  • Some note titles might not be well identified, even if they exist. This is especially the case if the note title contains some special characters. - Should this be problematic, consider Reporting issues.
  • + Should this be problematic, consider Reporting issues.
\ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Documentation.md b/docs/Developer Guide/Developer Guide/Documentation.md index 4f833f93b..73e47ab6b 100644 --- a/docs/Developer Guide/Developer Guide/Documentation.md +++ b/docs/Developer Guide/Developer Guide/Documentation.md @@ -1,5 +1,5 @@ # Documentation -There are multiple types of documentation for Trilium: +There are multiple types of documentation for Trilium: * The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing F1. * The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers. diff --git a/docs/Release Notes/Release Notes/v0.101.2.md b/docs/Release Notes/Release Notes/v0.101.2.md index 6942b2e8e..d6baaabde 100644 --- a/docs/Release Notes/Release Notes/v0.101.2.md +++ b/docs/Release Notes/Release Notes/v0.101.2.md @@ -18,5 +18,5 @@ * [Max content width is not respected when switching between note types in the same tab](https://github.com/TriliumNext/Trilium/issues/8065) * [Crash When a Note Includes Itself](https://github.com/TriliumNext/Trilium/issues/8294) * [Severe Performance Degradation and Crash Issues Due to Recursive Inclusion in Included Notes](https://github.com/TriliumNext/Trilium/issues/8017) -* [ is not a launcher even though it's in the launcher subtree](https://github.com/TriliumNext/Trilium/issues/8218) +* [\ is not a launcher even though it's in the launcher subtree](https://github.com/TriliumNext/Trilium/issues/8218) * [Archived subnotes of direct children appear in grid view without #includeArchived](https://github.com/TriliumNext/Trilium/issues/8184) \ No newline at end of file diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json index 2c8eafa53..9965fbda6 100644 --- a/docs/User Guide/!!!meta.json +++ b/docs/User Guide/!!!meta.json @@ -6169,6 +6169,20 @@ "type": "text", "mime": "text/markdown", "attributes": [ + { + "type": "relation", + "name": "internalLink", + "value": "dj3j8dG4th4l", + "isInheritable": false, + "position": 10 + }, + { + "type": "relation", + "name": "internalLink", + "value": "wy8So3yZZlH9", + "isInheritable": false, + "position": 20 + }, { "type": "label", "name": "shareAlias", @@ -6182,20 +6196,6 @@ "value": "bx bx-window-open", "isInheritable": false, "position": 30 - }, - { - "type": "relation", - "name": "internalLink", - "value": "dj3j8dG4th4l", - "isInheritable": false, - "position": 40 - }, - { - "type": "relation", - "name": "internalLink", - "value": "wy8So3yZZlH9", - "isInheritable": false, - "position": 50 } ], "format": "markdown", diff --git a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes.md b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes.md index 539f42ac0..7519e7be4 100644 --- a/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes.md +++ b/docs/User Guide/User Guide/Basic Concepts and Features/Notes/Sorting Notes.md @@ -28,4 +28,4 @@ Sorting is done by comparing note properties or specific labels on child notes. * **Label Sorting**: If `#sorted` has any other value, this value is treated as the name of a child note's label, and sorting is based on the values of this label. For example, setting `#sorted=myOrder` on the parent note and using `#myOrder=001`, `#myOrder=002`, etc., on child notes. 4. **Alphabetical Sorting**: Used as a last resort when other criteria result in equality. -All comparisons are made string-wise (e.g., "1" < "2" or "2020-10-10" < "2021-01-15", but also "2" > "10"). \ No newline at end of file +All comparisons are made string-wise (e.g., "1" \< "2" or "2020-10-10" < "2021-01-15", but also "2" \> "10"). \ No newline at end of file From fabab6abb120eeb46f671c90ef0767a4238fae16 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 13:16:21 +0200 Subject: [PATCH 7/8] refactor(export/markdown): spacing issues --- apps/server/src/services/export/markdown.ts | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/server/src/services/export/markdown.ts b/apps/server/src/services/export/markdown.ts index 7c90143e7..35e08ee11 100644 --- a/apps/server/src/services/export/markdown.ts +++ b/apps/server/src/services/export/markdown.ts @@ -27,7 +27,7 @@ const fencedCodeBlockFilter: Rule = { const className = node.firstChild.getAttribute("class") || ""; const language = rewriteLanguageTag((className.match(/language-(\S+)/) || [null, ""])[1]); - return `\n\n${ options.fence }${language }\n${ node.firstChild.textContent }\n${ options.fence }\n\n`; + return `\n\n${options.fence}${language}\n${node.firstChild.textContent}\n${options.fence}\n\n`; } }; @@ -87,11 +87,11 @@ function buildImageFilter() { after: /((?:^\d+(?=\.)))/ }; - const escapePattern = new RegExp(`(?:${ ESCAPE_PATTERNS.before.source }|${ ESCAPE_PATTERNS.after.source })`, 'g'); + const escapePattern = new RegExp(`(?:${ESCAPE_PATTERNS.before.source}|${ESCAPE_PATTERNS.after.source})`, 'g'); function escapeMarkdown (content: string) { return content.replace(escapePattern, (match, before, after) => { - return before ? `\\${ before}` : `${after }\\`; + return before ? `\\${before}` : `${after}\\`; }); } @@ -120,9 +120,9 @@ function buildImageFilter() { const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt'))); const src = escapeLinkDestination(untypedNode.getAttribute('src') || ''); const title = cleanAttribute(untypedNode.getAttribute('title')); - const titlePart = title ? ` "${ escapeLinkTitle(title) }"` : ''; + const titlePart = title ? ` "${escapeLinkTitle(title)}"` : ''; - return src ? `![${ alt }]` + `(${ src }${titlePart })` : ''; + return src ? `![${alt}](${src}${titlePart})` : ''; } }; return imageFilter; @@ -161,9 +161,9 @@ function buildAdmonitionFilter() { content = content.replace(/^\n+|\n+$/g, ''); content = content.replace(/^/gm, '> '); - content = `> [!${admonitionType}]\n${ content}`; + content = `> [!${admonitionType}]\n${content}`; - return `\n\n${ content }\n\n`; + return `\n\n${content}\n\n`; } }; return admonitionFilter; @@ -199,8 +199,8 @@ function buildInlineLinkFilter(): Rule { let href = node.getAttribute('href'); if (href) href = href.replace(/([()])/g, '\\$1'); let title = cleanAttribute(node.getAttribute('title')); - if (title) title = ` "${ title.replace(/"/g, '\\"') }"`; - return `[${ content }](${ href }${title })`; + if (title) title = ` "${title.replace(/"/g, '\\"')}"`; + return `[${content}](${href}${title})`; } }; } @@ -225,12 +225,12 @@ function buildListItemFilter(): Rule { content = content .trim() .replace(/\n/gm, '\n '); // indent - let prefix = `${options.bulletListMarker } `; + let prefix = `${options.bulletListMarker} `; const parent = node.parentNode as HTMLElement; if (parent.nodeName === 'OL') { const start = parent.getAttribute('start'); const index = Array.prototype.indexOf.call(parent.children, node); - prefix = `${start ? Number(start) + index : index + 1 }. `; + prefix = `${start ? Number(start) + index : index + 1}. `; } else if (parent.classList.contains("todo-list")) { const isChecked = node.querySelector("input[type=checkbox]:checked"); prefix = (isChecked ? "- [x] " : "- [ ] "); From 5a7fc1c8b696e39c6dfdfa890c629f707b5c131d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 17 Jan 2026 13:22:24 +0200 Subject: [PATCH 8/8] chore(markdown): address requested changes --- apps/server/src/services/export/markdown.spec.ts | 2 +- apps/server/src/services/import/markdown.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/export/markdown.spec.ts b/apps/server/src/services/export/markdown.spec.ts index 80827c2b1..505d4b02d 100644 --- a/apps/server/src/services/export/markdown.spec.ts +++ b/apps/server/src/services/export/markdown.spec.ts @@ -435,7 +435,7 @@ describe("Markdown export", () => { expect(markdownExportService.toMarkdown(html)).toBe(expected); }); - it("renders underline with underscore", () => { + it("renders emphasis with underscore", () => { const html = /*html*/`

This is underlined text.

`; const expected = `This is _underlined_ text.`; expect(markdownExportService.toMarkdown(html)).toBe(expected); diff --git a/apps/server/src/services/import/markdown.spec.ts b/apps/server/src/services/import/markdown.spec.ts index 453db33b8..e692c7baa 100644 --- a/apps/server/src/services/import/markdown.spec.ts +++ b/apps/server/src/services/import/markdown.spec.ts @@ -314,7 +314,7 @@ $$`; expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected); }); - it("doesn't unescape HTML in list", () => { + it("preserves HTML entities in list", () => { const input = `* <note> is note.`; const expected = /*html*/`
  • <note> is note.
`; expect(markdownService.renderToHtml(input, "Title")).toStrictEqual(expected);