fix(tui): hide HTML comments in Markdown rendering (#11755)

Fixes #11710.

## Problem

The tui `Markdown` component printed `<!-- ... -->` comments verbatim.
marked lexes them as `html` tokens and both the block and inline `html`
cases pushed `token.raw` as text, so comments leaked into every Markdown
surface (Extension Updates, What's New, `/changelog`, messages).

```
## [1.0.0]

<!-- release-notes:start -->
Fixed a bug.

See <!-- todo: link --> the docs.
```

rendered both comments.

## Fix

- After lexing (next to `trimPartialClosingFences`, before tokens are
cached), remove block `html` tokens that contain only comments,
recursing into lists and blockquotes. If the removed comment sat between
two blank lines, or at the start/end, one blank line is dropped too, so
no extra gap remains.
- Block HTML with other content keeps rendering as text, with comments
stripped (`<!-- x --> text` renders `text`).
- Inline comments are skipped; if text before ended with a space, the
next text's leading space is dropped (`See <!-- c --> the docs.` renders
`See the docs.`).
- An unclosed block comment runs to the end of the block (CommonMark),
so streamed comments stay hidden.
- Other HTML still renders as text (`with <tag> markers`).

Parsing stays in marked; the change only filters tokens marked already
produced. Cost: one pass over top-level tokens per lex (cached), a regex
on `html` tokens only.

## Tests

`packages/tui/test/markdown.test.ts`, "HTML comments": compares
rendering with comments against the same Markdown without them (issue
repro, blank-line handling, multi-line/unclosed, blockquotes and lists,
other HTML kept).

This PR is AI-generated.
This commit is contained in:
Petr Houška 2026-11-03 22:33:03 +01:00 • committed by GitHub
parent ace560c302
commit 54280c4ff0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 5 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Markdown rendering HTML comments (`<!-- ... -->`) as visible text ([#11710](https://github.com/earendil-works/pi/issues/11710))
## [1.5.0] - 2026-11-02
### Added

View file

@ -197,6 +197,36 @@ function trimPartialClosingFences(tokens: readonly Token[]): void {
token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, "");
}
// An unclosed comment runs to the end of an HTML block, as in CommonMark, so streamed comments stay hidden.
const HTML_COMMENT_REGEX = /<!--[\s\S]*?(?:-->|$)/g;
function stripHtmlComments(html: string): string {
return html.replace(HTML_COMMENT_REGEX, "");
}
/**
* Remove block HTML tokens that hold only comments, so they render no line. A removed comment between two blank
* lines, or at the start or end, also drops a blank line, so it leaves no extra gap.
* See https://github.com/earendil-works/pi/issues/11710.
*/
function removeHtmlCommentBlocks(tokens: Token[]): void {
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i];
if (token.type === "list") {
for (const item of token.items) removeHtmlCommentBlocks(item.tokens);
} else if (token.type === "blockquote") {
removeHtmlCommentBlocks(token.tokens ?? []);
} else if (token.type === "html" && stripHtmlComments(token.raw).trim() === "") {
tokens.splice(i, 1);
if (tokens[i]?.type === "space" && (i === 0 || tokens[i - 1]?.type === "space")) {
tokens.splice(i, 1);
} else if (i === tokens.length && tokens[i - 1]?.type === "space") {
tokens.splice(--i, 1);
}
}
}
}
const markdownParser = new Marked();
markdownParser.setOptions({
tokenizer: new MarkdownTokenizer(),
@ -365,6 +395,7 @@ export class Markdown implements Component {
if (!tokens) {
tokens = markdownParser.lexer(normalizedText);
trimPartialClosingFences(tokens);
removeHtmlCommentBlocks(tokens);
this.cachedTokens = new WeakRef({ source: normalizedText, tokens });
}
@ -715,9 +746,9 @@ export class Markdown implements Component {
break;
case "html":
// Render HTML as plain text (escaped for terminal)
// Render HTML as plain text (escaped for terminal), without its comments
if ("raw" in token && typeof token.raw === "string") {
lines.push(this.applyDefaultStyle(token.raw.trim()));
lines.push(this.applyDefaultStyle(stripHtmlComments(token.raw).trim()));
}
break;
@ -745,7 +776,15 @@ export class Markdown implements Component {
return segments.map((segment: string) => applyText(segment)).join("\n");
};
for (const token of tokens) {
// After a skipped HTML comment, drop the next text's leading space if the text before ended with one,
// so "a <!-- c --> b" renders as "a b".
let dropLeadingSpace = false;
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (dropLeadingSpace && token.type === "text" && !token.tokens?.length && /^[ \t]/.test(token.text)) {
token = { ...token, text: token.text.replace(/^[ \t]+/, "") };
}
dropLeadingSpace = false;
switch (token.type) {
case "latex": {
const latexToken = token as LatexToken;
@ -824,8 +863,10 @@ export class Markdown implements Component {
}
case "html":
// Render inline HTML as plain text
if ("raw" in token && typeof token.raw === "string") {
// Render inline HTML as plain text; comments are hidden
if (token.raw.startsWith("<!--")) {
dropLeadingSpace = i === 0 || /[ \t]$/.test(tokens[i - 1].raw);
} else {
result += applyTextWithNewlines(token.raw);
}
break;

View file

@ -1973,6 +1973,49 @@ bar`,
});
});
describe("HTML comments", () => {
// Regression tests for https://github.com/earendil-works/pi/issues/11710
function renderPlain(text: string): string[] {
return new Markdown(text, 0, 0, defaultMarkdownTheme).render(60).map((line) => stripAnsi(line).trimEnd());
}
it("hides block and inline comments without extra spacing", () => {
assert.deepStrictEqual(
renderPlain(
"## [1.0.0]\n\n<!-- release-notes:start -->\nFixed a bug.\n\nSee <!-- todo: link --> the docs.",
),
renderPlain("## [1.0.0]\n\nFixed a bug.\n\nSee the docs."),
);
});
it("drops one blank line around comment-only blocks", () => {
assert.deepStrictEqual(
renderPlain(
"<!-- start -->\n\n## [1.0.0]\n\n<!-- a -->\n\n- item\n\n<!-- b -->\n\n## [0.9.0]\n\n<!-- end -->",
),
renderPlain("## [1.0.0]\n\n- item\n\n## [0.9.0]"),
);
});
it("hides multi-line and unclosed comments", () => {
assert.deepStrictEqual(renderPlain("<!--\nmulti\nline\n-->\ntext"), renderPlain("text"));
assert.deepStrictEqual(renderPlain("text\n\n<!-- streaming"), renderPlain("text"));
});
it("hides comments in blockquotes and list items", () => {
assert.deepStrictEqual(
renderPlain("> q\n>\n> <!-- c -->\n>\n> r\n\n- a\n\n <!-- c -->\n\n b\n- <!-- c --> item"),
renderPlain("> q\n>\n> r\n\n- a\n\n b\n- item"),
);
});
it("keeps other HTML and the text after a comment in a block", () => {
assert.deepStrictEqual(renderPlain("with <tag> markers"), ["with <tag> markers"]);
assert.deepStrictEqual(renderPlain("<!-- c --> trailing text"), ["trailing text"]);
assert.deepStrictEqual(renderPlain("<div>\n<!-- c -->a</div>"), ["<div>", "a</div>"]);
});
});
describe("Streaming code fences", () => {
it("stabilizes partial closing fence rendering", () => {
const cases = [