Skip to content
Open
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
137 changes: 137 additions & 0 deletions packages/cli/src/ui/opentui/a11y-plain-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { markdownToPlainText, stripAnsi } from './a11y-plain-text.js';

describe('stripAnsi', () => {
it('removes SGR color and attribute sequences', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red');
expect(stripAnsi('\x1b[1;33mbold yellow\x1b[0m')).toBe('bold yellow');
expect(stripAnsi('\x1b[38:5:208mext\x1b[0m')).toBe('ext');
});

it('removes cursor movement and erase sequences', () => {
expect(stripAnsi('\x1b[2K\x1b[1Atext\x1b[2J')).toBe('text');
expect(stripAnsi('\x1b[?25lhidden cursor\x1b[?25h')).toBe('hidden cursor');
});

it('removes OSC sequences with BEL or ST terminators', () => {
expect(stripAnsi('\x1b]8;;https://example.com\x07link\x1b]8;;\x07')).toBe(
'link',
);
expect(stripAnsi('\x1b]0;window title\x1b\\body')).toBe('body');
});

it('leaves plain text untouched', () => {
expect(stripAnsi('hello world')).toBe('hello world');
expect(stripAnsi('')).toBe('');
});

it('removes device escape sequences (R2-10)', () => {
// SGR mouse reports and DEC save/restore cursor leaked past the old
// hand-rolled pattern.
expect(stripAnsi('\x1b[<0;5;1Mclick')).toBe('click');
expect(stripAnsi('\x1b7x\x1b8')).toBe('x');
});
});

describe('markdownToPlainText', () => {
it('strips heading markers', () => {
expect(markdownToPlainText('# Title')).toBe('Title');
expect(markdownToPlainText('### Deep heading')).toBe('Deep heading');
});

it('strips emphasis and inline code markers', () => {
expect(markdownToPlainText('**bold** and *em* and _u_')).toBe(
'bold and em and u',
);
expect(markdownToPlainText('run `npm test` now')).toBe('run npm test now');
expect(markdownToPlainText('__strong__ ~~gone~~')).toBe('strong gone');
});

it('reduces links and images to their text', () => {
expect(markdownToPlainText('see [docs](https://x.dev) now')).toBe(
'see docs now',
);
expect(markdownToPlainText('logo ![alt text](img.png) end')).toBe(
'logo alt text end',
);
});

it('keeps fenced code bodies, dropping the fences', () => {
const md = ['```ts', 'const a = 1;', '```'].join('\n');
expect(markdownToPlainText(md)).toBe('const a = 1;');
});

it('drops blockquote prefixes and horizontal rules', () => {
expect(markdownToPlainText('> quoted line')).toBe('quoted line');
expect(markdownToPlainText('a\n---\nb')).toBe('a\n\nb');
});

it('keeps bullet markers and plain lines', () => {
expect(markdownToPlainText('- first\n- second')).toBe('- first\n- second');
expect(markdownToPlainText('just text')).toBe('just text');
});

it('leaves dunders and snake_case identifiers untouched', () => {
expect(markdownToPlainText('def __init__(self):')).toBe(
'def __init__(self):',
);
expect(markdownToPlainText('use snake_case_name here')).toBe(
'use snake_case_name here',
);
// Boundary-guarded underscore emphasis still works.
expect(markdownToPlainText('really _important_ now')).toBe(
'really important now',
);
});

it('keeps fence-like lines inside a block opened by the other character (R1-47/48)', () => {
// CommonMark: a fence only closes on the same character.
expect(markdownToPlainText('~~~\n```\nbody\n~~~\nafter')).toBe(
'```\nbody\nafter',
);
expect(markdownToPlainText('```\n~~~\nbody\n```\nafter')).toBe(
'~~~\nbody\nafter',
);
});

it('recognizes headings inside blockquotes (R1-1)', () => {
expect(markdownToPlainText('> # Title')).toBe('Title');
});

it('keeps code-span contents literal — no link/emphasis consumption (R1-1)', () => {
expect(markdownToPlainText('`[a](b)`')).toBe('[a](b)');
expect(markdownToPlainText('`**not bold**`')).toBe('**not bold**');
});

it('tracks fence length — a shorter run does not close a longer fence (R2-10)', () => {
expect(
markdownToPlainText('````\ncode\n```\nstill code\n````\nafter'),
).toBe('code\n```\nstill code\nafter');
});

it('keeps fence-like quoted lines inside a fence literal (R2-10)', () => {
// A de-quoted ``` inside a fenced body must not flip fence state.
expect(markdownToPlainText('```\n> ```\n```\nafter **bold**')).toBe(
'> ```\nafter bold',
);
});

it('keeps inner backticks in multi-backtick code spans (R2-10)', () => {
expect(markdownToPlainText('a ``b ` c`` d')).toBe('a b ` c d');
});

it('a fence line with info text does not close an open fence (R3-3)', () => {
// CommonMark: a closing fence cannot carry info text, so ```js inside
// an open block is literal body — not an early close that drops the
// block and inverts parse state for the rest of the document.
expect(markdownToPlainText('```\n```js\nbody\n```\nafter **x**')).toBe(
'```js\nbody\nafter x',
);
});
});
141 changes: 141 additions & 0 deletions packages/cli/src/ui/opentui/a11y-plain-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Plain-text conversions for screen-reader parity. Ink's screen-reader path
* renders squashed text only (no styles, borders or backgrounds), so the
* OpenTUI equivalent needs ANSI-stripped, markdown-reduced text for anything
* it would otherwise draw with colors or structure.
*
* The reduction stays line-based on purpose: ink's InlineMarkdownRenderer
* guards underscore emphasis at word boundaries so identifiers like
* `__init__` survive, and a CommonMark parser (markdown-it) emphasizes them.
* Fences, code spans and quote prefixes below follow the CommonMark rules
* ink's renderer applies.
*/

import stripAnsiLib from 'strip-ansi';

// strip-ansi 7.x does not strip CSI sequences with intermediate bytes
// (0x20-0x2F) or private parameter markers (e.g. SGR mouse \x1b[<0;5;1M);
// remove the full CSI production first: parameter bytes 0x30-0x3F,
// intermediate bytes 0x20-0x2F, final byte 0x40-0x7E — one regex
// covers both private and non-private CSI.
/* eslint-disable no-control-regex */
const CSI_SEQUENCE = /\x1b\[[0-9;:<=>?]*[\x20-\x2F]*[@-~]/g;
/* eslint-enable no-control-regex */

// strip-ansi also leaves DCS/SOS/PM/APC sequences (only the 2-byte
// introducer of a DCS is consumed) and unterminated OSC bodies in place;
// consume them through ST/BEL/end-of-input so SIXEL payloads or tmux
// passthroughs never reach the screen reader as announced garbage.
/* eslint-disable no-control-regex */
const OTHER_ESCAPE_SEQUENCE =
/\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07|$)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
Comment on lines +36 to +37

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-14: [certifies-falsely] [new-surface] (fix-induced) The round-4 fix of R4-14 added OTHER_ESCAPE_SEQUENCE, but an OSC sequence followed by another escape without BEL/ST is not consumed, so its body leaks into screen-reader text despite the regex's stated purpose. A truncated stream or tool output whose title/hyperlink OSC lost its BEL, followed by colored output, leaks the OSC body into the announced text via ScreenReaderOutputWriter.sanitize.

Witness:

probe: stripAnsi('\x1b]0;secret\x1b[31mred\x1b[0m') = 'ecretred' (expected 'red'); leak reaches the writer: appendStatic emits 'ecretred\n'

Suggested fix: Make the OSC alternative stop (without consuming) at any bare ESC that does not start ST: \x1b][^\x07\x1b]*(?:\x07|\x1b\|(?=\x1b)|$) — an ESC inside an OSC body is invalid, so truncating there leaves the following sequence intact for strip-ansi.

Fix witness: Add expect(stripAnsi('\x1b]0;secret\x1b[31mred\x1b[0m')).toBe('red') to a11y-plain-text.test.ts; removing the (?=\x1b) alternative makes it red again. Please add this test and confirm the mutation — remove the fix/guard and verify the test goes red.

中文说明

(fix-induced)第 4 轮对 R4-14 的修复新增了 OTHER_ESCAPE_SEQUENCE 预处理,但未带 BEL/ST 终止、后随另一个转义序列的 OSC 不会被消费,其正文仍会泄漏进读屏文本(实测泄漏到 ScreenReaderOutputWriter 的播报输出)。建议 OSC 分支在遇到不构成 ST 的裸 ESC 时停止且不消费:改为 \x1b][^\x07\x1b]*(?:\x07|\x1b\|(?=\x1b)|$)。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-14: [certifies-falsely] [new-surface] An OSC sequence followed by another escape without BEL/ST still violates the strip contract — the visible text after it is swallowed from the screen-reader announcement entirely.

The round-4 fix added OTHER_ESCAPE_SEQUENCE and this round's broadened CSI_SEQUENCE changed the symptom without closing it: for '\x1b]0;secret\x1b[31mred\x1b[0m' (a title OSC that lost its BEL, followed by colored output), the CSI pass strips '\x1b[31m'/'\x1b[0m' first, leaving '\x1b]0;secretred'; the OSC alternative then takes its end-of-input branch and consumes everything, so stripAnsi returns '' and ScreenReaderOutputWriter.appendStatic writes nothing — the legitimate text 'red' never reaches the announcement. Any truncated stream or tool output whose OSC lost its BEL, followed by real content, goes silent.

Witness:

probe at HEAD: stripAnsi('\x1b]0;secret\x1b[31mred\x1b[0m') = ""   (expected "red")
writer.appendStatic(same) -> writes: []                            (expected ["red\n"])
mutant ($ branch removed): "ecretred" — round-5 leak shape, suite still 19/19 green

Make the OSC alternative stop (without consuming) at any bare ESC that does not start ST, so the following sequence survives for the CSI pass and the text after it is kept — add a (?=\x1b) alternative to the OSC half. The header comment's "consume through ST/BEL/end-of-input" rationale answers dropping the garbage body itself; it does not answer swallowing the legitimate trailing text.

Suggested change
/\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07|$)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
/\x1b[PX^_][\s\S]*?(?:\x1b\\|\x07|$)|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|(?=\x1b)|$)/g;

Please add expect(stripAnsi('\x1b]0;secret\x1b[31mred\x1b[0m')).toBe('red') to a11y-plain-text.test.ts, and confirm the mutation — remove the lookahead alternative and verify the new test goes red.

中文说明

R4-14(仍成立):未带 BEL/ST 终止、后随另一个转义序列的 OSC 仍然违反剥离契约——其后的可见文本会被整体吞掉,无法进入读屏播报。第 4 轮新增的 OTHER_ESCAPE_SEQUENCE 与本轮放宽的 CSI_SEQUENCE 改变了症状但未关闭问题:对 '\x1b]0;secret\x1b[31mred\x1b[0m'(丢了 BEL 的标题 OSC + 彩色输出),CSI 通道先剥掉 '\x1b[31m'/'\x1b[0m',OSC 分支随后走到"直到输入结束"分支,把剩余文本全部消费——stripAnsi 返回 '',读屏写入器什么都不写,合法文本 'red' 丢失。建议把 OSC 分支改为在遇到不构成 ST 的裸 ESC 时停止(前瞻 (?=\x1b)),让后续序列留给 CSI 通道、其后的文本保留。请补充上述断言并做变异验证(移除前瞻分支后新测试应变红)。

— qwen3.8-max via Qwen Code /review (v0.22.3)

/* eslint-enable no-control-regex */

/** Strips all ANSI escape sequences, leaving the readable text. */
export function stripAnsi(text: string): string {
return stripAnsiLib(
text.replace(CSI_SEQUENCE, '').replace(OTHER_ESCAPE_SEQUENCE, ''),
);
}
Comment thread
chiga0 marked this conversation as resolved.

/**
* Reduces markdown to the plain text a screen reader should announce:
* headings lose their hashes, fenced code keeps its body, emphasis markers
* disappear, links and images reduce to their text/alt, blockquote prefixes
* and horizontal rules are dropped. Bullet markers stay — they are readable
* content in the ink parity path too.
*/
export function markdownToPlainText(markdown: string): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-1: [certifies-falsely] [new-surface] markdownToPlainText hand-rolls a regex mirror of ink's inline/block markdown semantics over unbounded model output instead of deriving text from the renderer's tokenizer or a real CommonMark parser; the corner space does not converge — rounds 1, 3, 4, 5 and now 6 have each found new corners.

Round 6 re-executed the round-5 corners at this commit (all still reproduce: italic divergence on 'foo * bar', the NUL-placeholder collision, a backtick fence whose info string contains a backtick inverting parse state, spaced thematic breaks '* * *' slipping past the hr regex) and found two more: the ... underline construct is missing entirely — markdownToPlainText('hi') announces the literal tags where ink renders 'hi' — and the unescapeMarkdownDollars prose pass is missing, so 'costs $5' reaches the reader with the backslash where ink says '$'. Any model output containing one of these corners is announced to screen-reader users differently from what ink renders.

Witness:

probe at HEAD vs ink authority:
'*foo * bar*' -> 'foo * bar' (ink tokenizes 'foo bar*')
'> ```\n> code\n> ```' -> '```\ncode\n```' (fence detection runs before de-quote)
'``` `weird\nstill?\n```' -> 'still?' (ink CODE_FENCE_RE rejects the opener; two lines dropped)
'* * *' unmatched, ink hrRegex.test('* * *') = true
inline-pass diff vs InlineMarkdownRenderer.tsx: no <u> branch, no unescapeMarkdownDollars

Derive the reduction from the renderer's authoritative tokenization (getPlainTextLength in InlineMarkdownRenderer.tsx already walks INLINE_MARKDOWN_REGEX and strips markers — extract and share it) or from a real CommonMark parser AST, instead of parallel regexes; if the hand-rolled reducer is deliberately kept for this foundation PR, at minimum add the pass and the unescapeMarkdownDollars parity pass before the placeholder restore. Note the module's own pinned invariant: ink guards underscore emphasis at word boundaries so identifiers like init survive (test 'leaves dunders and snake_case identifiers untouched') — any replacement must preserve that.

Please pin the divergence cases above in a11y-plain-text.test.ts, and confirm the mutation — they are red today and only go green once the reduction is derived from ink's tokenizer or the missing passes are added.

中文说明

R5-1(仍成立):markdownToPlainText 用手工正则镜像 ink 的内联/块级 markdown 语义,作用于无界的模型输出,而不是从渲染器的 tokenizer 或真正的 CommonMark 解析器推导纯文本;corner 空间无法收敛——第 1、3、4、5、6 轮各发现新 corner。本轮在该提交上复测第 5 轮 corner 全部仍可复现('foo * bar' 斜体分歧、NUL 占位符冲突、info 串含反引号的围栏反转解析状态、带空格的 '* * *' 逃过 hr 正则),并新发现两处:... 下划线结构完全缺失('hi' 会把字面标签读出来,ink 渲染为 'hi');缺少 unescapeMarkdownDollars 通道('costs $5' 会带着反斜杠播报)。建议改为从渲染器权威 token 流(InlineMarkdownRenderer.tsx 的 getPlainTextLength)或真正的 markdown 解析器 AST 推导;若本基础 PR 刻意保留手工归约器,至少补上 通道与 $ 反转义通道。注意模块自身已固定的约束:ink 在词边界保护下划线强调,init 这类标识符必须原样保留。请补充上述分歧用例并做变异验证。

— qwen3.8-max via Qwen Code /review (v0.22.3)

const result: string[] = [];
// The character AND length of the fence that opened the current code
// block, or null outside one. CommonMark: a fence only closes on the
// same character with at least the opening length.
let fenceChar: '`' | '~' | null = null;
let fenceLength = 0;

// CommonMark line endings: \r\n, \n, and lone \r all terminate a line;
// splitting on \n alone leaves \r on the line, which `.` excludes and
// `$` cannot see past, deadening fence detection for CRLF markdown.
for (const rawLine of markdown.split(/\r\n|\n|\r/)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-1: [certifies-falsely] [new-surface] markdownToPlainText hand-rolls a regex mirror of ink's inline/block markdown semantics over unbounded model output instead of deriving text from the renderer's tokenizer or a real CommonMark parser; the corner space does not converge — rounds 1, 3, 4 and 5 have each found new corners. Round-5 demonstrated corners: (a) italic pass diverges from ink's INLINE_MARKDOWN_REGEX ('foo * bar' -> 'foo * bar' vs ink's 'foo bar*'); (b) NUL-digit-NUL placeholder restore collides with literal text ('real \u00000\u0000 end' gets the code-span content substituted); (c) the fence opener accepts a backtick fence whose info string contains a backtick, which CommonMark/ink reject, inverting parse state for the rest of the document; (d) spaced thematic breaks ('* * *') slip past the hr regex while ink's hrRegex matches them. Any model output containing one of these corners is announced to screen-reader users differently from what ink renders; each round patches corners without closing the class.

Witness:

probe: markdownToPlainText('*foo * bar*')='foo * bar' vs ink tokenizer squashed='foo bar*'; 'real `code` here \u00000\u0000 end' -> 'real code here code end'; '```a`b\n# Not a heading' -> '# Not a heading...' while ink CODE_FENCE_RE.exec('```a`b')=null; '* * *' -> '* * *' while ink hrRegex.test('* * *')=true

Suggested fix: Derive the reduction from the renderer's authoritative tokenization (getPlainTextLength in InlineMarkdownRenderer.tsx already walks INLINE_MARKDOWN_REGEX and strips markers — extract and share it), or from a real markdown parser's AST, instead of parallel regexes.

Fix witness: Pin the divergence cases above in a11y-plain-text.test.ts; they are red today and only go green once the reduction is derived from ink's tokenizer. Please add this test and confirm the mutation — remove the fix/guard and verify the test goes red.

中文说明

markdownToPlainText 用手工正则镜像 ink 的内联/块级 markdown 语义,而不是从渲染器的 tokenizer 或真正的 CommonMark 解析器推导纯文本;corner 空间无法收敛——第 1、3、4、5 轮各发现新 corner。本轮实证:(a) 斜体处理与 ink 的 INLINE_MARKDOWN_REGEX 分歧('foo * bar' -> 'foo * bar',ink 为 'foo bar*');(b) NUL-数字-NUL 占位符还原与字面文本冲突,用户文本被代码段内容替换;(c) 围栏开启正则接受 info 串含反引号的反引号围栏(CommonMark/ink 均拒绝),反转文档其余部分的解析状态;(d) 带空格的分割线 '* * *' 逃过 hr 正则。建议改为从渲染器的权威 token 流推导(InlineMarkdownRenderer.tsx 的 getPlainTextLength 已遍历 INLINE_MARKDOWN_REGEX 并去标记,可抽出共享),或从真正的 markdown 解析器 AST 推导。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-3: The CRLF/CR-tolerant line split (round-4 fix of R4-1) has no test pinning it; every existing test joins lines with \n, so a regression to split('\n') stays green while fence/heading detection dies for CRLF markdown. A future simplification to '\n'-only leaves \r on every line of CRLF markdown; the . / $ -anchored fence and heading regexes can no longer match, and no test goes red.

Witness:

sweep: all markdownToPlainText test inputs join with \n; zero contain \r. probe: '```ts\r\nconst a = 1;\r\n```\r\n# Title' reduces correctly today

Suggested fix: Add a11y-plain-text.test.ts cases using \r\n and lone-\r inputs, e.g. expect(markdownToPlainText('\r\ncode\r\n')).toBe('code').

Fix witness: That test goes red if the split regex loses \r\n/\r. Please add this test and confirm the mutation — remove the fix/guard and verify the test goes red.

中文说明

CRLF/CR 容忍的行切分(第 4 轮对 R4-1 的修复)没有测试固定;现有测试全部用 \n 连接。将来若简化回 split('\n'),\r 会留在每行行尾,围栏/标题的 ^/$ 锚定正则全部失效,而套件不会变红。建议补 \r\n 与孤立 \r 用例。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// CommonMark fence: 3+ backticks/tildes, optionally indented up to 3
// spaces. An OPENING fence may carry info text (```js); a CLOSING
// fence cannot — a fence-like line with trailing content inside a
// block is literal body, not a close.
const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(rawLine);
Comment thread
chiga0 marked this conversation as resolved.
if (fenceMatch) {
const run = fenceMatch[1]!;
const trailing = fenceMatch[2] ?? '';
const char = run.charAt(0) as '`' | '~';
if (fenceChar === null) {
fenceChar = char;
fenceLength = run.length;
} else if (
fenceChar === char &&
run.length >= fenceLength &&
trailing.trim() === ''
) {
fenceChar = null;
fenceLength = 0;
} else {
result.push(rawLine);
}
continue;
}
if (fenceChar !== null) {
result.push(rawLine);
continue;
}

// Block-level passes run on the de-quoted view so headings inside
// blockquotes are recognized; the prefix is not content. (Only outside
// fences — a `>` line inside a fenced body is literal text.)
let text = rawLine.replace(/^(?:\s*>\s?)+/, '');
// Headings: "# Title" -> "Title".
text = text.replace(/^ {0,3}#{1,6}\s+/, '');
// Horizontal rules vanish in screen-reader output.
if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)) {
result.push('');
continue;
}
// Extract code spans before the other inline passes: their contents are
// literal text and must not be consumed as links/emphasis markup.
// Mirrors ink's INLINE_CODE_SPAN_PATTERN_SOURCE: non-empty content, and
// the closing run is neither preceded nor followed by another backtick,
// so `` and stray runs stay literal instead of being consumed reordered.
const codeSpans: string[] = [];
text = text.replace(
/(?<!`)(`+)(?!`)([\s\S]+?)(?<!`)\1(?!`)/g,
(_, _ticks, span) => {
codeSpans.push(span);
return `\u0000${codeSpans.length - 1}\u0000`;
},
);
// Images -> alt text, links -> link text.
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Bold before italic so "**x**" is not eaten twice.
text = text.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1');
// Underscore emphasis only applies at word boundaries (CommonMark), so
// "__init__" and snake_case identifiers survive untouched.
text = text.replace(/(^|\s)__(?=\S)([\s\S]*?\S)__(?=\s|$)/g, '$1$2');
text = text.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1');
text = text.replace(/\*(?=\S)([\s\S]*?\S)\*/g, '$1');
text = text.replace(/(^|\s)_(?=\S)([\s\S]*?\S)_(?=\s|$)/g, '$1$2');
// Restore the code-span contents last.
text = text.replace(
// eslint-disable-next-line no-control-regex -- NUL marks extracted code spans
/\u0000(\d+)\u0000/g,
(_, index: string) => codeSpans[Number(index)] ?? '',
);

result.push(text);
}

return result.join('\n');
}
Loading
Loading