From 54280c4ff07c4643da8e6429db3658ad765ab286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Tue, 3 Nov 2026 22:33:03 +0100 Subject: [PATCH] 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] Fixed a bug. See 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 (` text` renders `text`). - Inline comments are skipped; if text before ended with a space, the next text's leading space is dropped (`See 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 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. --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/markdown.ts | 51 ++++++++++++++++++++++--- packages/tui/test/markdown.test.ts | 43 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5c7b22acf..ea62e7fb1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a21f75181..c684c39c7 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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 = /|$)/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 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("\nFixed a bug.\n\nSee 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( + "\n\n## [1.0.0]\n\n\n\n- item\n\n\n\n## [0.9.0]\n\n", + ), + renderPlain("## [1.0.0]\n\n- item\n\n## [0.9.0]"), + ); + }); + + it("hides multi-line and unclosed comments", () => { + assert.deepStrictEqual(renderPlain("\ntext"), renderPlain("text")); + assert.deepStrictEqual(renderPlain("text\n\n\n>\n> r\n\n- a\n\n \n\n b\n- 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 markers"), ["with markers"]); + assert.deepStrictEqual(renderPlain(" trailing text"), ["trailing text"]); + assert.deepStrictEqual(renderPlain("
\na
"), ["
", "a
"]); + }); + }); + describe("Streaming code fences", () => { it("stabilizes partial closing fence rendering", () => { const cases = [