mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-27 13:36:51 +00:00
feat(markdown-editor): decode named HTML entities outside code instead of freezing them
The `literalAmpersand` extension pre-encoded every `&` so @tiptap/markdown's decodeHtmlEntities netted to a no-op, keeping `<`/`>`/`&`/`"` byte-verbatim. But bare-prose entities in the knowledge corpus are HTML-encoding artifacts from ingestion (e.g. `Time difference > 5 min` meaning `>`), and freezing them as `>` is not what the author wrote. Drop the extension so marked decodes the 4 named entities in prose (`<`->`<`, etc.). Verified scoped and safe: numeric refs (`{`, `(`) and any entity inside code / inline-code are NOT decoded (code content bypasses inline tokenizers), a bare `&` survives as `&` (only valid entities decode), raw `<tags>` unaffected, and the identity serializer keeps the decoded `<` from being re-encoded on save. Full corpus (378 real knowledge docs): zero new non-convergence, zero bare-&->& regressions. Live on :8000: `> 5 min` -> `> 5 min` on load+save while numeric/code entities stay put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b73350ae2f
commit
e4f80badd4
+5
-4
@@ -13,8 +13,9 @@ const mulberry32 = (seed: number) => () => {
|
||||
};
|
||||
|
||||
// Content atoms that MUST survive load→serialize verbatim: identifiers/dunders, Go-template variables,
|
||||
// xml-like tags, regex/path backslashes, HTML entities, C++. Whitespace/formatting may reflow; these
|
||||
// bytes may not disappear or mutate.
|
||||
// xml-like tags, regex/path backslashes, numeric HTML entities + a bare `&`, C++. Whitespace/formatting may
|
||||
// reflow; these bytes may not disappear or mutate. (Named entities like `<` decode to `<` by design —
|
||||
// see markdown-editor-marked.ts — so they are NOT survive-verbatim atoms.)
|
||||
const ATOMS = [
|
||||
'__init__',
|
||||
'__call__',
|
||||
@@ -32,9 +33,9 @@ const ATOMS = [
|
||||
'regex \\.php files',
|
||||
'glob \\* and \\?',
|
||||
'escaped \\[ \\| \\+ here',
|
||||
'<script>',
|
||||
'(paren)',
|
||||
'AT&T',
|
||||
'x & y',
|
||||
'2>&1 redirect',
|
||||
'C++ then C++',
|
||||
'~10% left',
|
||||
];
|
||||
|
||||
@@ -109,16 +109,27 @@ describe('backslash before punctuation survives — escape tokenizer neutralized
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML entities stay literal — decode neutralized (a pentest doc teaching <script> keeps its source)', () => {
|
||||
describe('named HTML entities decode outside code; numeric refs, bare & and raw <tags> survive', () => {
|
||||
it.each([
|
||||
'encode <script> as text',
|
||||
'ampersand AT&T and a & b',
|
||||
'quote "value" and > alone and < too',
|
||||
['encode <script> as text', 'encode <script> as text'],
|
||||
['ampersand AT&T and a & b', 'ampersand AT&T and a & b'],
|
||||
['quote "value" and > alone and < too', 'quote "value" and > alone and < too'],
|
||||
])('decodes %s to its characters', (src, expected) => {
|
||||
expect(roundTrip(src)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'numeric { and ( and % stay', // numeric refs are not decoded
|
||||
'bare AT&T and a & b and 2>&1 survive', // a lone & is left literal
|
||||
])('keeps %s byte-identical', (s) => {
|
||||
expect(roundTrip(s)).toBe(s);
|
||||
});
|
||||
|
||||
it('literal <tags> (no entity) still stay literal, never entity-encoded', () => {
|
||||
it('entities inside inline code are preserved (code content is not decoded)', () => {
|
||||
expect(roundTrip('`<script>`')).toBe('`<script>`');
|
||||
});
|
||||
|
||||
it('literal <tags> (raw, no entity) still stay literal, never entity-encoded', () => {
|
||||
expect(roundTrip('use <input> and <container_environment> here')).toBe(
|
||||
'use <input> and <container_environment> here',
|
||||
);
|
||||
|
||||
@@ -14,24 +14,6 @@ const createFaithfulMarked = () => {
|
||||
const instance = new Marked();
|
||||
|
||||
instance.use({
|
||||
extensions: [
|
||||
// @tiptap/markdown runs decodeHtmlEntities (`<`→`<`, `&`→`&`, `>`→`>`, `"`→`"`) on
|
||||
// every text token during parse — a module-scope call its `.lexer()`/`.inlineTokens()` path never
|
||||
// routes through marked's walkTokens/hooks, so those can't intercept it. Emit each literal `&` as
|
||||
// its own text token pre-encoded to `&`; the decode then nets back to the original byte
|
||||
// (`&`→`&`), leaving a source `<`/`&quot;` intact instead of collapsing it to `<`/`"`.
|
||||
{
|
||||
level: 'inline',
|
||||
name: 'literalAmpersand',
|
||||
start: (src: string) => {
|
||||
const index = src.indexOf('&');
|
||||
|
||||
return index < 0 ? undefined : index;
|
||||
},
|
||||
tokenizer: (src: string) =>
|
||||
src[0] === '&' ? { raw: '&', text: '&', type: 'text' as const } : undefined,
|
||||
},
|
||||
],
|
||||
tokenizer: {
|
||||
// These marked tokenizers auto-convert literal text into markup, mangling Go-template / pentest
|
||||
// prose on round-trip. Returning `undefined` forces the char to stay literal text; `false` defers
|
||||
@@ -43,6 +25,10 @@ const createFaithfulMarked = () => {
|
||||
// • html/tag — keep `<xml-like>` tags literal (marked swallows real-HTML-element names)
|
||||
// NB: autolink/url are intentionally NOT neutralised — a bare `https://…`, `<url>` or email is
|
||||
// meant to become a link (see markdown-editor-extensions.ts link config, kept symmetric with typing).
|
||||
// NB: named HTML entities (`< > & "`) are LEFT to marked's decoder — a bare-prose
|
||||
// `<` decodes to `<` (fixes HTML-encoding artifacts from ingestion). Numeric refs (`{`) and
|
||||
// anything inside code are untouched; a bare `&` survives as `&`. Don't re-add a literalAmpersand
|
||||
// token to "preserve" `<` — that re-freezes the artifacts.
|
||||
del: (src: string) => (/^~~(?!~)/.test(src) ? false : undefined),
|
||||
emStrong: (src: string) => (/^_/.test(src) ? undefined : false),
|
||||
escape: () => undefined,
|
||||
@@ -57,10 +43,10 @@ const createFaithfulMarked = () => {
|
||||
|
||||
// The serialize-side counterpart to createFaithfulMarked. @tiptap/markdown's MarkdownManager
|
||||
// .encodeTextForMarkdown HTML-entity-encodes text (`<` → `<`) and backslash-escapes ``` ` * _ [ ] ~ \ ```.
|
||||
// Both are wrong here: the load side neutralises marked's escape/html/tag tokenizers, so a `\`-escape or
|
||||
// `<`-entity is NEVER decoded on parse — anything we encode now resurfaces as a literal backslash / entity
|
||||
// on the next load, the exact corruption this editor avoids. Serialization is hard-coded in the manager (no
|
||||
// per-extension hook), so replace that one method with identity, keeping load and save byte-symmetric.
|
||||
// Both are wrong here: the load side keeps `\`+punct literal (escape tokenizer off), so re-escaping on save
|
||||
// would double every backslash; and named HTML entities are DECODED on load (`<`→`<`), so re-encoding
|
||||
// would freeze them back into `<`. Serialization is hard-coded in the manager (no per-extension hook), so
|
||||
// replace that one method with identity — save emits exactly the text the doc holds.
|
||||
type ManagerWithEncode = { encodeTextForMarkdown: (text: string) => string };
|
||||
|
||||
const FaithfulMarkdownText = Extension.create({
|
||||
|
||||
Reference in New Issue
Block a user