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 = [