From 65f16f6322acfe9732d9054b04fb98a0a3a4de2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 16:52:02 +0200 Subject: [PATCH] fix(markdown): preserve spacing between HTML elements --- packages/app/src/components/markdown/html.ts | 66 +++++++++++++++++-- .../src/components/markdown/renderer.test.ts | 17 +++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/markdown/html.ts b/packages/app/src/components/markdown/html.ts index 1cde4dad46..34c88b2d5b 100644 --- a/packages/app/src/components/markdown/html.ts +++ b/packages/app/src/components/markdown/html.ts @@ -9,6 +9,25 @@ const HTML = new RegExp( String.raw`(?|]*>[\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> = { 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(")` : 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 /^ { "![chart]()\n\n[image]: first.png\n[image]: second.png", ); }); + +it("keeps Greptile header links and block text separated", () => { + const source = + '

RetriggerView in GreptileConfidence Score: 2/5

'; + const rendered = prepareMarkdownSource(source, undefined, { supportsSvg: false }).render(); + expect(rendered).toContain( + "[Retrigger]() [View in Greptile]", + ); + expect(rendered).toContain(") Confidence Score: 2/5"); +}); + +it("keeps paragraph, heading and table-row boundaries when stripping HTML", () => { + const source = + "
First

Second
line

Heading

AB
CD
"; + const rendered = prepareMarkdownSource(source).render(); + expect(rendered).toBe("First\n\nSecond \nline\n\nHeading\n\nA B\n\nC D"); +});