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( + `

Guide

Example

${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( + '

Guide

Example

\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( + `

Guide

Example

${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 sole  child 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`;
   },
 });