From c1e5ee9d8e9bff2eec22018e69a3832e146b7464 Mon Sep 17 00:00:00 2001 From: itsmunzir Date: Sun, 4 Oct 2026 11:47:21 +0300 Subject: [PATCH 1/4] fix(#184): escape the overlay's plain-text fallback `speculativeHighlightUpdate` returns the editor's text unchanged when the overlayhas no `.line` spans (before the highlighter's first pass, or when asanitizer strips those spans). The caller parses that return value as HTML andnever runs the configured sanitizer over it, so markup typed into the editorexecuted inside the overlay. The fallback now escapes `&`, `<` and `>`, with a regression test that runsthe reported repro: the old code fails it, the new code passes. --- .../carta-md/src/lib/internal/speculative.ts | 10 +++++++++- packages/carta-md/test/speculative.test.mjs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/carta-md/test/speculative.test.mjs diff --git a/packages/carta-md/src/lib/internal/speculative.ts b/packages/carta-md/src/lib/internal/speculative.ts index fd1f5a07..596ea164 100644 --- a/packages/carta-md/src/lib/internal/speculative.ts +++ b/packages/carta-md/src/lib/internal/speculative.ts @@ -14,6 +14,10 @@ const clonePosition = (position: Position): Position => { }; }; +/** Escape text so it is parsed as text, and not as markup, by the overlay. */ +const escapeHtml = (text: string): string => + text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + /** * Temporary updates the highlight overlay to reflect the changes between two text strings, * waiting for the actual update to be applied. This way, the user can immediately see the changes, @@ -32,7 +36,11 @@ export function speculativeHighlightUpdate(from: string, to: string, currentHTML let writingPosition: Position = { line: 0, span: 0, char: 0 }; let readingPosition: Position = { line: 0, span: 0, char: 0 }; - if (lines.length === 0) return to; + // No highlighted lines to patch: the overlay is empty until the highlighter's first pass, or + // forever when a sanitizer strips Shiki's `.line` spans. The caller parses the return value as + // HTML and never runs the configured sanitizer over it, so the escape has to happen here; + // returning the text raw let markup typed into the editor execute in the overlay. + if (lines.length === 0) return escapeHtml(to); const advance = () => { writingPosition = clonePosition(readingPosition); diff --git a/packages/carta-md/test/speculative.test.mjs b/packages/carta-md/test/speculative.test.mjs new file mode 100644 index 00000000..980b0173 --- /dev/null +++ b/packages/carta-md/test/speculative.test.mjs @@ -0,0 +1,17 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { speculativeHighlightUpdate } from '../dist/internal/speculative.js'; + +// The overlay is empty until the highlighter's first pass, and a sanitizer that strips Shiki's +// `.line` spans keeps it that way — the branch this test covers. Only `document.createElement` +// and `querySelectorAll` are reached before the early return, so a minimal stub is enough here. +globalThis.document = { + createElement: () => ({ innerHTML: '', querySelectorAll: () => [] }) +}; + +test('the no-lines branch escapes the text instead of returning markup', () => { + const payload = ''; + const out = speculativeHighlightUpdate('', payload, ''); + assert.equal(out, '<img src=x onerror="alert(1)">'); + assert.ok(!out.includes(' Date: Mon, 5 Oct 2026 09:28:01 +0000 Subject: [PATCH 2/4] chore: use provided sanitizer instead of manually escaping HTML characters --- .../src/lib/internal/components/Input.svelte | 6 +++++- .../carta-md/src/lib/internal/speculative.ts | 10 ---------- packages/carta-md/test/speculative.test.mjs | 17 ----------------- pnpm-workspace.yaml | 4 ++++ 4 files changed, 9 insertions(+), 28 deletions(-) delete mode 100644 packages/carta-md/test/speculative.test.mjs diff --git a/packages/carta-md/src/lib/internal/components/Input.svelte b/packages/carta-md/src/lib/internal/components/Input.svelte index 87257f52..5b38496c 100644 --- a/packages/carta-md/src/lib/internal/components/Input.svelte +++ b/packages/carta-md/src/lib/internal/components/Input.svelte @@ -143,7 +143,11 @@ const html = speculativeHighlightUpdate(currentlyHighlightedValue, value, currentOverlay); currentlyHighlightedValue = value; - return { html, timestamp }; + if (carta.sanitizer) { + return { html: carta.sanitizer(html), timestamp }; + } else { + return { html, timestamp }; + } } catch (e) { console.error(`Error executing speculative update: ${e}.`); // The overlay still holds the previous text, but the debounced highlight is already diff --git a/packages/carta-md/src/lib/internal/speculative.ts b/packages/carta-md/src/lib/internal/speculative.ts index 596ea164..2683fa11 100644 --- a/packages/carta-md/src/lib/internal/speculative.ts +++ b/packages/carta-md/src/lib/internal/speculative.ts @@ -14,10 +14,6 @@ const clonePosition = (position: Position): Position => { }; }; -/** Escape text so it is parsed as text, and not as markup, by the overlay. */ -const escapeHtml = (text: string): string => - text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); - /** * Temporary updates the highlight overlay to reflect the changes between two text strings, * waiting for the actual update to be applied. This way, the user can immediately see the changes, @@ -36,12 +32,6 @@ export function speculativeHighlightUpdate(from: string, to: string, currentHTML let writingPosition: Position = { line: 0, span: 0, char: 0 }; let readingPosition: Position = { line: 0, span: 0, char: 0 }; - // No highlighted lines to patch: the overlay is empty until the highlighter's first pass, or - // forever when a sanitizer strips Shiki's `.line` spans. The caller parses the return value as - // HTML and never runs the configured sanitizer over it, so the escape has to happen here; - // returning the text raw let markup typed into the editor execute in the overlay. - if (lines.length === 0) return escapeHtml(to); - const advance = () => { writingPosition = clonePosition(readingPosition); writingPosition.char++; // Always advance the writing position diff --git a/packages/carta-md/test/speculative.test.mjs b/packages/carta-md/test/speculative.test.mjs deleted file mode 100644 index 980b0173..00000000 --- a/packages/carta-md/test/speculative.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { speculativeHighlightUpdate } from '../dist/internal/speculative.js'; - -// The overlay is empty until the highlighter's first pass, and a sanitizer that strips Shiki's -// `.line` spans keeps it that way — the branch this test covers. Only `document.createElement` -// and `querySelectorAll` are reached before the early return, so a minimal stub is enough here. -globalThis.document = { - createElement: () => ({ innerHTML: '', querySelectorAll: () => [] }) -}; - -test('the no-lines branch escapes the text instead of returning markup', () => { - const payload = ''; - const out = speculativeHighlightUpdate('', payload, ''); - assert.equal(out, '<img src=x onerror="alert(1)">'); - assert.ok(!out.includes(' Date: Mon, 5 Oct 2026 11:42:42 +0200 Subject: [PATCH 3/4] chore: remove allowBuilds section from pnpm-workspace.yaml Removed allowBuilds section from pnpm workspace configuration. --- pnpm-workspace.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 47f5219b..20f4bab8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,10 +2,6 @@ packages: - packages/* - docs -allowBuilds: - core-js: set this to true or false - esbuild: set this to true or false - catalog: 'svelte': '^5.53.5' 'svelte-check': '^4.1.4' From 6eaa1b970e47c2c1360f483461a9e3a0a7d01c8e Mon Sep 17 00:00:00 2001 From: Davide <47280851+BearToCode@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:43:45 +0200 Subject: [PATCH 4/4] chore: readd early return if lines array is empty Add early return if no lines are found in the tree. --- packages/carta-md/src/lib/internal/speculative.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/carta-md/src/lib/internal/speculative.ts b/packages/carta-md/src/lib/internal/speculative.ts index 2683fa11..fd1f5a07 100644 --- a/packages/carta-md/src/lib/internal/speculative.ts +++ b/packages/carta-md/src/lib/internal/speculative.ts @@ -32,6 +32,8 @@ export function speculativeHighlightUpdate(from: string, to: string, currentHTML let writingPosition: Position = { line: 0, span: 0, char: 0 }; let readingPosition: Position = { line: 0, span: 0, char: 0 }; + if (lines.length === 0) return to; + const advance = () => { writingPosition = clonePosition(readingPosition); writingPosition.char++; // Always advance the writing position