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 review | Bearer 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)}${tagName}>`;
+ 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)}${token.name}>`;
+}
+
+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) {