diff --git a/.changeset/preserve-html-code-languages.md b/.changeset/preserve-html-code-languages.md new file mode 100644 index 0000000..a66a72f --- /dev/null +++ b/.changeset/preserve-html-code-languages.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": patch +--- + +Preserve language fences and code indentation when HTML pre/code blocks contain surrounding formatting whitespace or comments. diff --git a/packages/context/src/html.test.ts b/packages/context/src/html.test.ts index 961ce73..c1daaba 100644 --- a/packages/context/src/html.test.ts +++ b/packages/context/src/html.test.ts @@ -34,4 +34,54 @@ describe("DocBook HTML examples", () => { ); expect(parsed.sections[0]?.content).toContain("```sh\necho hello\n```"); }); + + it.each([ + ["spaces", " ", " "], + ["newlines", "\n ", "\n"], + ["tabs", "\t", "\t\n"], + ["comments", "\n\n ", "\n\n"], + ])("preserves language and indentation with %s around code", (_, before, after) => { + const parsed = parseHtml( + `
${before}if true; then\n echo hello\nfi\n${after}`,
+ "guide.html",
+ );
+ expect(parsed.sections).toHaveLength(1);
+ expect(parsed.sections[0]?.hasCode).toBe(true);
+ expect(parsed.sections[0]?.content).toBe(
+ "```sh\nif true; then\n echo hello\nfi\n```",
+ );
+ });
+
+ it("keeps embedded backticks inside a language fence with wrapper whitespace", () => {
+ const parsed = parseHtml(
+ '\n first\n```\nlast\n',
+ "guide.html",
+ );
+ expect(parsed.sections).toHaveLength(1);
+ expect(parsed.sections[0]?.hasCode).toBe(true);
+ expect(parsed.sections[0]?.content).toBe(
+ "````markdown\nfirst\n```\nlast\n````",
+ );
+ });
+
+ it.each([
+ [
+ 'prefix echo hello suffix',
+ "prefix echo hello suffix",
+ ],
+ [
+ 'prefix echo hello',
+ "prefix echo hello",
+ ],
+ [
+ ' echo hello suffix',
+ " echo hello suffix",
+ ],
+ ])("preserves meaningful siblings in mixed preformatted content: %s", (content, expected) => {
+ const parsed = parseHtml(
+ `${content}`,
+ "guide.html",
+ );
+ expect(parsed.sections[0]?.content).toBe(`\`\`\`\n${expected}\n\`\`\``);
+ });
});
diff --git a/packages/context/src/html.ts b/packages/context/src/html.ts
index cabfcf5..0276592 100644
--- a/packages/context/src/html.ts
+++ b/packages/context/src/html.ts
@@ -40,17 +40,31 @@ for (const tag of REMOVED_TAGS) {
// DocBook emits bare elements; Turndown's code rule requires. // Preserve their whitespace and prevent Markdown escaping of unit-file examples. +// Also recognize a solechild preceded by whitespace or comments. turndown.addRule("barePre", { filter: (node) => node.nodeName === "PRE" && node.firstChild?.nodeName !== "CODE", replacement: (_content, node) => { - const code: string = node.textContent ?? ""; + const codeElement = node.firstElementChild; + const isWrappedCode = + codeElement?.nodeName === "CODE" && + [...node.childNodes].every( + (child) => + child === codeElement || + (child.nodeType === 3 && !/\S/.test(child.textContent ?? "")) || + child.nodeType === 8, + ); + const className = isWrappedCode + ? (codeElement.getAttribute("class") ?? "") + : ""; + const language = className.match(/\blanguage-(\S+)/)?.[1] ?? ""; + const code: string = (isWrappedCode ? codeElement : node).textContent ?? ""; const longestRun = (code.match(/`+/g) ?? []).reduce( (longest, run) => Math.max(longest, run.length), 0, ); const fence = "`".repeat(Math.max(3, longestRun + 1)); - return `\n\n${fence}\n${code.replace(/\n$/, "")}\n${fence}\n\n`; + return `\n\n${fence}${language}\n${code.replace(/\n$/, "")}\n${fence}\n\n`; }, });