Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions src/webview/cm/inline/inline-emphasis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,30 @@ export type Segment<L> =
| { 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*` → `<em>a~~b</em>c~~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<L> =
| { 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)
Expand All @@ -56,7 +67,7 @@ type Inline<L> =
kind: "wrap";
prev: Inline<L> | null;
next: Inline<L> | null;
tag: "em" | "strong";
tag: WrapTag;
openDelim: Span;
closeDelim: Span;
span: Span;
Expand All @@ -76,12 +87,7 @@ function makeNode<L>(leaf: L, span: Span): InlineNode<L> {
return { kind: "node", prev: null, next: null, leaf, span };
}

function makeWrap<L>(
tag: "em" | "strong",
openDelim: Span,
closeDelim: Span,
span: Span
): InlineWrap<L> {
function makeWrap<L>(tag: WrapTag, openDelim: Span, closeDelim: Span, span: Span): InlineWrap<L> {
return {
kind: "wrap",
prev: null,
Expand All @@ -99,7 +105,9 @@ function makeWrap<L>(
// run's literal characters, so unconsumed delimiters render verbatim as text.
interface Delimiter<L> {
inline: Extract<Inline<L>, { 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;
Expand Down Expand Up @@ -165,6 +173,11 @@ function processEmphasis<L>(bottom: Delimiter<L> | null): void {
const openersBottom: Record<string, Array<Delimiter<L> | 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
Expand Down Expand Up @@ -206,7 +219,12 @@ function processEmphasis<L>(bottom: Delimiter<L> | 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);
Expand Down Expand Up @@ -236,7 +254,7 @@ function processEmphasis<L>(bottom: Delimiter<L> | null): void {
function wrapBetween<L>(
opener: Delimiter<L>,
closer: Delimiter<L>,
tag: "em" | "strong",
tag: WrapTag,
useDelims: number
): void {
// Span threading: the opener run currently spans [os, oe) and the closer run
Expand Down
22 changes: 22 additions & 0 deletions src/webview/cm/inline/inline-ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export type CellLeaf =
closeAngle: Span;
safeUrl: AllowlistedUrl | null;
};
// 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`
Expand Down Expand Up @@ -277,6 +281,24 @@ function tokenize(raw: string): Segment<CellLeaf>[] {
i = runEnd;
continue;
}
// 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 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: "delim", ch, span: { from: i, to: i + 2 }, canOpen, canClose });
i += 2;
continue;
}
}
// Inline image: ![alt](url)
if (raw[i] === "!" && raw[i + 1] === "[") {
const img = tryParseLink(raw, i + 1);
Expand Down
6 changes: 4 additions & 2 deletions src/webview/cm/table/cell-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,10 @@ export function renderReadonly(
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).
Expand Down
19 changes: 19 additions & 0 deletions src/webview/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,25 @@ html body {
font-size: 0.875em;
}

/* 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 <mark> 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;
}

.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. */
Expand Down
7 changes: 7 additions & 0 deletions test/webview/inline/cm-alt-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img alt>` 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");
});
Expand Down
11 changes: 11 additions & 0 deletions test/webview/styles-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <del>/<mark>). happy-dom does not apply CSS, so pin the source rule text
// (same idiom as the table-link/code pins above): <del> line-through, <mark>
// 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)", () => {
Expand Down
110 changes: 110 additions & 0 deletions test/webview/table/cm-table-cell-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,112 @@ describe("renderCellInline", () => {
expect(html(renderCellInline("__b__"))).toBe("<strong>b</strong>");
});

// 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). They are emitted as delimiter runs
// into the SAME stack as `*`/`_` (inline-emphasis.ts), so resolveInline pairs
// them into <del>/<mark> wraps that interleave with emphasis exactly as the
// editor's @lezer/markdown parser does.
it("renders `~~x~~` as a live <del> (strikethrough)", () => {
expect(html(renderCellInline("~~x~~"))).toBe("<del>x</del>");
});

it("renders `==x==` as a live <mark> (highlight)", () => {
expect(html(renderCellInline("==x=="))).toBe("<mark>x</mark>");
});

it("nests emphasis inside a mark (`~~*x*~~`, `==**b**==`)", () => {
expect(html(renderCellInline("~~*x*~~"))).toBe("<del><em>x</em></del>");
expect(html(renderCellInline("==**b**=="))).toBe("<mark><strong>b</strong></mark>");
});

it("renders a mark amid surrounding text (`a ~~b~~ ==c== d`)", () => {
expect(html(renderCellInline("a ~~b~~ ==c== d"))).toBe("a <del>b</del> <mark>c</mark> 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). 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 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");
});

// 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("<code>~~x~~</code>");
});

// 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("<em>a~~b</em>c~~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("<del>a <del>b</del> c</del>");
});

// 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("<em>a==b</em>c==d*");
});

it("nests same-type highlight marks (`==a ==b== c==`)", () => {
expect(html(renderCellInline("==a ==b== c=="))).toBe("<mark>a <mark>b</mark> c</mark>");
});

// 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("<del>a <mark>b</mark> c</del>");
});

// 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("=<mark>x=</mark>");
});

// 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(
"<del>[bad](javascript:1)</del>"
);
});

it("keeps a safe link live inside a mark (`==[ok](https://x.test)==`)", () => {
expect(html(renderCellInline("==[ok](https://x.test)==")).replace(/ title="[^"]*"/g, "")).toBe(
'<mark><a href="https://x.test" rel="noopener noreferrer">ok</a></mark>'
);
});

// Image alt (commonMarkAltText → flattenInlineText) flattens a mark to its
// text content, same as emphasis — used for `<img alt>` 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");
Expand Down Expand Up @@ -597,6 +703,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`, () => {
Expand Down