diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts index c47a718fe..37e3aeddb 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => { ); }); + it("normalizes HTML table cells and inline formatting into markdown", () => { + expect( + normalizeHtmlishMarkdown( + "
Score: 78
No security concerns identified
Recommended focus areas for reviewBearer authentication
", + ), + ).toBe( + "\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n", + ); + }); + it("leaves complex code tags inert instead of parsing HTML", () => { expect(normalizeHtmlishMarkdown('')).toBe( '', diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts index 7a92f0d20..93a88cd4b 100644 --- a/packages/app/src/components/markdown/html-ish.ts +++ b/packages/app/src/components/markdown/html-ish.ts @@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g; const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i; const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i; const VOID_HTML_TAGS = new Set(["br", "img"]); +const MARKDOWN_TAG_WRAPPERS: Readonly> = { + b: ["**", "**"], + del: ["~~", "~~"], + em: ["*", "*"], + i: ["*", "*"], + s: ["~~", "~~"], + strike: ["~~", "~~"], + strong: ["**", "**"], +}; interface ProtectedMarkdownRange { start: number; @@ -313,36 +322,83 @@ function renderInlineTokens(tokens: HtmlToken[]): string { } const children = tokens.slice(index + 1, closeIndex); - if (token.name === "a") { - output += renderLinkToken(token, children); - index = closeIndex; - continue; - } - if (token.name === "sub") { - output += renderInlineTokens(children); - index = closeIndex; - continue; - } - if (token.name === "code" && children.every((child) => child.kind === "text")) { - output += `\`${renderInlineTokens(children)}\``; - index = closeIndex; - continue; - } - const rawTag = token.raw; - const tagName = token.name; - if (isHeadingTag(token)) { - output += renderInlineTokens(children); - index = closeIndex; - continue; - } - - output += `${rawTag}${renderInlineTokens(children)}`; + output += renderHtmlTag(token, children); index = closeIndex; } return output; } +function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string { + if (token.name === "a") { + return renderLinkToken(token, children); + } + if (token.name === "sub" || isHeadingTagName(token.name)) { + return renderInlineTokens(children); + } + if (token.name === "code" && children.every((child) => child.kind === "text")) { + return `\`${renderInlineTokens(children)}\``; + } + const wrapper = MARKDOWN_TAG_WRAPPERS[token.name]; + if (wrapper) { + return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`; + } + if (token.name === "table") { + return renderTableTokens(children); + } + if (token.name === "p" || token.name === "div") { + return `\n\n${renderInlineTokens(children).trim()}\n\n`; + } + return `${token.raw}${renderInlineTokens(children)}`; +} + +function renderTableTokens(tokens: HtmlToken[]): string { + const rows: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + if (!isOpenTag(tokens[index], "tr")) { + continue; + } + const closeIndex = findMatchingClose(tokens, index, "tr"); + if (closeIndex === null) { + continue; + } + const cells = renderTableCells(tokens.slice(index + 1, closeIndex)); + if (cells.length === 1) { + rows.push(`- ${cells[0]}`); + } else if (cells.length > 1) { + const label = + cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`; + rows.push(`- ${label}: ${cells.slice(1).join(" ")}`); + } + index = closeIndex; + } + + return rows.length > 0 ? `\n${rows.join("\n")}\n` : ""; +} + +function renderTableCells(tokens: HtmlToken[]): string[] { + const cells: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) { + continue; + } + const closeIndex = findMatchingClose(tokens, index, token.name); + if (closeIndex === null) { + continue; + } + const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim(); + if (cell) { + cells.push(cell); + } + index = closeIndex; + } + + return cells; +} + function renderImageToken(token: HtmlTagToken): string { const image = imageTokenToInlineImage(token, undefined); if (!image) {