This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-website.git


The following commit(s) were added to refs/heads/main by this push:
     new 3fe58f7c fix: repair malformed HTML before converting pages to 
Markdown (#1750)
3fe58f7c is described below

commit 3fe58f7c1f00fc7fa8c6b69fee91098ec4f90f5a
Author: Adriano Machado <[email protected]>
AuthorDate: Mon Sep 14 02:57:01 2026 -0400

    fix: repair malformed HTML before converting pages to Markdown (#1750)
    
    Asciidoctor emits mis-nested inline tags for some sources: a `*` inside
    backticks renders as <code>core/camel-<strong></code>...</strong>.
    node-html-parser drops the unmatched close tag and, at end of input,
    unwraps every unclosed ancestor including article.doc, so
    generate-markdown skipped the page silently and its .md mirror 404ed
    (35 pages in production).
    
    Pages that node-html-parser's valid() rejects are now repaired with
    jsdom's HTML5 parser before extraction, the way browsers do. The
    parseNoneClosedTags option is not enough: it keeps the unclosed <code>
    open and Turndown flattens the rest of the article into one inline code
    span. Well-formed pages take the unchanged path.
    
    Repaired pages and pages without main content are now logged instead of
    skipped silently, so the build names the sources to fix upstream.
    
    Fixes #1746
    
    _Claude Code on behalf of Adriano Machado (@ammachado)_
    
    _This was generated by an AI agent and may contain inaccuracies.
    Please verify before relying on it._
---
 gulp/tasks/generate-markdown.js | 108 +++++++++++++++++++++++++---------------
 test/generate-markdown-test.js  |  90 +++++++++++++++++++++++++++++++++
 2 files changed, 158 insertions(+), 40 deletions(-)

diff --git a/gulp/tasks/generate-markdown.js b/gulp/tasks/generate-markdown.js
index c4c93534..8c210a03 100644
--- a/gulp/tasks/generate-markdown.js
+++ b/gulp/tasks/generate-markdown.js
@@ -1,5 +1,6 @@
 const fs = require('fs');
-const { parse } = require('node-html-parser');
+const { JSDOM } = require('jsdom');
+const { parse, valid } = require('node-html-parser');
 const { createTurndownService } = require('../helpers/turndown-config');
 const { generateToonSitemaps } = require('../helpers/toon-format');
 const { generateLlmsTxt } = require('../helpers/llms-txt');
@@ -42,50 +43,17 @@ async function generateMarkdown() {
     for (const htmlFile of batch) {
       try {
         const htmlContent = fs.readFileSync(htmlFile, 'utf8');
-        const root = parse(htmlContent);
+        const { markdown, repaired } = convertPage(htmlContent, 
turndownService);
 
-        // Extract only the main article content
-        // Try different selectors based on Antora and Hugo structure
-        let mainContent = root.querySelector('article.doc') ||
-                         root.querySelector('main') ||
-                         root.querySelector('.article') ||
-                         root.querySelector('article');
+        if (repaired) {
+          console.warn(`Repaired malformed HTML in ${htmlFile}, fix the 
mis-nested markup in its source`);
+        }
 
-        if (!mainContent) {
-          // Silently skip files without main content
+        if (markdown === null) {
+          console.warn(`Skipping ${htmlFile}: no main content found`);
           continue;
         }
 
-        // Remove navigation elements, headers, and footers from the content
-        const elementsToRemove = mainContent.querySelectorAll('nav, header, 
footer, .nav, .navbar, .toolbar');
-        elementsToRemove.forEach(el => el.remove());
-
-        // Remove anchor links (they are just UI navigation aids)
-        const anchors = mainContent.querySelectorAll('a.anchor');
-        anchors.forEach(el => el.remove());
-
-        // Clean up table cells by unwrapping div.content and div.paragraph 
wrappers
-        const tableCells = mainContent.querySelectorAll('td.tableblock, 
th.tableblock');
-        tableCells.forEach(cell => {
-          let html = cell.innerHTML;
-          // Unwrap <div class="content"><div 
class="paragraph"><p>...</p></div></div>
-          html = html.replace(/<div class="content"><div 
class="paragraph">\s*<p>(.*?)<\/p>\s*<\/div><\/div>/gs, '$1');
-          // Unwrap <div class="content"><div id="..." 
class="paragraph"><p>...</p></div></div>
-          html = html.replace(/<div 
class="content"><div[^>]*class="paragraph"[^>]*>\s*<p>(.*?)<\/p>\s*<\/div><\/div>/gs,
 '$1');
-          // Also handle simple <p class="tableblock">...</p> wrappers
-          html = html.replace(/<p class="tableblock">(.*?)<\/p>/gs, '$1');
-          cell.set_content(html);
-        });
-
-        // Convert to Markdown
-        let markdown = turndownService.turndown(mainContent.innerHTML);
-
-        // Update links to point to .md files instead of .html
-        // Replace https://camel.apache.org/**/*.html with 
https://camel.apache.org/**/*.md
-        markdown = 
markdown.replace(/(https:\/\/camel\.apache\.org\/[^)\s]*?)\.html/g, '$1.md');
-        // Replace relative links *.html with *.md
-        markdown = markdown.replace(/\[([^\]]+)\]\(([^)]+?)\.html\)/g, 
'[$1]($2.md)');
-
         // Write .md file (replace .html extension with .md)
         const mdFile = htmlFile.replace(/\.html$/, '.md');
         fs.writeFileSync(mdFile, markdown, 'utf8');
@@ -119,4 +87,64 @@ async function generateMarkdown() {
   await generateAllIndexes();
 }
 
+/**
+ * Converts the main content of one rendered HTML page to Markdown.
+ *
+ * @param {string} htmlContent the full HTML page
+ * @param {TurndownService} turndownService configured Turndown instance
+ * @returns {{markdown: string|null, repaired: boolean}} the Markdown (null 
when the page has no
+ *   main content), and whether the HTML was malformed and had to be repaired 
before parsing
+ */
+function convertPage(htmlContent, turndownService) {
+  // node-html-parser cannot repair mis-nested inline tags (Asciidoctor emits 
them for a `*` inside
+  // backticks): it unwraps every unclosed ancestor, article.doc included. Let 
jsdom's HTML5 parser
+  // repair such pages the way browsers do; it is much slower, so only 
malformed pages go through it.
+  const repaired = !valid(htmlContent);
+  const root = parse(repaired ? new JSDOM(htmlContent).serialize() : 
htmlContent);
+
+  // Extract only the main article content
+  // Try different selectors based on Antora and Hugo structure
+  let mainContent = root.querySelector('article.doc') ||
+                   root.querySelector('main') ||
+                   root.querySelector('.article') ||
+                   root.querySelector('article');
+
+  if (!mainContent) {
+    return { markdown: null, repaired };
+  }
+
+  // Remove navigation elements, headers, and footers from the content
+  const elementsToRemove = mainContent.querySelectorAll('nav, header, footer, 
.nav, .navbar, .toolbar');
+  elementsToRemove.forEach(el => el.remove());
+
+  // Remove anchor links (they are just UI navigation aids)
+  const anchors = mainContent.querySelectorAll('a.anchor');
+  anchors.forEach(el => el.remove());
+
+  // Clean up table cells by unwrapping div.content and div.paragraph wrappers
+  const tableCells = mainContent.querySelectorAll('td.tableblock, 
th.tableblock');
+  tableCells.forEach(cell => {
+    let html = cell.innerHTML;
+    // Unwrap <div class="content"><div 
class="paragraph"><p>...</p></div></div>
+    html = html.replace(/<div class="content"><div 
class="paragraph">\s*<p>(.*?)<\/p>\s*<\/div><\/div>/gs, '$1');
+    // Unwrap <div class="content"><div id="..." 
class="paragraph"><p>...</p></div></div>
+    html = html.replace(/<div 
class="content"><div[^>]*class="paragraph"[^>]*>\s*<p>(.*?)<\/p>\s*<\/div><\/div>/gs,
 '$1');
+    // Also handle simple <p class="tableblock">...</p> wrappers
+    html = html.replace(/<p class="tableblock">(.*?)<\/p>/gs, '$1');
+    cell.set_content(html);
+  });
+
+  // Convert to Markdown
+  let markdown = turndownService.turndown(mainContent.innerHTML);
+
+  // Update links to point to .md files instead of .html
+  // Replace https://camel.apache.org/**/*.html with 
https://camel.apache.org/**/*.md
+  markdown = 
markdown.replace(/(https:\/\/camel\.apache\.org\/[^)\s]*?)\.html/g, '$1.md');
+  // Replace relative links *.html with *.md
+  markdown = markdown.replace(/\[([^\]]+)\]\(([^)]+?)\.html\)/g, 
'[$1]($2.md)');
+
+  return { markdown, repaired };
+}
+
 module.exports = generateMarkdown;
+module.exports.convertPage = convertPage;
diff --git a/test/generate-markdown-test.js b/test/generate-markdown-test.js
new file mode 100644
index 00000000..e1941714
--- /dev/null
+++ b/test/generate-markdown-test.js
@@ -0,0 +1,90 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const test = require('node:test')
+
+const { convertPage } = require('../gulp/tasks/generate-markdown')
+const { createTurndownService } = require('../gulp/helpers/turndown-config')
+
+function page (body) {
+  return '<!DOCTYPE html><html><head><title>t</title></head><body><nav 
class="nav">menu</nav>' +
+    `<main class="article"><article 
class="doc">\n${body}\n</article></main></body></html>`
+}
+
+const LATER_SECTION = `<div class="sect2">
+<h3 id="_later"><a class="anchor" href="#_later"></a>Later section</h3>
+<div class="paragraph">
+<p>Still prose.</p>
+</div>
+</div>`
+
+test('a well-formed page converts its article, dropping anchors and rewriting 
.html links', () => {
+  const html = page(`<h2 id="_intro"><a class="anchor" 
href="#_intro"></a>Intro</h2>
+<div class="paragraph">
+<p>Use <code>camel run</code>, see <a href="other.html">Other</a>.</p>
+</div>`)
+
+  assert.deepEqual(convertPage(html, createTurndownService()), {
+    markdown: '## Intro\n\nUse `camel run`, see [Other](other.md).',
+    repaired: false,
+  })
+})
+
+test('a page without main content yields no Markdown', () => {
+  const html = '<!DOCTYPE 
html><html><head><title>t</title></head><body><p>verification</p></body></html>'
+
+  assert.deepEqual(convertPage(html, createTurndownService()), { markdown: 
null, repaired: false })
+})
+
+// NOTE the fixtures below are what Asciidoctor emits for real Camel docs 
(security-model.adoc,
+// camel-jbang-mcp.adoc). Browsers repair the mis-nesting, but 
node-html-parser drops the unmatched
+// close tag and, at end of input, unwraps every unclosed ancestor including 
article.doc, so the
+// page used to be skipped and its .md mirror 404ed. Asserting that the page 
merely converts is not
+// enough: keeping the unclosed element open instead (parseNoneClosedTags) 
retains article.doc but
+// nests everything after the glitch inside <code>, which Turndown flattens 
into one inline code
+// span. The later heading and paragraph must survive as their own Markdown 
lines.
+test('a wildcard read as bold inside backticks keeps the page and the sections 
after it', () => {
+  const html = page(`<div class="paragraph">
+<p>A candidate located in a <code>core/camel-<strong></code> module is judged 
against these
+invariants first. If the engine upheld the invariant *and</strong> nothing 
else.</p>
+</div>
+${LATER_SECTION}`)
+
+  const { markdown } = convertPage(html, createTurndownService())
+
+  assert.notEqual(markdown, null)
+  assert.match(markdown, /module is judged against these/)
+  assert.match(markdown, /^### Later section$/m)
+  assert.match(markdown, /^Still prose\.$/m)
+})
+
+test('an unterminated code span inside emphasis keeps the list items after 
it', () => {
+  const html = page(`<div class="ulist">
+<ul>
+<li>
+<p><em>"Validate this endpoint: 
<code>kafka:myTopic?brkers=localhost:9092\`"</em> - detects the typo and 
suggests \`brokers</code></p>
+</li>
+<li>
+<p><em>"Validate this YAML route"</em> - checks against the YAML DSL JSON 
schema</p>
+</li>
+</ul>
+</div>
+${LATER_SECTION}`)
+
+  const { markdown } = convertPage(html, createTurndownService())
+
+  assert.notEqual(markdown, null)
+  assert.match(markdown, /^-\s+_"Validate this YAML route"_ - checks against 
the YAML DSL JSON schema$/m)
+  assert.match(markdown, /^### Later section$/m)
+})
+
+// NOTE the repair hides the defect from readers of the .md mirror, but the 
rendered HTML is still
+// invalid and the fix belongs in the upstream .adoc source. Flagging the page 
is what lets the build
+// log name it.
+test('a page that needed repair is flagged so its source can be fixed 
upstream', () => {
+  const html = page(`<div class="paragraph">
+<p>A <code>core/camel-<strong></code> module</strong> x</p>
+</div>`)
+
+  assert.equal(convertPage(html, createTurndownService()).repaired, true)
+})

Reply via email to