From aedc34636759806697c35c9566152b43f047c376 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:09:18 +0200 Subject: [PATCH] fix(app): render mixed HTML headings without literal tags Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag. --- .../src/components/markdown/html-ish.test.ts | 65 +++++++++++++++++++ .../app/src/components/markdown/html-ish.ts | 50 ++++++++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts index d8b45ae415..f8606d2714 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest"; import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish"; describe("splitHtmlishMarkdown", () => { + it("renders heading text beside a linked picture without literal heading tags", () => { + const source = + '

RetriggerConfidence Score: 5/5

'; + + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "inlineImage", + alt: "Retrigger", + src: "https://example.com/l.svg", + href: "https://example.com/r", + flowsWithText: true, + }, + { kind: "markdown", text: "Confidence Score: 5/5" }, + ]); + }); + + it.each(["h1", "h2", "h3", "h4", "h5", "h6"])( + "keeps a mixed %s heading together after preceding markdown", + (heading) => { + const source = `Intro\n\n<${heading}>IconCaption\n\nOutro`; + + expect(splitHtmlishMarkdown(source)).toEqual([ + { kind: "markdown", text: "Intro\n\n" }, + { + kind: "inlineImage", + alt: "Icon", + src: "https://example.com/icon.svg", + flowsWithText: true, + }, + { kind: "markdown", text: "Caption\n\nOutro" }, + ]); + }, + ); + + it("preserves formatted text before and after images in a details heading", () => { + const source = + '
More

BeforeIconAfter

'; + + expect(splitHtmlishMarkdown(source)).toEqual([ + { + kind: "details", + summary: "More", + body: "**Before***After*", + bodyParts: [ + { kind: "markdown", text: "**Before**" }, + { kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" }, + { kind: "markdown", text: "*After*" }, + ], + }, + ]); + }); + + it("keeps mixed HTML headings literal in code examples", () => { + const heading = '

Caption

'; + const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``; + + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it("preserves unknown wrappers around text-only headings", () => { + expect(splitHtmlishMarkdown("

Heading

")).toEqual([ + { kind: "markdown", text: "
Heading
" }, + ]); + }); + const inlineImageBody = [ 'Priority Spoofed browser User-Agent allows access control bypass', "", diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts index bd095c60b3..94eb7cab5b 100644 --- a/packages/app/src/components/markdown/html-ish.ts +++ b/packages/app/src/components/markdown/html-ish.ts @@ -125,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] { continue; } - const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details"); - const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1); - const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length); + if (isHeadingTag(token)) { + const closeIndex = findMixedHeadingClose(tokens, cursor); + if (closeIndex !== null) { + // Headings render as their contents. Split those contents together so an + // embedded image cannot strand the opening heading tag in a text slice. + for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) { + appendDisplayPart(parts, part); + } + cursor = closeIndex + 1; + continue; + } + } + + const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length; appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end))); cursor = end; } @@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number) return false; } -function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null { +function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null { for (let index = start; index < tokens.length; index += 1) { - if (parseInlineImageAt(tokens, index)) { + if ( + isOpenTag(tokens[index], "details") || + findMixedHeadingClose(tokens, index) !== null || + parseInlineImageAt(tokens, index) + ) { return index; } } return null; } +function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null { + const token = tokens[start]; + if (!isHeadingTag(token)) { + return null; + } + const closeIndex = findMatchingClose(tokens, start, token.name); + if (closeIndex === null) { + return null; + } + for (let index = start + 1; index < closeIndex; index += 1) { + if (parseInlineImageAt(tokens, index)) { + return closeIndex; + } + } + return null; +} + +function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void { + if (part.kind === "markdown") { + appendMarkdownPart(parts, part.text); + } else { + parts.push(part); + } +} + function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void { if (!text) { return;