diff --git a/README.md b/README.md index 10548a9..ebfb2ff 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,38 @@ flowchart LR Tree-sitter changed ranges and syntax errors identify source positions that may invalidate emitted output. Recovery selects a stored checkpoint, reconstructs generator state, and replays source from that checkpoint. Public offsets remain in rendered UTF-16 coordinates even though recovery decisions use raw source positions internally. +## ProseMirror Demo Integration + +The Svelte demo renders streaming chunks with a read-only ProseMirror `EditorView`. Its framework-free assembly layer lives in `demo/svelte-demo/src/lib/prosemirror/` and accepts a ProseMirror schema plus `Chunk[]`, so the projection logic can be reused with compatible schemas. + +The demo keeps a chunk buffer. For each `STREAMING` event, it removes buffered chunks whose rendered range crosses `backtrackOffset`, appends the replacement chunk, rebuilds the document, and replaces the editor content. `START_STREAM` and reset clear the buffer. A bounded recovery emits a console warning with the parser's recovery metadata. + +The demo schema represents paragraphs, headings, code blocks, blockquotes, ordered and unordered lists, task items, tables, inline images, and the `strong`, `em`, `code`, `strikethrough`, and `link` marks. List assembly preserves same-depth siblings, nested siblings, ordered-list start values, and the parent list when returning from a nested list. Table cells are grouped by the parser's table, row, and cell identifiers. + +Inline spans use rendered offsets across the entire block. Closed image spans replace their covered text with one image node even when the span crosses chunk boundaries; open image spans remain text until metadata arrives. The assembler validates completed documents and falls back to plain paragraphs for incomplete or unsupported structures. Unexpected assembly failures are logged before that fallback is used. + +### URL Policy in the Demo + +Link marks accept absolute `http:`, `https:`, and `mailto:` URLs. Image nodes accept absolute `http:` and `https:` URLs, root-relative and path-relative paths, and base64-encoded PNG, APNG, GIF, JPEG, WebP, and AVIF data URLs. Protocol-relative URLs, query- and fragment-only sources, scriptable schemes, non-image data URLs, malformed values, and SVG data URLs render as ordinary text. + +## Demo Verification + +The demo unit and integration tests run through the container: + +```bash +docker exec -it lixpi-markdown-stream-parser-demo \ + pnpm --dir demo/svelte-demo run test +``` + +The integration tests read the tracked JSON fixtures in `demo/llm-streams-examples`; development commands copy those fixtures into the demo's static directory for the example picker. + +Type-check the demo with: + +```bash +docker exec -it lixpi-markdown-stream-parser-demo \ + pnpm --dir demo/svelte-demo run check +``` + ## Development The repository's Docker service installs the root and demo dependencies. Start it from the repository root: diff --git a/demo/svelte-demo/package.json b/demo/svelte-demo/package.json index 2d1da56..a24ef4d 100644 --- a/demo/svelte-demo/package.json +++ b/demo/svelte-demo/package.json @@ -7,6 +7,7 @@ "serve": "http-server ./build -p 5173", "build-and-serve": "pnpm run build && pnpm run serve", "preview": "vite preview", + "test": "vitest run", "prepare": "svelte-kit sync || echo ''", "prepack": "svelte-kit sync && svelte-package && publint", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -54,6 +55,7 @@ "ts-node": "^10.9.2", "typescript": "^5.0.0", "vite": "^6.2.6", + "vitest": "^2.0.0", "web-tree-sitter": "*" }, "keywords": [ @@ -65,6 +67,9 @@ ] }, "dependencies": { - "@lixpi/markdown-stream-parser": "0.0.3-31" + "@lixpi/markdown-stream-parser": "0.0.3-31", + "prosemirror-model": "^1.25.10", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.0" } -} \ No newline at end of file +} diff --git a/demo/svelte-demo/pnpm-lock.yaml b/demo/svelte-demo/pnpm-lock.yaml index 71cdb65..69d19ec 100644 --- a/demo/svelte-demo/pnpm-lock.yaml +++ b/demo/svelte-demo/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@lixpi/markdown-stream-parser': specifier: 0.0.3-31 version: 0.0.3-31 + prosemirror-model: + specifier: ^1.25.10 + version: 1.25.10 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-view: + specifier: ^1.42.0 + version: 1.42.0 devDependencies: '@sveltejs/adapter-auto': specifier: ^6.0.0 @@ -60,6 +69,9 @@ importers: vite: specifier: ^6.2.6 version: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.15.3)(lightningcss@1.29.2) web-tree-sitter: specifier: '*' version: 0.25.10 @@ -74,102 +86,204 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.3': resolution: {integrity: sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.3': resolution: {integrity: sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.3': resolution: {integrity: sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.3': resolution: {integrity: sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.3': resolution: {integrity: sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.3': resolution: {integrity: sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.3': resolution: {integrity: sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.3': resolution: {integrity: sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.3': resolution: {integrity: sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.3': resolution: {integrity: sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.3': resolution: {integrity: sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.3': resolution: {integrity: sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.3': resolution: {integrity: sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.3': resolution: {integrity: sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.3': resolution: {integrity: sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.3': resolution: {integrity: sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.3': resolution: {integrity: sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==} engines: {node: '>=18'} @@ -182,6 +296,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.3': resolution: {integrity: sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==} engines: {node: '>=18'} @@ -194,30 +314,60 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.3': resolution: {integrity: sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.3': resolution: {integrity: sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.3': resolution: {integrity: sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.3': resolution: {integrity: sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.3': resolution: {integrity: sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==} engines: {node: '>=18'} @@ -522,6 +672,35 @@ packages: '@types/node@22.15.3': resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -542,6 +721,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -553,6 +736,10 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -561,10 +748,18 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -608,6 +803,10 @@ packages: dedent-js@1.0.1: resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -639,10 +838,18 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.3: resolution: {integrity: sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==} engines: {node: '>=18'} @@ -654,9 +861,16 @@ packages: esrap@1.4.6: resolution: {integrity: sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.4.4: resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} peerDependencies: @@ -820,6 +1034,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -872,12 +1089,22 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + package-manager-detector@1.2.0: resolution: {integrity: sha512-PutJepsOtsqVfUsxCzgTTpyXmiAgvKptIgY4th5eq5UXXFhj5PxfQ9hnGkypMeovpAvVshFRItoFHYO18TCOqA==} pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -897,6 +1124,18 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + prosemirror-model@1.25.10: + resolution: {integrity: sha512-9n6rH4DbJU1eH4SxLt6Y0HhJIo6cZsb7DJ/30uob1hOKPeO6TAaMWI2tc7kwR92BjfPOU2fFHWbZLovLi3XQfA==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.42.0: + resolution: {integrity: sha512-N54DF3OXNWDuP81G1kbfCys8ZzIjuL1VnvJ2mk5STSu/fNxWIcX/EutQLA3s9KR/2wVhgDi4hzBB/1fINVxk0A==} + publint@0.3.12: resolution: {integrity: sha512-1w3MMtL9iotBjm1mmXtG3Nk06wnq9UhGNRpQ2j6n1Zq7YAD6gnxMMZMIxlRPAydVjVbjSm+n0lhwqsD1m4LD5w==} engines: {node: '>=18'} @@ -955,6 +1194,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@3.0.1: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} @@ -963,6 +1205,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -992,10 +1240,28 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1038,6 +1304,42 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.3: resolution: {integrity: sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1086,6 +1388,31 @@ packages: vite: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-tree-sitter@0.25.10: resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} peerDependencies: @@ -1098,6 +1425,11 @@ packages: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -1116,78 +1448,147 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.3': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.3': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.3': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.3': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.3': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.3': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.3': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.3': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.3': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.3': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.3': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.3': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.3': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.3': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.3': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.3': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.3': optional: true '@esbuild/netbsd-arm64@0.25.3': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.3': optional: true '@esbuild/openbsd-arm64@0.25.3': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.3': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.3': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.3': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.3': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.3': optional: true @@ -1436,6 +1837,46 @@ snapshots: dependencies: undici-types: 6.21.0 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.15.3)(lightningcss@1.29.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 5.4.21(@types/node@22.15.3)(lightningcss@1.29.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.17 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + acorn-walk@8.3.4: dependencies: acorn: 8.14.1 @@ -1450,6 +1891,8 @@ snapshots: aria-query@5.3.2: {} + assertion-error@2.0.1: {} + async@3.2.6: {} axobject-query@4.1.0: {} @@ -1458,6 +1901,8 @@ snapshots: dependencies: safe-buffer: 5.1.2 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1468,11 +1913,21 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1499,6 +1954,8 @@ snapshots: dedent-js@1.0.1: {} + deep-eql@5.0.2: {} + deepmerge@4.3.1: {} detect-libc@2.0.4: {} @@ -1522,10 +1979,38 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.3: optionalDependencies: '@esbuild/aix-ppc64': 0.25.3 @@ -1560,8 +2045,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + eventemitter3@4.0.7: {} + expect-type@1.4.0: {} + fdir@6.4.4(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -1703,6 +2194,8 @@ snapshots: lodash.merge@4.6.2: {} + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -1738,6 +2231,8 @@ snapshots: opener@1.5.2: {} + orderedmap@2.1.1: {} + package-manager-detector@1.2.0: {} pascal-case@3.1.2: @@ -1745,6 +2240,10 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.2: {} @@ -1767,6 +2266,26 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prosemirror-model@1.25.10: + dependencies: + orderedmap: 2.1.1 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.10 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.0 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.10 + + prosemirror-view@1.42.0: + dependencies: + prosemirror-model: 1.25.10 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + publint@0.3.12: dependencies: '@publint/pack': 0.1.2 @@ -1850,6 +2369,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + sirv@3.0.1: dependencies: '@polka/url': 1.0.0-next.29 @@ -1858,6 +2379,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -1902,11 +2427,21 @@ snapshots: tapable@2.2.1: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + totalist@3.0.1: {} ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3): @@ -1943,6 +2478,34 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + vite-node@2.1.9(@types/node@22.15.3)(lightningcss@1.29.2): + dependencies: + cac: 6.7.14 + debug: 4.4.0 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.15.3)(lightningcss@1.29.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.15.3)(lightningcss@1.29.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.3 + rollup: 4.40.1 + optionalDependencies: + '@types/node': 22.15.3 + fsevents: 2.3.3 + lightningcss: 1.29.2 + vite@6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2): dependencies: esbuild: 0.25.3 @@ -1961,12 +2524,52 @@ snapshots: optionalDependencies: vite: 6.3.3(@types/node@22.15.3)(jiti@2.4.2)(lightningcss@1.29.2) + vitest@2.1.9(@types/node@22.15.3)(lightningcss@1.29.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.15.3)(lightningcss@1.29.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.0 + expect-type: 1.4.0 + magic-string: 0.30.17 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.15.3)(lightningcss@1.29.2) + vite-node: 2.1.9(@types/node@22.15.3)(lightningcss@1.29.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + web-tree-sitter@0.25.10: {} whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yn@3.1.1: {} zimmerframe@1.1.2: {} diff --git a/demo/svelte-demo/src/app.css b/demo/svelte-demo/src/app.css index cd67023..eede70c 100644 --- a/demo/svelte-demo/src/app.css +++ b/demo/svelte-demo/src/app.css @@ -1,3 +1,4 @@ @import 'tailwindcss'; +@import './lib/prosemirror/prosemirror.css'; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; diff --git a/demo/svelte-demo/src/lib/prosemirror/ProseMirrorRenderer.svelte b/demo/svelte-demo/src/lib/prosemirror/ProseMirrorRenderer.svelte new file mode 100644 index 0000000..0cd432d --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/ProseMirrorRenderer.svelte @@ -0,0 +1,30 @@ + + +
diff --git a/demo/svelte-demo/src/lib/prosemirror/editor.ts b/demo/svelte-demo/src/lib/prosemirror/editor.ts new file mode 100644 index 0000000..5bccee9 --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/editor.ts @@ -0,0 +1,51 @@ +import { EditorState } from 'prosemirror-state' +import { EditorView } from 'prosemirror-view' +import type { Chunk, StreamingChunk } from '../../../../../src/markdown-stream-parser.ts' +import { createEmptyDoc, schema } from './schema.ts' +import { applyStreamingChunkToBuffer, buildDocFromChunks } from './stream-assembly.ts' + +export type StreamRenderer = { + handleStreamingChunk(parsed: StreamingChunk): void + reset(): void + destroy(): void +} + +export function createStreamRenderer(mount: HTMLElement): StreamRenderer { + let buffer: Chunk[] = [] + const view = new EditorView(mount, { + state: EditorState.create({ schema, doc: createEmptyDoc() }), + editable: () => false, + }) + + function reset(): void { + buffer = [] + const nextDoc = createEmptyDoc() + view.updateState(EditorState.create({ schema, doc: nextDoc })) + } + + return { + handleStreamingChunk(parsed: StreamingChunk): void { + if (parsed.status === 'START_STREAM') { + reset() + return + } + if (parsed.status === 'END_STREAM') return + + const chunk = parsed.chunk + if (chunk.recovery?.type === 'window_overflow') { + console.warn('Recovery exceeded windowSize; only the bounded suffix was replaced.', chunk.recovery) + } + + buffer = applyStreamingChunkToBuffer(buffer, chunk) + const nextDoc = buildDocFromChunks(schema, buffer) + if (nextDoc.eq(view.state.doc)) return + + const tr = view.state.tr.replaceWith(0, view.state.doc.content.size, nextDoc.content) + view.dispatch(tr) + }, + reset, + destroy(): void { + view.destroy() + }, + } +} diff --git a/demo/svelte-demo/src/lib/prosemirror/prosemirror.css b/demo/svelte-demo/src/lib/prosemirror/prosemirror.css new file mode 100644 index 0000000..cd098f5 --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/prosemirror.css @@ -0,0 +1,72 @@ +.ProseMirror { + outline: none; + word-wrap: break-word; +} + +.ProseMirror p { + margin: 0.5rem 0; +} + +.ProseMirror blockquote { + border-left: 4px solid #60a5fa; + color: #374151; + font-style: italic; + margin: 0.75rem 0; + padding-left: 0.75rem; +} + +.ProseMirror pre { + background: #f3f4f6; + border-radius: 0.25rem; + color: #1f2937; + font-size: 0.875rem; + overflow-x: auto; + padding: 0.5rem; + position: relative; +} + +.ProseMirror pre[data-language]::after { + color: #6b7280; + content: attr(data-language); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.75rem; + position: absolute; + right: 0.5rem; + top: 0.35rem; +} + +.ProseMirror table { + border-collapse: collapse; + font-size: 0.875rem; + margin: 0.75rem 0; +} + +.ProseMirror th, +.ProseMirror td { + border: 1px solid #d1d5db; + min-width: 6rem; + padding: 0.25rem 0.5rem; +} + +.ProseMirror th { + background: #f3f4f6; + font-weight: 600; +} + +.ProseMirror li[data-task] { + list-style: none; +} + +.ProseMirror li[data-task]::before { + display: inline-block; + margin-left: -1.25rem; + width: 1.25rem; +} + +.ProseMirror li[data-task="unchecked"]::before { + content: "☐"; +} + +.ProseMirror li[data-task="checked"]::before { + content: "☑"; +} diff --git a/demo/svelte-demo/src/lib/prosemirror/schema.ts b/demo/svelte-demo/src/lib/prosemirror/schema.ts new file mode 100644 index 0000000..7aacb57 --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/schema.ts @@ -0,0 +1,163 @@ +import { Schema, type DOMOutputSpec, type MarkSpec, type NodeSpec } from 'prosemirror-model' + +const paragraphDOM: DOMOutputSpec = ['p', 0] +const blockquoteDOM: DOMOutputSpec = ['blockquote', 0] +const bulletListDOM: DOMOutputSpec = ['ul', 0] +const tableDOM: DOMOutputSpec = ['table', ['tbody', 0]] +const tableRowDOM: DOMOutputSpec = ['tr', 0] +const emDOM: DOMOutputSpec = ['em', 0] +const strongDOM: DOMOutputSpec = ['strong', 0] +const codeDOM: DOMOutputSpec = ['code', 0] +const strikethroughDOM: DOMOutputSpec = ['s', 0] + +export const nodes = { + doc: { + content: 'block+', + } as NodeSpec, + + paragraph: { + content: 'inline*', + group: 'block', + toDOM() { return paragraphDOM }, + } as NodeSpec, + + heading: { + attrs: { level: { default: 1 } }, + content: 'inline*', + group: 'block', + defining: true, + toDOM(node) { + const level = Math.min(6, Math.max(1, Number(node.attrs.level) || 1)) + return [`h${level}`, 0] + }, + } as NodeSpec, + + code_block: { + attrs: { language: { default: '' } }, + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + toDOM(node) { + const attrs: Record = {} + if (node.attrs.language) attrs['data-language'] = node.attrs.language + return ['pre', attrs, ['code', 0]] + }, + } as NodeSpec, + + blockquote: { + content: 'block+', + group: 'block', + defining: true, + toDOM() { return blockquoteDOM }, + } as NodeSpec, + + bullet_list: { + content: 'list_item+', + group: 'block', + toDOM() { return bulletListDOM }, + } as NodeSpec, + + ordered_list: { + attrs: { order: { default: 1 } }, + content: 'list_item+', + group: 'block', + toDOM(node) { + const order = Number(node.attrs.order) || 1 + return order === 1 ? ['ol', 0] : ['ol', { start: order }, 0] + }, + } as NodeSpec, + + list_item: { + attrs: { task: { default: null } }, + content: 'block+', + defining: true, + toDOM(node) { + const task = node.attrs.task as { checked: boolean } | null + if (!task) return ['li', 0] + return ['li', { 'data-task': task.checked ? 'checked' : 'unchecked' }, 0] + }, + } as NodeSpec, + + table: { + content: 'table_row+', + group: 'block', + toDOM() { return tableDOM }, + } as NodeSpec, + + table_row: { + content: '(table_header_cell|table_cell)+', + toDOM() { return tableRowDOM }, + } as NodeSpec, + + table_header_cell: { + attrs: { align: { default: null } }, + content: 'inline*', + toDOM(node) { return createTableCellDOM('th', node.attrs.align) }, + } as NodeSpec, + + table_cell: { + attrs: { align: { default: null } }, + content: 'inline*', + toDOM(node) { return createTableCellDOM('td', node.attrs.align) }, + } as NodeSpec, + + image: { + inline: true, + group: 'inline', + attrs: { + src: {}, + alt: { default: null }, + }, + draggable: false, + toDOM(node) { + const attrs: Record = { src: node.attrs.src } + if (node.attrs.alt) attrs.alt = node.attrs.alt + return ['img', attrs] + }, + } as NodeSpec, + + text: { + group: 'inline', + } as NodeSpec, +} + +export const marks = { + link: { + attrs: { href: {} }, + inclusive: false, + toDOM(node) { + return ['a', { href: node.attrs.href, rel: 'noopener noreferrer' }, 0] + }, + } as MarkSpec, + + em: { + toDOM() { return emDOM }, + } as MarkSpec, + + strong: { + toDOM() { return strongDOM }, + } as MarkSpec, + + code: { + toDOM() { return codeDOM }, + } as MarkSpec, + + strikethrough: { + toDOM() { return strikethroughDOM }, + } as MarkSpec, +} + +function createTableCellDOM(tag: 'th' | 'td', align: unknown): DOMOutputSpec { + if (align === 'left' || align === 'center' || align === 'right') { + return [tag, { style: `text-align: ${align}` }, 0] + } + return [tag, 0] +} + +export const schema = new Schema({ nodes, marks }) + +export function createEmptyDoc() { + return schema.nodes.doc.create(null, schema.nodes.paragraph.create()) +} diff --git a/demo/svelte-demo/src/lib/prosemirror/stream-assembly.test.ts b/demo/svelte-demo/src/lib/prosemirror/stream-assembly.test.ts new file mode 100644 index 0000000..0924e46 --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/stream-assembly.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import type { Chunk } from '../../../../../src/markdown-stream-parser.ts' +import { schema } from './schema.ts' +import { + applyStreamingChunkToBuffer, + buildDocFromChunks, + buildInlineContent, + groupChunksIntoBlocks, + isChunkBeforeBacktrack, + sanitizeImageSrc, + sanitizeLinkHref, +} from './stream-assembly.ts' + +function chunk(overrides: Partial & Pick): Chunk { + return { + text: overrides.text, + offset: overrides.offset, + length: overrides.length ?? overrides.text.length, + block: overrides.block ?? { type: 'paragraph' }, + opening: overrides.opening ?? [], + closing: overrides.closing ?? [], + contained: overrides.contained ?? [], + backtrackOffset: overrides.backtrackOffset, + recovery: overrides.recovery, + original: overrides.original, + } +} + +describe('stream assembly helpers', () => { + it('shares the legacy backtrack predicate', () => { + const first = chunk({ text: 'hello', offset: 0 }) + const stale = chunk({ text: ' world', offset: 5 }) + const replacement = chunk({ text: ' there', offset: 5, backtrackOffset: 5 }) + + expect(isChunkBeforeBacktrack(first, 5)).toBe(true) + expect(isChunkBeforeBacktrack(stale, 5)).toBe(false) + expect(applyStreamingChunkToBuffer([first, stale], replacement)).toEqual([first, replacement]) + }) + + it('sanitizes link and image urls', () => { + expect(sanitizeLinkHref('https://example.com')).toBe('https://example.com') + expect(sanitizeLinkHref('mailto:test@example.com')).toBe('mailto:test@example.com') + expect(sanitizeLinkHref('javascript:alert(1)')).toBeNull() + expect(sanitizeImageSrc('/asset.png')).toBe('/asset.png') + expect(sanitizeImageSrc('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA') + expect(sanitizeImageSrc('//example.com/image.png')).toBeNull() + expect(sanitizeImageSrc('data:text/html;base64,AAAA')).toBeNull() + expect(sanitizeImageSrc('image.png')).toBe('image.png') + expect(sanitizeImageSrc('../image.png')).toBe('../image.png') + expect(sanitizeImageSrc('?cache=1')).toBeNull() + expect(sanitizeImageSrc('#fragment')).toBeNull() + expect(sanitizeImageSrc('JaVaScRiPt:alert(1)')).toBeNull() + expect(sanitizeImageSrc('data:image/svg+xml;base64,PHN2Zz4=')).toBeNull() + expect(sanitizeImageSrc('data:image/jpeg;base64,AAAA')).toBe('data:image/jpeg;base64,AAAA') + }) + + it('builds marks and rejects unsafe links', () => { + const safe = chunk({ + text: 'safe bad', + offset: 0, + contained: [ + { type: 'link', offset: 0, length: 4, url: 'https://example.com' }, + { type: 'link', offset: 5, length: 3, url: 'javascript:alert(1)' }, + ], + }) + + const nodes = buildInlineContent(schema, [safe]) + expect(nodes.map(node => node.text).join('')).toBe('safe bad') + expect(nodes[0].marks[0]?.type.name).toBe('link') + expect(nodes[nodes.length - 1].marks).toHaveLength(0) + }) + + it('styles a span that opens in one chunk and closes in a later chunk', () => { + const nodes = buildInlineContent(schema, [ + chunk({ + text: 'bo', + offset: 0, + opening: [{ type: 'bold', openOffset: 0 }], + }), + chunk({ + text: 'ld', + offset: 2, + }), + chunk({ + text: ' text', + offset: 4, + closing: [{ type: 'bold', offset: 0, length: 4 }], + }), + ]) + + const textNodes = nodes.filter(node => node.isText) + expect(textNodes.map(node => node.text).join('')).toBe('bold text') + expect(textNodes.slice(0, 2).every(node => node.marks.some(mark => mark.type.name === 'strong'))).toBe(true) + expect(textNodes[textNodes.length - 1].marks.some(mark => mark.type.name === 'strong')).toBe(false) + }) + + it('styles an unclosed span through the end of the current buffer', () => { + const nodes = buildInlineContent(schema, [ + chunk({ + text: 'plain ', + offset: 0, + }), + chunk({ + text: 'bold', + offset: 6, + opening: [{ type: 'bold', openOffset: 6 }], + }), + ]) + + expect(nodes.map(node => node.text).join('')).toBe('plain bold') + expect(nodes[0].marks.some(mark => mark.type.name === 'strong')).toBe(false) + expect(nodes[nodes.length - 1].marks.some(mark => mark.type.name === 'strong')).toBe(true) + }) + + it('creates image nodes only for safe image sources', () => { + const nodes = buildInlineContent(schema, [ + chunk({ + text: 'logo evil', + offset: 0, + contained: [ + { type: 'image', offset: 0, length: 4, src: '/logo.png', alt: 'Logo' }, + { type: 'image', offset: 5, length: 4, src: 'javascript:alert(1)', alt: 'Bad' }, + ], + }), + ]) + + expect(nodes[0].type.name).toBe('image') + expect(nodes[0].attrs.src).toBe('/logo.png') + expect(nodes[nodes.length - 1].text).toBe('evil') + }) + + it('groups list items by depth and type boundaries', () => { + const blocks = groupChunksIntoBlocks([ + chunk({ text: 'one\n', offset: 0, block: { type: 'list_item', list: { type: 'unordered', depth: 0, marker: '-' } } }), + chunk({ text: 'two', offset: 4, block: { type: 'list_item', list: { type: 'unordered', depth: 0, marker: '-' } } }), + chunk({ text: 'nested', offset: 7, block: { type: 'list_item', list: { type: 'unordered', depth: 1, marker: '-' } } }), + ]) + + expect(blocks).toHaveLength(3) + }) + + it('builds lists, task items, tables, and code blocks', () => { + const doc = buildDocFromChunks(schema, [ + chunk({ text: 'Task', offset: 0, block: { type: 'list_item', list: { type: 'unordered', depth: 0, marker: '-', task: { checked: true } } } }), + chunk({ text: 'Head', offset: 4, block: { type: 'table_header_cell', table: { tableId: 't1', rowIndex: 0, columnIndex: 0, cellId: 't1:0:0' } } }), + chunk({ text: 'Cell', offset: 8, block: { type: 'table_cell', table: { tableId: 't1', rowIndex: 1, columnIndex: 0, cellId: 't1:1:0' } } }), + chunk({ text: 'const x = 1\n', offset: 12, block: { type: 'code_block', language: 'ts' } }), + ]) + + expect(doc.child(0).type.name).toBe('bullet_list') + expect(doc.child(0).child(0).attrs.task).toEqual({ checked: true }) + expect(doc.child(1).type.name).toBe('table') + expect(doc.child(2).type.name).toBe('code_block') + expect(doc.child(2).attrs.language).toBe('ts') + expect(() => doc.check()).not.toThrow() + }) + + it('preserves same-depth siblings, nested siblings, and the parent list on return', () => { + const list = (text: string, offset: number, depth: number, type: 'ordered' | 'unordered' = 'unordered', ordinal?: number) => chunk({ + text, + offset, + block: { type: 'list_item', list: { type, depth, marker: type === 'ordered' ? '.' : '-', ordinal } }, + }) + const doc = buildDocFromChunks(schema, [ + list('one\n', 0, 0), list('child a\n', 4, 1), list('child b\n', 12, 1), list('two', 20, 0), + list('numbered', 23, 0, 'ordered', 3), + ]) + expect(doc.child(0).type.name).toBe('bullet_list') + expect(doc.child(0).childCount).toBe(2) + const nested = doc.child(0).child(0).lastChild! + expect(nested.type.name).toBe('bullet_list') + expect(nested.childCount).toBe(2) + expect(doc.child(1).type.name).toBe('ordered_list') + expect(doc.child(1).attrs.order).toBe(3) + expect(() => doc.check()).not.toThrow() + }) + + it('projects a closed image span across chunks once while retaining surrounding text', () => { + const nodes = buildInlineContent(schema, [ + chunk({ text: 'before lo', offset: 0 }), + chunk({ text: 'go after', offset: 9, closing: [{ type: 'image', offset: 7, length: 4, src: 'image.png', alt: 'logo' }] }), + ]) + expect(nodes.map(node => node.isText ? node.text : `[${node.type.name}]`).join('')).toBe('before [image] after') + expect(nodes.filter(node => node.type.name === 'image')).toHaveLength(1) + }) + + it('falls back instead of throwing for malformed block states', () => { + const doc = buildDocFromChunks(schema, [ + chunk({ text: '', offset: 0, block: { type: 'table_cell' } }), + ]) + + expect(doc.type.name).toBe('doc') + expect(doc.childCount).toBeGreaterThan(0) + expect(() => doc.check()).not.toThrow() + }) +}) diff --git a/demo/svelte-demo/src/lib/prosemirror/stream-assembly.ts b/demo/svelte-demo/src/lib/prosemirror/stream-assembly.ts new file mode 100644 index 0000000..624d20e --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/stream-assembly.ts @@ -0,0 +1,251 @@ +import { Fragment, type Mark, type Node as ProseMirrorNode, type Schema } from 'prosemirror-model' +import type { Chunk, ClosedSpan, OpenSpan, SpanType } from '../../../../../src/markdown-stream-parser.ts' + +type ListMetadata = NonNullable +type ListFrame = { type: ListMetadata['type']; depth: number; order: number; items: ProseMirrorNode[] } +type CellGroup = { cellId: string; columnIndex: number; type: 'table_header_cell' | 'table_cell'; align?: 'left' | 'center' | 'right'; chunks: Chunk[] } +type MarkRange = { type: Exclude; start: number; end: number } +type TextRun = { start: number; end: number; text: string; image?: ClosedSpan & { type: 'image'; src: string; alt?: string } } + +export function isChunkBeforeBacktrack(chunk: Chunk, backtrackOffset: number): boolean { + return chunk.offset + chunk.length <= backtrackOffset +} + +export function applyStreamingChunkToBuffer(buffer: Chunk[], chunk: Chunk): Chunk[] { + const next = chunk.backtrackOffset === undefined ? buffer : buffer.filter(item => isChunkBeforeBacktrack(item, chunk.backtrackOffset!)) + return [...next, chunk] +} + +export function sanitizeLinkHref(rawHref: string): string | null { + const href = rawHref.trim() + if (!href) return null + try { + const url = new URL(href) + return ['http:', 'https:', 'mailto:'].includes(url.protocol.toLowerCase()) ? href : null + } catch { return null } +} + +// SVG data URLs are intentionally excluded: SVG is an active document format, not a raster image. +const safeDataImage = /^data:image\/(?:png|apng|gif|jpe?g|webp|avif);base64,[a-z0-9+/]*={0,2}$/i + +export function sanitizeImageSrc(rawSrc: string): string | null { + const src = rawSrc.trim() + if (!src || src.startsWith('//') || src.startsWith('?') || src.startsWith('#')) return null + if (src.startsWith('/') || /^(?:\.\.?\/)?[^:/?#][^:]*$/u.test(src)) return src + if (safeDataImage.test(src)) return src + try { + const url = new URL(src) + return ['http:', 'https:'].includes(url.protocol.toLowerCase()) ? src : null + } catch { return null } +} + +export function groupChunksIntoBlocks(chunks: Chunk[]): Chunk[][] { + const result: Chunk[][] = [] + let current: Chunk[] = [] + for (const chunk of chunks) { + const previous = current.at(-1) + const typeChanged = previous && chunk.block.type !== previous.block.type && !(isTableCellBlockType(chunk.block.type) && isTableCellBlockType(previous.block.type) && chunk.block.table?.tableId === previous.block.table?.tableId) + const tableChanged = previous && chunk.block.table?.tableId !== previous.block.table?.tableId + const headingChanged = previous && chunk.block.type === 'heading' && chunk.block.level !== previous.block.level + const listChanged = previous && chunk.block.type === 'list_item' && (chunk.block.list?.depth !== previous.block.list?.depth || chunk.block.list?.type !== previous.block.list?.type) + const nextListItem = previous && chunk.block.type === 'list_item' && previous.text.endsWith('\n') && chunk.text.trim().length > 0 + if ((typeChanged || tableChanged || headingChanged || listChanged || nextListItem) && current.length) { + result.push(current) + current = [] + } + current.push(chunk) + } + if (current.length) result.push(current) + return result +} + +export function buildInlineContent(schema: Schema, blockChunks: Chunk[]): ProseMirrorNode[] { + const chunks = trimTrailingNewline(blockChunks) + const closed = dedupeClosedSpans(chunks.flatMap(chunk => [...chunk.contained, ...chunk.closing])) + const marks = buildMarkRanges(chunks, closed) + return buildTextRuns(chunks, closed, marks).flatMap(run => { + if (run.image) { + const src = sanitizeImageSrc(run.image.src) + if (src && schema.nodes.image?.isInline) return [schema.nodes.image.create({ src, alt: run.image.alt ?? null })] + } + if (!run.text) return [] + const active = createMarksForRange(schema, closed, marks, run.start, run.end) + return [schema.text(run.text, active)] + }) +} + +/** Schema-parameterized content builder. Schemas without optional list/table/image nodes degrade to valid paragraphs/text. */ +export function buildContentFromChunks(schema: Schema, chunks: Chunk[]): Fragment { + const nodes: ProseMirrorNode[] = [] + const frames: ListFrame[] = [] + const flush = (minimumDepth = -1) => { + while (frames.length && frames.at(-1)!.depth >= minimumDepth) { + const frame = frames.pop()! + const listNode = createListNode(schema, frame) + if (!listNode) { + nodes.push(...frame.items.map(item => schema.nodes.paragraph.create(null, item.textContent ? schema.text(item.textContent) : null))) + } else if (frames.length) { + const parent = frames.at(-1)! + const item = parent.items.pop() + parent.items.push(item ? appendBlockToListItem(schema, item, listNode) : schema.nodes.list_item.create(null, listNode)) + } else nodes.push(listNode) + } + } + const blocks = groupChunksIntoBlocks(chunks) + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index] + const first = block[0] + try { + if (first?.block.type === 'list_item' && first.block.list) { + const list = first.block.list + // Keep same-depth siblings; close only descendants. A different list type replaces the frame. + while (frames.length && frames.at(-1)!.depth > list.depth) flush(frames.at(-1)!.depth) + if (frames.at(-1)?.depth === list.depth && frames.at(-1)?.type !== list.type) flush(list.depth) + if (!frames.at(-1) || frames.at(-1)!.depth !== list.depth) frames.push({ type: list.type, depth: list.depth, order: list.ordinal ?? 1, items: [] }) + frames.at(-1)!.items.push(createListItemNode(schema, block, list)) + if (!blocks[index + 1]?.[0]?.block.list) flush(0) + continue + } + flush(0) + if (isTableCellBlockType(first?.block.type)) { + const tableId = first.block.table?.tableId + const tableBlocks = [block] + while (tableId !== undefined && blocks[index + 1]?.[0]?.block.table?.tableId === tableId) tableBlocks.push(blocks[++index]) + const table = createTableNode(schema, tableBlocks.flat()) + nodes.push(table ?? fallbackParagraph(schema, tableBlocks.flat())) + } else nodes.push(createBlockNode(schema, block)) + } catch (error) { + console.warn('Unexpected ProseMirror stream assembly failure; using plain-text fallback.', error) + flush(0) + nodes.push(fallbackParagraph(schema, block)) + } + } + flush(0) + return nodes.length ? Fragment.fromArray(nodes) : Fragment.from(schema.nodes.paragraph.create()) +} + +export function buildDocFromChunks(schema: Schema, chunks: Chunk[]): ProseMirrorNode { + const doc = schema.nodes.doc.createAndFill(null, buildContentFromChunks(schema, chunks)) + if (!doc) throw new Error('Schema cannot create a valid document from streaming content') + doc.check() + return doc +} + +function createBlockNode(schema: Schema, block: Chunk[]): ProseMirrorNode { + const first = block[0] + const inline = buildInlineContent(schema, block) + const node = first?.block.type === 'heading' ? schema.nodes.heading?.createAndFill({ level: first.block.level ?? 1 }, inline) + : first?.block.type === 'code_block' ? schema.nodes.code_block?.createAndFill({ language: first.block.language ?? '' }, createTextNodeOrNull(schema, trimTrailingNewline(block).map(c => c.text).join(''))) + : first?.block.type === 'blockquote' ? schema.nodes.blockquote?.createAndFill(null, schema.nodes.paragraph.create(null, inline)) + : schema.nodes.paragraph.createAndFill(null, inline) + return node ?? fallbackParagraph(schema, block) +} + +function createListItemNode(schema: Schema, block: Chunk[], list: ListMetadata): ProseMirrorNode { + return schema.nodes.list_item.createAndFill({ task: list.task ?? null }, schema.nodes.paragraph.create(null, buildInlineContent(schema, block))) ?? fallbackParagraph(schema, block) +} + +function createListNode(schema: Schema, frame: ListFrame): ProseMirrorNode | null { + const type = frame.type === 'ordered' ? schema.nodes.ordered_list : schema.nodes.bullet_list + return type?.createAndFill(frame.type === 'ordered' ? { order: frame.order } : null, frame.items) ?? null +} + +function appendBlockToListItem(schema: Schema, item: ProseMirrorNode, block: ProseMirrorNode): ProseMirrorNode { + return schema.nodes.list_item.createAndFill(item.attrs, item.content.append(Fragment.from(block))) ?? item +} + +function createTableNode(schema: Schema, chunks: Chunk[]): ProseMirrorNode | null { + if (!schema.nodes.table || !schema.nodes.table_row || !schema.nodes.table_cell || !schema.nodes.table_header_cell) return null + const rows = buildTableRows(chunks) + if (!rows.length || rows.some(row => !row.cells.length)) return null + const rowNodes = rows.map(row => { + const cells = row.cells + .map(cell => (cell.type === 'table_header_cell' ? schema.nodes.table_header_cell : schema.nodes.table_cell).createAndFill({ align: cell.align ?? null }, buildInlineContent(schema, cell.chunks))) + .filter((node): node is ProseMirrorNode => node !== null) + return schema.nodes.table_row.createAndFill(null, cells) + }) + if (rowNodes.some(node => !node)) return null + const table = schema.nodes.table.createAndFill(null, rowNodes.filter((node): node is ProseMirrorNode => node !== null)) + if (table) table.check() + return table +} + +function buildTableRows(chunks: Chunk[]): Array<{ rowIndex: number; cells: CellGroup[] }> { + const rows = new Map>() + for (const chunk of chunks) { + const meta = chunk.block.table + if (!meta) continue + let row = rows.get(meta.rowIndex); if (!row) rows.set(meta.rowIndex, row = new Map()) + let cell = row.get(meta.cellId) + if (!cell) row.set(meta.cellId, cell = { cellId: meta.cellId, columnIndex: meta.columnIndex, type: chunk.block.type === 'table_header_cell' ? 'table_header_cell' : 'table_cell', align: meta.align, chunks: [] }) + cell.chunks.push(chunk) + } + return [...rows].sort(([a], [b]) => a - b).map(([rowIndex, cells]) => ({ rowIndex, cells: [...cells.values()].sort((a, b) => a.columnIndex - b.columnIndex) })) +} + +function buildTextRuns(chunks: Chunk[], spans: ClosedSpan[], markRanges: MarkRange[]): TextRun[] { + const images = spans.filter((span): span is ClosedSpan & { type: 'image'; src: string; alt?: string } => span.type === 'image' && isFullyCovered(chunks, span.offset, span.offset + span.length)) + const boundaries = new Set() + for (const chunk of chunks) { boundaries.add(chunk.offset); boundaries.add(chunk.offset + chunk.length) } + for (const span of spans) { boundaries.add(span.offset); boundaries.add(span.offset + span.length) } + for (const range of markRanges) { boundaries.add(range.start); boundaries.add(range.end) } + const points = [...boundaries].sort((a, b) => a - b) + const runs: TextRun[] = [] + for (let i = 0; i < points.length - 1;) { + const start = points[i], image = images.find(span => span.offset === start) + const end = image ? image.offset + image.length : points[i + 1] + const text = textForRange(chunks, start, end) + runs.push({ start, end, text, image }) + i = image ? points.findIndex(point => point === end) : i + 1 + } + return runs +} + +function textForRange(chunks: Chunk[], start: number, end: number): string { + return chunks.map(chunk => { + const from = Math.max(start, chunk.offset), to = Math.min(end, chunk.offset + chunk.length) + return from < to ? chunk.text.slice(from - chunk.offset, to - chunk.offset) : '' + }).join('') +} + +function isFullyCovered(chunks: Chunk[], start: number, end: number): boolean { + let cursor = start + for (const chunk of [...chunks].sort((a, b) => a.offset - b.offset)) { + if (chunk.offset > cursor) return false + if (chunk.offset + chunk.length > cursor) cursor = Math.min(end, chunk.offset + chunk.length) + if (cursor === end) return true + } + return false +} + +function buildMarkRanges(chunks: Chunk[], spans: ClosedSpan[]): MarkRange[] { + const ranges = spans.filter(span => span.type !== 'link' && span.type !== 'image').map(span => ({ type: span.type, start: span.offset, end: span.offset + span.length })) + const open: Array = [], end = Math.max(0, ...chunks.map(chunk => chunk.offset + chunk.length)) + for (const chunk of chunks) { + for (const span of chunk.opening) if (span.type !== 'link' && span.type !== 'image') open.push(span as OpenSpan & { type: MarkRange['type'] }) + for (const span of chunk.closing) { + if (span.type === 'link' || span.type === 'image') continue + const index = open.findIndex(item => item.type === span.type && item.openOffset === span.offset) + if (index >= 0) { ranges.push({ type: span.type, start: open[index].openOffset, end: span.offset + span.length }); open.splice(index, 1) } + } + } + return [...ranges, ...open.filter(span => span.openOffset < end).map(span => ({ type: span.type, start: span.openOffset, end }))] +} + +function createMarksForRange(schema: Schema, spans: ClosedSpan[], ranges: MarkRange[], start: number, end: number): Mark[] { + const marks: Mark[] = [] + for (const span of spans) if (span.type === 'link' && span.offset < end && span.offset + span.length > start) { const href = sanitizeLinkHref(span.url); if (href && schema.marks.link) marks.push(schema.marks.link.create({ href })) } + for (const range of ranges) if (range.start < end && range.end > start) { const mark = createMarkForSpanType(schema, range.type); if (mark && !marks.some(item => item.type === mark.type)) marks.push(mark) } + return marks +} + +function createMarkForSpanType(schema: Schema, type: SpanType): Mark | null { + const name = type === 'bold' ? 'strong' : type === 'italic' ? 'em' : type + return name === 'code' || name === 'strikethrough' || name === 'strong' || name === 'em' ? schema.marks[name]?.create() ?? null : null +} + +function dedupeClosedSpans(spans: T[]): T[] { const seen = new Set(); return spans.filter(span => { const key = `${span.type}:${span.offset}:${span.length}:${'url' in span ? span.url : ''}:${'src' in span ? span.src : ''}`; if (seen.has(key)) return false; seen.add(key); return true }) } +function trimTrailingNewline(chunks: Chunk[]): Chunk[] { const copy = [...chunks], last = copy.at(-1); if (!last?.text.endsWith('\n')) return copy; copy[copy.length - 1] = { ...last, text: last.text.slice(0, -1), length: Math.max(0, last.length - 1) }; return copy } +function createTextNodeOrNull(schema: Schema, text: string): ProseMirrorNode | null { return text ? schema.text(text) : null } +function fallbackParagraph(schema: Schema, chunks: Chunk[]): ProseMirrorNode { const text = trimTrailingNewline(chunks).map(chunk => chunk.text).join(''); return schema.nodes.paragraph.create(null, text ? schema.text(text) : null) } +function isTableCellBlockType(type: string | undefined): boolean { return type === 'table_header_cell' || type === 'table_cell' } diff --git a/demo/svelte-demo/src/lib/prosemirror/stream-examples.integration.test.ts b/demo/svelte-demo/src/lib/prosemirror/stream-examples.integration.test.ts new file mode 100644 index 0000000..5cfa677 --- /dev/null +++ b/demo/svelte-demo/src/lib/prosemirror/stream-examples.integration.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { MarkdownStreamParser, type Chunk, type StreamingChunk } from '../../../../../src/markdown-stream-parser.ts' +import { schema } from './schema.ts' +import { applyStreamingChunkToBuffer, buildDocFromChunks } from './stream-assembly.ts' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const repoRoot = join(__dirname, '../../../../..') +// Tests must not depend on generated Svelte static output. +const examplesDir = join(repoRoot, 'demo/llm-streams-examples') +const wasmDir = join(repoRoot, 'demo/svelte-demo/static') + +type ParsedExample = { + chunks: Chunk[] + activeChunks: Chunk[] + doc: ReturnType +} + +function readExampleTokens(name: string): string[] { + return JSON.parse(readFileSync(join(examplesDir, `${name}.json`), 'utf8')) as string[] +} + +async function parseExample(name: string, limit = Number.POSITIVE_INFINITY): Promise { + MarkdownStreamParser.configureWasmPath(join(wasmDir, 'tree-sitter-markdown.wasm')) + const parser = await MarkdownStreamParser.getInstance(`pm-integration-${name}-${limit}`) + const chunks: Chunk[] = [] + let activeChunks: Chunk[] = [] + + parser.subscribeToTokenParse((parsed: StreamingChunk) => { + if (parsed.status !== 'STREAMING') return + chunks.push(parsed.chunk) + activeChunks = applyStreamingChunkToBuffer(activeChunks, parsed.chunk) + }) + + parser.startParsing() + for (const token of readExampleTokens(name).slice(0, limit)) { + parser.parseToken(token) + } + parser.stopParsing() + MarkdownStreamParser.removeInstance(`pm-integration-${name}-${limit}`) + + return { + chunks, + activeChunks, + doc: buildDocFromChunks(schema, activeChunks), + } +} + +function hasNode(doc: ParsedExample['doc'], type: string): boolean { + let found = false + doc.descendants(node => { + if (node.type.name === type) found = true + }) + return found +} + +function hasMark(doc: ParsedExample['doc'], type: string): boolean { + let found = false + doc.descendants(node => { + if (node.marks.some(mark => mark.type.name === type)) found = true + }) + return found +} + +function taskAttrs(doc: ParsedExample['doc']): unknown[] { + const attrs: unknown[] = [] + doc.descendants(node => { + if (node.type.name === 'list_item' && node.attrs.task) attrs.push(node.attrs.task) + }) + return attrs +} + +function compactText(text: string): string { + return text.replace(/\s+/g, '') +} + +describe('real stream examples to ProseMirror documents', () => { + it('renders heading, list, emphasis, and code examples into document structure', async () => { + const quantum = await parseExample('claude-3.5-1-quantum-physics') + const history = await parseExample('gpt-4.o-history-of-cats') + const code = await parseExample('claude-3.7-happy-number-5-programs') + + expect(hasNode(quantum.doc, 'heading')).toBe(true) + expect(hasNode(history.doc, 'bullet_list') || hasNode(history.doc, 'ordered_list')).toBe(true) + expect(hasMark(history.doc, 'strong') || hasMark(history.doc, 'em')).toBe(true) + expect(hasNode(code.doc, 'code_block')).toBe(true) + }) + + it('self-corrects backtracking streams without stale active text', async () => { + const nestedCode = await parseExample('claude-3.7-markdown-with-nested-code-block') + const errorRecovery = await parseExample('test-error-recovery') + + expect(nestedCode.chunks.some(chunk => chunk.backtrackOffset !== undefined)).toBe(true) + expect(errorRecovery.chunks.some(chunk => chunk.backtrackOffset !== undefined)).toBe(true) + expect(compactText(nestedCode.doc.textContent)).toBe(compactText(nestedCode.activeChunks.map(chunk => chunk.text).join(''))) + expect(compactText(errorRecovery.doc.textContent)).toBe(compactText(errorRecovery.activeChunks.map(chunk => chunk.text).join(''))) + expect(hasNode(errorRecovery.doc, 'table')).toBe(true) + expect(hasNode(errorRecovery.doc, 'code_block')).toBe(true) + }) + + it('renders strikethrough, tables, and task lists', async () => { + const strikethrough = await parseExample('test-strikethrough') + const table = await parseExample('test-error-recovery') + const taskDoc = buildDocFromChunks(schema, [ + { + text: 'Completed\n', + offset: 0, + length: 10, + block: { type: 'list_item', list: { type: 'unordered', depth: 0, marker: '-', task: { checked: true } } }, + opening: [], + closing: [], + contained: [], + }, + { + text: 'Incomplete', + offset: 10, + length: 10, + block: { type: 'list_item', list: { type: 'unordered', depth: 0, marker: '-', task: { checked: false } } }, + opening: [], + closing: [], + contained: [], + }, + ]) + + expect(hasMark(strikethrough.doc, 'strikethrough')).toBe(true) + expect(hasNode(table.doc, 'table')).toBe(true) + expect(taskAttrs(taskDoc)).toEqual([{ checked: true }, { checked: false }]) + }) + + it('supports reset, replay after completion, and switching examples at the buffer level', async () => { + const partial = await parseExample('gpt-4.5-cat-coding', 5) + const replayA = await parseExample('test-strikethrough') + const replayB = await parseExample('test-strikethrough') + const switched = await parseExample('test-error-recovery', 3) + + expect(partial.activeChunks.length).toBeGreaterThan(0) + expect(buildDocFromChunks(schema, []).textContent).toBe('') + expect(replayA.doc.eq(replayB.doc)).toBe(true) + expect(switched.doc.textContent).not.toBe(partial.doc.textContent) + }) +}) diff --git a/demo/svelte-demo/src/routes/+page.svelte b/demo/svelte-demo/src/routes/+page.svelte index e7cfa81..ae70bb3 100644 --- a/demo/svelte-demo/src/routes/+page.svelte +++ b/demo/svelte-demo/src/routes/+page.svelte @@ -1,27 +1,13 @@
@@ -598,233 +384,8 @@ class="md:col-span-2 bg-white rounded shadow p-4 min-h-[400px] flex flex-col" >

Parsed Stream

-
- {#each parsedBlocks as block} - {@const blockType = block[0]?.block.type} - {@const blockLevel = block[0]?.block.level} - {@const blockLanguage = block[0]?.block.language} - {@const hasTableCells = - blockType === "table_header_cell" || blockType === "table_cell"} - {@const tableRows = hasTableCells ? buildTableRows(block) : []} - -
- {#if blockType === "heading"} - {#if blockLevel === 1} -

- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -

- {:else if blockLevel === 2} -

- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -

- {:else if blockLevel === 3} -

- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -

- {:else if blockLevel === 4} -

- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -

- {:else if blockLevel === 5} -
- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -
- {:else if blockLevel === 6} -
- {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} -
- {:else} - - {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} - - {/if} - {:else if blockType === "code_block"} -
{#each block as chunk}{chunk.text}{/each}
- {#if blockLanguage} - {blockLanguage} - {/if} - {:else if blockType === "blockquote"} - - {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} - - {:else if blockType === "list_item"} - {@const list = block[0]?.block.list} - - - {#if list?.type === "ordered"} - {list.ordinal ?? ""}{list.marker} - {:else} - • - {/if} - - {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} - - {:else if blockType === "table_header_cell" || blockType === "table_cell"} -
- - {#each tableRows as row} - - {#each row.cells as cell} - - {#each cell.chunks as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} - - {/each} - - {/each} -
-
- {:else} - - - {#each block as chunk} - {@const styles = [ - ...chunk.contained.map((s) => s.type), - ...chunk.opening.map((s) => s.type), - ]} - {#if hasCodeStyle(styles)} - {chunk.text} - {:else} - {chunk.text} - {/if} - {/each} - - {/if} -
- {/each} +
+
diff --git a/demo/svelte-demo/tsconfig.json b/demo/svelte-demo/tsconfig.json index 6f788f1..5622bac 100644 --- a/demo/svelte-demo/tsconfig.json +++ b/demo/svelte-demo/tsconfig.json @@ -5,6 +5,7 @@ "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, diff --git a/demo/svelte-demo/vitest.config.ts b/demo/svelte-demo/vitest.config.ts new file mode 100644 index 0000000..1dace44 --- /dev/null +++ b/demo/svelte-demo/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + exclude: ['.svelte-kit/**', 'dist/**', 'node_modules/**'], + }, +}) diff --git a/src/tree-sitter-markdown-stream-parser.ts b/src/tree-sitter-markdown-stream-parser.ts index f440876..0867fdc 100644 --- a/src/tree-sitter-markdown-stream-parser.ts +++ b/src/tree-sitter-markdown-stream-parser.ts @@ -1,4 +1,4 @@ -import { Parser, Language, type Tree, type Node } from 'web-tree-sitter' +import { Parser, Language, type Tree, type Node, type Edit } from 'web-tree-sitter' import TokensStreamBuffer from './tokens-stream-buffer.ts' import type { Chunk, @@ -353,14 +353,14 @@ export class MarkdownStreamParser { // For proper incremental parsing, tell tree-sitter what changed if (this.currentTree) { - this.currentTree.edit({ + this.currentTree.edit(({ startIndex: oldLength, oldEndIndex: oldLength, newEndIndex: this.content.length, startPosition: oldEndPosition, oldEndPosition, newEndPosition: this.endPosition - }) + }) as Edit) } // Parse the updated content diff --git a/src/tree-sitter/inline-detection.ts b/src/tree-sitter/inline-detection.ts index 1723e40..3996a6a 100644 --- a/src/tree-sitter/inline-detection.ts +++ b/src/tree-sitter/inline-detection.ts @@ -263,6 +263,9 @@ export function isInsideCodeBlock( if (inlineNode) { const inlineContent = inlineNode.text const inlineTree = inlineParser.parse(inlineContent) + if (!inlineTree) { + return false + } const relativePos = position - inlineNode.startIndex // Check if position is inside any code_span diff --git a/src/tree-sitter/segment-generator.ts b/src/tree-sitter/segment-generator.ts index caf88d8..ae286ef 100644 --- a/src/tree-sitter/segment-generator.ts +++ b/src/tree-sitter/segment-generator.ts @@ -379,79 +379,80 @@ export function generateSegments( if (inlineNode && inlineParser) { const inlineContent = inlineNode.text const inlineTree = inlineParser.parse(inlineContent) + if (inlineTree) { + // Count the range in the new portion + const newPortionStart = actualFromIndex - inlineNode.startIndex + const newPortionEnd = actualToIndex - inlineNode.startIndex + const newPortion = inlineContent.substring(Math.max(0, newPortionStart), newPortionEnd) + + // Check for unmatched backtick + if (newPortion.includes('`')) { + const hasCompleteCodeSpan = hasCompleteCodeSpanAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteCodeSpan) { + state.pendingInlineContent = newContent + state.pendingInlineStartIndex = actualFromIndex + state.sourceOffset = actualToIndex + return { segments, state } + } + } - // Count the range in the new portion - const newPortionStart = actualFromIndex - inlineNode.startIndex - const newPortionEnd = actualToIndex - inlineNode.startIndex - const newPortion = inlineContent.substring(Math.max(0, newPortionStart), newPortionEnd) - - // Check for unmatched backtick - if (newPortion.includes('`')) { - const hasCompleteCodeSpan = hasCompleteCodeSpanAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteCodeSpan) { - state.pendingInlineContent = newContent - state.pendingInlineStartIndex = actualFromIndex - state.sourceOffset = actualToIndex - return { segments, state } + // Check for unmatched bold markers + if (newPortion.includes('**')) { + const hasCompleteBold = hasCompleteBoldAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteBold) { + state.pendingInlineContent = newContent + state.pendingInlineStartIndex = actualFromIndex + state.sourceOffset = actualToIndex + return { segments, state } + } } - } - // Check for unmatched bold markers - if (newPortion.includes('**')) { - const hasCompleteBold = hasCompleteBoldAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteBold) { - state.pendingInlineContent = newContent - state.pendingInlineStartIndex = actualFromIndex - state.sourceOffset = actualToIndex - return { segments, state } + // Check for unmatched italic markers (skip if inside code block) + const insideCodeBlock = isInsideCodeBlock(currentTree.rootNode, actualFromIndex, currentTree, inlineParser) + if (!insideCodeBlock) { + const hasUnmatchedItalic = hasUnmatchedItalicMarker(newPortion, inlineParser) + if (hasUnmatchedItalic) { + const hasCompleteItalic = hasCompleteItalicAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteItalic) { + state.pendingInlineContent = newContent + state.pendingInlineStartIndex = actualFromIndex + state.sourceOffset = actualToIndex + return { segments, state } + } + } } - } - // Check for unmatched italic markers (skip if inside code block) - const insideCodeBlock = isInsideCodeBlock(currentTree.rootNode, actualFromIndex, currentTree, inlineParser) - if (!insideCodeBlock) { - const hasUnmatchedItalic = hasUnmatchedItalicMarker(newPortion, inlineParser) - if (hasUnmatchedItalic) { - const hasCompleteItalic = hasCompleteItalicAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteItalic) { + // Check for unmatched strikethrough markers + if (newPortion.includes('~~')) { + const hasCompleteStrikethrough = hasCompleteStrikethroughAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteStrikethrough) { state.pendingInlineContent = newContent state.pendingInlineStartIndex = actualFromIndex state.sourceOffset = actualToIndex return { segments, state } } } - } - - // Check for unmatched strikethrough markers - if (newPortion.includes('~~')) { - const hasCompleteStrikethrough = hasCompleteStrikethroughAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteStrikethrough) { - state.pendingInlineContent = newContent - state.pendingInlineStartIndex = actualFromIndex - state.sourceOffset = actualToIndex - return { segments, state } - } - } - // Check for incomplete link opening [ - if (newPortion.includes('[')) { - const hasCompleteLink = hasCompleteLinkAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteLink && hasIncompleteLinkOpening(newPortion, inlineParser)) { - state.pendingInlineContent = newContent - state.pendingInlineStartIndex = actualFromIndex - state.sourceOffset = actualToIndex - return { segments, state } + // Check for incomplete link opening [ + if (newPortion.includes('[')) { + const hasCompleteLink = hasCompleteLinkAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteLink && hasIncompleteLinkOpening(newPortion, inlineParser)) { + state.pendingInlineContent = newContent + state.pendingInlineStartIndex = actualFromIndex + state.sourceOffset = actualToIndex + return { segments, state } + } } - } - // Check for incomplete image opening ![ - if (newPortion.includes('![')) { - const hasCompleteImage = hasCompleteImageAt(inlineTree.rootNode, newPortionStart, newPortionEnd) - if (!hasCompleteImage && hasIncompleteImageOpening(newPortion, inlineParser)) { - state.pendingInlineContent = newContent - state.pendingInlineStartIndex = actualFromIndex - state.sourceOffset = actualToIndex - return { segments, state } + // Check for incomplete image opening ![ + if (newPortion.includes('![')) { + const hasCompleteImage = hasCompleteImageAt(inlineTree.rootNode, newPortionStart, newPortionEnd) + if (!hasCompleteImage && hasIncompleteImageOpening(newPortion, inlineParser)) { + state.pendingInlineContent = newContent + state.pendingInlineStartIndex = actualFromIndex + state.sourceOffset = actualToIndex + return { segments, state } + } } } } @@ -641,40 +642,42 @@ export function generateSegments( usedInlineContent = hostInlineNode !== null const inlineContent = hostInlineNode?.text ?? processedContent const inlineTree = inlineParser.parse(inlineContent) - const delimiterRanges = collectInlineDelimiterRanges(inlineTree) - const chunkStartInInline = hostInlineNode - ? Math.max(0, actualFromIndex - hostInlineNode.startIndex) - : 0 - const chunkEndInInline = hostInlineNode - ? Math.max(chunkStartInInline, actualToIndex - hostInlineNode.startIndex) - : processedContent.length - - const spanResult = processInlineSpans( - inlineTree, - chunkStartInInline, - chunkEndInInline, - inlineContent, - state, - delimiterRanges, - chunkStartUtf16 - rawToRenderedOffset(chunkStartInInline, delimiterRanges) - ) - opening = spanResult.opening - closing = spanResult.closing - contained = spanResult.contained - state = { ...state, openSpans: spanResult.newOpenSpans } - - // Strip inline markers from the content - strippedContent = getInlineContent( - hostInlineNode ? inlineContent.substring(chunkStartInInline, chunkEndInInline) : processedContent, - inlineTree, - chunkStartInInline, - chunkEndInInline - ) - - if (hostInlineNode && blockInfo.type !== 'header' && actualToIndex > hostInlineNode.endIndex) { - const tailStart = Math.max(actualFromIndex, hostInlineNode.endIndex) - const tailText = content.substring(tailStart, actualToIndex) - strippedContent += stripListSuppressedRanges(tailText, currentTree.rootNode, content, tailStart, actualToIndex) + if (inlineTree) { + const delimiterRanges = collectInlineDelimiterRanges(inlineTree) + const chunkStartInInline = hostInlineNode + ? Math.max(0, actualFromIndex - hostInlineNode.startIndex) + : 0 + const chunkEndInInline = hostInlineNode + ? Math.max(chunkStartInInline, actualToIndex - hostInlineNode.startIndex) + : processedContent.length + + const spanResult = processInlineSpans( + inlineTree, + chunkStartInInline, + chunkEndInInline, + inlineContent, + state, + delimiterRanges, + chunkStartUtf16 - rawToRenderedOffset(chunkStartInInline, delimiterRanges) + ) + opening = spanResult.opening + closing = spanResult.closing + contained = spanResult.contained + state = { ...state, openSpans: spanResult.newOpenSpans } + + // Strip inline markers from the content + strippedContent = getInlineContent( + hostInlineNode ? inlineContent.substring(chunkStartInInline, chunkEndInInline) : processedContent, + inlineTree, + chunkStartInInline, + chunkEndInInline + ) + + if (hostInlineNode && blockInfo.type !== 'header' && actualToIndex > hostInlineNode.endIndex) { + const tailStart = Math.max(actualFromIndex, hostInlineNode.endIndex) + const tailText = content.substring(tailStart, actualToIndex) + strippedContent += stripListSuppressedRanges(tailText, currentTree.rootNode, content, tailStart, actualToIndex) + } } } diff --git a/vitest.config.ts b/vitest.config.ts index bb2b066..ef3f5f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: 'node', globals: true, + include: ['src/**/*.test.ts'], }, esbuild: { target: 'node23'