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 =
+ '
Confidence 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}>
Caption${heading}>\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
Before
After
';
+
+ 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("")).toEqual([
+ { kind: "markdown", text: "" },
+ ]);
+ });
+
const inlineImageBody = [
'
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;