From 1a615a3cd9186af9226d23d4838630c60a1625c8 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Thu, 16 Jul 2026 20:04:02 +1000 Subject: [PATCH 1/3] feat(table): render ~~strikethrough~~ and ==highlight== in table cells The block-table widget's inline renderer had its own IR/tokenizer that handled code/emphasis/link/image/autolink but not strikethrough or highlight, so `| ~~x~~ |` and `| ==x== |` leaked their raw delimiters inside an otherwise-rendered table while the same text rendered formatted everywhere else. Add two paired-inline-mark CellLeaf kinds (strikethrough, highlight) with a flanking-aware tokenizer arm mirroring the source parsers' open/close rules, and / render arms whose content is re-parsed for nested inline. Style both under .quoll-table-block to match the editor's own token paint. Display-only: byte-identical round-trip is preserved. --- src/webview/cm/inline/inline-ir.ts | 75 ++++++++++++++++++- src/webview/cm/table/cell-render.ts | 26 +++++++ src/webview/styles.css | 16 ++++ .../table/cm-table-cell-render.test.ts | 50 +++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/webview/cm/inline/inline-ir.ts b/src/webview/cm/inline/inline-ir.ts index 49e31ff8..05d1be0c 100644 --- a/src/webview/cm/inline/inline-ir.ts +++ b/src/webview/cm/inline/inline-ir.ts @@ -68,7 +68,14 @@ export type CellLeaf = content: Span; closeAngle: Span; safeUrl: AllowlistedUrl | null; - }; + } + // Paired inline marks tokenized as opaque leaves: `~~strikethrough~~` (GFM) + // and `==highlight==` (Obsidian-style, cf. src/markdown/highlight-mark.ts). + // Unlike code, their CONTENT is re-parsed for nested inline at render time + // (cell-render.ts) so `~~*x*~~` stays formatted — hence only boundary spans + // are stored, mirroring `code`'s openFence/content/closeFence shape. + | { kind: "strikethrough"; openMark: Span; content: Span; closeMark: Span } + | { kind: "highlight"; openMark: Span; content: Span; closeMark: Span }; // Exhaustiveness guard for the `CellLeaf` discriminated union: if a future // leaf kind is added without a matching render/flatten arm, the `switch` @@ -173,6 +180,35 @@ function flanking( return { canOpen: leftFlanking, canClose: rightFlanking }; } +// Paired inline mark (`~~strikethrough~~`, `==highlight==`). Both use a 2-char +// delimiter and the plain left/right flanking rules the source parsers apply +// (GFM Strikethrough + highlight-mark.ts) — reused verbatim via `flanking("*",…)` +// so the widget never renders a mark the editor's own parser would reject. The +// opener must be exactly two `markChar` (a third rejects, mirroring the source +// `char(pos+2) === delim` guard, which also rules out empty `~~~~`/`====`); the +// closer is the nearest following `markChar markChar` that can close. Returns the +// content span + end index, or null when no valid pair exists. +function scanPairedMark( + raw: string, + i: number, + markChar: "~" | "=" +): { content: Span; end: number } | null { + if (raw[i] !== markChar || raw[i + 1] !== markChar || raw[i + 2] === markChar) { + return null; + } + if (!flanking("*", charBefore(raw, i), charAfter(raw, i + 2)).canOpen) { + return null; + } + for (let k = i + 2; k + 1 < raw.length; k++) { + if (raw[k] === markChar && raw[k + 1] === markChar) { + if (flanking("*", charBefore(raw, k), charAfter(raw, k + 2)).canClose) { + return { content: { from: i + 2, to: k }, end: k + 2 }; + } + } + } + return null; +} + // Tokenize a cell's raw Markdown into a flat segment list. Links / images / // autolinks / code spans / backslash escapes are resolved here (with the // URL-safety gate intact); `*`/`_` emphasis runs become delimiter segments @@ -277,6 +313,28 @@ function tokenize(raw: string): Segment[] { i = runEnd; continue; } + // Paired inline marks: strikethrough `~~…~~` and highlight `==…==`. Opaque + // leaves (content re-parsed at render time). Bind tighter than nothing here + // — a single `~`/`=` (or an unmatched run) falls through to literal text. + if (raw[i] === "~" || raw[i] === "=") { + const markChar = raw[i] as "~" | "="; + const mark = scanPairedMark(raw, i, markChar); + if (mark !== null) { + flushText(); + segments.push({ + kind: "leaf", + leaf: { + kind: markChar === "~" ? "strikethrough" : "highlight", + openMark: { from: i, to: i + 2 }, + content: mark.content, + closeMark: { from: mark.content.to, to: mark.end }, + }, + span: { from: i, to: mark.end }, + }); + i = mark.end; + continue; + } + } // Inline image: ![alt](url) if (raw[i] === "!" && raw[i + 1] === "[") { const img = tryParseLink(raw, i + 1); @@ -551,6 +609,21 @@ function flattenInlineText(ir: Resolved[], raw: string, depth = 0): st case "autolink": out += raw.slice(leaf.content.from, leaf.content.to); break; + case "strikethrough": + case "highlight": { + // Flatten the re-parsed content (nested emphasis/code contribute + // their text). Depth-bounded like the emphasis arm: past the cap, + // emit the inert literal source of the whole mark span. Recurse via + // flattenInlineText (NOT commonMarkAltText) so the depth budget + // threads through — a crafted `~~==~~==…` alternating nest stays + // bounded instead of resetting depth on each re-entry. + const inner = raw.slice(leaf.content.from, leaf.content.to); + out += + depth >= MAX_INLINE_NESTING_DEPTH + ? raw.slice(node.span.from, node.span.to) + : flattenInlineText(parseCellInline(inner), inner, depth + 1); + break; + } default: assertNever(leaf); } diff --git a/src/webview/cm/table/cell-render.ts b/src/webview/cm/table/cell-render.ts index 57796485..3b71756a 100644 --- a/src/webview/cm/table/cell-render.ts +++ b/src/webview/cm/table/cell-render.ts @@ -173,6 +173,32 @@ export function renderReadonly( pendingText += raw.slice(node.span.from, node.span.to); } break; + case "strikethrough": + case "highlight": { + // Re-parse the content slice for nested inline (so `~~*x*~~` renders + // bold/italic inside the mark), mirroring the emphasis arm below. + // Depth-bounded the same way: past the cap, merge the inert literal + // source instead of recursing. ``/`` are styled under + // `.quoll-table-block` in styles.css to match the editor's own + // strikethrough / highlight token paint. + if (depth >= MAX_INLINE_NESTING_DEPTH) { + pendingText += raw.slice(node.span.from, node.span.to); + break; + } + flushPending(); + const el = document.createElement(leaf.kind === "strikethrough" ? "del" : "mark"); + const inner = raw.slice(leaf.content.from, leaf.content.to); + for (const child of renderReadonly( + parseCellInline(inner), + inner, + resourceBase, + depth + 1 + )) { + el.appendChild(child); + } + out.push(el); + break; + } default: assertNever(leaf); } diff --git a/src/webview/styles.css b/src/webview/styles.css index 5d5a35cd..3d9ea0a1 100644 --- a/src/webview/styles.css +++ b/src/webview/styles.css @@ -619,6 +619,22 @@ html body { font-size: 0.875em; } + /* Strikethrough / highlight inside a table cell — parity with the editor's + * own token paint (cm/theme.ts: t.strikethrough → line-through, highlightTag → + * warm tint). The tint reuses the shared --quoll-highlight-bg token so both + * surfaces track the same theme value; `color: inherit` keeps the cell + * foreground legible (the UA default is black-on-yellow). Geometry-safe + * like the editor rule: bg + radius, no padding. */ + .quoll-table-block del { + text-decoration: line-through; + } + + .quoll-table-block mark { + background: var(--quoll-highlight-bg, var(--vscode-editor-findMatchHighlightBackground, rgba(255, 214, 92, 0.4))); + color: inherit; + border-radius: 3px; + } + /* C7: image block widget. Root carries `quoll-block` (margin:0 invariant); * breathing room via padding, never margin, so CM's getBoundingClientRect * block-height measurement stays in lockstep with the DOM. */ diff --git a/test/webview/table/cm-table-cell-render.test.ts b/test/webview/table/cm-table-cell-render.test.ts index 15bb4e24..2ab72f9e 100644 --- a/test/webview/table/cm-table-cell-render.test.ts +++ b/test/webview/table/cm-table-cell-render.test.ts @@ -295,6 +295,47 @@ describe("renderCellInline", () => { expect(html(renderCellInline("__b__"))).toBe("b"); }); + // Strikethrough (`~~…~~`) + highlight (`==…==`) parity: these render formatted + // everywhere else in the editor, but the table-cell widget used to leak the raw + // delimiters (`| ~~x~~ |` showed the tildes). Pin the widget paint: for + // strikethrough, for highlight, with content re-parsed for nested inline. + it("renders `~~x~~` as a live (strikethrough)", () => { + expect(html(renderCellInline("~~x~~"))).toBe("x"); + }); + + it("renders `==x==` as a live (highlight)", () => { + expect(html(renderCellInline("==x=="))).toBe("x"); + }); + + it("re-parses nested emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => { + expect(html(renderCellInline("~~*x*~~"))).toBe("x"); + expect(html(renderCellInline("==**b**=="))).toBe("b"); + }); + + it("renders a mark amid surrounding text (`a ~~b~~ ==c== d`)", () => { + expect(html(renderCellInline("a ~~b~~ ==c== d"))).toBe("a b c d"); + }); + + // Flanking parity with the source parsers: a leading space after the opener + // means it cannot open, so the run stays literal (the editor would not strike + // it either). Guards against over-matching a bare greedy scan. + it("leaves a non-flanking mark literal (`~~ x~~`, `a == b`)", () => { + expect(html(renderCellInline("~~ x~~"))).toBe("~~ x~~"); + expect(html(renderCellInline("a == b"))).toBe("a == b"); + }); + + // An unmatched opener (no closing pair) stays literal — the single/lone + // delimiter characters fall through to plain text. + it("leaves an unmatched mark opener literal (`~~x`, `a==b`)", () => { + expect(html(renderCellInline("~~x"))).toBe("~~x"); + expect(html(renderCellInline("a==b"))).toBe("a==b"); + }); + + // A `~~`/`==` inside inline code is inert (code binds tighter, content literal). + it("does not mark inside an inline code span (`` `~~x~~` ``)", () => { + expect(html(renderCellInline("`~~x~~`"))).toBe("~~x~~"); + }); + it("leaves intraword underscores literal (`a_b_c`, `foo_bar_baz`)", () => { expect(html(renderCellInline("a_b_c"))).toBe("a_b_c"); expect(html(renderCellInline("foo_bar_baz"))).toBe("foo_bar_baz"); @@ -597,6 +638,10 @@ describe("parseCellInline losslessness", () => { "[bad](javascript:1)", "a*b©*c", "pre **a *b* c** post", + "~~x~~", + "==x==", + "~~*x*~~", + "a ~~b~~ ==c== d", ]; for (const raw of corpus) { it(`partitions ${JSON.stringify(raw)} into ordered leaves that reconstruct the source`, () => { @@ -637,6 +682,8 @@ describe("parseCellInline losslessness", () => { { raw: "see [docs](https://example.com)", kind: "link" }, { raw: "![alt](https://x.test/i.png)", kind: "image" }, { raw: "", kind: "autolink" }, + { raw: "~~struck~~", kind: "strikethrough" }, + { raw: "==marked==", kind: "highlight" }, ]; for (const { raw, kind } of samples) { const leaves = walkLeaves(parseCellInline(raw)); @@ -724,6 +771,9 @@ function leafBoundarySpans(leaf: CellLeaf): Span[] { ]; case "autolink": return [leaf.openAngle, leaf.content, leaf.closeAngle]; + case "strikethrough": + case "highlight": + return [leaf.openMark, leaf.content, leaf.closeMark]; } } From a343cc694fb572add81cfc6f412f6d8415af5e0b Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Thu, 16 Jul 2026 20:26:50 +1000 Subject: [PATCH 2/3] refactor(table): resolve ~~/== via the shared delimiter stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Codex + code-quality) found the initial greedy nearest-closer scan for ~~/== both (a) diverged from the editor's @lezer/markdown delimiter stack for nested / triple / emphasis-crossing marks (e.g. *a~~b*c~~d* rendered a strikethrough the editor never shows) and (b) was O(n^2) on adversarial cell content (unmatched ~/= runs re-scan to end on every opener — a webview main-thread freeze on pasted logs). Both share one root cause: the marks were resolved in an isolated pre-pass instead of the same stack the * / _ path uses. Emit ~~/== as fixed-length delimiter runs into resolveInline's existing delimiter stack, pairing them into del/mark wraps that interleave with emphasis exactly as the editor does. This is a single O(n) pass and deletes the leaf/re-parse machinery entirely; / now ride the existing emphasis render/flatten arms. Adds parity regression tests (~~a ~~b~~ c~~, ===x===, *a~~b*c~~d*), unsafe-URL-in-mark + alt-flatten coverage, a styles-contract pin for the del/mark rules, and corrects a styles.css comment that misattributed the editor's coordsAtPos rationale to the display-only table widget. --- src/webview/cm/inline/inline-emphasis.ts | 38 +++++--- src/webview/cm/inline/inline-ir.ts | 87 ++++--------------- src/webview/cm/table/cell-render.ts | 32 +------ src/webview/styles.css | 15 ++-- test/webview/inline/cm-alt-text.test.ts | 7 ++ test/webview/styles-contract.test.ts | 11 +++ .../table/cm-table-cell-render.test.ts | 65 +++++++++++--- 7 files changed, 130 insertions(+), 125 deletions(-) diff --git a/src/webview/cm/inline/inline-emphasis.ts b/src/webview/cm/inline/inline-emphasis.ts index 37990bc4..65903721 100644 --- a/src/webview/cm/inline/inline-emphasis.ts +++ b/src/webview/cm/inline/inline-emphasis.ts @@ -26,19 +26,30 @@ export type Segment = | { readonly kind: "leaf"; readonly leaf: L; readonly span: Span } | { readonly kind: "delim"; - readonly ch: "*" | "_"; + readonly ch: DelimiterChar; readonly span: Span; readonly canOpen: boolean; readonly canClose: boolean; }; +// Delimiter-run characters the resolver pairs. `*`/`_` produce `em`/`strong` +// (CommonMark emphasis); `~`/`=` produce `del`/`mark` (GFM strikethrough `~~` +// and Obsidian-style highlight `==`). All four share ONE delimiter stack so a +// mark and an emphasis interleave exactly as the editor's @lezer/markdown parser +// resolves them (e.g. `*a~~b*c~~d*` → `a~~bc~~d*`, the mark left inert). +export type DelimiterChar = "*" | "_" | "~" | "="; + +// The inline wrapper tag a matched delimiter pair produces. +export type WrapTag = "em" | "strong" | "del" | "mark"; + // Resolved output — a flat or tree-structured IR with no DOM. export type Resolved = | { readonly kind: "text"; readonly value: string; readonly span: Span } | { readonly kind: "leaf"; readonly leaf: L; readonly span: Span } | { + // A delimiter-run wrapper (em/strong from `*`/`_`, del/mark from `~~`/`==`). readonly kind: "emphasis"; - readonly tag: "em" | "strong"; + readonly tag: WrapTag; readonly openDelim: Span; // the consumed opening delimiter characters readonly closeDelim: Span; // the consumed closing delimiter characters readonly span: Span; // openDelim.from .. closeDelim.to (outer) @@ -56,7 +67,7 @@ type Inline = kind: "wrap"; prev: Inline | null; next: Inline | null; - tag: "em" | "strong"; + tag: WrapTag; openDelim: Span; closeDelim: Span; span: Span; @@ -76,12 +87,7 @@ function makeNode(leaf: L, span: Span): InlineNode { return { kind: "node", prev: null, next: null, leaf, span }; } -function makeWrap( - tag: "em" | "strong", - openDelim: Span, - closeDelim: Span, - span: Span -): InlineWrap { +function makeWrap(tag: WrapTag, openDelim: Span, closeDelim: Span, span: Span): InlineWrap { return { kind: "wrap", prev: null, @@ -165,6 +171,11 @@ function processEmphasis(bottom: Delimiter | null): void { const openersBottom: Record | null>> = { "*": [null, null, null, null, null, null], _: [null, null, null, null, null, null], + // `~~`/`==` are fixed length-2 runs: the rule-of-3 (`oddMatch`) can never + // fire for them (2 % 3 ≠ 0, 2 + 2 = 4 not ≡ 0 mod 3), so the slot split is + // inert — but the key must exist for the `openersBottom[ch]` lookup below. + "~": [null, null, null, null, null, null], + "=": [null, null, null, null, null, null], }; // Slot index: a SEPARATE lower bound per (run-length mod 3) AND per "can this // closer also open?". The second split is essential — a closer that can also @@ -206,7 +217,12 @@ function processEmphasis(bottom: Delimiter | null): void { if (openerFound && opener !== null) { const useDelims = closer.length >= 2 && opener.length >= 2 ? 2 : 1; - wrapBetween(opener, closer, useDelims === 2 ? "strong" : "em", useDelims); + // `~~`/`==` wrap into del/mark (always 2 chars consumed — the tokenizer + // only ever emits length-2 runs for them). `*`/`_` keep the CommonMark + // 2-vs-1 → strong/em split. + const tag: WrapTag = + ch === "~" ? "del" : ch === "=" ? "mark" : useDelims === 2 ? "strong" : "em"; + wrapBetween(opener, closer, tag, useDelims); opener.length -= useDelims; closer.length -= useDelims; removeDelimitersBetween(opener, closer); @@ -236,7 +252,7 @@ function processEmphasis(bottom: Delimiter | null): void { function wrapBetween( opener: Delimiter, closer: Delimiter, - tag: "em" | "strong", + tag: WrapTag, useDelims: number ): void { // Span threading: the opener run currently spans [os, oe) and the closer run diff --git a/src/webview/cm/inline/inline-ir.ts b/src/webview/cm/inline/inline-ir.ts index 05d1be0c..9deade14 100644 --- a/src/webview/cm/inline/inline-ir.ts +++ b/src/webview/cm/inline/inline-ir.ts @@ -68,14 +68,11 @@ export type CellLeaf = content: Span; closeAngle: Span; safeUrl: AllowlistedUrl | null; - } - // Paired inline marks tokenized as opaque leaves: `~~strikethrough~~` (GFM) - // and `==highlight==` (Obsidian-style, cf. src/markdown/highlight-mark.ts). - // Unlike code, their CONTENT is re-parsed for nested inline at render time - // (cell-render.ts) so `~~*x*~~` stays formatted — hence only boundary spans - // are stored, mirroring `code`'s openFence/content/closeFence shape. - | { kind: "strikethrough"; openMark: Span; content: Span; closeMark: Span } - | { kind: "highlight"; openMark: Span; content: Span; closeMark: Span }; + }; +// NOTE: `~~strikethrough~~` and `==highlight==` are NOT leaves. They are emitted +// as delimiter runs (see the tokenizer arm below) that resolveInline pairs into +// `del`/`mark` emphasis-wrap nodes — sharing the ONE delimiter stack with `*`/`_` +// so a mark interleaves with emphasis exactly as @lezer/markdown resolves it. // Exhaustiveness guard for the `CellLeaf` discriminated union: if a future // leaf kind is added without a matching render/flatten arm, the `switch` @@ -180,35 +177,6 @@ function flanking( return { canOpen: leftFlanking, canClose: rightFlanking }; } -// Paired inline mark (`~~strikethrough~~`, `==highlight==`). Both use a 2-char -// delimiter and the plain left/right flanking rules the source parsers apply -// (GFM Strikethrough + highlight-mark.ts) — reused verbatim via `flanking("*",…)` -// so the widget never renders a mark the editor's own parser would reject. The -// opener must be exactly two `markChar` (a third rejects, mirroring the source -// `char(pos+2) === delim` guard, which also rules out empty `~~~~`/`====`); the -// closer is the nearest following `markChar markChar` that can close. Returns the -// content span + end index, or null when no valid pair exists. -function scanPairedMark( - raw: string, - i: number, - markChar: "~" | "=" -): { content: Span; end: number } | null { - if (raw[i] !== markChar || raw[i + 1] !== markChar || raw[i + 2] === markChar) { - return null; - } - if (!flanking("*", charBefore(raw, i), charAfter(raw, i + 2)).canOpen) { - return null; - } - for (let k = i + 2; k + 1 < raw.length; k++) { - if (raw[k] === markChar && raw[k + 1] === markChar) { - if (flanking("*", charBefore(raw, k), charAfter(raw, k + 2)).canClose) { - return { content: { from: i + 2, to: k }, end: k + 2 }; - } - } - } - return null; -} - // Tokenize a cell's raw Markdown into a flat segment list. Links / images / // autolinks / code spans / backslash escapes are resolved here (with the // URL-safety gate intact); `*`/`_` emphasis runs become delimiter segments @@ -313,25 +281,21 @@ function tokenize(raw: string): Segment[] { i = runEnd; continue; } - // Paired inline marks: strikethrough `~~…~~` and highlight `==…==`. Opaque - // leaves (content re-parsed at render time). Bind tighter than nothing here - // — a single `~`/`=` (or an unmatched run) falls through to literal text. + // Strikethrough `~~` / highlight `==` delimiter runs. Modelled on the GFM + // Strikethrough + highlight-mark.ts source rules: EXACTLY two delimiter chars + // (a third rejects — mirrors Lezer's `char(pos + 2) === delim` guard, so + // `~~~x` rescans and marks at [1,3)), flanking computed like `*`. Emitted as + // a `delim` segment into the SAME stack as `*`/`_`, so resolveInline pairs + // them into del/mark wraps that interleave with emphasis just as the editor's + // parser does. A single `~`/`=` (or the odd char of a 3-run) falls through to + // literal text; an unpaired run survives as its literal delimiter characters. if (raw[i] === "~" || raw[i] === "=") { - const markChar = raw[i] as "~" | "="; - const mark = scanPairedMark(raw, i, markChar); - if (mark !== null) { + const ch = raw[i] as "~" | "="; + if (raw[i + 1] === ch && raw[i + 2] !== ch) { + const { canOpen, canClose } = flanking("*", charBefore(raw, i), charAfter(raw, i + 2)); flushText(); - segments.push({ - kind: "leaf", - leaf: { - kind: markChar === "~" ? "strikethrough" : "highlight", - openMark: { from: i, to: i + 2 }, - content: mark.content, - closeMark: { from: mark.content.to, to: mark.end }, - }, - span: { from: i, to: mark.end }, - }); - i = mark.end; + segments.push({ kind: "delim", ch, span: { from: i, to: i + 2 }, canOpen, canClose }); + i += 2; continue; } } @@ -609,21 +573,6 @@ function flattenInlineText(ir: Resolved[], raw: string, depth = 0): st case "autolink": out += raw.slice(leaf.content.from, leaf.content.to); break; - case "strikethrough": - case "highlight": { - // Flatten the re-parsed content (nested emphasis/code contribute - // their text). Depth-bounded like the emphasis arm: past the cap, - // emit the inert literal source of the whole mark span. Recurse via - // flattenInlineText (NOT commonMarkAltText) so the depth budget - // threads through — a crafted `~~==~~==…` alternating nest stays - // bounded instead of resetting depth on each re-entry. - const inner = raw.slice(leaf.content.from, leaf.content.to); - out += - depth >= MAX_INLINE_NESTING_DEPTH - ? raw.slice(node.span.from, node.span.to) - : flattenInlineText(parseCellInline(inner), inner, depth + 1); - break; - } default: assertNever(leaf); } diff --git a/src/webview/cm/table/cell-render.ts b/src/webview/cm/table/cell-render.ts index 3b71756a..d8723d17 100644 --- a/src/webview/cm/table/cell-render.ts +++ b/src/webview/cm/table/cell-render.ts @@ -173,40 +173,16 @@ export function renderReadonly( pendingText += raw.slice(node.span.from, node.span.to); } break; - case "strikethrough": - case "highlight": { - // Re-parse the content slice for nested inline (so `~~*x*~~` renders - // bold/italic inside the mark), mirroring the emphasis arm below. - // Depth-bounded the same way: past the cap, merge the inert literal - // source instead of recursing. ``/`` are styled under - // `.quoll-table-block` in styles.css to match the editor's own - // strikethrough / highlight token paint. - if (depth >= MAX_INLINE_NESTING_DEPTH) { - pendingText += raw.slice(node.span.from, node.span.to); - break; - } - flushPending(); - const el = document.createElement(leaf.kind === "strikethrough" ? "del" : "mark"); - const inner = raw.slice(leaf.content.from, leaf.content.to); - for (const child of renderReadonly( - parseCellInline(inner), - inner, - resourceBase, - depth + 1 - )) { - el.appendChild(child); - } - out.push(el); - break; - } default: assertNever(leaf); } break; } case "emphasis": { - // Past the nesting cap, merge the inert literal source of the whole - // emphasis span (node.span covers openDelim..closeDelim) into the + // Delimiter-run wrapper: em/strong (`*`/`_`) or del/mark (`~~`/`==`). + // `node.tag` is a valid element name, so createElement builds the right + // box for all four. Past the nesting cap, merge the inert literal source + // of the whole span (node.span covers openDelim..closeDelim) into the // pending-text buffer instead of recursing — bounds this walker's // recursion depth. No flushPending(): we emit no element, so the slice // merges naturally with adjacent text (same topology as inert links). diff --git a/src/webview/styles.css b/src/webview/styles.css index 3d9ea0a1..0d1036a2 100644 --- a/src/webview/styles.css +++ b/src/webview/styles.css @@ -619,12 +619,15 @@ html body { font-size: 0.875em; } - /* Strikethrough / highlight inside a table cell — parity with the editor's - * own token paint (cm/theme.ts: t.strikethrough → line-through, highlightTag → - * warm tint). The tint reuses the shared --quoll-highlight-bg token so both - * surfaces track the same theme value; `color: inherit` keeps the cell - * foreground legible (the UA default is black-on-yellow). Geometry-safe - * like the editor rule: bg + radius, no padding. */ + /* Strikethrough / highlight inside a table cell — visual parity with the + * editor's own token paint (cm/theme.ts: t.strikethrough → line-through, + * highlightTag → warm tint). The tint reuses the shared --quoll-highlight-bg + * token so both surfaces track the same theme value; `color: inherit` keeps + * the cell foreground legible (the UA default is black-on-yellow). + * No padding — matching the editor rule's look, not a geometry constraint: + * this widget DOM is display-only (TableBlockWidget.toDOM) and click-to-reveal + * resolves to a fixed per-cell offset (data-cell-from), so coordsAtPos + * accuracy never rides on these boxes. */ .quoll-table-block del { text-decoration: line-through; } diff --git a/test/webview/inline/cm-alt-text.test.ts b/test/webview/inline/cm-alt-text.test.ts index 2501550b..e43e9815 100644 --- a/test/webview/inline/cm-alt-text.test.ts +++ b/test/webview/inline/cm-alt-text.test.ts @@ -19,6 +19,13 @@ describe("commonMarkAltText", () => { expect(commonMarkAltText("**bold** x")).toBe("bold x"); }); + it("flattens strikethrough / highlight marks to their text (~~s~~ ==h== -> s h)", () => { + // Marks resolve to del/mark wraps in the shared delimiter stack, so the + // emphasis flatten arm handles them — used for `` in both the + // table-cell renderer and the block-image widget's aria-label. + expect(commonMarkAltText("~~s~~ ==h==")).toBe("s h"); + }); + it("decodes a backslash escape (a\\*b -> a*b)", () => { expect(commonMarkAltText("a\\*b")).toBe("a*b"); }); diff --git a/test/webview/styles-contract.test.ts b/test/webview/styles-contract.test.ts index e39914ed..1ff67252 100644 --- a/test/webview/styles-contract.test.ts +++ b/test/webview/styles-contract.test.ts @@ -428,6 +428,17 @@ describe("styles.css — widgets consume the accent tokens (palette refresh use /\.quoll-table-block code\s*\{[^}]*background\s*:\s*var\(--quoll-surface-fill/s ); }); + // Strikethrough / highlight inside a table cell (cm-table-cell-render renders + // /). happy-dom does not apply CSS, so pin the source rule text + // (same idiom as the table-link/code pins above): line-through, + // reusing the shared --quoll-highlight-bg tint. Non-vacuous — both red if the + // rules are dropped. + it("table strikethrough / highlight consume line-through and the highlight tint", () => { + expect(css).toMatch(/\.quoll-table-block del\s*\{[^}]*text-decoration\s*:\s*line-through/s); + expect(css).toMatch( + /\.quoll-table-block mark\s*\{[^}]*background\s*:\s*var\(--quoll-highlight-bg/s + ); + }); }); describe("styles.css — bullet-list marker token (HC-sensitive)", () => { diff --git a/test/webview/table/cm-table-cell-render.test.ts b/test/webview/table/cm-table-cell-render.test.ts index 2ab72f9e..f2c714cc 100644 --- a/test/webview/table/cm-table-cell-render.test.ts +++ b/test/webview/table/cm-table-cell-render.test.ts @@ -297,8 +297,10 @@ describe("renderCellInline", () => { // Strikethrough (`~~…~~`) + highlight (`==…==`) parity: these render formatted // everywhere else in the editor, but the table-cell widget used to leak the raw - // delimiters (`| ~~x~~ |` showed the tildes). Pin the widget paint: for - // strikethrough, for highlight, with content re-parsed for nested inline. + // delimiters (`| ~~x~~ |` showed the tildes). They are emitted as delimiter runs + // into the SAME stack as `*`/`_` (inline-emphasis.ts), so resolveInline pairs + // them into / wraps that interleave with emphasis exactly as the + // editor's @lezer/markdown parser does. it("renders `~~x~~` as a live (strikethrough)", () => { expect(html(renderCellInline("~~x~~"))).toBe("x"); }); @@ -307,7 +309,7 @@ describe("renderCellInline", () => { expect(html(renderCellInline("==x=="))).toBe("x"); }); - it("re-parses nested emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => { + it("nests emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => { expect(html(renderCellInline("~~*x*~~"))).toBe("x"); expect(html(renderCellInline("==**b**=="))).toBe("b"); }); @@ -318,14 +320,15 @@ describe("renderCellInline", () => { // Flanking parity with the source parsers: a leading space after the opener // means it cannot open, so the run stays literal (the editor would not strike - // it either). Guards against over-matching a bare greedy scan. + // it either). The `a == b` case is the common false-trigger — an `==` flanked + // by spaces neither opens nor closes. it("leaves a non-flanking mark literal (`~~ x~~`, `a == b`)", () => { expect(html(renderCellInline("~~ x~~"))).toBe("~~ x~~"); expect(html(renderCellInline("a == b"))).toBe("a == b"); }); - // An unmatched opener (no closing pair) stays literal — the single/lone - // delimiter characters fall through to plain text. + // An unmatched opener (no closing pair) stays literal — the delimiter run + // survives as its literal characters, merged with adjacent text. it("leaves an unmatched mark opener literal (`~~x`, `a==b`)", () => { expect(html(renderCellInline("~~x"))).toBe("~~x"); expect(html(renderCellInline("a==b"))).toBe("a==b"); @@ -336,6 +339,51 @@ describe("renderCellInline", () => { expect(html(renderCellInline("`~~x~~`"))).toBe("~~x~~"); }); + // Shared-delimiter-stack interleaving parity. A greedy nearest-closer scan + // would mis-pair these; the delimiter stack reproduces @lezer/markdown exactly. + // `*a~~b*c~~d*`: emphasis wins, the crossing `~~` are left inert. Verified + // against @lezer/markdown + GFM directly (code-quality review). + it("interleaves a mark with emphasis like the editor (`*a~~b*c~~d*`)", () => { + expect(html(renderCellInline("*a~~b*c~~d*"))).toBe("a~~bc~~d*"); + }); + + // Nested same-type marks: outer wraps the inner via the delimiter stack, no + // literal tail left behind (the greedy scan closed the outer at the inner). + it("nests same-type marks (`~~a ~~b~~ c~~`)", () => { + expect(html(renderCellInline("~~a ~~b~~ c~~"))).toBe("a b c"); + }); + + // Triple run: Lezer rescans from pos+1, so `===x===` opens at [1,3) and closes + // at [5,7), wrapping content `x=` (Highlight span [1,7) — the measured span + // highlight-mark.ts documents). Only the leading `=` (index 0) stays literal. + it("handles a triple-delimiter run like the editor (`===x===`)", () => { + expect(html(renderCellInline("===x==="))).toBe("=x="); + }); + + // The mark wrap does NOT bypass the URL render-gate: an unsafe link nested + // inside `~~…~~` still renders inert (mirrors the emphasis arm's + // `**[bad](javascript:1)**` case). The link leaf carries the safeUrl=null + // verdict; the surrounding del/mark is just a wrapper. + it("keeps the URL gate for an unsafe link inside a mark (`~~[bad](javascript:1)~~`)", () => { + expect(html(renderCellInline("~~[bad](javascript:1)~~"))).toBe( + "[bad](javascript:1)" + ); + }); + + it("keeps a safe link live inside a mark (`==[ok](https://x.test)==`)", () => { + expect(html(renderCellInline("==[ok](https://x.test)==")).replace(/ title="[^"]*"/g, "")).toBe( + 'ok' + ); + }); + + // Image alt (commonMarkAltText → flattenInlineText) flattens a mark to its + // text content, same as emphasis — used for `` in both the table-cell + // renderer and the shared block-image widget. + it("flattens a mark in an image alt (`![a ~~b~~ c](url)` -> alt=`a b c`)", () => { + const nodes = renderCellInline("![a ~~b~~ c](https://x.test/i.png)"); + expect((nodes[0] as HTMLImageElement).alt).toBe("a b c"); + }); + it("leaves intraword underscores literal (`a_b_c`, `foo_bar_baz`)", () => { expect(html(renderCellInline("a_b_c"))).toBe("a_b_c"); expect(html(renderCellInline("foo_bar_baz"))).toBe("foo_bar_baz"); @@ -682,8 +730,6 @@ describe("parseCellInline losslessness", () => { { raw: "see [docs](https://example.com)", kind: "link" }, { raw: "![alt](https://x.test/i.png)", kind: "image" }, { raw: "", kind: "autolink" }, - { raw: "~~struck~~", kind: "strikethrough" }, - { raw: "==marked==", kind: "highlight" }, ]; for (const { raw, kind } of samples) { const leaves = walkLeaves(parseCellInline(raw)); @@ -771,9 +817,6 @@ function leafBoundarySpans(leaf: CellLeaf): Span[] { ]; case "autolink": return [leaf.openAngle, leaf.content, leaf.closeAngle]; - case "strikethrough": - case "highlight": - return [leaf.openMark, leaf.content, leaf.closeMark]; } } From 64d0f40b08183967226b58db04048605c55fe29c Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Thu, 16 Jul 2026 20:37:18 +1000 Subject: [PATCH 3/3] test(table): pin == interleaving/nesting parity; tighten Delimiter.ch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-2 review (2 MEDIUM): the interleave/nesting parity tests exercised only ~~, and Delimiter.ch stayed `string` — discarding the DelimiterChar union at the ch->WrapTag decision point. Add == vs *, == self-nesting, and cross-type (highlight-in-strikethrough) cases so a future edit that broke only the mark branch cannot pass on ~~ alone; narrow Delimiter.ch to DelimiterChar so the ch->tag ternary type-narrows. No behaviour change. --- src/webview/cm/inline/inline-emphasis.ts | 4 +++- test/webview/table/cm-table-cell-render.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/webview/cm/inline/inline-emphasis.ts b/src/webview/cm/inline/inline-emphasis.ts index 65903721..5a70eb5f 100644 --- a/src/webview/cm/inline/inline-emphasis.ts +++ b/src/webview/cm/inline/inline-emphasis.ts @@ -105,7 +105,9 @@ function makeWrap(tag: WrapTag, openDelim: Span, closeDelim: Span, span: Span // run's literal characters, so unconsumed delimiters render verbatim as text. interface Delimiter { inline: Extract, { kind: "text" }>; - ch: string; + // Tightened to DelimiterChar (not `string`) so the ch → WrapTag ternary in + // processEmphasis type-narrows: after excluding `~`/`=`, `ch` is `*`/`_`. + ch: DelimiterChar; length: number; // remaining (unconsumed) delimiters origLength: number; // run length at tokenization (drives the rule of 3) canOpen: boolean; diff --git a/test/webview/table/cm-table-cell-render.test.ts b/test/webview/table/cm-table-cell-render.test.ts index f2c714cc..716be8e3 100644 --- a/test/webview/table/cm-table-cell-render.test.ts +++ b/test/webview/table/cm-table-cell-render.test.ts @@ -353,6 +353,23 @@ describe("renderCellInline", () => { expect(html(renderCellInline("~~a ~~b~~ c~~"))).toBe("a b c"); }); + // The SAME interleave/nesting behaviour on the `==` side (highlight is the + // newer, less battle-tested delimiter — pin it independently so a future edit + // that broke only the `=` slot / `mark` tag branch cannot pass on `~~` alone). + it("interleaves `==` with emphasis like the editor (`*a==b*c==d*`)", () => { + expect(html(renderCellInline("*a==b*c==d*"))).toBe("a==bc==d*"); + }); + + it("nests same-type highlight marks (`==a ==b== c==`)", () => { + expect(html(renderCellInline("==a ==b== c=="))).toBe("a b c"); + }); + + // Cross-type nesting: a highlight inside a strikethrough, resolved in the one + // shared stack (both are length-2 delimiters that only differ by tag). + it("nests a highlight inside a strikethrough (`~~a ==b== c~~`)", () => { + expect(html(renderCellInline("~~a ==b== c~~"))).toBe("a b c"); + }); + // Triple run: Lezer rescans from pos+1, so `===x===` opens at [1,3) and closes // at [5,7), wrapping content `x=` (Highlight span [1,7) — the measured span // highlight-mark.ts documents). Only the leading `=` (index 0) stays literal.