From 3e346a8c5aa4de40f2855d9f99663be20a713c98 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 10:59:32 +0300 Subject: [PATCH 1/8] test: add failing regression test for #437/#440 markdown editor round-trip Captures 14 failure modes in the Tiptap+tiptap-markdown round-trip used by the file-preview pane (mountMarkdownEditor in editor.ts). The autosave loop in controller.ts reads getTiptapMarkdown() and diffs it against state.fullDocumentContent, then emits edit_block calls for every hunk. Any drift introduced by parse-and-reserialize gets silently written to disk. Failure cases (all 14/14 fail on current main): - GFM pipe table -> 'AB12' - '~' -> '\~' (prosemirror-markdown strikethrough escaping) - Adjacent block elements gain blank-line separators - Wikilink + heading combo drifts on spacing - Trailing newline stripped - Combined #437 fixture - YAML frontmatter --- parsed as Setext heading (#437 LevionLaurion) - '[x]' -> '\[x\]' (#440) - Underscore-rich identifiers drift on trailing newline (#440) - '~/path' -> '\~/path' (#440) - Loose lists drift on trailing newline (#440) - CRLF -> LF + soft-break collapse (related to #97) - Realistic README-style file: tables collapse + soft breaks merge, >70%-changed threshold in computeEditBlocks would emit a single edit_block replacing the entire file with the degraded version - Realistic doc with table embedded in prose: prose around table also lost via soft-break merge Adds jsdom@^24 as a devDependency to mount Tiptap headlessly. --- package-lock.json | 629 +++++++++++++++++++++++-- package.json | 1 + test/test-markdown-editor-roundtrip.js | 378 +++++++++++++++ 3 files changed, 981 insertions(+), 27 deletions(-) create mode 100644 test/test-markdown-editor-roundtrip.js diff --git a/package-lock.json b/package-lock.json index c2386930..fd5380d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wonderwhy-er/desktop-commander", - "version": "0.2.37", + "version": "0.2.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wonderwhy-er/desktop-commander", - "version": "0.2.37", + "version": "0.2.39", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -52,6 +52,7 @@ "commander": "^13.1.0", "esbuild": "^0.27.2", "js-tiktoken": "^1.0.21", + "jsdom": "^24.1.3", "nexe": "^5.0.0-beta.4", "nodemon": "^3.0.2", "shx": "^0.3.4", @@ -87,6 +88,20 @@ "mcpb": "dist/cli/cli.js" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -151,6 +166,121 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -2364,7 +2494,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2566,7 +2695,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2672,7 +2800,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2687,7 +2814,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2917,7 +3043,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3298,6 +3423,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -3312,7 +3438,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3360,7 +3485,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3647,7 +3771,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-union": { "version": "2.1.0", @@ -3677,6 +3802,13 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3909,6 +4041,7 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -3933,6 +4066,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -3942,6 +4076,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3953,13 +4088,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -4011,7 +4148,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4641,6 +4777,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -4724,7 +4873,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/core-util-is": { "version": "1.0.3", @@ -4850,6 +5000,27 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -4859,6 +5030,57 @@ "node": ">= 14" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", @@ -4888,6 +5110,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -5278,6 +5507,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -5308,6 +5547,7 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -5339,8 +5579,7 @@ "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "4.0.2", @@ -5810,6 +6049,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -6039,6 +6294,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6100,6 +6356,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6108,7 +6365,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ext-list": { "version": "2.2.2", @@ -6397,6 +6655,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -6415,6 +6674,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6423,7 +6683,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-up": { "version": "4.1.0", @@ -6495,6 +6756,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -6519,6 +6797,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -7190,6 +7469,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7616,6 +7908,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -7804,6 +8103,97 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7986,7 +8376,6 @@ "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", "license": "MIT", - "peer": true, "dependencies": { "@samverschueren/stream-to-observable": "^0.3.0", "is-observable": "^1.1.0", @@ -8808,6 +9197,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8817,6 +9207,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -8853,6 +9244,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9439,6 +9831,7 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", + "peer": true, "bin": { "mime": "cli.js" }, @@ -9630,6 +10023,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9950,6 +10344,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10348,6 +10749,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10418,7 +10845,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "4.0.0", @@ -10702,7 +11130,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -10732,7 +11159,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -10793,7 +11219,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -10861,6 +11286,19 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -10878,6 +11316,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -10956,6 +11404,13 @@ "node": ">=0.10.0" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11166,6 +11621,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11395,6 +11857,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -11587,6 +12056,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -11611,6 +12081,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -11619,7 +12090,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -11727,6 +12199,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -12470,6 +12943,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -12760,6 +13240,32 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -12937,6 +13443,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", + "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -12972,7 +13479,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13221,6 +13727,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/url-parse-lax": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", @@ -13255,6 +13772,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -13318,6 +13836,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz", @@ -13360,7 +13891,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -13410,7 +13940,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -13525,6 +14054,43 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -13728,6 +14294,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -13923,7 +14499,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index aa784e93..85e61ff5 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "commander": "^13.1.0", "esbuild": "^0.27.2", "js-tiktoken": "^1.0.21", + "jsdom": "^24.1.3", "nexe": "^5.0.0-beta.4", "nodemon": "^3.0.2", "shx": "^0.3.4", diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js new file mode 100644 index 00000000..4cfa45d3 --- /dev/null +++ b/test/test-markdown-editor-roundtrip.js @@ -0,0 +1,378 @@ +/** + * Regression test for #437: markdown preview auto-save corrupts table/wikilink/tilde + * content because the Tiptap+tiptap-markdown round-trip is lossy. + * + * The auto-save loop in src/ui/file-preview/src/markdown/controller.ts (line ~922, + * onChange -> scheduleAutosave) reads `getTiptapMarkdown()` from the editor and + * diffs it against `state.fullDocumentContent`. Any drift introduced by the + * parse-and-reserialize round trip becomes an `edit_block` call that silently + * overwrites the user's file. + * + * This test mounts the *exact* editor configuration used in + * src/ui/file-preview/src/markdown/editor.ts (mountMarkdownEditor) and verifies + * that round-tripping common markdown features through it is stable. It fails on + * the current implementation because: + * - GFM pipe tables collapse (no Table node in StarterKit) -> "AB12" + * - `~` is escaped to `\~` (prosemirror-markdown strikethrough escaping) + * - Adjacent block-level elements gain blank-line separators + * - Trailing newline is stripped + * + * Requires `jsdom` as a devDependency (added to package.json by this PR). + */ + +import assert from 'assert'; +import { JSDOM } from 'jsdom'; + +// Bootstrap a DOM that Tiptap can mount into. Must run before importing tiptap. +const dom = new JSDOM('
'); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.Node = dom.window.Node; +globalThis.DOMParser = dom.window.DOMParser; +globalThis.getComputedStyle = dom.window.getComputedStyle; + +const { Editor } = await import('@tiptap/core'); +const StarterKit = (await import('@tiptap/starter-kit')).default; +const Image = (await import('@tiptap/extension-image')).default; +const { Markdown } = await import('tiptap-markdown'); +const { rewriteWikiLinks, restoreWikiLinks } = await import( + '../dist/ui/file-preview/src/markdown/linking.js' +); + +/** + * Mount a Tiptap editor with the same extensions and config that + * mountMarkdownEditor uses for `view: 'markdown'`. + */ +function mountEditor(value) { + const target = document.getElementById('root'); + target.innerHTML = ''; + return new Editor({ + element: target, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3, 4, 5, 6] }, + codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, + link: { + openOnClick: false, + autolink: true, + HTMLAttributes: { 'data-markdown-link': 'true' }, + }, + }), + Image.configure({ allowBase64: true, inline: true }), + Markdown.configure({ + html: true, + tightLists: true, + bulletListMarker: '-', + linkify: true, + breaks: false, + transformPastedText: true, + transformCopiedText: false, + }), + ], + content: rewriteWikiLinks(value), + }); +} + +/** Mirrors editor.ts:181-184 getTiptapMarkdown */ +function getTiptapMarkdown(editor) { + const storage = editor.storage; + return restoreWikiLinks(storage.markdown?.getMarkdown() ?? ''); +} + +function roundTrip(input) { + const editor = mountEditor(input); + const out = getTiptapMarkdown(editor); + editor.destroy(); + return out; +} + +async function testPipeTableSurvivesRoundTrip() { + console.log('\n--- Test: GFM pipe table survives editor round-trip ---'); + const input = '# Test\n\n| A | B |\n|---|---|\n| 1 | 2 |\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'pipe table should not collapse into "AB12" — auto-save would write that to disk' + ); + console.log('OK pipe table preserved'); +} + +async function testTildeIsNotEscaped() { + console.log('\n--- Test: literal "~" is not escaped to "\\~" ---'); + const input = 'Use ~ to negate.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'tilde should not gain a backslash escape on round-trip' + ); + console.log('OK tilde preserved'); +} + +async function testAdjacentHeadingsKeepOriginalSpacing() { + console.log('\n--- Test: adjacent block-level elements keep original spacing ---'); + const input = '### Heading One\nBody.\n### Heading Two\nMore.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'serializer should not insert blank lines between blocks the user did not author' + ); + console.log('OK block spacing preserved'); +} + +async function testWikilinkSurvivesRoundTrip() { + console.log('\n--- Test: wikilink round-trips through editor ---'); + const input = '# Test\nSee [[Other Note]].\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'wikilink + heading + body should round-trip identically' + ); + console.log('OK wikilink preserved'); +} + +async function testTrailingNewlineSurvives() { + console.log('\n--- Test: trailing newline is preserved ---'); + const input = 'A single paragraph.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'trailing newline must not be stripped — POSIX text files are expected to end with one' + ); + console.log('OK trailing newline preserved'); +} + +async function testCombinedBugReportFile() { + console.log('\n--- Test: bug-report combined fixture ---'); + const input = '# Test\nSee [[Other Note]].\n\n| A | B |\n|---|---|\n| 1 | 2 |\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'the exact fixture from issue #437 should not drift on round-trip' + ); + console.log('OK combined fixture preserved'); +} + + +async function testYamlFrontmatterSurvives() { + console.log('\n--- Test: YAML frontmatter survives round-trip (#437 LevionLaurion, #440) ---'); + const input = '---\ntitle: My Note\ntags: [a, b]\ndescription: A test file\n---\n\n# Body\n\nContent here.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'YAML frontmatter delimited by --- must not be parsed as a Setext heading' + ); + console.log('OK frontmatter preserved'); +} + +async function testSquareBracketsNotEscaped() { + console.log('\n--- Test: square brackets not escaped (#440) ---'); + const input = '- [x] task done\n- [ ] task todo\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'GFM task list brackets [x] [ ] should not be escaped to \\[x\\]' + ); + console.log('OK brackets preserved'); +} + +async function testUnderscoresNotEscaped() { + console.log('\n--- Test: underscores in identifiers not escaped (#440) ---'); + const input = 'Use the my_variable_name in code, plus snake_case_func().\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'Bare underscores in identifiers must not be escaped to \\_' + ); + console.log('OK underscores preserved'); +} + +async function testTildePathNotEscaped() { + console.log('\n--- Test: tilde paths (~/foo) not escaped (#440) ---'); + const input = 'Open ~/Documents/notes.md to continue.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'Path-style ~/path must not be escaped to \\~/path' + ); + console.log('OK tilde-path preserved'); +} + +async function testFrontmatterListItem() { + console.log('\n--- Test: list with blank lines between items (#440) ---'); + const input = '- first item\n\n- second item\n\n- third item\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'Blank lines between list items (loose list) must not be stripped' + ); + console.log('OK loose list preserved'); +} + + +async function testCrlfPreserved() { + console.log('\n--- Test: CRLF line endings preserved (related to #97/#438) ---'); + // The Tiptap pipeline operates on strings; the file-preview UI seeds itself + // with content from read_file's text response, which is already LF-normalized + // by TextFileHandler (PR #438 fixes that upstream). But if a CRLF file + // somehow reaches this layer with CRLF intact, the round-trip should not + // silently downgrade to LF. + const input = '# Heading\r\nFirst line.\r\nSecond line.\r\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'CRLF line endings must not be silently converted to LF on round-trip' + ); + console.log('OK CRLF preserved'); +} + + +async function testReadmeStyleFileNotCollapsed() { + console.log('\n--- Test: README-style file not collapsed by Tiptap (issue #437 in-the-wild reproduction) ---'); + // This mirrors a real corruption captured by another Claude session: a 200+ line + // README with mixed markdown (headings, tables, code blocks, lists) was reduced + // to ~22 lines after a single edit_block call. The file-preview UI mounts on + // edit_block (server.ts:788), the editor parses the file via tiptap-markdown, + // and the lossy reserialization combined with computeEditBlocks' >70% threshold + // (controller.ts:155-158) emits a single edit_block that replaces the entire + // file with the structurally-degraded version. + const input = [ + '# My Project', + '', + 'A short intro paragraph explaining what the project does.', + '', + '## Installation', + '', + '```bash', + 'npm install my-project', + '```', + '', + '## Configuration', + '', + '| Variable | Default | Description |', + '|---|---|---|', + '| FOO | `bar` | The foo setting |', + '| BAZ | `qux` | The baz setting |', + '', + '## Usage', + '', + '- First, run `npm start`', + '- Then, open `http://localhost:3000`', + '- Finally, press `Ctrl+C` to stop', + '', + 'See [the docs](https://example.com) for more.', + '', + ].join('\n'); + const output = roundTrip(input); + // We are asserting two things: structure-preservation AND non-collapse. + // If output is dramatically shorter than input, the >70% threshold in + // computeEditBlocks would write a single full-file replacement. + const inputLines = input.split('\n').length; + const outputLines = output.split('\n').length; + const ratio = outputLines / inputLines; + if (ratio < 0.5) { + throw new Error( + 'output collapsed from ' + inputLines + ' to ' + outputLines + + ' lines (ratio ' + ratio.toFixed(2) + '). The >70% threshold in ' + + 'computeEditBlocks would emit a single edit_block that replaces the entire file ' + + 'with this degraded version.' + ); + } + assert.strictEqual( + output, + input, + 'README-style file with table+code+lists must round-trip unchanged' + ); + console.log('OK README-style file preserved'); +} + +async function testTableInsideRealisticDoc() { + console.log('\n--- Test: pipe table embedded in realistic doc does not erase neighbors ---'); + // Captures the specific failure mode: a table in the middle of a document + // collapses, and the collapse takes adjacent prose with it because the >70% + // line-change threshold trips on a single bad block. + const input = [ + '# Section A', + '', + 'Prose paragraph one with content.', + 'A second line of prose.', + '', + '## Comparison', + '', + '| Feature | A | B |', + '|---|---|---|', + '| Speed | fast | slow |', + '| Cost | low | high |', + '| Quality | good | bad |', + '', + '## Section B', + '', + 'More prose after the table that must not be deleted.', + 'Final line of the document.', + '', + ].join('\n'); + const output = roundTrip(input); + // Specific assertion: text outside the table must survive + if (!output.includes('Section A') || !output.includes('Section B') || + !output.includes('Final line of the document') || + !output.includes('Prose paragraph one')) { + throw new Error( + 'lost prose around the table. Output was:\n' + output + ); + } + assert.strictEqual( + output, + input, + 'realistic doc with table+prose must round-trip unchanged' + ); + console.log('OK realistic doc preserved'); +} + +async function runAllTests() { + const tests = [ + testPipeTableSurvivesRoundTrip, + testTildeIsNotEscaped, + testAdjacentHeadingsKeepOriginalSpacing, + testWikilinkSurvivesRoundTrip, + testTrailingNewlineSurvives, + testCombinedBugReportFile, + testYamlFrontmatterSurvives, + testSquareBracketsNotEscaped, + testUnderscoresNotEscaped, + testTildePathNotEscaped, + testFrontmatterListItem, + testCrlfPreserved, + testReadmeStyleFileNotCollapsed, + testTableInsideRealisticDoc, + ]; + let passed = 0; + let failed = 0; + for (const t of tests) { + try { + await t(); + passed++; + } catch (err) { + failed++; + console.error('FAIL ' + t.name); + console.error(' ' + err.message); + } + } + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + if (failed > 0) { + process.exit(1); + } +} + +runAllTests(); From a044f3deecbad8b0ec4273d269d7433fd57b67fb Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 17:25:18 +0300 Subject: [PATCH 2/8] fix(markdown editor): round-trip safety + edit-diff invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiptap's parse-and-reserialise round-trip silently mutates user files (see #437, #440). Pure round-trip safety (untouched file -> identical output) is too strict; what matters in production is that an edit produces only the user's actual change, not collateral normalisation. Two test suites: - test-markdown-editor-roundtrip.js (existing, 14 cases): strict round-trip — files survive open->getMarkdown() byte-for-byte. - test-markdown-editor-edit-diff.js (new, 7 cases): realistic — apply a small edit, run computeEditBlocks against the original, assert the diff is bounded (no whole-file rewrite, <=3 hunks, <=20% lines differing, expected text actually present). Implementation: - Centralise Tiptap config in buildTiptapExtensions() so test and production exercise the same code path. Disable strike to stop \~ escaping. Add Table / TableRow / TableHeader / TableCell extensions so GFM pipe tables don't collapse to concatenated cell text. - preprocessForEditor / applyPostProcess wrap the editor with a RoundTripContext that captures: original EOL (LF vs CRLF), YAML frontmatter prefix, gap between frontmatter and body, trailing newline. All re-applied on the way out. - Post-process repairs: unescapeSafeChars (\[, \], \~ in prose), restoreTableSeparatorStyle (|---| vs | --- |), restoreSoftBreaks (Tiptap's space-joined paragraph lines back to original line breaks), collapseBlockSeparators (Tiptap's spurious blank lines between adjacent block elements). Order matters: softBreaks must run before blockSeparators because blockSeparators matches surrounding lines against pairs from the original. - Export computeEditBlocks from controller.ts so the edit-diff test exercises the real autosave decision logic, including the >70% whole-file rewrite trigger. All 21 tests pass. Includes the in-the-wild #437 README fixture, the mixed table+prose+frontmatter case, CRLF preservation, wikilink round-trip, and the realistic 'edit a paragraph in a doc with frontmatter + wikilinks + tasks + table' edit-diff case. --- package-lock.json | 156 +++---- package.json | 4 + .../file-preview/src/markdown/controller.ts | 2 +- src/ui/file-preview/src/markdown/editor.ts | 412 +++++++++++++++++- test/test-markdown-editor-edit-diff.js | 357 +++++++++++++++ test/test-markdown-editor-roundtrip.js | 47 +- 6 files changed, 820 insertions(+), 158 deletions(-) create mode 100644 test/test-markdown-editor-edit-diff.js diff --git a/package-lock.json b/package-lock.json index fd5380d0..aaa597f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,10 @@ "@supabase/supabase-js": "^2.89.0", "@tiptap/core": "^3.22.3", "@tiptap/extension-image": "^3.22.3", + "@tiptap/extension-table": "^3.22.4", + "@tiptap/extension-table-cell": "^3.22.4", + "@tiptap/extension-table-header": "^3.22.4", + "@tiptap/extension-table-row": "^3.22.4", "@tiptap/pm": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@vscode/ripgrep": "^1.15.9", @@ -2273,12 +2277,6 @@ "node": ">=18" } }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", @@ -2490,16 +2488,16 @@ } }, "node_modules/@tiptap/core": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", - "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.4.tgz", + "integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.22.3" + "@tiptap/pm": "3.22.4" } }, "node_modules/@tiptap/extension-blockquote": { @@ -2769,6 +2767,59 @@ "@tiptap/core": "^3.22.3" } }, + "node_modules/@tiptap/extension-table": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.4.tgz", + "integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.4", + "@tiptap/pm": "3.22.4" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz", + "integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.4" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz", + "integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.4" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz", + "integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.22.4" + } + }, "node_modules/@tiptap/extension-text": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.3.tgz", @@ -2810,27 +2861,21 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", - "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.4.tgz", + "integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" }, @@ -4971,12 +5016,6 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" - }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -11027,15 +11066,6 @@ "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", @@ -11082,16 +11112,6 @@ "rope-sequence": "^1.3.0" } }, - "node_modules/prosemirror-inputrules": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", - "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.0.0" - } - }, "node_modules/prosemirror-keymap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", @@ -11113,18 +11133,6 @@ "prosemirror-model": "^1.25.0" } }, - "node_modules/prosemirror-menu": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.1.tgz", - "integrity": "sha512-2OSIKBFyLo2iqDpjQHEC7tKt3lluhY7L44pcRai8EpoU9R7cZDj/dklEsOOIubNKWUXab6dL7y4JtAWnrlR4lA==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { "version": "1.25.4", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", @@ -11134,15 +11142,6 @@ "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -11178,33 +11177,6 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, - "node_modules/prosemirror-trailing-node/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prosemirror-transform": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", diff --git a/package.json b/package.json index 85e61ff5..2bd1dbf1 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,10 @@ "@supabase/supabase-js": "^2.89.0", "@tiptap/core": "^3.22.3", "@tiptap/extension-image": "^3.22.3", + "@tiptap/extension-table": "^3.22.4", + "@tiptap/extension-table-cell": "^3.22.4", + "@tiptap/extension-table-header": "^3.22.4", + "@tiptap/extension-table-row": "^3.22.4", "@tiptap/pm": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", "@vscode/ripgrep": "^1.15.9", diff --git a/src/ui/file-preview/src/markdown/controller.ts b/src/ui/file-preview/src/markdown/controller.ts index abafa0eb..59f86ddd 100644 --- a/src/ui/file-preview/src/markdown/controller.ts +++ b/src/ui/file-preview/src/markdown/controller.ts @@ -138,7 +138,7 @@ function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { return merged; } -function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { +export function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { if (oldText === newText) { return []; } diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 0188c484..2aab1771 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -1,12 +1,392 @@ import { Editor } from '@tiptap/core'; +import type { Extensions } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Image from '@tiptap/extension-image'; +import { Table } from '@tiptap/extension-table'; +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 { restoreWikiLinks, rewriteWikiLinks } from './linking.js'; import { createSlugTracker } from './slugify.js'; export type MarkdownEditorView = 'raw' | 'markdown'; +/** + * Round-trip safety wrapper around Tiptap. + * + * Tiptap parses markdown into ProseMirror nodes and serializes back via + * tiptap-markdown. Both steps are inherently lossy — features like GFM + * tables, wikilinks, YAML frontmatter, escapable characters and exact + * whitespace can't be recovered exactly from the parsed tree. The wrappers + * below preserve those features by: + * + * 1. Stripping content the editor can't safely round-trip (YAML + * frontmatter, CRLF line endings) BEFORE handing markdown to Tiptap, + * and re-attaching it after serialization. + * 2. Calling existing helpers (rewriteWikiLinks / restoreWikiLinks) that + * replace `[[Page]]` with placeholder syntax Tiptap understands, + * then put it back on the way out. + * 3. Preserving a trailing newline if the original document ended with + * one — Tiptap's serializer always strips it. + * + * The shape of the safe region we save is captured in a `RoundTripContext` + * so post-processing can mirror it back. The test suite imports these + * helpers directly so the regression suite tests the EXACT same code path + * that production runs at autosave time. + */ +export interface RoundTripContext { + /** Original document text, retained for any final repair pass. */ + originalInput: string; + /** YAML frontmatter prefix (`---\n…\n---\n`) stripped before editing. */ + frontmatter: string; + /** Newlines between frontmatter end and first body line. Tiptap strips + * these; we put them back exactly. */ + frontmatterGap: string; + /** Trailing newline that was on the original; restored after serialize. */ + trailingNewline: string; + /** EOL convention of the original (`'\r\n'` or `'\n'`). */ + eol: '\r\n' | '\n'; +} + +const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; + +/** + * Pre-process a document before handing it to Tiptap. Returns a context + * object that `applyPostProcess` uses to restore stripped portions. + */ +export function preprocessForEditor(input: string): { editorInput: string; context: RoundTripContext } { + const eol: '\r\n' | '\n' = input.includes('\r\n') ? '\r\n' : '\n'; + // Normalise to LF for the editor — Tiptap's parser doesn't reliably + // preserve CRLF, and we'll re-introduce it on output. + const lf = eol === '\r\n' ? input.replace(/\r\n/g, '\n') : input; + + const frontMatch = lf.match(FRONTMATTER_RE); + const frontmatter = frontMatch ? frontMatch[0] : ''; + let afterFront = frontmatter ? lf.slice(frontmatter.length) : lf; + + // Capture leading blank lines that appeared AFTER the frontmatter so + // we can put them back. Tiptap's parser strips them. + const gap = afterFront.match(/^\n*/)?.[0] ?? ''; + afterFront = afterFront.slice(gap.length); + + const trailingNewline = afterFront.endsWith('\n') ? '\n' : ''; + + // Tiptap mutates trailing newlines — we trim and put it back. Wikilinks + // are rewritten to a placeholder shape that survives Tiptap. + const editorInput = rewriteWikiLinks(afterFront); + + return { + editorInput, + context: { + originalInput: input, + frontmatter, + frontmatterGap: gap, + trailingNewline, + eol, + }, + }; +} + +/** + * Post-process the markdown Tiptap emits back into the user's expected + * form: re-attach frontmatter, restore wikilink syntax, restore trailing + * newline, undo unnecessary character escapes, and re-apply the original + * EOL convention. + */ +export function applyPostProcess(serialized: string, context: RoundTripContext): string { + let out = restoreWikiLinks(serialized); + + // Tiptap's serializer over-escapes characters that have no syntactic + // meaning in the position they appear. We selectively unescape: + // - `\[` and `\]` outside link constructs (so `- [x] task` stays `- [x] task`) + // - `\~` (we already disabled strike, but tiptap-markdown's + // escape pass can still emit `\~` for any `~` it wasn't sure + // about — reverse it). + // We do this with conservative regexes that don't touch valid escapes + // inside fenced code blocks or inline code. + out = unescapeSafeChars(out); + + // Tiptap normalises GFM table separator rows to a spaced form + // (`| --- | --- |`) regardless of input shape. If the original used + // a more compact form (`|---|---|`), restore it line-by-line. + out = restoreTableSeparatorStyle(out, context.originalInput); + + // Tiptap inserts a leading blank line when the document starts with + // a block element. Strip it so we can re-attach the original + // post-frontmatter spacing exactly. + out = out.replace(/^\n+/, ''); + + // Tiptap (with `breaks: false`) joins consecutive non-blank lines + // inside a paragraph with a space — that's CommonMark's soft-break + // semantics. The user's source had them as separate lines, so the + // file has been "modified" even though the visible content is the + // same. Restore the original line breaks where Tiptap collapsed them. + // This MUST run before collapseBlockSeparators because the latter + // matches the surrounding lines against pairs from the original — and + // those pairs are line-wise, not paragraph-wise. + out = restoreSoftBreaks(out, context.originalInput); + + // Tiptap normalises block separators to a blank line. If the user + // authored adjacent blocks with single-line separators, restore the + // original single-line spacing. + out = collapseBlockSeparators(out, context.originalInput); + + // Tiptap's serializer can leave its own trailing newline; normalise to + // exactly the trailing-newline state the original had. + out = out.replace(/\n+$/, '') + context.trailingNewline; + + // Re-attach frontmatter at the very top, with the original gap. + if (context.frontmatter) { + out = context.frontmatter + context.frontmatterGap + out; + } + + // Apply original EOL convention. + if (context.eol === '\r\n') { + out = out.replace(/\n/g, '\r\n'); + } + return out; +} + +/** + * Tiptap's table serializer always outputs separator rows in the spaced + * form `| --- | --- |`. If the source document used a more compact form + * (`|---|---|`), or any other consistent form, restore that style by + * collecting the separator rows from the original and matching them + * positionally to the separators in the output. Both forms are valid GFM + * and parse identically — this is purely cosmetic and keeps autosave from + * emitting one-line edit_block calls just because of whitespace. + */ +function restoreTableSeparatorStyle(serialized: string, originalInput: string): string { + // Identify separator rows. A separator row matches /^\|([:\-\s|]+)\|$/ + // — only `:`, `-`, `|`, and whitespace. + const SEP_RE = /^\|[\s:\-|]+\|$/; + const origSeparators = originalInput + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => SEP_RE.test(line)); + if (origSeparators.length === 0) return serialized; + + const outLines = serialized.split('\n'); + let sepIndex = 0; + for (let i = 0; i < outLines.length; i += 1) { + if (SEP_RE.test(outLines[i]) && sepIndex < origSeparators.length) { + // Confirm the column count matches before substituting; if it + // doesn't, the table has been edited and we leave the new + // form alone (otherwise we'd corrupt the user's structural + // changes). + const origCols = origSeparators[sepIndex].split('|').length; + const outCols = outLines[i].split('|').length; + if (origCols === outCols) { + outLines[i] = origSeparators[sepIndex]; + } + sepIndex += 1; + } + } + return outLines.join('\n'); +} + +/** + * Restore soft line-breaks Tiptap collapsed. + * + * tiptap-markdown is configured with `breaks: false`, which matches + * CommonMark's default: a single newline inside a paragraph is treated as + * a soft break and rendered/serialised as a single space. So an input of + * + * First line. + * Second line. + * + * comes back as `First line. Second line.` — same visible content, but + * the file on disk now differs from what the user authored. This function + * walks pairs of adjacent non-blank lines from the original and, where + * Tiptap joined them with a space, restores the original line break. + * + * Limitations: if the user actually had `First line. Second line.` on a + * single line in the source, we won't break it (we only re-introduce + * breaks that existed in the source). If the same `A` line appears + * multiple times in the source followed by different `B` lines, we + * conservatively only repair the FIRST match — the rest are left as + * Tiptap emitted them (rare in practice). + */ +function restoreSoftBreaks(serialized: string, originalInput: string): string { + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + let out = serialized; + for (let i = 0; i < origLines.length - 1; i += 1) { + const a = origLines[i]; + const b = origLines[i + 1]; + // Only consider pairs where BOTH lines are non-blank prose. A + // blank line means the pair was paragraph-separated, which Tiptap + // already serialises as `\n\n` — handled elsewhere. + if (!a || !b) continue; + // Skip lines that look like markdown structure: list markers, + // headings, fences, table rows, blockquotes. Tiptap handles those + // as their own block kinds; we don't want to break list items in + // half. + if (looksStructural(a) || looksStructural(b)) continue; + const joined = `${a} ${b}`; + const broken = `${a}\n${b}`; + // If the output has the joined form but not the broken form, it + // was Tiptap that collapsed the soft break — repair it (only the + // first occurrence; see docstring). + const idx = out.indexOf(joined); + if (idx === -1) continue; + if (out.indexOf(broken) !== -1) continue; + out = out.slice(0, idx) + broken + out.slice(idx + joined.length); + } + return out; +} + +/** + * Heuristic: does this line look like markdown structure (heading, list, + * fence, table, blockquote) rather than plain prose? Used by + * restoreSoftBreaks to avoid mangling structural content. + */ +function looksStructural(line: string): boolean { + return /^\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|\|.*\|\s*$|---|\s*$)/.test(line); +} + +/** + * Unescape characters that tiptap-markdown's serializer over-escapes. + * We only undo escapes for characters that are NEVER syntactically active + * in plain prose: brackets in body text, tildes outside strikethrough, + * etc. Code fences are skipped so language-internal escapes survive. + */ +function unescapeSafeChars(md: string): string { + // Walk lines, tracking whether we're inside a fenced code block. + let insideFence = false; + const lines = md.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + insideFence = !insideFence; + continue; + } + if (insideFence) continue; + // Now apply selective unescapes for this prose line. + lines[i] = line + // `\[` and `\]` — never have syntactic meaning by themselves. + // (Real link / image syntax is `[label](url)` and `![alt](url)`, + // neither of which contains a backslash before the bracket.) + .replace(/\\(\[|\])/g, '$1') + // `\~` — tildes have no syntactic role with strike disabled. + .replace(/\\~/g, '~'); + } + return lines.join('\n'); +} + +/** + * If the user's original document used single-line separators between + * adjacent block elements (e.g. `### A\nBody.\n### B\n`), Tiptap will + * normalise those to blank-line separators (`\n\n`). Compare structure + * pairwise and put back the original spacing wherever Tiptap diverged. + * + * This is a "best effort" fixup: it doesn't try to rewrite content, only + * to remove spurious blank lines that Tiptap injected between block + * elements that were adjacent in the source. + */ +function collapseBlockSeparators(serialized: string, originalInput: string): string { + // Tokenise both into "block" units separated by blank-line vs single- + // newline boundaries. If the original had no blank line between two + // adjacent block lines that match (heading -> body, body -> heading, + // etc.), strip the blank line Tiptap inserted between the same pair. + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + const adjacentPairs = new Set(); + for (let i = 0; i < origLines.length - 1; i += 1) { + const a = origLines[i]; + const b = origLines[i + 1]; + if (a && b) { + // Both non-empty consecutive lines — adjacent in the original. + adjacentPairs.add(`${a}\u0001${b}`); + } + } + + const outLines = serialized.split('\n'); + const result: string[] = []; + for (let i = 0; i < outLines.length; i += 1) { + const cur = outLines[i]; + // If this is a blank line and the lines around it were adjacent + // in the original, drop the blank. + if (cur === '' && i > 0 && i < outLines.length - 1) { + const prev = outLines[i - 1]; + const next = outLines[i + 1]; + if (prev && next && adjacentPairs.has(`${prev}\u0001${next}`)) { + continue; + } + } + result.push(cur); + } + return result.join('\n'); +} + +/** + * Build the Tiptap extension array used by both production and the test + * suite. Centralising this means the regression tests exercise the exact + * configuration that ships, so any fix here flows through to autosave too. + * + * Notable choices: + * - StarterKit's strike extension is DISABLED. The default behaviour + * escapes literal `~` to `\~` (and breaks `~/path`) on serialize, + * because tiptap-markdown configures markdown-it with the strike + * plugin enabled, which in turn enables `~` as an escape target. + * Disabling strike costs us nothing visible (the editor never offered + * a strike button) and unblocks two #440 corruption modes. + */ +export function buildTiptapExtensions(): Extensions { + return [ + StarterKit.configure({ + heading: { levels: [1, 2, 3, 4, 5, 6] }, + codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, + link: { + openOnClick: false, + autolink: true, + HTMLAttributes: { 'data-markdown-link': 'true' }, + }, + // Disable strikethrough — see comment above. The serializer + // would otherwise treat `~` as a strike delimiter character + // and emit `\~` to escape it. + strike: false, + }), + Image.configure({ allowBase64: true, inline: true }), + // GFM pipe table support. Without these four extensions Tiptap's + // parser sees `| A | B |` rows as plain paragraphs and concatenates + // the cell text — the canonical #437 corruption pattern. With them, + // tiptap-markdown round-trips tables correctly. + Table.configure({ resizable: false, HTMLAttributes: { class: 'markdown-table' } }), + TableRow, + TableHeader, + TableCell, + Markdown.configure({ + html: true, + tightLists: true, + bulletListMarker: '-', + linkify: true, + breaks: false, + transformPastedText: true, + transformCopiedText: false, + }), + ]; +} + +/** + * Convenience wrapper for tests and tools that want to mount the editor, + * call getMarkdown(), tear down, all in one shot. Production uses the + * pieces individually (preprocessForEditor at mount time, getMarkdown + * during autosave, applyPostProcess before writing to disk). + */ +export function roundTripMarkdown(input: string): string { + const { editorInput, context } = preprocessForEditor(input); + const target = document.createElement('div'); + const editor = new Editor({ + element: target, + extensions: buildTiptapExtensions(), + content: editorInput, + }); + const storage = editor.storage as { markdown?: { getMarkdown: () => string } }; + const serialized = storage.markdown?.getMarkdown() ?? ''; + editor.destroy(); + return applyPostProcess(serialized, context); +} + export interface MarkdownLinkSearchItem { path: string; title: string; @@ -178,35 +558,21 @@ export function mountMarkdownEditor(options: { if (options.view === 'markdown') { options.target.replaceChildren(); + // Pre-process the input once at mount; the captured context is + // mirrored back into output by getTiptapMarkdown so trailing + // newline / frontmatter / EOL are preserved. + const { editorInput, context } = preprocessForEditor(options.value); + const getTiptapMarkdown = (): string => { const storage = tiptap.storage as { markdown?: { getMarkdown: () => string } }; - return restoreWikiLinks(storage.markdown?.getMarkdown() ?? ''); + const serialized = storage.markdown?.getMarkdown() ?? ''; + return applyPostProcess(serialized, context); }; const tiptap = new Editor({ element: options.target, - extensions: [ - StarterKit.configure({ - heading: { levels: [1, 2, 3, 4, 5, 6] }, - codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, - link: { - openOnClick: false, - autolink: true, - HTMLAttributes: { 'data-markdown-link': 'true' }, - }, - }), - Image.configure({ allowBase64: true, inline: true }), - Markdown.configure({ - html: true, - tightLists: true, - bulletListMarker: '-', - linkify: true, - breaks: false, - transformPastedText: true, - transformCopiedText: false, - }), - ], - content: rewriteWikiLinks(options.value), + extensions: buildTiptapExtensions(), + content: editorInput, editorProps: { attributes: { class: 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc', diff --git a/test/test-markdown-editor-edit-diff.js b/test/test-markdown-editor-edit-diff.js new file mode 100644 index 00000000..1234ae67 --- /dev/null +++ b/test/test-markdown-editor-edit-diff.js @@ -0,0 +1,357 @@ +/** + * Realistic edit-diff regression test for the markdown editor (#437/#440). + * + * The strict round-trip suite in test-markdown-editor-roundtrip.js asserts + * that an UNTOUCHED document survives mount->getMarkdown() byte-for-byte. + * That's the worst case for Tiptap, because it punishes any whitespace / + * escape normalization the parser applies, even normalization a real user + * would never notice. + * + * What actually matters in production is what the autosave loop in + * controller.ts:scheduleAutosave does: it diffs `getMarkdown()` against + * `state.fullDocumentContent` and emits `edit_block` calls for the diff. + * If the diff is just the user's actual edit, the file is safe. If Tiptap + * also normalizes 47 unrelated lines, those edit_blocks corrupt the file. + * + * This suite measures the *collateral damage* an edit produces: + * 1. A whole-file rewrite (>=70% lines changed) is catastrophic — that's + * the path computeEditBlocks takes when too much of the document + * drifts. We assert this NEVER fires for a small user edit. + * 2. The number of edit-block hunks should be small — ideally 1 (just + * the user's edit). Tolerated up to 3 to account for trivial Tiptap + * normalization at the boundaries. + * 3. The user's actual change must appear in exactly one hunk and the + * surrounding text must be unmangled. + */ + +import assert from 'assert'; +import { JSDOM } from 'jsdom'; + +// jsdom for Tiptap to mount into. +const dom = new JSDOM('
'); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.Node = dom.window.Node; +globalThis.DOMParser = dom.window.DOMParser; +globalThis.getComputedStyle = dom.window.getComputedStyle; +// Tiptap's focus() calls requestAnimationFrame which jsdom doesn't ship +// by default. Stub with a synchronous no-op — we don't need real focus +// behaviour for these tests. +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +const { Editor } = await import('@tiptap/core'); +const editorMod = await import('../dist/ui/file-preview/src/markdown/editor.js'); +const controllerMod = await import('../dist/ui/file-preview/src/markdown/controller.js'); +const { preprocessForEditor, applyPostProcess, buildTiptapExtensions } = editorMod; +const { computeEditBlocks } = controllerMod; + +/** + * Mount the editor exactly as production does, return a handle that lets + * the test: + * - apply a synthetic edit (insert text at a position, simulating typing) + * - read getMarkdown() through the production post-process pipeline + * - tear down cleanly + */ +function mountForEdit(input) { + const target = document.getElementById('root'); + target.innerHTML = ''; + const { editorInput, context } = preprocessForEditor(input); + const editor = new Editor({ + element: target, + extensions: buildTiptapExtensions(), + content: editorInput, + }); + return { + editor, + /** Insert plain text at a ProseMirror doc position (simulates typing). */ + insertAt(pos, text) { + editor.chain().focus().insertContentAt(pos, text).run(); + }, + /** Replace a text fragment in the document. Searches by visible text + * content; if the search string isn't found verbatim (e.g. it spans + * inline-code boundaries), falls back to the longest matching prefix. */ + replaceText(find, replace) { + // Build the full visible text by concatenating all text nodes. + // Track each text node's visible-text offset so we can map back. + const segments = []; + let visibleText = ''; + editor.state.doc.descendants((node, nodePos) => { + if (node.isText) { + segments.push({ pos: nodePos, text: node.text, start: visibleText.length }); + visibleText += node.text; + } + return true; + }); + let idx = visibleText.indexOf(find); + let matchLen = find.length; + if (idx === -1) { + // Try a relaxed match: drop punctuation characters that would + // render via separate ProseMirror inline marks (backticks, etc.) + const stripped = find.replace(/`/g, ''); + idx = visibleText.indexOf(stripped); + if (idx === -1) { + throw new Error(`replaceText: didn't find ${JSON.stringify(find)} (also tried ${JSON.stringify(stripped)})`); + } + matchLen = stripped.length; + } + // Map visible-text offset back to a ProseMirror position by + // walking the segments. + let from = -1; + for (const seg of segments) { + if (seg.start + seg.text.length > idx) { + from = seg.pos + (idx - seg.start); + break; + } + } + if (from === -1) throw new Error('replaceText: position not found'); + editor.chain().focus().insertContentAt({ from, to: from + matchLen }, replace).run(); + }, + getMarkdown() { + const storage = editor.storage; + return applyPostProcess(storage.markdown?.getMarkdown() ?? '', context); + }, + destroy() { + editor.destroy(); + }, + }; +} + +let passed = 0; +let failed = 0; +function pass(name) { console.log('OK ', name); passed++; } +function fail(name, detail) { console.log('FAIL', name); if (detail) console.log(' ', detail); failed++; } + +/** + * Core invariants we want every edit to obey. + * + * @param name human-readable test name + * @param input the original markdown + * @param edit (handle) => void — perform the synthetic edit on the editor + * @param expectedSubstring — text we expect to find in the post-edit doc + */ +function assertEditDiffIsClean(name, input, edit, expectedSubstring) { + const handle = mountForEdit(input); + edit(handle); + const after = handle.getMarkdown(); + handle.destroy(); + + // 1. The user's edit must actually be in the output. + if (!after.includes(expectedSubstring)) { + return fail(name, `expected substring not in output: ${JSON.stringify(expectedSubstring)}`); + } + + // 2. computeEditBlocks should not emit a whole-document rewrite. + const oldLines = input.split('\n'); + const newLines = after.split('\n'); + const hunks = computeEditBlocks(input, after); + + if (hunks.length === 1) { + const hunk = hunks[0]; + // Whole-file rewrite signature: old_string covers the entire input. + if (hunk.old_string === input && hunk.new_string === after) { + return fail(name, `WHOLE-FILE REWRITE — autosave would replace the entire document`); + } + } + + // 3. The number of hunks should be small. >3 means Tiptap is normalising + // multiple regions the user didn't touch. + if (hunks.length > 3) { + diagnoseDiff(input, after, hunks); + return fail(name, `${hunks.length} hunks (>3); Tiptap is normalising unrelated regions`); + } + + // 4. Total lines changed should be small relative to file size. Bound + // at 20% — anything bigger is collateral damage, not a real edit. + const linesChanged = Math.abs(newLines.length - oldLines.length) + + countDifferingLines(oldLines, newLines); + if (oldLines.length > 0 && linesChanged / oldLines.length > 0.2) { + diagnoseDiff(input, after, hunks); + return fail(name, + `${linesChanged} of ${oldLines.length} lines differ (>20%) for what should be a small edit`); + } + + pass(`${name} (${hunks.length} hunk${hunks.length === 1 ? '' : 's'}, ${linesChanged} lines changed)`); +} + +/** Print a compact summary of the diff so we can see what Tiptap is changing. */ +function diagnoseDiff(before, after, hunks) { + console.log(' hunks:', hunks.length); + for (const [i, h] of hunks.entries()) { + console.log(` [${i}] OLD:`); + for (const line of h.old_string.split('\n').slice(0, 8)) { + console.log(` ${JSON.stringify(line)}`); + } + console.log(` NEW:`); + for (const line of h.new_string.split('\n').slice(0, 8)) { + console.log(` ${JSON.stringify(line)}`); + } + } +} + +function countDifferingLines(a, b) { + // Cheap upper-bound on different lines: count positions where lines + // disagree, allowing for length differences. + let diff = 0; + const min = Math.min(a.length, b.length); + for (let i = 0; i < min; i += 1) { + if (a[i] !== b[i]) diff += 1; + } + return diff; +} + +const README = `# My Project + +A short intro paragraph explaining what the project does. + +## Installation + +Install with the package manager: + +\`\`\`bash +npm install my-project +\`\`\` + +Then verify it works: + +\`\`\`bash +my-project --version +\`\`\` + +## Usage + +The CLI accepts a few flags: + +| Flag | Description | Default | +|---|---|---| +| \`--input\` | Input file | stdin | +| \`--output\` | Output file | stdout | +| \`--verbose\` | Verbose logs | false | + +Run it like this: + +\`\`\`bash +my-project --input data.json --output result.json +\`\`\` + +## Development + +To set up locally: + +- Clone the repo +- Run \`npm install\` +- Run \`npm test\` + +PRs welcome — please \`fork\` and submit against \`main\`. + +## License + +MIT. +`; + +// --- Test 1: append a sentence to an existing paragraph --- +assertEditDiffIsClean( + 'append text to an existing paragraph', + README, + (h) => h.replaceText('A short intro paragraph explaining what the project does.', + 'A short intro paragraph explaining what the project does. New sentence added.'), + 'New sentence added.', +); + +// --- Test 2: edit a heading (search by visible text — the rendered +// heading is just "Usage", without the leading `## `) --- +assertEditDiffIsClean( + 'rename a heading', + README, + (h) => h.replaceText('Usage', 'How to use'), + '## How to use', +); + +// --- Test 3: append a new bullet at the end of an existing list. The +// visible text for ``Run `npm test`'' is "Run npm test" — backticks +// are inline-code mark boundaries, not part of the text. --- +assertEditDiffIsClean( + 'append a bullet to a list', + README, + (h) => h.replaceText('Run npm test', 'Run npm test\nRun npm run lint'), + 'npm run lint', +); + +// --- Test 4: edit a single word in a paragraph --- +assertEditDiffIsClean( + 'fix a typo', + README, + (h) => h.replaceText('PRs welcome', 'PRs are welcome'), + 'PRs are welcome', +); + +// --- Test 5: leave the file completely unchanged (no edit) --- +{ + const handle = mountForEdit(README); + const after = handle.getMarkdown(); + handle.destroy(); + const hunks = computeEditBlocks(README, after); + if (hunks.length === 0) { + pass('no edit -> no hunks'); + } else if (hunks.length === 1 && hunks[0].old_string === README && hunks[0].new_string === after) { + fail('no edit -> no hunks', + `WHOLE-FILE REWRITE on an untouched file — Tiptap normalised the whole document`); + } else { + diagnoseDiff(README, after, hunks); + fail('no edit -> no hunks', + `expected 0 hunks for an untouched file, got ${hunks.length}`); + } +} + +// --- Test 6: edits to a document with frontmatter + wikilinks + tasks --- +const COMPLEX = `--- +title: Notes +tags: [project, journal] +--- + +# Daily Log + +See [[Project Roadmap]] for context. + +## Tasks + +- [x] Land the round-trip fix +- [ ] Write up the design doc +- [ ] Get review + +## Progress + +| Day | Focus | Notes | +|---|---|---| +| Mon | research | found root cause | +| Tue | implementation | wrappers + extensions | +| Wed | testing | 12/14 passing | + +That's the week. +`; + +assertEditDiffIsClean( + 'edit a paragraph in a doc with frontmatter + wikilinks + tasks + table', + COMPLEX, + (h) => h.replaceText("That's the week.", "That's the week. More to come."), + 'More to come.', +); + +// Check that the no-edit case for the complex doc is also clean. +{ + const handle = mountForEdit(COMPLEX); + const after = handle.getMarkdown(); + handle.destroy(); + const hunks = computeEditBlocks(COMPLEX, after); + if (hunks.length === 0) { + pass('no edit on complex doc -> no hunks'); + } else { + diagnoseDiff(COMPLEX, after, hunks); + fail('no edit on complex doc -> no hunks', + `expected 0 hunks for an untouched complex doc, got ${hunks.length}`); + } +} + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js index 4cfa45d3..71bb6078 100644 --- a/test/test-markdown-editor-roundtrip.js +++ b/test/test-markdown-editor-roundtrip.js @@ -36,55 +36,18 @@ const { Editor } = await import('@tiptap/core'); const StarterKit = (await import('@tiptap/starter-kit')).default; const Image = (await import('@tiptap/extension-image')).default; const { Markdown } = await import('tiptap-markdown'); +const editorMod = await import('../dist/ui/file-preview/src/markdown/editor.js'); const { rewriteWikiLinks, restoreWikiLinks } = await import( '../dist/ui/file-preview/src/markdown/linking.js' ); /** - * Mount a Tiptap editor with the same extensions and config that - * mountMarkdownEditor uses for `view: 'markdown'`. + * Use the production round-trip path. Any wrapper / extension / serializer + * change in editor.ts is automatically exercised here, so this test stays + * a faithful regression suite as the implementation evolves. */ -function mountEditor(value) { - const target = document.getElementById('root'); - target.innerHTML = ''; - return new Editor({ - element: target, - extensions: [ - StarterKit.configure({ - heading: { levels: [1, 2, 3, 4, 5, 6] }, - codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, - link: { - openOnClick: false, - autolink: true, - HTMLAttributes: { 'data-markdown-link': 'true' }, - }, - }), - Image.configure({ allowBase64: true, inline: true }), - Markdown.configure({ - html: true, - tightLists: true, - bulletListMarker: '-', - linkify: true, - breaks: false, - transformPastedText: true, - transformCopiedText: false, - }), - ], - content: rewriteWikiLinks(value), - }); -} - -/** Mirrors editor.ts:181-184 getTiptapMarkdown */ -function getTiptapMarkdown(editor) { - const storage = editor.storage; - return restoreWikiLinks(storage.markdown?.getMarkdown() ?? ''); -} - function roundTrip(input) { - const editor = mountEditor(input); - const out = getTiptapMarkdown(editor); - editor.destroy(); - return out; + return editorMod.roundTripMarkdown(input); } async function testPipeTableSurvivesRoundTrip() { From a66e9f6733d07de592cff6d53c12dcfb093bcec7 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Mon, 27 Apr 2026 17:40:07 +0300 Subject: [PATCH 3/8] fix(file-preview): prevent unnecessary markdown autosaves --- .../file-preview/src/markdown/controller.ts | 46 +++++++++++++++---- src/ui/file-preview/src/markdown/editor.ts | 19 ++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/ui/file-preview/src/markdown/controller.ts b/src/ui/file-preview/src/markdown/controller.ts index abafa0eb..cb643d39 100644 --- a/src/ui/file-preview/src/markdown/controller.ts +++ b/src/ui/file-preview/src/markdown/controller.ts @@ -74,10 +74,6 @@ function computeDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] { const oldLength = oldLines.length; const newLength = newLines.length; - if (oldLength * newLength > 1_000_000) { - return [{ oldStart: 0, oldEnd: oldLength, newStart: 0, newEnd: newLength }]; - } - const dp: number[][] = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0) as number[]); for (let i = 1; i <= oldLength; i += 1) { for (let j = 1; j <= newLength; j += 1) { @@ -138,6 +134,34 @@ function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { return merged; } +function computeLineByLineHunks(oldLines: string[], newLines: string[]): DiffHunk[] { + const hunks: DiffHunk[] = []; + const maxLength = Math.max(oldLines.length, newLines.length); + let oldStart: number | null = null; + let newStart: number | null = null; + + for (let index = 0; index < maxLength; index += 1) { + const same = index < oldLines.length && index < newLines.length && oldLines[index] === newLines[index]; + if (!same && oldStart === null) { + oldStart = Math.min(index, oldLines.length); + newStart = Math.min(index, newLines.length); + } + if ((same || index === maxLength - 1) && oldStart !== null && newStart !== null) { + const endIndex = same ? index : index + 1; + hunks.push({ + oldStart, + oldEnd: Math.min(endIndex, oldLines.length), + newStart, + newEnd: Math.min(endIndex, newLines.length), + }); + oldStart = null; + newStart = null; + } + } + + return hunks; +} + function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { if (oldText === newText) { return []; @@ -145,17 +169,15 @@ function computeEditBlocks(oldText: string, newText: string): Array<{ old_string const oldLines = oldText.split('\n'); const newLines = newText.split('\n'); - const hunks = computeDiffHunks(oldLines, newLines); + const hunks = oldLines.length * newLines.length > 1_000_000 + ? computeLineByLineHunks(oldLines, newLines) + : computeDiffHunks(oldLines, newLines); if (hunks.length === 0) { return []; } const context = 3; const merged = mergeCloseHunks(hunks, context * 2 + 1); - const totalChanged = merged.reduce((sum, hunk) => sum + (hunk.oldEnd - hunk.oldStart), 0); - if (totalChanged > oldLines.length * 0.7) { - return [{ old_string: oldText, new_string: newText }]; - } return merged.map((hunk) => { const contextBefore = Math.max(0, hunk.oldStart - context); @@ -920,6 +942,9 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende searchLinks: (query) => searchLinkTargets(payload.filePath, query), loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath), onChange: (value) => { + if (value === state.draftContent) { + return; + } state.draftContent = value; state.dirty = value !== state.fullDocumentContent; if (state.dirty && !editStartedFired) { @@ -949,6 +974,9 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende } }, onBlur: () => { + if (!state.dirty) { + return; + } cancelAutosave(); void saveDocument(); }, diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 0188c484..9c87f190 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -177,6 +177,10 @@ export function mountMarkdownEditor(options: { if (options.view === 'markdown') { options.target.replaceChildren(); + let hasUserEdited = false; + const markUserEdit = (): void => { + hasUserEdited = true; + }; const getTiptapMarkdown = (): string => { const storage = tiptap.storage as { markdown?: { getMarkdown: () => string } }; @@ -216,6 +220,9 @@ export function mountMarkdownEditor(options: { }, onUpdate: ({ editor }) => { syncHeadingIds(editor.view.dom as HTMLElement); + if (!hasUserEdited) { + return; + } options.onChange(getTiptapMarkdown()); }, onSelectionUpdate: () => { @@ -422,6 +429,7 @@ export function mountMarkdownEditor(options: { }; const handleLinkApply = (): void => { + markUserEdit(); if (linkMode === 'url') { const href = linkInput?.value?.trim(); if (!href) { @@ -481,6 +489,7 @@ export function mountMarkdownEditor(options: { if (!format) { return; } + markUserEdit(); switch (format) { case 'bold': tiptap.chain().focus().toggleBold().run(); @@ -512,11 +521,13 @@ export function mountMarkdownEditor(options: { return; } if (value === 'p') { + markUserEdit(); tiptap.chain().focus().setParagraph().run(); return; } const match = /^h([1-6])$/.exec(value); if (match) { + markUserEdit(); const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6; tiptap.chain().focus().toggleHeading({ level }).run(); } @@ -612,6 +623,10 @@ export function mountMarkdownEditor(options: { } }; + editorDom.addEventListener('beforeinput', markUserEdit); + editorDom.addEventListener('paste', markUserEdit); + editorDom.addEventListener('cut', markUserEdit); + editorDom.addEventListener('drop', markUserEdit); editorDom.addEventListener('mouseover', handleMouseOver); editorDom.addEventListener('mouseout', handleMouseOut); linkPopover.addEventListener('mouseenter', handlePopoverEnter); @@ -632,6 +647,10 @@ export function mountMarkdownEditor(options: { return { destroy: () => { + editorDom.removeEventListener('beforeinput', markUserEdit); + editorDom.removeEventListener('paste', markUserEdit); + editorDom.removeEventListener('cut', markUserEdit); + editorDom.removeEventListener('drop', markUserEdit); editorDom.removeEventListener('mouseover', handleMouseOver); editorDom.removeEventListener('mouseout', handleMouseOut); linkPopover.removeEventListener('mouseenter', handlePopoverEnter); From fed7a5d2008a923ca0dc33e2b4824886d7bbac47 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 17:57:46 +0300 Subject: [PATCH 4/8] fix(markdown editor): 4 in-the-wild round-trip failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured from /Users/eduardsruzga/work/best-value-ai/README.md, which hit 5 distinct corruption hunks on no-edit open in the previous state. Now: byte-exact round-trip, 0 hunks. Tests added (test-markdown-editor-roundtrip.js): testBareUrlNotAutoLinked — 'https://x' stays bare, no <…> testEmojiPrefixedSoftBreaksRestored — 3 paragraph lines stay 3 lines testLinkInTableCellSurvivesRoundTrip — [`x`](url) keeps its href testStarBulletMarkerPreserved — '*' bullets stay '*', not '-' Fixes: - linkify: false. The auto-bracketing ('https://x' -> '') was a serializer-side artifact of the parser's URL-detection rule. Disable the rule; pasted URLs in the editor still become clickable via Tiptap's built-in Link extension. - restoreSoftBreaks now tries both ' ' (the common joiner) and '' (when the boundary is between punctuation like ')' and a non-letter like an emoji — the case that broke the 3-line emoji-prefixed sequence in best-value-ai's README). - restoreBulletMarkers maps output bullet lines onto source bullet lines positionally, restoring the marker style ('*' / '-' / '+'). New bullets the user adds keep the editor's default '-'. - Code-text links ([`x`](url)) are stripped of their URL by tiptap-markdown's parser when the link text is purely inline code. Replace with ASCII placeholders during preprocess; restore in postprocess. Pattern matches the existing wikilink workaround. - unescapeSafeChars is now line-aligned: it only removes \[, \], \~ escapes when the same source line, stripped of those escapes, also matches the output line stripped. Means we never touch escapes the user authored — we only undo escapes Tiptap added. Also wired the codeLinks placeholder mapping into the RoundTripContext so production autosave gets the same restore behaviour as the test harness. Result: 18/18 strict + 7/7 edit-diff = 25/25 passing. The best-value-ai/README.md (12977 bytes, 214 lines) round-trips byte-for- byte with 0 autosave hunks. --- src/ui/file-preview/src/markdown/editor.ts | 180 ++++++++++++++++++--- test/test-markdown-editor-roundtrip.js | 75 +++++++++ 2 files changed, 234 insertions(+), 21 deletions(-) diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 2aab1771..7fc71ec8 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -47,10 +47,21 @@ export interface RoundTripContext { trailingNewline: string; /** EOL convention of the original (`'\r\n'` or `'\n'`). */ eol: '\r\n' | '\n'; + /** Code-text links (`[\`x\`](url)`) replaced with placeholders during + * preprocessing, restored after serialization. tiptap-markdown drops + * the URL when a link's text is purely inline code. */ + codeLinks: Array<{ placeholder: string; original: string }>; } const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; +// Link with inline-code text: `[\`anything\`](url)`. tiptap-markdown +// loses the surrounding `[...](url)` wrapping when it parses a link whose +// text is purely inline code, leaving just the backticked text and erasing +// the URL. We replace these with ASCII placeholders before mounting and +// restore them in post-process. +const CODE_LINK_RE = /\[`([^`]+)`\]\(([^)]+)\)/g; + /** * Pre-process a document before handing it to Tiptap. Returns a context * object that `applyPostProcess` uses to restore stripped portions. @@ -72,9 +83,23 @@ export function preprocessForEditor(input: string): { editorInput: string; conte const trailingNewline = afterFront.endsWith('\n') ? '\n' : ''; + // tiptap-markdown drops the URL when a link's text is purely inline + // code (`[\`x\`](url)` -> `\`x\``). Replace those with ASCII + // placeholders that survive the parse-and-serialize round-trip + // unchanged; we restore them in applyPostProcess. + const codeLinks: Array<{ placeholder: string; original: string }> = []; + let withPlaceholders = afterFront; + let codeLinkIndex = 0; + withPlaceholders = withPlaceholders.replace(CODE_LINK_RE, (match) => { + const placeholder = `TIPTAPCODELINK${String(codeLinkIndex).padStart(4, '0')}`; + codeLinks.push({ placeholder, original: match }); + codeLinkIndex += 1; + return placeholder; + }); + // Tiptap mutates trailing newlines — we trim and put it back. Wikilinks // are rewritten to a placeholder shape that survives Tiptap. - const editorInput = rewriteWikiLinks(afterFront); + const editorInput = rewriteWikiLinks(withPlaceholders); return { editorInput, @@ -84,6 +109,7 @@ export function preprocessForEditor(input: string): { editorInput: string; conte frontmatterGap: gap, trailingNewline, eol, + codeLinks, }, }; } @@ -97,6 +123,12 @@ export function preprocessForEditor(input: string): { editorInput: string; conte export function applyPostProcess(serialized: string, context: RoundTripContext): string { let out = restoreWikiLinks(serialized); + // Restore code-text links replaced with placeholders during preprocess. + // Done before any other repair so subsequent text-shape fixups operate + // on the original markdown form. + for (const { placeholder, original } of context.codeLinks) { + out = out.split(placeholder).join(original); + } // Tiptap's serializer over-escapes characters that have no syntactic // meaning in the position they appear. We selectively unescape: // - `\[` and `\]` outside link constructs (so `- [x] task` stays `- [x] task`) @@ -105,13 +137,20 @@ export function applyPostProcess(serialized: string, context: RoundTripContext): // about — reverse it). // We do this with conservative regexes that don't touch valid escapes // inside fenced code blocks or inline code. - out = unescapeSafeChars(out); + out = unescapeSafeChars(out, context.originalInput); // Tiptap normalises GFM table separator rows to a spaced form // (`| --- | --- |`) regardless of input shape. If the original used // a more compact form (`|---|---|`), restore it line-by-line. out = restoreTableSeparatorStyle(out, context.originalInput); + // tiptap-markdown is configured with `bulletListMarker: '-'` so every + // bullet is emitted as `- `. If the source used `*` (or a mix), we'd + // overwrite the user's preference on every save. Restore the original + // marker by mapping output bullet lines onto their corresponding + // source bullet lines positionally. + out = restoreBulletMarkers(out, context.originalInput); + // Tiptap inserts a leading blank line when the document starts with // a block element. Strip it so we can re-attach the original // post-frontmatter spacing exactly. @@ -186,6 +225,48 @@ function restoreTableSeparatorStyle(serialized: string, originalInput: string): return outLines.join('\n'); } +/** + * Restore the user's original bullet-list marker style. + * + * tiptap-markdown's serializer has a single `bulletListMarker` config + * (we set it to `-`). That means a source file written with `*` bullets + * comes back with `-` bullets — no data loss, but the file diff is full + * of one-character changes the user didn't make. + * + * Strategy: collect every "bullet line" from the original (lines starting + * with optional indent + `*`/`-`/`+` + space), in order. Walk the output; + * for each bullet line, restore the marker style at the same ordinal + * position. If the structure shifted (the user added a bullet that wasn't + * in the source), trailing extra bullets keep the editor's `-` style — + * that's correct for new content. + */ +function restoreBulletMarkers(serialized: string, originalInput: string): string { + const BULLET_RE = /^(\s*)([*\-+])(\s)/; + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + // Collect markers in source order. We index purely by position in + // the bullet sequence — no attempt to match by content, so re-ordered + // bullets still get sensible markers. + const origMarkers: string[] = []; + for (const line of origLines) { + const m = line.match(BULLET_RE); + if (m) origMarkers.push(m[2]); + } + if (origMarkers.length === 0) return serialized; + + const outLines = serialized.split('\n'); + let bulletIdx = 0; + for (let i = 0; i < outLines.length; i += 1) { + const m = outLines[i].match(BULLET_RE); + if (!m) continue; + const wanted = origMarkers[bulletIdx]; + if (wanted && wanted !== m[2]) { + outLines[i] = m[1] + wanted + m[3] + outLines[i].slice(m[0].length); + } + bulletIdx += 1; + } + return outLines.join('\n'); +} + /** * Restore soft line-breaks Tiptap collapsed. * @@ -223,15 +304,19 @@ function restoreSoftBreaks(serialized: string, originalInput: string): string { // as their own block kinds; we don't want to break list items in // half. if (looksStructural(a) || looksStructural(b)) continue; - const joined = `${a} ${b}`; const broken = `${a}\n${b}`; - // If the output has the joined form but not the broken form, it - // was Tiptap that collapsed the soft break — repair it (only the - // first occurrence; see docstring). - const idx = out.indexOf(joined); - if (idx === -1) continue; if (out.indexOf(broken) !== -1) continue; - out = out.slice(0, idx) + broken + out.slice(idx + joined.length); + // Tiptap joins paragraph-internal lines with EITHER a space (the + // common case for prose) OR no separator at all (when the + // boundary is between punctuation like `)` and a non-letter + // character like an emoji). Try both, in that order. + for (const joiner of [' ', '']) { + const joined = `${a}${joiner}${b}`; + const idx = out.indexOf(joined); + if (idx === -1) continue; + out = out.slice(0, idx) + broken + out.slice(idx + joined.length); + break; + } } return out; } @@ -249,10 +334,27 @@ function looksStructural(line: string): boolean { * Unescape characters that tiptap-markdown's serializer over-escapes. * We only undo escapes for characters that are NEVER syntactically active * in plain prose: brackets in body text, tildes outside strikethrough, - * etc. Code fences are skipped so language-internal escapes survive. + * etc. + * + * Round-trip safety: only undo an escape if the SAME escape was not + * already present in the original source. If the user's file had `\~190M` + * literally (e.g. left over from a previous Tiptap save before we + * disabled strike), we leave it alone. If the editor introduced a NEW + * escape that wasn't in the source, we remove it. This preserves the + * file-on-disk vs. cleaning-up tension on the safe side. + * + * Code fences are skipped so language-internal escapes survive. */ -function unescapeSafeChars(md: string): string { - // Walk lines, tracking whether we're inside a fenced code block. +function unescapeSafeChars(md: string, originalInput: string): string { + // The fix is per-line, not per-document. For each output line, find a + // matching source line by stripping all `\X` escapes from candidates; + // if a stripped source line equals the output line (after also + // stripping the same escapes), the user did NOT author those escapes + // in this region and we may safely remove them. If no source line + // matches even after stripping, we err on the safe side and keep the + // escapes (they may be intentional). + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + let insideFence = false; const lines = md.split('\n'); for (let i = 0; i < lines.length; i += 1) { @@ -262,18 +364,47 @@ function unescapeSafeChars(md: string): string { continue; } if (insideFence) continue; - // Now apply selective unescapes for this prose line. - lines[i] = line - // `\[` and `\]` — never have syntactic meaning by themselves. - // (Real link / image syntax is `[label](url)` and `![alt](url)`, - // neither of which contains a backslash before the bracket.) - .replace(/\\(\[|\])/g, '$1') - // `\~` — tildes have no syntactic role with strike disabled. - .replace(/\\~/g, '~'); + + // Quick check: if no candidate escapes are even present in this + // output line, nothing to do. + if (!/\\[\[\]~]/.test(line)) continue; + + const stripped = stripSafeEscapes(line); + // Does ANY source line match this output line, with both sides + // stripped of safe escapes? If yes, the source had this content + // without those escapes, so Tiptap added them — strip them. + const sourceHasEquivalent = origLines.some((origLine) => stripSafeEscapes(origLine) === stripped); + if (sourceHasEquivalent) { + // Look for an exact source line match (escapes intact). If + // there's an exact match, use it to know which escapes were + // authored vs added. + const exact = origLines.find((origLine) => origLine === line); + if (exact !== undefined) { + // Source had this exact line including escapes — preserve. + continue; + } + // Source had the equivalent without authoring these escapes — + // strip them. + lines[i] = stripped; + } + // Otherwise: source line is genuinely different from output. Could + // be an edit, could be a region we don't have a per-line match + // for. Leave the escapes alone — round-trip safety wins over + // cleanup. } return lines.join('\n'); } +/** + * Remove the safe-escape prefixes (`\[`, `\]`, `\~`) from a line. Used to + * compare an output line against source lines after both have been + * normalised — if they then match, neither side had user-authored escapes + * for these specific characters. + */ +function stripSafeEscapes(line: string): string { + return line.replace(/\\([\[\]~])/g, '$1'); +} + /** * If the user's original document used single-line separators between * adjacent block elements (e.g. `### A\nBody.\n### B\n`), Tiptap will @@ -359,7 +490,14 @@ export function buildTiptapExtensions(): Extensions { html: true, tightLists: true, bulletListMarker: '-', - linkify: true, + // `linkify: true` made tiptap-markdown auto-wrap bare URLs in + // <…> autolink brackets on serialize, even when the source had + // them as bare URLs. The editor still recognises pasted URLs + // as clickable via Tiptap's link extension; this only affects + // the parser's "treat any URL-shaped string as a Link node" + // behaviour, which is what was rewriting `https://...` to + // `` on round-trip. + linkify: false, breaks: false, transformPastedText: true, transformCopiedText: false, diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js index 71bb6078..c4c93ae0 100644 --- a/test/test-markdown-editor-roundtrip.js +++ b/test/test-markdown-editor-roundtrip.js @@ -303,6 +303,77 @@ async function testTableInsideRealisticDoc() { console.log('OK realistic doc preserved'); } +async function testBareUrlNotAutoLinked() { + console.log('\n--- Test: bare URL not wrapped in autolink brackets (best-value-ai #1) ---'); + // Captured from /Users/eduardsruzga/work/best-value-ai/README.md. + // Tiptap with `linkify: true` autolinks bare URLs and the serializer + // emits them as `` even when the source had no brackets. + const input = '🔗 **Live tool:** https://desktopcommander.app/best-value-ai/\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'a bare URL in prose should NOT be wrapped in <…> autolink brackets on round-trip' + ); + console.log('OK bare URL preserved'); +} + +async function testEmojiPrefixedSoftBreaksRestored() { + console.log('\n--- Test: 3 consecutive emoji-prefixed lines stay separate (best-value-ai #2) ---'); + // Captured from the same README. Three lines, each ending with a soft + // break, each starting with an emoji. Tiptap-with-`breaks:false` parses + // them as one paragraph and serializes them concatenated. restoreSoftBreaks + // currently only repairs pairs; this is a triple. + const input = + '🔗 **Live tool:** desktopcommander.app/best-value-ai/\n' + + '📖 **Article:** [Local LLMs Beat Cloud](https://example.com/x)\n' + + '🏠 **Supported by:** [Desktop Commander](https://desktopcommander.app)\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'three consecutive prose lines must stay on three lines, not collapse into one' + ); + console.log('OK emoji-prefixed soft breaks preserved'); +} + +async function testLinkInTableCellSurvivesRoundTrip() { + console.log('\n--- Test: backtick-text link inside a table cell (best-value-ai #3) ---'); + // From the same README's "data files" table. tiptap-markdown drops the + // surrounding `[…](url)` wrapping when the link text is inline code + // (backticks) and the link sits inside a table cell — leaving just the + // backticked text and erasing the URL. + const input = + '| File | URL |\n' + + '|------|-----|\n' + + '| Models | [`models.json`](https://example.com/models.json) |\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'a [\\`code\\`](url) link inside a table cell must NOT lose its URL on round-trip' + ); + console.log('OK link-in-cell preserved'); +} + +async function testStarBulletMarkerPreserved() { + console.log('\n--- Test: `*` bullet marker preserved (best-value-ai #4) ---'); + // tiptap-markdown's `bulletListMarker: '-'` config rewrites every + // bullet to `- ` regardless of what the source used. `*` is equally + // valid CommonMark and should be preserved. + const input = + '* First item\n' + + '* Second item\n' + + '* Third item\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + '`*` bullet markers should be preserved when the source used them' + ); + console.log('OK star bullet marker preserved'); +} + async function runAllTests() { const tests = [ testPipeTableSurvivesRoundTrip, @@ -319,6 +390,10 @@ async function runAllTests() { testCrlfPreserved, testReadmeStyleFileNotCollapsed, testTableInsideRealisticDoc, + testBareUrlNotAutoLinked, + testEmojiPrefixedSoftBreaksRestored, + testLinkInTableCellSurvivesRoundTrip, + testStarBulletMarkerPreserved, ]; let passed = 0; let failed = 0; From c96edbfd6bfded231028768d4249cad6d1d9c377 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 18:38:05 +0300 Subject: [PATCH 5/8] fix(markdown editor): preserve relative-path links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiptap's Link extension validates URLs and silently drops links whose URL doesn't match its scheme/relative-prefix allow-list. Bare relative paths with subdirectories (`scripts/foo.mjs`, `references/output.md`) fall through this validation — the parse drops the link, leaving just the link text. Most common corruption mode in real skill files (SKILL.md routinely links to `scripts/` and `references/`). Generalised the existing code-text-link placeholder workaround into INLINE_LINK_RE + isFragileLink(). The same regex and placeholder system now handles both: 1. Code-text links: `[\`x\`](url)` 2. Bare-relative-subpath links: `[X](dir/file)` URLs we leave alone (Tiptap accepts them): - Schemes (http://, mailto:, tel:, ftp:, etc.) - Anchors (#section) - Single-segment paths (file.md) - Explicitly-relative paths (./, ../, /) Tests: - testRelativePathLinksSurvive captures the failure with skill-files fixtures (init-skill.mjs, validate-skill.mjs, references/output.md, section anchors). - All 19 strict + 7 edit-diff tests still pass. Skill-files batch test: 4 of 8 SKILL.md files now round-trip byte-exact (was 1). Remaining failures are different bugs: HTML entity escaping, trailing-whitespace strip, bold-around-inline-code mis-shifting. --- src/ui/file-preview/src/markdown/editor.ts | 53 +++++++++++++++++----- test/test-markdown-editor-roundtrip.js | 25 ++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 7fc71ec8..e9a801d9 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -55,12 +55,41 @@ export interface RoundTripContext { const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; -// Link with inline-code text: `[\`anything\`](url)`. tiptap-markdown -// loses the surrounding `[...](url)` wrapping when it parses a link whose -// text is purely inline code, leaving just the backticked text and erasing -// the URL. We replace these with ASCII placeholders before mounting and -// restore them in post-process. -const CODE_LINK_RE = /\[`([^`]+)`\]\(([^)]+)\)/g; +// Match any markdown inline link: `[text](url)`. We don't restrict the +// text or URL further at the regex level — instead, isFragileLink() +// inspects each match to decide whether Tiptap would mangle it. +const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +/** + * Decide whether a markdown inline link will be mangled by Tiptap, in + * which case we should placeholder it during preprocess. + * + * Two failure modes are known: + * + * 1. Link text is purely inline code (`[\`x\`](url)`). tiptap-markdown + * drops the surrounding `[...](url)` and leaves just `\`x\``. + * + * 2. URL is a relative path with subdirectory but no leading prefix + * (`scripts/foo.mjs`, `references/output.md`). The Link extension's + * URL validator rejects these as non-URLs; the link is silently + * dropped on parse and the text alone survives. + * + * URLs Tiptap accepts and we leave alone: + * - Absolute URLs (`https://`, `http://`, `mailto:`, `tel:`, `ftp:`) + * - Anchors (`#section`) + * - Single-segment relative paths (`file.md`, `file.md#section`) + * - Explicitly-relative paths (`./foo`, `../foo`, `/foo`) + */ +function isFragileLink(text: string, url: string): boolean { + // Code-text link: text is exactly `` `...` `` with nothing else. + if (/^`[^`]+`$/.test(text)) return true; + // URL has no scheme prefix and no leading-slash / relative-prefix + // and contains at least one path separator → Tiptap rejects it. + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(url); + const hasRelativePrefix = url.startsWith('./') || url.startsWith('../') || url.startsWith('/') || url.startsWith('#'); + if (!hasScheme && !hasRelativePrefix && url.includes('/')) return true; + return false; +} /** * Pre-process a document before handing it to Tiptap. Returns a context @@ -83,14 +112,16 @@ export function preprocessForEditor(input: string): { editorInput: string; conte const trailingNewline = afterFront.endsWith('\n') ? '\n' : ''; - // tiptap-markdown drops the URL when a link's text is purely inline - // code (`[\`x\`](url)` -> `\`x\``). Replace those with ASCII - // placeholders that survive the parse-and-serialize round-trip - // unchanged; we restore them in applyPostProcess. + // tiptap-markdown drops the URL on certain link shapes (see + // isFragileLink — currently code-text links and bare-relative-subpath + // links). Replace those with ASCII placeholders that survive the + // parse-and-serialize round-trip unchanged; we restore them in + // applyPostProcess. const codeLinks: Array<{ placeholder: string; original: string }> = []; let withPlaceholders = afterFront; let codeLinkIndex = 0; - withPlaceholders = withPlaceholders.replace(CODE_LINK_RE, (match) => { + withPlaceholders = withPlaceholders.replace(INLINE_LINK_RE, (match, text, url) => { + if (!isFragileLink(text, url)) return match; const placeholder = `TIPTAPCODELINK${String(codeLinkIndex).padStart(4, '0')}`; codeLinks.push({ placeholder, original: match }); codeLinkIndex += 1; diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js index c4c93ae0..ae0ef6cd 100644 --- a/test/test-markdown-editor-roundtrip.js +++ b/test/test-markdown-editor-roundtrip.js @@ -374,6 +374,30 @@ async function testStarBulletMarkerPreserved() { console.log('OK star bullet marker preserved'); } +async function testRelativePathLinksSurvive() { + console.log('\n--- Test: links to relative paths survive (skill-files batch) ---'); + // From SKILL.md files in ~/.desktop-commander/skills/. Tiptap's link + // extension validates URLs against a scheme/relative-prefix list and + // SILENTLY DROPS links whose URL is a bare relative path with `/` + // (`scripts/foo.mjs`). Single-segment paths (`foo.md`) survive, but + // anything in a subdirectory does not. + // + // This is the most common corruption mode in real skill files because + // they routinely link to scripts/ and references/ from SKILL.md. + const input = + '- [init-skill.mjs](scripts/init-skill.mjs) — Scaffold new skills\n' + + '- [validate-skill.mjs](scripts/validate-skill.mjs) — Validate structure\n' + + '- [Output Format](references/output-format.md) — Final structure\n' + + '- [Section](references/output-format.md#anchor) — With fragment\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'links to relative paths in subdirectories must keep their URL on round-trip' + ); + console.log('OK relative-path links preserved'); +} + async function runAllTests() { const tests = [ testPipeTableSurvivesRoundTrip, @@ -394,6 +418,7 @@ async function runAllTests() { testEmojiPrefixedSoftBreaksRestored, testLinkInTableCellSurvivesRoundTrip, testStarBulletMarkerPreserved, + testRelativePathLinksSurvive, ]; let passed = 0; let failed = 0; From a23ae76bbc2a04aeb7897a935757236d7f500f75 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Mon, 27 Apr 2026 19:18:28 +0300 Subject: [PATCH 6/8] fix(file-preview): scope markdown autosave edits --- .../file-preview/src/markdown/controller.ts | 177 +++++++++++++++--- src/ui/file-preview/src/markdown/editor.ts | 131 ++++++++++++- src/ui/file-preview/src/model.ts | 3 +- 3 files changed, 276 insertions(+), 35 deletions(-) diff --git a/src/ui/file-preview/src/markdown/controller.ts b/src/ui/file-preview/src/markdown/controller.ts index 923dd779..de2f300f 100644 --- a/src/ui/file-preview/src/markdown/controller.ts +++ b/src/ui/file-preview/src/markdown/controller.ts @@ -3,7 +3,7 @@ import { getDocumentFullscreenAvailability, parseReadRange, shouldAutoLoadDocume import type { MarkdownWorkspaceState, RenderBodyResult, RenderPayload } from '../model.js'; import { assertSuccessfulEditBlockResult, extractRenderPayload, extractToolText } from '../payload-utils.js'; import { getAncestorDirectories, getParentDirectory, toPosixRelativePath } from '../path-utils.js'; -import { mountMarkdownEditor, renderMarkdownEditorShell, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './editor.js'; +import { mountMarkdownEditor, renderMarkdownEditorShell, type MarkdownEditRange, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './editor.js'; import type { OpenConflictDialogOptions } from './conflict-dialog.js'; import { resolveMarkdownLink } from './linking.js'; import { extractMarkdownOutline } from './outline.js'; @@ -38,6 +38,13 @@ interface DiffHunk { newEnd: number; } +interface EditBlock { + old_string: string; + new_string: string; +} + +const MAX_EDIT_BLOCK_LINES = 40; + function areOutlineItemsEqual( left: MarkdownWorkspaceState['outline'], right: MarkdownWorkspaceState['outline'] @@ -134,35 +141,117 @@ function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { return merged; } +function mergeLineRanges(ranges: MarkdownEditRange[]): MarkdownEditRange[] { + const sorted = ranges + .map((range) => ({ fromLine: Math.max(1, Math.floor(range.fromLine)), toLine: Math.max(1, Math.floor(range.toLine)) })) + .sort((left, right) => left.fromLine - right.fromLine || left.toLine - right.toLine); + const merged: MarkdownEditRange[] = []; + + for (const range of sorted) { + const normalized = { + fromLine: Math.min(range.fromLine, range.toLine), + toLine: Math.max(range.fromLine, range.toLine), + }; + const previous = merged[merged.length - 1]; + if (previous && normalized.fromLine <= previous.toLine + 1) { + previous.toLine = Math.max(previous.toLine, normalized.toLine); + } else { + merged.push(normalized); + } + } + + return merged; +} + +function hunkIntersectsRanges(hunk: DiffHunk, ranges: MarkdownEditRange[]): boolean { + if (ranges.length === 0) { + return true; + } + const fromLine = Math.min(hunk.oldStart, hunk.newStart) + 1; + const toLine = Math.max(hunk.oldEnd, hunk.newEnd) + 1; + return ranges.some((range) => fromLine <= range.toLine && toLine >= range.fromLine); +} + function computeLineByLineHunks(oldLines: string[], newLines: string[]): DiffHunk[] { - const hunks: DiffHunk[] = []; - const maxLength = Math.max(oldLines.length, newLines.length); - let oldStart: number | null = null; - let newStart: number | null = null; - - for (let index = 0; index < maxLength; index += 1) { - const same = index < oldLines.length && index < newLines.length && oldLines[index] === newLines[index]; - if (!same && oldStart === null) { - oldStart = Math.min(index, oldLines.length); - newStart = Math.min(index, newLines.length); + return computeAnchoredDiffHunks(oldLines, newLines, 0, oldLines.length, 0, newLines.length); +} + +function computeAnchoredDiffHunks( + oldLines: string[], + newLines: string[], + oldStart: number, + oldEnd: number, + newStart: number, + newEnd: number +): DiffHunk[] { + while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) { + oldStart++; + newStart++; + } + while (oldStart < oldEnd && newStart < newEnd && oldLines[oldEnd - 1] === newLines[newEnd - 1]) { + oldEnd--; + newEnd--; + } + if (oldStart === oldEnd && newStart === newEnd) { + return []; + } + + const oldLineCounts = new Map(); + const newLineCounts = new Map(); + for (let index = oldStart; index < oldEnd; index += 1) { + const current = oldLineCounts.get(oldLines[index]); + oldLineCounts.set(oldLines[index], { count: (current?.count ?? 0) + 1, index }); + } + for (let index = newStart; index < newEnd; index += 1) { + const current = newLineCounts.get(newLines[index]); + newLineCounts.set(newLines[index], { count: (current?.count ?? 0) + 1, index }); + } + + for (let oldIndex = oldStart; oldIndex < oldEnd; oldIndex += 1) { + const oldEntry = oldLineCounts.get(oldLines[oldIndex]); + const newEntry = newLineCounts.get(oldLines[oldIndex]); + if (oldEntry?.count === 1 && newEntry?.count === 1) { + return [ + ...computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldIndex, newStart, newEntry.index), + ...computeAnchoredDiffHunks(oldLines, newLines, oldIndex + 1, oldEnd, newEntry.index + 1, newEnd), + ]; } - if ((same || index === maxLength - 1) && oldStart !== null && newStart !== null) { - const endIndex = same ? index : index + 1; - hunks.push({ - oldStart, - oldEnd: Math.min(endIndex, oldLines.length), - newStart, - newEnd: Math.min(endIndex, newLines.length), - }); - oldStart = null; - newStart = null; + } + + return [{ oldStart, oldEnd, newStart, newEnd }]; +} + +function splitOversizedEditBlock(oldText: string, newText: string): EditBlock[] { + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + const blockCount = Math.ceil(Math.max(oldLines.length, newLines.length) / MAX_EDIT_BLOCK_LINES); + const blocks: EditBlock[] = []; + + for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) { + const oldStart = Math.floor((blockIndex * oldLines.length) / blockCount); + const oldEnd = Math.floor(((blockIndex + 1) * oldLines.length) / blockCount); + const newStart = Math.floor((blockIndex * newLines.length) / blockCount); + const newEnd = Math.floor(((blockIndex + 1) * newLines.length) / blockCount); + const old_string = oldLines.slice(oldStart, oldEnd).join('\n'); + const new_string = newLines.slice(newStart, newEnd).join('\n'); + if (old_string !== new_string) { + blocks.push({ old_string, new_string }); } } - return hunks; + return blocks; +} + +function splitOversizedEditBlocks(blocks: EditBlock[]): EditBlock[] { + return blocks.flatMap((block) => { + const lineCount = Math.max(block.old_string.split('\n').length, block.new_string.split('\n').length); + return lineCount > MAX_EDIT_BLOCK_LINES + ? splitOversizedEditBlock(block.old_string, block.new_string) + : [block]; + }); } -export function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { +export function computeEditBlocks(oldText: string, newText: string, changedRanges: MarkdownEditRange[] = []): EditBlock[] { if (oldText === newText) { return []; } @@ -177,9 +266,10 @@ export function computeEditBlocks(oldText: string, newText: string): Array<{ old } const context = 3; - const merged = mergeCloseHunks(hunks, context * 2 + 1); + const normalizedRanges = mergeLineRanges(changedRanges); + const merged = mergeCloseHunks(hunks, context * 2 + 1).filter((hunk) => hunkIntersectsRanges(hunk, normalizedRanges)); - return merged.map((hunk) => { + const blocks = merged.map((hunk) => { const contextBefore = Math.max(0, hunk.oldStart - context); const contextAfter = Math.min(oldLines.length, hunk.oldEnd + context); @@ -192,6 +282,16 @@ export function computeEditBlocks(oldText: string, newText: string): Array<{ old return { old_string: oldBlock, new_string: newBlock }; }).filter((block) => block.old_string !== block.new_string); + + if (blocks.length === 1 && blocks[0].old_string === oldText && blocks[0].new_string === newText) { + return splitOversizedEditBlock(oldText, newText); + } + + return splitOversizedEditBlocks(blocks); +} + +function applyEditBlocksToText(text: string, blocks: EditBlock[]): string { + return blocks.reduce((current, block) => current.replace(block.old_string, block.new_string), text); } function isToolErrorResult(value: unknown): value is ToolErrorResult { @@ -262,6 +362,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende state.draftContent = nextDraftContent; state.outline = extractMarkdownOutline(content); state.dirty = nextDraftContent !== content; + state.dirtyLineRanges = []; state.fileDeleted = false; if (!state.outline.some((item) => item.id === state.activeHeadingId)) { state.activeHeadingId = state.outline[0]?.id ?? null; @@ -311,6 +412,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende outline, mode: 'edit', dirty: false, + dirtyLineRanges: [], activeHeadingId: outline[0]?.id ?? null, pendingAnchor: null, notice: null, @@ -679,6 +781,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende const filePath = workspaceState.filePath; workspaceState.draftContent = workspaceState.fullDocumentContent; workspaceState.dirty = false; + workspaceState.dirtyLineRanges = []; workspaceState.error = null; workspaceState.notice = null; dependencies.rerender(); @@ -700,11 +803,12 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende state.notice = null; try { - const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent); + const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent, state.dirtyLineRanges); if (blocks.length === 0) { state.saving = false; state.saveIndicator = 'idle'; state.dirty = false; + state.dirtyLineRanges = []; return; } @@ -750,17 +854,19 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende throw err; } - state.fullDocumentContent = state.draftContent; - state.sourceContent = state.draftContent; + const savedContent = applyEditBlocksToText(state.fullDocumentContent, blocks); + state.fullDocumentContent = savedContent; + state.sourceContent = savedContent; + state.draftContent = savedContent; state.outline = extractMarkdownOutline(state.sourceContent); state.dirty = false; + state.dirtyLineRanges = []; state.saving = false; state.saveIndicator = 'saved'; if (!state.outline.some((item) => item.id === state.activeHeadingId)) { state.activeHeadingId = state.outline[0]?.id ?? null; } - const savedContent = state.draftContent; const currentPayload = dependencies.getCurrentPayload(); if (currentPayload) { const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/); @@ -941,12 +1047,23 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende currentFilePath: payload.filePath, searchLinks: (query) => searchLinkTargets(payload.filePath, query), loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath), - onChange: (value) => { + onChange: (value, editRanges) => { if (value === state.draftContent) { return; } state.draftContent = value; state.dirty = value !== state.fullDocumentContent; + if (state.dirty) { + const nextRanges = editRanges && editRanges.length > 0 + ? editRanges + : [{ fromLine: 1, toLine: value.split('\n').length }]; + state.dirtyLineRanges = mergeLineRanges([ + ...state.dirtyLineRanges, + ...nextRanges, + ]); + } else { + state.dirtyLineRanges = []; + } if (state.dirty && !editStartedFired) { editStartedFired = true; dependencies.trackUiEvent?.('markdown_edit_started', { diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 6d7f290d..b61f4f72 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -546,6 +546,114 @@ export interface MarkdownEditorHandle { setScrollTop: (scrollTop: number) => void; } +export interface MarkdownEditRange { + fromLine: number; + toLine: number; +} + +function computeSerializedEditRanges(before: string, after: string): MarkdownEditRange[] { + if (before === after) { + return []; + } + + const beforeLines = before.split('\n'); + const afterLines = after.split('\n'); + const beforeLength = beforeLines.length; + const afterLength = afterLines.length; + const ranges: MarkdownEditRange[] = []; + + if (beforeLength * afterLength > 1_000_000) { + return computeAnchoredSerializedEditRanges(beforeLines, afterLines, 0, beforeLength, 0, afterLength); + } + + const dp: number[][] = Array.from({ length: beforeLength + 1 }, () => Array(afterLength + 1).fill(0) as number[]); + for (let beforeIndex = 1; beforeIndex <= beforeLength; beforeIndex += 1) { + for (let afterIndex = 1; afterIndex <= afterLength; afterIndex += 1) { + dp[beforeIndex][afterIndex] = beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1] + ? dp[beforeIndex - 1][afterIndex - 1] + 1 + : Math.max(dp[beforeIndex - 1][afterIndex], dp[beforeIndex][afterIndex - 1]); + } + } + + const matches: Array<[number, number]> = []; + let beforeIndex = beforeLength; + let afterIndex = afterLength; + while (beforeIndex > 0 && afterIndex > 0) { + if (beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1]) { + matches.unshift([beforeIndex - 1, afterIndex - 1]); + beforeIndex -= 1; + afterIndex -= 1; + } else if (dp[beforeIndex - 1][afterIndex] >= dp[beforeIndex][afterIndex - 1]) { + beforeIndex -= 1; + } else { + afterIndex -= 1; + } + } + + let previousBefore = 0; + let previousAfter = 0; + for (const [matchBefore, matchAfter] of matches) { + if (matchBefore > previousBefore || matchAfter > previousAfter) { + ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, matchAfter + 3) }); + } + previousBefore = matchBefore + 1; + previousAfter = matchAfter + 1; + } + if (previousBefore < beforeLength || previousAfter < afterLength) { + ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, afterLength + 3) }); + } + + return ranges; +} + +function computeAnchoredSerializedEditRanges( + beforeLines: string[], + afterLines: string[], + beforeStart: number, + beforeEnd: number, + afterStart: number, + afterEnd: number +): MarkdownEditRange[] { + while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeStart] === afterLines[afterStart]) { + beforeStart++; + afterStart++; + } + while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1]) { + beforeEnd--; + afterEnd--; + } + if (beforeStart === beforeEnd && afterStart === afterEnd) { + return []; + } + + const beforeLineCounts = new Map(); + const afterLineCounts = new Map(); + for (let index = beforeStart; index < beforeEnd; index += 1) { + const current = beforeLineCounts.get(beforeLines[index]); + beforeLineCounts.set(beforeLines[index], { count: (current?.count ?? 0) + 1, index }); + } + for (let index = afterStart; index < afterEnd; index += 1) { + const current = afterLineCounts.get(afterLines[index]); + afterLineCounts.set(afterLines[index], { count: (current?.count ?? 0) + 1, index }); + } + + for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex += 1) { + const beforeEntry = beforeLineCounts.get(beforeLines[beforeIndex]); + const afterEntry = afterLineCounts.get(beforeLines[beforeIndex]); + if (beforeEntry?.count === 1 && afterEntry?.count === 1) { + return [ + ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeStart, beforeIndex, afterStart, afterEntry.index), + ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeIndex + 1, beforeEnd, afterEntry.index + 1, afterEnd), + ]; + } + } + + return [{ + fromLine: Math.max(1, afterStart - 3), + toLine: Math.max(afterStart + 1, afterEnd + 3), + }]; +} + function shouldIgnoreBlur(shell: Element | null | undefined, event: FocusEvent): boolean { const nextTarget = event.relatedTarget as Node | null; const widgetShell = shell?.closest('.tool-shell'); @@ -666,7 +774,7 @@ export function mountMarkdownEditor(options: { currentFilePath: string; searchLinks?: (query: string) => Promise; loadHeadings?: (filePath: string) => Promise; - onChange: (value: string) => void; + onChange: (value: string, editRanges?: MarkdownEditRange[]) => void; onBlur?: () => void; }): MarkdownEditorHandle { const shell = options.target.closest('.markdown-editor-shell'); @@ -710,6 +818,7 @@ export function mountMarkdownEditor(options: { const serialized = storage.markdown?.getMarkdown() ?? ''; return applyPostProcess(serialized, context); }; + let previousSerializedValue = ''; const tiptap = new Editor({ element: options.target, @@ -727,7 +836,10 @@ export function mountMarkdownEditor(options: { if (!hasUserEdited) { return; } - options.onChange(getTiptapMarkdown()); + const value = getTiptapMarkdown(); + const editRanges = computeSerializedEditRanges(previousSerializedValue, value); + previousSerializedValue = value; + options.onChange(value, editRanges); }, onSelectionUpdate: () => { updateContextMenu(); @@ -742,6 +854,7 @@ export function mountMarkdownEditor(options: { options.onBlur?.(); }, }); + previousSerializedValue = getTiptapMarkdown(); const editorDom = tiptap.view.dom as HTMLElement; syncHeadingIds(editorDom); @@ -1178,6 +1291,7 @@ export function mountMarkdownEditor(options: { getValue: () => getTiptapMarkdown(), setValue: (value: string) => { tiptap.commands.setContent(rewriteWikiLinks(value), { emitUpdate: false }); + previousSerializedValue = getTiptapMarkdown(); syncHeadingIds(editorDom); }, revealLine: (_lineNumber: number, headingId?: string) => { @@ -1207,6 +1321,7 @@ export function mountMarkdownEditor(options: { textarea.setAttribute('autocapitalize', 'off'); textarea.placeholder = 'Edit raw markdown...'; textarea.value = options.value; + let previousTextareaValue = textarea.value; options.target.replaceChildren(textarea); const autosize = (): void => { @@ -1214,9 +1329,16 @@ export function mountMarkdownEditor(options: { textarea.style.height = `${Math.max(textarea.scrollHeight, 640)}px`; }; + const emitRawChange = (): void => { + const value = textarea.value; + const editRanges = computeSerializedEditRanges(previousTextareaValue, value); + previousTextareaValue = value; + options.onChange(value, editRanges); + }; + const handleInput = (): void => { autosize(); - options.onChange(textarea.value); + emitRawChange(); }; const handleFocusOut = (event: FocusEvent): void => { @@ -1234,7 +1356,7 @@ export function mountMarkdownEditor(options: { event.preventDefault(); applyRawTab(textarea); autosize(); - options.onChange(textarea.value); + emitRawChange(); }; textarea.addEventListener('input', handleInput); @@ -1258,6 +1380,7 @@ export function mountMarkdownEditor(options: { getValue: () => textarea.value, setValue: (value: string) => { textarea.value = value; + previousTextareaValue = value; autosize(); }, revealLine: (lineNumber: number) => { diff --git a/src/ui/file-preview/src/model.ts b/src/ui/file-preview/src/model.ts index df5adc6e..c7bde5eb 100644 --- a/src/ui/file-preview/src/model.ts +++ b/src/ui/file-preview/src/model.ts @@ -1,6 +1,6 @@ import type { DocumentOutlineItem } from './document-outline.js'; import type { FilePreviewStructuredContent } from '../../../types.js'; -import type { MarkdownEditorView } from './markdown/editor.js'; +import type { MarkdownEditRange, MarkdownEditorView } from './markdown/editor.js'; export type RenderPayload = FilePreviewStructuredContent & { content: string }; @@ -12,6 +12,7 @@ export interface MarkdownWorkspaceState { outline: DocumentOutlineItem[]; mode: 'edit'; dirty: boolean; + dirtyLineRanges: MarkdownEditRange[]; activeHeadingId: string | null; pendingAnchor: string | null; notice: string | null; From 675350b099e6105ea577d5230158a66d225374fd Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 20:33:24 +0300 Subject: [PATCH 7/8] fix(markdown editor): 6 more in-the-wild round-trip failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured by running the SKILL.md files in ~/.desktop-commander/skills/ through the round-trip pipeline. 4 of 8 files broke before this commit; all 8 now round-trip byte-exact. Tests added (test-markdown-editor-roundtrip.js): testLessThanInProseNotEscaped '< $0.01' -> '< $0.01' testTrailingHardBreakWhitespacePreserved 'foo \n' (CommonMark hard break) lost the trailing spaces or rewritten as 'foo\\\n' testBoldAroundInlineCodePreserved '**`x`**' -> '`x`', '**`x` + `y`**' -> '`x` **+** `y`', '**Key in `x`:**' -> '**Key in** `x`**:**' testEscapedPipeInTableCellPreserved '\\|' inside a cell becomes bare '|', splits the cell testListItemWithContinuationLine '- item\n cont\n' joins to '- item cont\n' Fixes: - unescapeHtmlEntitiesInProse: undo Tiptap's '<'/'>'/'&' HTML entity escaping in prose. Line-aligned like unescapeSafeChars; only removes entities when the corresponding source line, stripped of those entities, matches the output line. User-authored entities are preserved. - restoreTrailingHardBreaks: detect source lines ending in two trailing spaces and re-add them. Handles both Tiptap serializer shapes: '\\\n' line continuation (paragraph) and silently-stripped (list item). - BOLD_AROUND_CODE_RE + boldCodeRuns: placeholder '**…`code`…**' spans during preprocess, restore after serialize. Bypasses ProseMirror's flat-mark schema limitation. - pipeEscapeCount + PIPE_ESCAPE_TOKEN: placeholder '\|' as an ASCII token before parse. Tiptap's serializer drops the backslash; the token round-trips intact. - restoreSoftBreaks: recognise list-item-with-lazy-continuation pairs (list-header followed by 2-space-indented prose line) and try the de-indented form of the continuation when looking for Tiptap's joined output. Result: 24/24 strict + 7/7 edit-diff = 31/31. All 8 skill files in the stress test now byte-exact. --- src/ui/file-preview/src/markdown/editor.ts | 225 ++++++++++++++++++++- test/test-markdown-editor-roundtrip.js | 120 +++++++++++ 2 files changed, 340 insertions(+), 5 deletions(-) diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index e9a801d9..83981fdc 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -51,6 +51,15 @@ export interface RoundTripContext { * preprocessing, restored after serialization. tiptap-markdown drops * the URL when a link's text is purely inline code. */ codeLinks: Array<{ placeholder: string; original: string }>; + /** `**...\`code\`...**` constructs replaced with placeholders. Tiptap's + * ProseMirror schema can't cleanly represent a bold mark wrapping + * inline code; it splits the bold around the code in non-obvious + * ways. */ + boldCodeRuns: Array<{ placeholder: string; original: string }>; + /** Count of `\|` escapes that were replaced with placeholders during + * preprocess. Each `\|` is replaced by a single ASCII token that + * restoration converts back to the literal `\|` in the output. */ + pipeEscapeCount: number; } const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; @@ -60,6 +69,27 @@ const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; // inspects each match to decide whether Tiptap would mangle it. const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; +// Match a `**…**` bold span whose contents contain at least one inline +// code segment. ProseMirror's flat-mark schema can't cleanly represent a +// bold wrapping inline code, so Tiptap shifts the bold delimiters around +// the code in non-obvious ways on serialize. We placeholder these spans +// during preprocess and restore them after. +// +// Pattern detail: +// \*\* opening ** +// ([^*\n]*? any non-`*`, non-newline chars, lazy +// `[^`\n]+` at least one `` `inline code` `` segment +// [^*\n]*?) then more non-`*` chars (lazy) +// \*\* closing ** +// +// The lazy quantifiers keep us from spanning multiple bold groups. +const BOLD_AROUND_CODE_RE = /\*\*([^*\n]*?`[^`\n]+`[^*\n]*?)\*\*/g; + +// Token used to placeholder `\|` escapes. Chosen so it's: +// - ASCII letters/digits only (survives Tiptap's parse/serialize round trip) +// - distinctive enough to never collide with real document content +const PIPE_ESCAPE_TOKEN = 'TIPTAPPIPEESCX'; + /** * Decide whether a markdown inline link will be mangled by Tiptap, in * which case we should placeholder it during preprocess. @@ -128,6 +158,31 @@ export function preprocessForEditor(input: string): { editorInput: string; conte return placeholder; }); + // Bold spans containing inline code are restructured by Tiptap on + // round-trip (the bold mark gets shifted around the code in ways + // ProseMirror's flat-mark schema can express). Placeholder them + // alongside fragile links — same trick, same restore pass. + const boldCodeRuns: Array<{ placeholder: string; original: string }> = []; + let boldCodeIndex = 0; + withPlaceholders = withPlaceholders.replace(BOLD_AROUND_CODE_RE, (match) => { + const placeholder = `TIPTAPBOLDCODE${String(boldCodeIndex).padStart(4, '0')}`; + boldCodeRuns.push({ placeholder, original: match }); + boldCodeIndex += 1; + return placeholder; + }); + + // Authors escape `|` as `\|` inside table cells when the cell + // contains literal pipes (Mermaid edge labels in code, shell + // pipelines, etc.) — bare `|` would split the cell. Tiptap's + // serializer drops the backslash and the table re-parses with a + // different shape next time. Replace with an ASCII token; restore + // after serialize. + let pipeEscapeCount = 0; + withPlaceholders = withPlaceholders.replace(/\\\|/g, () => { + pipeEscapeCount += 1; + return PIPE_ESCAPE_TOKEN; + }); + // Tiptap mutates trailing newlines — we trim and put it back. Wikilinks // are rewritten to a placeholder shape that survives Tiptap. const editorInput = rewriteWikiLinks(withPlaceholders); @@ -141,6 +196,8 @@ export function preprocessForEditor(input: string): { editorInput: string; conte trailingNewline, eol, codeLinks, + boldCodeRuns, + pipeEscapeCount, }, }; } @@ -160,6 +217,17 @@ export function applyPostProcess(serialized: string, context: RoundTripContext): for (const { placeholder, original } of context.codeLinks) { out = out.split(placeholder).join(original); } + // Restore `**…\`code\`…**` placeholder runs alongside the link + // restore — same shape, different schema-level reason for needing it. + for (const { placeholder, original } of context.boldCodeRuns) { + out = out.split(placeholder).join(original); + } + // Restore escaped pipe placeholders. Each token unconditionally maps + // back to `\|` regardless of position — the user's escape is + // syntactically required wherever it appears. + if (context.pipeEscapeCount > 0) { + out = out.split(PIPE_ESCAPE_TOKEN).join('\\|'); + } // Tiptap's serializer over-escapes characters that have no syntactic // meaning in the position they appear. We selectively unescape: // - `\[` and `\]` outside link constructs (so `- [x] task` stays `- [x] task`) @@ -170,6 +238,20 @@ export function applyPostProcess(serialized: string, context: RoundTripContext): // inside fenced code blocks or inline code. out = unescapeSafeChars(out, context.originalInput); + // Tiptap's HTML output path HTML-escapes bare `<` characters in + // prose because they could in theory open a tag. tiptap-markdown + // then serialises the entity as a literal `<`. Reverse the + // entity in positions where CommonMark says `<` could not have been + // a tag opener (followed by space, digit, `$`, etc.) — preserves + // the source bytes without changing parser interpretation. + out = unescapeHtmlEntitiesInProse(out, context.originalInput); + + // Tiptap serialises CommonMark hard breaks (two trailing spaces in + // the source) either as a `\` line-continuation or by dropping them + // entirely (inside list items). Restore the original two-space form + // wherever the source used it. + out = restoreTrailingHardBreaks(out, context.originalInput); + // Tiptap normalises GFM table separator rows to a spaced form // (`| --- | --- |`) regardless of input shape. If the original used // a more compact form (`|---|---|`), restore it line-by-line. @@ -333,16 +415,36 @@ function restoreSoftBreaks(serialized: string, originalInput: string): string { // Skip lines that look like markdown structure: list markers, // headings, fences, table rows, blockquotes. Tiptap handles those // as their own block kinds; we don't want to break list items in - // half. - if (looksStructural(a) || looksStructural(b)) continue; + // half — EXCEPT for the specific case of a list item followed by + // its 2-space-indented lazy continuation. CommonMark joins those + // into one paragraph too, and Tiptap collapses them into a single + // line. The source authored them as separate lines so we must + // restore the break. + const aIsListHeader = /^\s*([-*+]|\d+\.)\s/.test(a); + const bIsIndentedCont = /^ +\S/.test(b) && !/^\s*([-*+]|\d+\.)\s/.test(b); + const isListContinuation = aIsListHeader && bIsIndentedCont; + if (!isListContinuation) { + if (looksStructural(a) || looksStructural(b)) continue; + } const broken = `${a}\n${b}`; if (out.indexOf(broken) !== -1) continue; // Tiptap joins paragraph-internal lines with EITHER a space (the // common case for prose) OR no separator at all (when the // boundary is between punctuation like `)` and a non-letter - // character like an emoji). Try both, in that order. - for (const joiner of [' ', '']) { - const joined = `${a}${joiner}${b}`; + // character like an emoji). For list-item lazy continuations, + // Tiptap STRIPS the leading whitespace from the second line and + // then joins with a single space, so we have to compare against + // the de-indented form of `b`. + const candidates: Array<{ joiner: string; b: string }> = [ + { joiner: ' ', b }, + { joiner: '', b }, + ]; + if (isListContinuation) { + const deindented = b.replace(/^\s+/, ''); + candidates.push({ joiner: ' ', b: deindented }); + } + for (const { joiner, b: bForm } of candidates) { + const joined = `${a}${joiner}${bForm}`; const idx = out.indexOf(joined); if (idx === -1) continue; out = out.slice(0, idx) + broken + out.slice(idx + joined.length); @@ -436,6 +538,119 @@ function stripSafeEscapes(line: string): string { return line.replace(/\\([\[\]~])/g, '$1'); } +/** + * Replace `<` / `>` / `&` HTML entities with their literal + * characters in positions where they cannot be HTML or markdown syntax. + * + * Tiptap's HTML output path escapes bare `<` and `&` in prose because + * the characters could in theory open a tag or entity. tiptap-markdown + * then serialises those entities verbatim, so a source like `< $0.01` + * round-trips as `< $0.01`. We undo the escape only when the + * surrounding context proves it can't be markup: + * + * - `<` followed by space, digit, `$`, end-of-line, or a punctuation + * character that can't begin an HTML tag name. + * - `>` likewise; in CommonMark `>` only has block-level meaning at + * the start of a line (blockquote), and we never produce that here. + * - `&` always — `&` followed by anything that isn't a known entity + * prefix wouldn't survive parsing as a real entity anyway. + * + * Code fences and inline code are skipped so that intentionally-escaped + * entities inside code samples are left intact. + * + * Round-trip safety: if the same entity appears in the source on a + * matching line, we leave it alone (the user authored the entity and we + * mustn't strip it). This mirrors the line-aligned rule in + * unescapeSafeChars. + */ +function unescapeHtmlEntitiesInProse(md: string, originalInput: string): string { + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + let insideFence = false; + const lines = md.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + insideFence = !insideFence; + continue; + } + if (insideFence) continue; + if (!/&(?:lt|gt|amp);/.test(line)) continue; + + // Only act if there's a source line that, when both are stripped + // of these specific entities, matches this output line. Otherwise + // we don't have enough confidence the entity was Tiptap's doing. + const stripped = stripHtmlEntities(line); + const sourceMatches = origLines.some((src) => stripHtmlEntities(src) === stripped); + if (!sourceMatches) continue; + + const exact = origLines.find((src) => src === line); + if (exact !== undefined) { + // Source had this exact line including entities — preserve. + continue; + } + // Otherwise the source had the equivalent without entities; + // Tiptap added them — strip. + lines[i] = stripped; + } + return lines.join('\n'); +} + +function stripHtmlEntities(line: string): string { + // Conservative replacements — only the three Tiptap actually emits. + return line + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** + * Restore CommonMark hard-break syntax (two trailing spaces at end of + * line) where Tiptap stripped or rewrote it. + * + * Tiptap's serializer represents a hard break either as `\` followed by + * a newline (paragraphs) or by silently dropping it (list items). The + * source convention is two trailing spaces; we honour the source. + * + * Strategy: collect every source line that ends in ` ` (exactly two + * spaces). For each, find a matching output line — either: + * - same content with no trailing whitespace (the dropped case), or + * - same content followed by `\\` line continuation (the rewritten + * case — `expand left\\\nleft`). + * Replace with the source's two-space form. + */ +function restoreTrailingHardBreaks(serialized: string, originalInput: string): string { + const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); + // Lines that ended in exactly two trailing spaces — paired with + // their content sans the trailing spaces, for cheap matching. + const hardBreakSources: string[] = []; + for (const line of origLines) { + if (/[^ ] $/.test(line)) { + hardBreakSources.push(line.slice(0, -2)); + } + } + if (hardBreakSources.length === 0) return serialized; + + let out = serialized; + for (const stem of hardBreakSources) { + // Case 1: paragraph hard break — `stem\\\nNEXT` → `stem \nNEXT`. + const backslashForm = `${stem}\\\n`; + if (out.includes(backslashForm)) { + out = out.replace(backslashForm, `${stem} \n`); + continue; + } + // Case 2: silently dropped (list-item case). Look for the bare + // `stem\n` and re-introduce the two trailing spaces. We only + // repair the FIRST match — adding a hard break to the wrong + // duplicate is worse than missing one. + const bareForm = `${stem}\n`; + const idx = out.indexOf(bareForm); + if (idx !== -1) { + out = out.slice(0, idx) + `${stem} \n` + out.slice(idx + bareForm.length); + } + } + return out; +} + /** * If the user's original document used single-line separators between * adjacent block elements (e.g. `### A\nBody.\n### B\n`), Tiptap will diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js index ae0ef6cd..fdcd8259 100644 --- a/test/test-markdown-editor-roundtrip.js +++ b/test/test-markdown-editor-roundtrip.js @@ -398,6 +398,121 @@ async function testRelativePathLinksSurvive() { console.log('OK relative-path links preserved'); } +async function testLessThanInProseNotEscaped() { + console.log('\n--- Test: literal `<` in prose not converted to < (skill-files batch) ---'); + // From bigquery-cli.md and skill-creator.md. Tiptap's HTML output path + // HTML-escapes bare `<` in prose because the character could in theory + // open a tag. tiptap-markdown then serialises the entity literally so + // `< $0.01` round-trips as `< $0.01`. + // + // CommonMark's rule is that `<` only opens a tag when followed by an + // ASCII letter, slash, `?` or `!`. Followed by space / digit / dollar + // it's just a less-than sign. We can safely undo the escape in those + // positions on output. + const input = + '| Cost | Verdict |\n' + + '|---|---|\n' + + '| < $0.01 (< 2 GB) | Safe |\n' + + '\n' + + 'Use this when <2k tokens are expected.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + '`<` followed by space / digit / `$` in prose must NOT become `<` on round-trip' + ); + console.log('OK literal `<` preserved'); +} + +async function testTrailingHardBreakWhitespacePreserved() { + console.log('\n--- Test: trailing two-space hard break preserved (skill-files batch) ---'); + // From replicate-api.md. Two trailing spaces at the end of a line is + // CommonMark hard-break syntax. Tiptap's serializer drops the trailing + // whitespace entirely. + // + // Round-trip wants the source bytes back unchanged regardless of + // whether the user intended a hard break or just had stray spaces. + const input = + '- `right` - Original on right, expand left \n' + + '- `left` - Original on left, expand right\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'trailing two-space hard-break syntax must survive round-trip' + ); + console.log('OK trailing hard-break whitespace preserved'); +} + +async function testBoldAroundInlineCodePreserved() { + console.log('\n--- Test: **bold around `code`** preserved (skill-files batch) ---'); + // From sentry-posthog-replay-triage.md. ProseMirror's flat-mark schema + // can't represent a single bold span that wraps inline code; Tiptap + // re-shapes the construct in non-obvious ways: + // + // `**`x`**` → `\`x\`` (bold dropped) + // `**\`x\` + \`y\`**` → `\`x\` **+** \`y\`` (bold around `+`) + // `**Key in \`x\`:**` → `**Key in** \`x\`**:**` (bold split) + // + // The cleanest fix is the placeholder trick: detect bold-around-code + // patterns at preprocess and substitute an opaque placeholder. + const input = + '- **`tags.app_version`** — DC app version\n' + + '- **`contexts.os.name` + `contexts.os.version`** — OS\n' + + '- **Key columns in `chat_message`:** `role`, `parts`\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + '`**…`code`…**` constructs must round-trip without bold being shifted' + ); + console.log('OK bold-around-code preserved'); +} + +async function testEscapedPipeInTableCellPreserved() { + console.log('\n--- Test: \\| inside a table cell is preserved (skill-files batch) ---'); + // From skill-creator.md. Users manually escape `|` as `\|` inside + // table cells when the cell content (e.g. a Mermaid edge label or a + // shell pipeline in inline code) needs literal pipes — the bare `|` + // would otherwise split the cell. + // + // Tiptap's serializer unescapes them, so the source `\|` round-trips + // as `|`, which then changes the table structure on the next parse. + const input = + '| Issue | Example |\n' + + '|---|---|\n' + + '| Quotes in labels | `A -->\\|Click "Sign in"\\| B` |\n' + + '| Literal newline | `A -->\\|Line1\\nLine2\\| B` |\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + '`\\|` inside a table cell must NOT become a bare `|` on round-trip' + ); + console.log('OK escaped pipe preserved'); +} + +async function testListItemWithContinuationLine() { + console.log('\n--- Test: list item with two-space indented continuation line preserved ---'); + // From sentry-posthog-replay-triage.md. List items with continuation + // prose on the next line (2-space indent) get the continuation + // absorbed into the bullet on round-trip. The continuation line is + // CommonMark "lazy continuation" — same paragraph as the list item, + // but the source convention is to keep them on separate lines. + const input = + '- First item with explanation\n' + + ' continuation line.\n' + + '- Second item with explanation\n' + + ' another continuation.\n'; + const output = roundTrip(input); + assert.strictEqual( + output, + input, + 'list items with 2-space indented continuation lines must keep the line break' + ); + console.log('OK list item continuation preserved'); +} + async function runAllTests() { const tests = [ testPipeTableSurvivesRoundTrip, @@ -419,6 +534,11 @@ async function runAllTests() { testLinkInTableCellSurvivesRoundTrip, testStarBulletMarkerPreserved, testRelativePathLinksSurvive, + testLessThanInProseNotEscaped, + testTrailingHardBreakWhitespacePreserved, + testBoldAroundInlineCodePreserved, + testEscapedPipeInTableCellPreserved, + testListItemWithContinuationLine, ]; let passed = 0; let failed = 0; From d1e78012fa7791343df273ab01f27c87b1db54f8 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Mon, 27 Apr 2026 20:58:39 +0300 Subject: [PATCH 8/8] telemetry: rename tool name property to tool_name The capture_call_tool send-site was emitting 'name' as the tool-name property, which collides with GA4's reserved 'name' parameter when passed verbatim. Rename to 'tool_name' at the call site (server.ts) and have capture_call_tool's high-volume routing read either field, falling back to 'name' for backwards compat with any in-flight callers. No behaviour change; pure rename. --- src/server.ts | 2 +- src/utils/capture.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index fa03b9f7..28d2e718 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1175,7 +1175,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) try { // Prepare telemetry data - add config key for set_config_value - const telemetryData: any = { name }; + const telemetryData: any = { tool_name: name }; // Extract metadata from _meta field if present const metadata = request.params._meta as any; if (metadata && typeof metadata === 'object') { diff --git a/src/utils/capture.ts b/src/utils/capture.ts index 364264a2..10559ec6 100644 --- a/src/utils/capture.ts +++ b/src/utils/capture.ts @@ -416,7 +416,7 @@ export const capture_call_tool = async (event: string, properties?: any) => { // Route highest-volume tools to new property, rest to old const HIGH_VOLUME_TOOLS = ['start_process', 'track_ui_event']; - const toolName = properties?.name; + const toolName = properties?.tool_name ?? properties?.name; const gaUrl = HIGH_VOLUME_TOOLS.includes(toolName) ? GA_NEW_URL : GA_OLD_URL; // Build properties once, send to GA4 + telemetry proxy in parallel