From 443fcc90eee11ade134aeee8e2382da961566cc8 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 30 Jun 2026 20:09:58 +0700 Subject: [PATCH] fix(editor): stop escaping a lone ~ so Go-template prose round-trips byte-exact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit faithfulEscape backslash-escaped every ~, but in GFM only ~~ is strikethrough — a lone ~ is literal. Escaping it injected a stray \ into prompt prose (e.g. ~10%/~30% in generator.tmpl + refiner.tmpl), and since the editor's getMarkdown output is the Go text/template stored and sent to the LLM, the \ shipped to the model. Escape ~ only in runs of 2+ (a balanced ~~strike~~ is handled by the Strike mark upstream and never reaches this path); backtick/backslash unchanged. Pinned by byte-identity tests for lone tildes (the corpus test asserts only word-multiset + convergence, so \~ slipped through). Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/shared/editor-markdown.ts | 8 ++++++-- .../shared/markdown-editor-extensions.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/shared/editor-markdown.ts b/frontend/src/components/shared/editor-markdown.ts index 028772b7..25f2c822 100644 --- a/frontend/src/components/shared/editor-markdown.ts +++ b/frontend/src/components/shared/editor-markdown.ts @@ -24,8 +24,12 @@ const createFaithfulMarked = () => { // ``` ` * _ [ ] ~ ``` — both corrupt our content (tags become entities, `[1-1000]`/`*.php`/`snake_case` // gain stray backslashes). Text serialization is hard-coded in the manager (no per-extension hook), so we // retune that one method: drop the entity-encoding entirely, and backslash-escape only the chars that -// would otherwise re-parse as inline syntax (`` ` ``, `~`, `\`). -const faithfulEscape = (text: string): string => text.replace(/([\\`~])/g, '\\$1'); +// would otherwise re-parse as inline syntax: `` ` `` and `\` always, and `~` only when doubled — a lone +// `~` is literal in GFM, so escaping it would inject a stray `\` into prose like `~10%`. +const faithfulEscape = (text: string): string => + text.replace(/[\\`]|~+/g, (match) => + match[0] === '~' ? (match.length > 1 ? match.replace(/~/g, '\\~') : match) : `\\${match}`, + ); type ManagerWithEncode = { codeTypes: Set; diff --git a/frontend/src/components/shared/markdown-editor-extensions.test.ts b/frontend/src/components/shared/markdown-editor-extensions.test.ts index 515e7102..9983097e 100644 --- a/frontend/src/components/shared/markdown-editor-extensions.test.ts +++ b/frontend/src/components/shared/markdown-editor-extensions.test.ts @@ -63,6 +63,19 @@ describe('selective escape — no stray backslashes on literal punctuation', () ); }); +describe('lone tilde stays literal — no stray backslash injected (M1)', () => { + it.each(['~10% for environment setup', 'approximately ~5 minutes', 'a ~ b ~ c'])( + 'keeps %s byte-identical', + (s) => { + expect(roundTrip(s)).toBe(s); + }, + ); + + it('still round-trips ~~strikethrough~~ (double tilde unaffected)', () => { + expect(roundTrip('use ~~deprecated~~ here')).toContain('~~deprecated~~'); + }); +}); + describe('inline marks round-trip', () => { it.each(['**bold**', '*italic*', '`code span`', '~~strike~~', '[a link](https://example.com)', '**bold `code` end**'])( 'preserves %s',