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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-01 18:37:59 +07:00
co-authored by Claude Opus 4.8
parent 18041520c3
commit b7e4dea1a2
2 changed files with 84 additions and 1 deletions
@@ -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 <https://example.com> 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('<strong>');
expect(html).not.toContain('<em>');
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('<a ');
});
it('typed *italic* / **bold** / ~~strike~~ still convert (star + double-tilde kept)', () => {
expect(typeString('a **bold** b').html).toContain('<strong>');
expect(typeString('a *ital* b').html).toContain('<em>');
expect(typeString('a ~~del~~ b').html).toContain('<s>');
});
});
@@ -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}} / <tags>) 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,