fix(markdown): preserve spacing between HTML elements

This commit is contained in:
Mohamed Boudra
2026-10-15 16:52:02 +02:00
parent e7589cbc86
commit 65f16f6322
2 changed files with 78 additions and 5 deletions
+61 -5
View File
@@ -9,6 +9,25 @@ const HTML = new RegExp(
String.raw`(?<!\\)(?:<!--[\s\S]*?-->|<code\b[^>]*>[\s\S]*?<\/code\s*>|${TAG})`,
"gi",
);
const BLOCK_TAGS = new Set([
"div",
"p",
"table",
"tr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"section",
"article",
"ul",
"ol",
"li",
"blockquote",
"pre",
]);
const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "*", em: "*" };
/** Convert comment HTML to Markdown while preserving the original spelling of code. */
@@ -48,11 +67,10 @@ export function normalizeMarkdownHtml(source: string): string {
const closing = raw.startsWith("</");
const name = /^<\/?([\w-]+)/.exec(raw)?.[1].toLowerCase() ?? "";
if (EMPHASIS[name]) return EMPHASIS[name];
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
if (name === "a") return markdownAnchor(raw, closing, links);
const boundary = layoutBoundary(name, source, raw, offset, closing);
if (boundary !== null) return boundary;
if (name === "a")
return markdownAnchor(raw, closing, links) + inlineBoundary(source, raw, offset, closing);
if (name === "img" && !closing) {
const { src, alt = "" } = attributes(raw);
return src ? `![${alt.replace(/[[\]\\]/g, "\\$&")}](<${markdownUrl(src)}>)` : alt;
@@ -111,3 +129,41 @@ function markdownAnchor(raw: string, closing: boolean, links: (string | undefine
links.push(href);
return href === undefined ? "" : "[";
}
function inlineBoundary(source: string, raw: string, offset: number, closing: boolean): string {
if (!closing) return "";
return /^(?:<[A-Za-z]|[A-Za-z0-9])/.test(source.slice(offset + raw.length)) ? " " : "";
}
function blockBoundary(source: string, raw: string, offset: number, closing: boolean): string {
if (closing) {
const following = source.slice(offset + raw.length);
if (following.trim() === "" || following.startsWith("\n\n")) return "";
const nextClosingTag = /^<\/([\w-]+)/.exec(following.trimStart());
if (nextClosingTag && BLOCK_TAGS.has(nextClosingTag[1].toLowerCase())) return "";
} else {
const preceding = source.slice(0, offset).trimEnd();
if (!preceding) return "";
const previousTag = /<\/?([\w-]+)[^>]*>$/.exec(preceding);
if (previousTag && BLOCK_TAGS.has(previousTag[1].toLowerCase())) return "";
}
return "\n\n";
}
function layoutBoundary(
name: string,
source: string,
raw: string,
offset: number,
closing: boolean,
): string | null {
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
if (BLOCK_TAGS.has(name)) return blockBoundary(source, raw, offset, closing);
if ((name === "td" || name === "th") && closing)
return /^<t[dh]\b/i.test(source.slice(offset + raw.length)) ? " " : "";
if (name === "span") return inlineBoundary(source, raw, offset, closing);
return null;
}
@@ -311,3 +311,20 @@ it("uses the first repeated image reference definition", () => {
"![chart](<blob:first>)\n\n[image]: first.png\n[image]: second.png",
);
});
it("keeps Greptile header links and block text separated", () => {
const source =
'<h2><a href="https://app.greptile.com/api/retrigger?id=81261069"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img alt="Retrigger" src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2" align="right"></picture></a><a href="https://app.greptile.com/paseo/github/getpaseo/paseo/-/pull/6973"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptileDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2"><img alt="View in Greptile" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewInGreptile.svg?v=2" align="right"></picture></a>Confidence Score: 2/5</h2>';
const rendered = prepareMarkdownSource(source, undefined, { supportsSvg: false }).render();
expect(rendered).toContain(
"[Retrigger](<https://app.greptile.com/api/retrigger?id=81261069>) [View in Greptile]",
);
expect(rendered).toContain(") Confidence Score: 2/5");
});
it("keeps paragraph, heading and table-row boundaries when stripping HTML", () => {
const source =
"<div>First</div><p>Second<br>line</p><h3>Heading</h3><table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>";
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toBe("First\n\nSecond \nline\n\nHeading\n\nA B\n\nC D");
});