mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
fix(editor): render the table-with-list HTML fallback safely instead of escaping it
The table markdown fallback (previous commit) embeds raw HTML in saved markdown when a cell holds a bullet list, since GFM can't express block content in a table cell. But the read-only renderer (note-card preview, public share page) ran with html:false, so that HTML rendered as escaped literal text instead of a table - moving the bug rather than fixing it. Enable html mode and sanitize the rendered output with sanitize-html. Sanitizing matters beyond this one fallback: note content isn't only ever written through the editor - the MCP server and notes API accept a raw markdown string directly, bypassing the editor's schema, so any HTML this renders has to be treated as untrusted regardless of source. Also make the table's HTML fallback respect the editor's own `html` option (falls back to a `[table]` placeholder when disabled), matching every other node type's markdown storage in tiptap-markdown. Found via a 3-agent adversarial review of the previous fix.
This commit is contained in:
committed by
Fred Cabassut
parent
64f96b21a3
commit
27c3e7dccd
@@ -59,6 +59,7 @@
|
|||||||
"nanoid": "^5.1.16",
|
"nanoid": "^5.1.16",
|
||||||
"node-diff3": "^3.2.1",
|
"node-diff3": "^3.2.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"sanitize-html": "^2.17.7",
|
||||||
"svelte-dnd-action": "^0.9.77",
|
"svelte-dnd-action": "^0.9.77",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"@vite-pwa/sveltekit": "^1.1.0",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
|
|||||||
Generated
+148
@@ -92,6 +92,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
sanitize-html:
|
||||||
|
specifier: ^2.17.7
|
||||||
|
version: 2.17.7
|
||||||
svelte-dnd-action:
|
svelte-dnd-action:
|
||||||
specifier: ^0.9.77
|
specifier: ^0.9.77
|
||||||
version: 0.9.77(svelte@5.56.8)
|
version: 0.9.77(svelte@5.56.8)
|
||||||
@@ -132,6 +135,9 @@ importers:
|
|||||||
'@types/nodemailer':
|
'@types/nodemailer':
|
||||||
specifier: ^8.0.1
|
specifier: ^8.0.1
|
||||||
version: 8.0.1
|
version: 8.0.1
|
||||||
|
'@types/sanitize-html':
|
||||||
|
specifier: ^2.16.1
|
||||||
|
version: 2.16.1
|
||||||
'@types/uuid':
|
'@types/uuid':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
@@ -1754,6 +1760,9 @@ packages:
|
|||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
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':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -2119,6 +2128,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
dayjs@1.11.23:
|
||||||
|
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2174,6 +2186,35 @@ packages:
|
|||||||
devalue@5.9.0:
|
devalue@5.9.0:
|
||||||
resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==}
|
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:
|
drizzle-kit@0.31.10:
|
||||||
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
|
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2309,6 +2350,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
entities@8.0.0:
|
entities@8.0.0:
|
||||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@@ -2364,6 +2409,10 @@ packages:
|
|||||||
escape-html@1.0.3:
|
escape-html@1.0.3:
|
||||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
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:
|
eslint-visitor-keys@4.2.1:
|
||||||
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2622,6 +2671,13 @@ packages:
|
|||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
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:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2732,6 +2788,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
|
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
@@ -2904,6 +2964,9 @@ packages:
|
|||||||
kolorist@1.8.0:
|
kolorist@1.8.0:
|
||||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||||
|
|
||||||
|
launder@1.7.1:
|
||||||
|
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
||||||
|
|
||||||
leven@3.1.0:
|
leven@3.1.0:
|
||||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3213,6 +3276,9 @@ packages:
|
|||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
@@ -3486,6 +3552,10 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sanitize-html@2.17.7:
|
||||||
|
resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==}
|
||||||
|
engines: {node: '>=22.12.0'}
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
@@ -5681,6 +5751,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.1':
|
||||||
|
dependencies:
|
||||||
|
htmlparser2: 10.1.0
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@types/uuid@10.0.0': {}
|
'@types/uuid@10.0.0': {}
|
||||||
@@ -6079,6 +6153,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-data-view: 1.0.2
|
is-data-view: 1.0.2
|
||||||
|
|
||||||
|
dayjs@1.11.23: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6119,6 +6195,42 @@ snapshots:
|
|||||||
|
|
||||||
devalue@5.9.0: {}
|
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:
|
drizzle-kit@0.31.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
@@ -6164,6 +6276,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
entities@8.0.0: {}
|
entities@8.0.0: {}
|
||||||
|
|
||||||
es-abstract-get@1.0.0:
|
es-abstract-get@1.0.0:
|
||||||
@@ -6320,6 +6434,8 @@ snapshots:
|
|||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-visitor-keys@4.2.1: {}
|
eslint-visitor-keys@4.2.1: {}
|
||||||
|
|
||||||
esm-env@1.2.2: {}
|
esm-env@1.2.2: {}
|
||||||
@@ -6598,6 +6714,20 @@ snapshots:
|
|||||||
|
|
||||||
html-escaper@2.0.2: {}
|
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:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -6705,6 +6835,8 @@ snapshots:
|
|||||||
|
|
||||||
is-obj@1.0.1: {}
|
is-obj@1.0.1: {}
|
||||||
|
|
||||||
|
is-plain-object@5.1.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
@@ -6896,6 +7028,10 @@ snapshots:
|
|||||||
|
|
||||||
kolorist@1.8.0: {}
|
kolorist@1.8.0: {}
|
||||||
|
|
||||||
|
launder@1.7.1:
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.23
|
||||||
|
|
||||||
leven@3.1.0: {}
|
leven@3.1.0: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
@@ -7169,6 +7305,8 @@ snapshots:
|
|||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2: {}
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 8.0.0
|
entities: 8.0.0
|
||||||
@@ -7518,6 +7656,16 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
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:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|||||||
@@ -7,67 +7,83 @@ import { TableHeader } from '@tiptap/extension-table-header';
|
|||||||
import { TableCell } from '@tiptap/extension-table-cell';
|
import { TableCell } from '@tiptap/extension-table-cell';
|
||||||
import { Markdown } from 'tiptap-markdown';
|
import { Markdown } from 'tiptap-markdown';
|
||||||
import { Table } from './Table.js';
|
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<string, any>).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
|
// 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,
|
// (never touches markdown) but breaks once the note is saved and reopened,
|
||||||
// because saving/reopening round-trips the doc through markdown text.
|
// because saving/reopening round-trips the doc through markdown text.
|
||||||
describe('Table markdown round-trip with a bullet list in a cell', () => {
|
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', () => {
|
it('preserves a bullet list inside a table cell after saving and reopening', () => {
|
||||||
const editor = createEditor({
|
const editor = createEditor(docWithListInCell());
|
||||||
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' }] }]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Save": serialize the crumb to markdown, the way onUpdate() does.
|
// "Save": serialize the crumb to markdown, the way onUpdate() does.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const savedMarkdown = getMarkdown(editor);
|
||||||
const savedMarkdown = (editor.storage as Record<string, any>).markdown.getMarkdown();
|
|
||||||
|
|
||||||
// "Reopen": load that markdown into a fresh editor, the way opening the
|
// "Reopen": load that markdown into a fresh editor, the way opening the
|
||||||
// crumb again does (content is passed in as a markdown string).
|
// 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');
|
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('<table>');
|
||||||
|
expect(html).toContain('<ul');
|
||||||
|
expect(html).toContain('First');
|
||||||
|
expect(html).toContain('Second');
|
||||||
|
expect(html).not.toContain('<table>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still serializes a table with only plain-text cells as GFM markdown, not HTML', () => {
|
||||||
|
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('<table');
|
||||||
|
expect(savedMarkdown).toContain('| A | B |');
|
||||||
|
expect(savedMarkdown).toContain('| 1 | 2 |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a placeholder instead of raw HTML when the editor has html mode disabled', () => {
|
||||||
|
const editor = createEditor(docWithListInCell(), { html: false });
|
||||||
|
|
||||||
|
const savedMarkdown = getMarkdown(editor);
|
||||||
|
|
||||||
|
expect(savedMarkdown).not.toContain('<table');
|
||||||
|
expect(savedMarkdown).toContain('[table]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ interface MarkdownSerializerState {
|
|||||||
inTable: boolean;
|
inTable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SerializeContext {
|
||||||
|
editor: { storage: { markdown: { options: { html: boolean } } } };
|
||||||
|
}
|
||||||
|
|
||||||
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
|
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
|
||||||
const nodes: ProseMirrorNode[] = [];
|
const nodes: ProseMirrorNode[] = [];
|
||||||
node.forEach((child) => nodes.push(child));
|
node.forEach((child) => nodes.push(child));
|
||||||
@@ -59,7 +63,25 @@ function formatBlock(html: string): string {
|
|||||||
return element.outerHTML;
|
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 schema = node.type.schema;
|
||||||
const html = getHTMLFromFragment(Fragment.from(node), schema);
|
const html = getHTMLFromFragment(Fragment.from(node), schema);
|
||||||
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
|
const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name;
|
||||||
@@ -76,9 +98,14 @@ export const Table = BaseTable.extend({
|
|||||||
addStorage() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
markdown: {
|
markdown: {
|
||||||
serialize(this: unknown, state: MarkdownSerializerState, node: ProseMirrorNode, parent: ProseMirrorNode | Fragment) {
|
serialize(
|
||||||
|
this: SerializeContext,
|
||||||
|
state: MarkdownSerializerState,
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
parent: ProseMirrorNode | Fragment
|
||||||
|
) {
|
||||||
if (!isMarkdownSerializable(node)) {
|
if (!isMarkdownSerializable(node)) {
|
||||||
serializeAsHTML(state, node, parent);
|
serializeAsHTML(this, state, node, parent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,39 @@ describe('renderMarkdown', () => {
|
|||||||
const html = renderMarkdown('line 1\nline 2');
|
const html = renderMarkdown('line 1\nline 2');
|
||||||
expect(html).toContain('<br>');
|
expect(html).toContain('<br>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<table><tbody><tr><td><ul><li>First</li><li>Second</li></ul></td><td>Plain cell</td></tr></tbody></table>'
|
||||||
|
);
|
||||||
|
expect(html).toContain('<table>');
|
||||||
|
expect(html).toContain('<li>First</li>');
|
||||||
|
expect(html).toContain('<li>Second</li>');
|
||||||
|
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('<p>hello</p><script>alert(1)</script><img src=x onerror="alert(2)">');
|
||||||
|
expect(html).not.toContain('<script');
|
||||||
|
expect(html).not.toContain('onerror');
|
||||||
|
expect(html).toContain('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still render task-list checkboxes after sanitization', () => {
|
||||||
|
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', () => {
|
describe('stripMarkdown', () => {
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: true,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
breaks: 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 (<br>, not <br />) so
|
||||||
|
// sanitizing doesn't change output for content that had no raw HTML to
|
||||||
|
// begin with.
|
||||||
|
selfClosing: []
|
||||||
|
};
|
||||||
|
|
||||||
// Add task list support
|
// Add task list support
|
||||||
md.core.ruler.after('inline', 'task-lists', (state) => {
|
md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||||
const tokens = state.tokens;
|
const tokens = state.tokens;
|
||||||
@@ -59,7 +79,7 @@ md.core.ruler.after('task-lists', 'note-links', (state) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function renderMarkdown(content: string): string {
|
export function renderMarkdown(content: string): string {
|
||||||
return md.render(content);
|
return sanitizeHtml(md.render(content), SANITIZE_OPTIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdown(content: string): string {
|
export function stripMarkdown(content: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user