forked from mirror/crumbs-original
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881a85a09e | ||
|
|
27c3e7dccd | ||
|
|
64f96b21a3 | ||
|
|
ff24fb6d5b |
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.33.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.34.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.34.0",
|
||||
"version": "0.34.1",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
@@ -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",
|
||||
|
||||
Generated
+148
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { Table } from './tiptap/Table.js';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Editor, type JSONContent } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
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<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
|
||||
// (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', () => {
|
||||
it('preserves a bullet list inside a table cell after saving and reopening', () => {
|
||||
const editor = createEditor(docWithListInCell());
|
||||
|
||||
// "Save": serialize the crumb to markdown, the way onUpdate() does.
|
||||
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).
|
||||
const reopened = createEditor(savedMarkdown);
|
||||
const json = reopened.getJSON() as JSONContent;
|
||||
|
||||
const table = json.content?.find((node) => node.type === 'table');
|
||||
expect(table).toBeDefined();
|
||||
|
||||
const rows = table!.content!;
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const [headerRow, bodyRow] = rows;
|
||||
expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']);
|
||||
|
||||
const [listCell, plainCell] = bodyRow.content!;
|
||||
expect(listCell.content?.[0]?.type).toBe('bulletList');
|
||||
const items = listCell.content?.[0]?.content ?? [];
|
||||
expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']);
|
||||
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { elementFromString, getHTMLFromFragment } from '@tiptap/core';
|
||||
import { Table as BaseTable } from '@tiptap/extension-table';
|
||||
import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
|
||||
interface MarkdownSerializerState {
|
||||
write(text: string): void;
|
||||
ensureNewLine(): void;
|
||||
closeBlock(node: ProseMirrorNode): void;
|
||||
renderInline(node: ProseMirrorNode): void;
|
||||
inTable: boolean;
|
||||
}
|
||||
|
||||
interface SerializeContext {
|
||||
editor: { storage: { markdown: { options: { html: boolean } } } };
|
||||
}
|
||||
|
||||
function childNodes(node: ProseMirrorNode): ProseMirrorNode[] {
|
||||
const nodes: ProseMirrorNode[] = [];
|
||||
node.forEach((child) => nodes.push(child));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function hasSpan(cell: ProseMirrorNode): boolean {
|
||||
return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell can only be squeezed onto a single `| cell |` markdown line via
|
||||
* `state.renderInline()` if its content is itself a textblock (a paragraph,
|
||||
* whose children are inline text runs). A block child instead - e.g. a
|
||||
* bullet list - has to be rejected here, or renderInline() misreads its
|
||||
* block-level children as inline ones and corrupts the row (crumbs#83).
|
||||
*/
|
||||
function hasOnlyInlineContent(cell: ProseMirrorNode): boolean {
|
||||
const content = cell.firstChild;
|
||||
return content === null || content.type.isTextblock;
|
||||
}
|
||||
|
||||
function isMarkdownSerializable(node: ProseMirrorNode): boolean {
|
||||
const rows = childNodes(node);
|
||||
const firstRow = rows[0];
|
||||
const bodyRows = rows.slice(1);
|
||||
|
||||
if (
|
||||
childNodes(firstRow).some(
|
||||
(cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !bodyRows.some((row) =>
|
||||
childNodes(row).some(
|
||||
(cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */
|
||||
function formatBlock(html: string): string {
|
||||
const element = elementFromString(html).firstElementChild!;
|
||||
element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n';
|
||||
return element.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
state.write(isTopLevel ? formatBlock(html) : html);
|
||||
state.closeBlock(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships
|
||||
* a default for it, but only guards against multi-child/spanning cells, not
|
||||
* a single block child - see hasOnlyInlineContent above).
|
||||
*/
|
||||
export const Table = BaseTable.extend({
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(
|
||||
this: SerializeContext,
|
||||
state: MarkdownSerializerState,
|
||||
node: ProseMirrorNode,
|
||||
parent: ProseMirrorNode | Fragment
|
||||
) {
|
||||
if (!isMarkdownSerializable(node)) {
|
||||
serializeAsHTML(this, state, node, parent);
|
||||
return;
|
||||
}
|
||||
|
||||
state.inTable = true;
|
||||
node.forEach((row, _rowOffset, rowIndex) => {
|
||||
state.write('| ');
|
||||
row.forEach((cell, _cellOffset, cellIndex) => {
|
||||
if (cellIndex) {
|
||||
state.write(' | ');
|
||||
}
|
||||
const cellContent = cell.firstChild;
|
||||
if (cellContent && cellContent.textContent.trim()) {
|
||||
state.renderInline(cellContent);
|
||||
}
|
||||
});
|
||||
state.write(' |');
|
||||
state.ensureNewLine();
|
||||
if (!rowIndex) {
|
||||
const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | ');
|
||||
state.write(`| ${delimiterRow} |`);
|
||||
state.ensureNewLine();
|
||||
}
|
||||
});
|
||||
state.closeBlock(node);
|
||||
state.inTable = false;
|
||||
},
|
||||
parse: {
|
||||
// handled by markdown-it, same as tiptap-markdown's default table spec
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,39 @@ describe('renderMarkdown', () => {
|
||||
const html = renderMarkdown('line 1\nline 2');
|
||||
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', () => {
|
||||
|
||||
@@ -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 (<br>, not <br />) 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 {
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.34.0</span>
|
||||
<span class="footer-version">v0.34.1</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user