From b7e4dea1a2d6c721460a85df32ada9fc3d15cd9a Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Wed, 1 Jul 2026 18:37:59 +0700 Subject: [PATCH] fix(editor): disable Underline + underscore/URL auto-format so typing matches load Three StarterKit defaults corrupted content or diverged from the load path: - Underline (`underline: false`): its `++text++` markdown tokenizer collapsed the space in `C++ then C++` on load, and Ctrl+U emitted non-standard `++`. No underline semantics in our content, so drop it outright. - Bold/Italic underscore input+paste rules: typing `__init__`/`_word_` created emphasis (`**init**`/`*word*`) while the same text loaded stays literal (the marked layer neutralizes `_`), breaking identifiers on the typing path only (input rules don't run on paste/load). Extend StarterKit to drop the underscore rules (regex mentions `_`), keeping the `*`/`**` rules. - Link autolink/linkOnPaste (`false`): a typed/pasted bare URL now stays literal, matching load; explicit [text](url) and the toolbar still work. Co-Authored-By: Claude Opus 4.8 --- .../markdown-editor-extensions.test.ts | 49 +++++++++++++++++++ .../markdown-editor-extensions.ts | 36 +++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts index 3e7e1a47..786a7cd2 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts @@ -105,6 +105,15 @@ describe('literal backslashes survive — no doubling (regex classes, Windows pa }); }); +describe('Underline disabled — ++ never parses, C++/++flags prose survives', () => { + it.each(['C++ then C++ again', 'a ++ b ++ c', 'compile ++flags++ here'])( + 'keeps %s byte-identical (no ++ collapse)', + (s) => { + expect(roundTrip(s)).toBe(s); + }, + ); +}); + describe('bare URLs and emails stay literal — explicit [links] still work', () => { it.each(['see https://example.com/path now', 'contact me@example.com today', 'a ref'])( 'keeps %s byte-identical', @@ -240,3 +249,43 @@ describe('findVariableOccurrences — doc spans for the Available-variables cycl } }); }); + +describe('typing matches load — underscore emphasis + bare-URL autolink disabled while typing', () => { + const typeString = (input: string): { html: string; md: string } => { + const editor = new Editor({ content: '', contentType: 'markdown', extensions: createMarkdownExtensions() }); + const { view } = editor; + + for (const ch of input) { + const { from } = view.state.selection; + const handled = view.someProp('handleTextInput', (handler) => handler(view, from, from, ch)); + + if (!handled) { + view.dispatch(view.state.tr.insertText(ch)); + } + } + + const result = { html: editor.getHTML(), md: editor.getMarkdown() }; + editor.destroy(); + + return result; + }; + + it('typed __dunder__ / _word_ stay literal (identifiers survive), matching load', () => { + const { html, md } = typeString('call __init__ and _word_ done'); + + expect(html).not.toContain(''); + expect(html).not.toContain(''); + expect(md).toContain('__init__'); + expect(md).toContain('_word_'); + }); + + it('typed bare URL stays literal — no autolink', () => { + expect(typeString('see https://evil.example.com/x done').html).not.toContain(' { + expect(typeString('a **bold** b').html).toContain(''); + expect(typeString('a *ital* b').html).toContain(''); + expect(typeString('a ~~del~~ b').html).toContain(''); + }); +}); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts index 0cf17ddf..bcadcbc8 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.ts @@ -9,12 +9,46 @@ import { MarkdownPaste } from './editor-paste'; import { TagHighlight } from './editor-tag-highlight'; import { VariableHighlight } from './editor-variable-highlight'; +// StarterKit's Bold/Italic register BOTH `**`/`*` and `__`/`_` input+paste rules. The marked layer keeps +// `_`-emphasis literal on load/paste, so leaving the underscore TYPING rules on would diverge — typed +// `__init__`/`_word_` would emphasize (→ `**init**`/`*word*`) while the same text loaded stays literal, +// breaking identifiers. Drop only the underscore rules (their `find` regex mentions `_`; the `*` rules +// stay) so typing matches load. (Link autolink + Underline are turned off in configure() below.) +const StarterKitFaithful = StarterKit.extend({ + addExtensions() { + return (this.parent?.() ?? []).map((extension) => { + if (extension.name !== 'bold' && extension.name !== 'italic') { + return extension; + } + + const dropUnderscore = (rules: { find: unknown }[]) => + rules.filter((rule) => !(rule.find instanceof RegExp && rule.find.source.includes('_'))); + + return extension.extend({ + addInputRules() { + return dropUnderscore(this.parent?.() ?? []); + }, + addPasteRules() { + return dropUnderscore(this.parent?.() ?? []); + }, + }); + }); + }, +}); + // Single source of truth for the editor's extension stack — shared by markdown-editor.tsx AND the // round-trip tests so they can never drift. createMarkdownLayer is the official @tiptap/markdown layer // tuned for our content (see editor-markdown.ts); VariableHighlight/TagHighlight are view-only decorations // ({{vars}} / ) that don't affect serialization. +// • underline: false — its `++text++` markdown corrupts `C++ … C++` prose on load and Ctrl+U emits `++`. +// • link autolink/linkOnPaste: false — a typed/pasted bare URL stays literal (matches load); explicit +// [text](url) and the toolbar link button still work. export const createMarkdownExtensions = (placeholder?: string) => [ - StarterKit.configure({ codeBlock: { HTMLAttributes: { class: 'hljs' } } }), + StarterKitFaithful.configure({ + codeBlock: { HTMLAttributes: { class: 'hljs' } }, + link: { autolink: false, linkOnPaste: false }, + underline: false, + }), MarkdownTable.configure({ resizable: true }), TableRow, TableHeader,