diff --git a/package.json b/package.json index 7d1678b..095edf2 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "nanoid": "^5.1.16", "node-diff3": "^3.2.1", "nodemailer": "^9.0.3", + "sanitize-html": "^2.17.7", "svelte-dnd-action": "^0.9.77", "tiptap-markdown": "^0.9.0", "uuid": "^11.1.1", @@ -74,6 +75,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/markdown-it": "^14.1.2", "@types/nodemailer": "^8.0.1", + "@types/sanitize-html": "^2.16.1", "@types/uuid": "^10.0.0", "@types/zxcvbn": "^4.4.5", "@vite-pwa/sveltekit": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 082b8a2..84d416a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: nodemailer: specifier: ^9.0.3 version: 9.0.3 + sanitize-html: + specifier: ^2.17.7 + version: 2.17.7 svelte-dnd-action: specifier: ^0.9.77 version: 0.9.77(svelte@5.56.8) @@ -132,6 +135,9 @@ importers: '@types/nodemailer': specifier: ^8.0.1 version: 8.0.1 + '@types/sanitize-html': + specifier: ^2.16.1 + version: 2.16.1 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -1754,6 +1760,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2119,6 +2128,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2174,6 +2186,35 @@ packages: devalue@5.9.0: resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2309,6 +2350,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2364,6 +2409,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + eslint-visitor-keys@4.2.1: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2622,6 +2671,13 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2732,6 +2788,10 @@ packages: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} + is-plain-object@5.1.0: + resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2904,6 +2964,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -3213,6 +3276,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3486,6 +3552,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-html@2.17.7: + resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==} + engines: {node: '>=22.12.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -5681,6 +5751,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + '@types/trusted-types@2.0.7': {} '@types/uuid@10.0.0': {} @@ -6079,6 +6153,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.23: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6119,6 +6195,42 @@ snapshots: devalue@5.9.0: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -6164,6 +6276,8 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + entities@8.0.0: {} es-abstract-get@1.0.0: @@ -6320,6 +6434,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + eslint-visitor-keys@4.2.1: {} esm-env@1.2.2: {} @@ -6598,6 +6714,20 @@ snapshots: html-escaper@2.0.2: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -6705,6 +6835,8 @@ snapshots: is-obj@1.0.1: {} + is-plain-object@5.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -6896,6 +7028,10 @@ snapshots: kolorist@1.8.0: {} + launder@1.7.1: + dependencies: + dayjs: 1.11.23 + leven@3.1.0: {} lightningcss-android-arm64@1.32.0: @@ -7169,6 +7305,8 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-srcset@1.0.2: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -7518,6 +7656,16 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-html@2.17.7: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.1.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.25 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 diff --git a/src/lib/components/tiptap/Table.test.ts b/src/lib/components/tiptap/Table.test.ts index 14ce383..346fd00 100644 --- a/src/lib/components/tiptap/Table.test.ts +++ b/src/lib/components/tiptap/Table.test.ts @@ -7,67 +7,83 @@ import { TableHeader } from '@tiptap/extension-table-header'; import { TableCell } from '@tiptap/extension-table-cell'; import { Markdown } from 'tiptap-markdown'; import { Table } from './Table.js'; +import { renderMarkdown } from '$lib/utils/markdown.js'; + +function createEditor(content: string | object, markdownOptions?: { html?: boolean }) { + return new Editor({ + extensions: [ + StarterKit, + Table.configure({ resizable: false }), + TableRow, + TableHeader, + TableCell, + Markdown.configure(markdownOptions ?? {}) + ], + content + }); +} + +function getMarkdown(editor: Editor): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (editor.storage as Record).markdown.getMarkdown(); +} + +function docWithListInCell(): JSONContent { + return { + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }] + }, + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] } + ] + } + ] + }, + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }] + } + ] + } + ] + } + ] + }; +} // crumbs#83: a bulleted list inside a table cell survives in the live editor // (never touches markdown) but breaks once the note is saved and reopened, // because saving/reopening round-trips the doc through markdown text. describe('Table markdown round-trip with a bullet list in a cell', () => { - function createEditor(content: string | object) { - return new Editor({ - extensions: [StarterKit, Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, Markdown], - content - }); - } - it('preserves a bullet list inside a table cell after saving and reopening', () => { - const editor = createEditor({ - type: 'doc', - content: [ - { - type: 'table', - content: [ - { - type: 'tableRow', - content: [ - { - type: 'tableHeader', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }] - }, - { - type: 'tableHeader', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }] - } - ] - }, - { - type: 'tableRow', - content: [ - { - type: 'tableCell', - content: [ - { - type: 'bulletList', - content: [ - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] }, - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] } - ] - } - ] - }, - { - type: 'tableCell', - content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }] - } - ] - } - ] - } - ] - }); + const editor = createEditor(docWithListInCell()); // "Save": serialize the crumb to markdown, the way onUpdate() does. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const savedMarkdown = (editor.storage as Record).markdown.getMarkdown(); + const savedMarkdown = getMarkdown(editor); // "Reopen": load that markdown into a fresh editor, the way opening the // crumb again does (content is passed in as a markdown string). @@ -90,4 +106,59 @@ describe('Table markdown round-trip with a bullet list in a cell', () => { expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell'); }); + + it('still renders as a real table (not escaped HTML) on the read-only preview/share path', () => { + const editor = createEditor(docWithListInCell()); + const savedMarkdown = getMarkdown(editor); + + const html = renderMarkdown(savedMarkdown); + + expect(html).toContain(''); + expect(html).toContain(' { + const editor = createEditor({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }] }, + { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }] } + ] + }, + { + type: 'tableRow', + content: [ + { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1' }] }] }, + { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2' }] }] } + ] + } + ] + } + ] + }); + + const savedMarkdown = getMarkdown(editor); + + expect(savedMarkdown).not.toContain(' { + const editor = createEditor(docWithListInCell(), { html: false }); + + const savedMarkdown = getMarkdown(editor); + + expect(savedMarkdown).not.toContain(' nodes.push(child)); @@ -59,7 +63,25 @@ function formatBlock(html: string): string { return element.outerHTML; } -function serializeAsHTML(state: MarkdownSerializerState, node: ProseMirrorNode, parent: ProseMirrorNode | Fragment) { +/** + * Same as tiptap-markdown's HTMLNode fallback: when the editor's `html` + * option is off, other node types degrade to a `[nodename]` placeholder + * instead of emitting HTML. A table with non-inline cell content has to + * follow the same rule, or it'd be the only node type still leaking raw HTML + * with html mode disabled. + */ +function serializeAsHTML( + context: SerializeContext, + state: MarkdownSerializerState, + node: ProseMirrorNode, + parent: ProseMirrorNode | Fragment +) { + if (!context.editor.storage.markdown.options.html) { + state.write(`[${node.type.name}]`); + state.closeBlock(node); + return; + } + const schema = node.type.schema; const html = getHTMLFromFragment(Fragment.from(node), schema); const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name; @@ -76,9 +98,14 @@ export const Table = BaseTable.extend({ addStorage() { return { markdown: { - serialize(this: unknown, state: MarkdownSerializerState, node: ProseMirrorNode, parent: ProseMirrorNode | Fragment) { + serialize( + this: SerializeContext, + state: MarkdownSerializerState, + node: ProseMirrorNode, + parent: ProseMirrorNode | Fragment + ) { if (!isMarkdownSerializable(node)) { - serializeAsHTML(state, node, parent); + serializeAsHTML(this, state, node, parent); return; } diff --git a/src/lib/utils/markdown.test.ts b/src/lib/utils/markdown.test.ts index d8c7152..71f2ecf 100644 --- a/src/lib/utils/markdown.test.ts +++ b/src/lib/utils/markdown.test.ts @@ -62,6 +62,39 @@ describe('renderMarkdown', () => { const html = renderMarkdown('line 1\nline 2'); expect(html).toContain('
'); }); + + it('should render a raw HTML table block (the table-with-a-list-in-a-cell fallback) instead of escaping it', () => { + // crumbs#83: a bulleted list inside a table cell can't be expressed in + // plain GFM, so the editor falls back to embedding the whole table as + // raw HTML in the saved markdown. The preview/share renderer must + // actually render that block, not show it as literal escaped text. + const html = renderMarkdown( + '
  • First
  • Second
Plain cell
' + ); + expect(html).toContain(''); + expect(html).toContain('
  • First
  • '); + expect(html).toContain('
  • Second
  • '); + expect(html).not.toContain('<table>'); + }); + + it('should strip a script tag from raw HTML content', () => { + // Note content isn't only ever written through the Tiptap editor - the + // MCP server and the notes API accept a raw markdown string directly, + // so this renderer can't trust that any HTML it's asked to render is + // safe just because rendering HTML at all is now allowed. + const html = renderMarkdown('

    hello

    '); + expect(html).not.toContain(' { + const html = renderMarkdown('[ ] todo\n\n[x] done'); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('checked'); + expect(html).toContain('disabled'); + expect(html).toContain('class="task-checkbox"'); + }); }); describe('stripMarkdown', () => { diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index 64cc985..f6e5875 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -1,12 +1,32 @@ import MarkdownIt from 'markdown-it'; +import sanitizeHtml from 'sanitize-html'; const md = new MarkdownIt({ - html: false, + html: true, linkify: true, typographer: true, breaks: true }); +// Raw HTML has to render (not just be escaped as visible text): a table with +// a list in a cell can't be expressed in plain GFM, so the editor falls back +// to embedding it as an HTML block (see tiptap/Table.ts). Note content also +// isn't only ever written through that editor - the MCP server and the notes +// API accept a raw markdown string directly - so any HTML this renders has to +// be sanitized regardless of where it came from. +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [...sanitizeHtml.defaults.allowedTags, 'input'], + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + input: ['type', 'checked', 'disabled', 'class'], + ul: ['class'] + }, + // Keep markdown-it's bare void-element style (
    , not
    ) so + // sanitizing doesn't change output for content that had no raw HTML to + // begin with. + selfClosing: [] +}; + // Add task list support md.core.ruler.after('inline', 'task-lists', (state) => { const tokens = state.tokens; @@ -59,7 +79,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => { }); export function renderMarkdown(content: string): string { - return md.render(content); + return sanitizeHtml(md.render(content), SANITIZE_OPTIONS); } export function stripMarkdown(content: string): string {