#!/usr/bin/env node /** * inject-structured-data.mjs * * Adds JSON-LD structured data to every built DashCaddy app-guide page * (out/docs/catalog//index.html) after `next build` has produced the * static export. * * Design rules: * - Runs on npm `postbuild`, so it operates on the real rendered HTML, not on * the TSX source. Everything it emits is therefore provably derived from * text a visitor can already see on the page (Google's requirement for * FAQPage / HowTo markup). * - Emits nothing it cannot ground. A page with no category badge gets no * applicationCategory; a page with no troubleshooting list gets no FAQPage; * a page with no dashboard install steps gets no HowTo. Missing fields are * reported, never invented. * - Idempotent: an already-injected page is detected and skipped. * * Never hand-edit this file's OUTPUT. Re-run the build. */ import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, ".."); const OUT = join(REPO, "out"); const CATALOG = join(OUT, "docs", "catalog"); const ORIGIN = "https://dashcaddy.net"; const MARKER = 'data-dashcaddy-jsonld="app-guide"'; const BLOG_MARKER = 'data-dashcaddy-jsonld="blog-breadcrumb-faq"'; /* ------------------------------------------------------------------ text --- */ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "39": "'", "x27": "'", "x2F": "/", "47": "/", }; function decodeEntities(s) { return s.replace(/&#?([a-zA-Z0-9]+);/g, (whole, code) => { if (Object.prototype.hasOwnProperty.call(ENTITIES, code)) return ENTITIES[code]; if (Object.prototype.hasOwnProperty.call(ENTITIES, "#" + code)) { return ENTITIES["#" + code]; } if (/^#?\d+$/.test(code)) { const n = Number(code.replace("#", "")); if (Number.isFinite(n) && n > 0 && n < 0x10ffff) return String.fromCodePoint(n); } if (/^#?x[0-9a-f]+$/i.test(code)) { const n = parseInt(code.replace(/^#?x/i, ""), 16); if (Number.isFinite(n) && n > 0 && n < 0x10ffff) return String.fromCodePoint(n); } return whole; }); } /** Strip tags, decode entities, collapse whitespace. */ function text(html) { return decodeEntities( html .replace(//gi, " ") .replace(/<[^>]+>/g, " "), ) .replace(/\s+/g, " ") .trim(); } /** Tags stripped / entities decoded for the WHOLE document — used to prove that * anything we mark up is genuinely visible to a visitor. */ function visibleText(doc) { return text( doc .replace(//gi, " ") .replace(//gi, " "), ); } /* ------------------------------------------------------------- extraction --- */ /** Return the inner HTML of the first ... starting at `from`. */ function innerOf(html, tag, from = 0) { const open = new RegExp(`<${tag}\\b[^>]*>`, "gi"); open.lastIndex = from; const m = open.exec(html); if (!m) return null; let depth = 1; const re = new RegExp(`<${tag}\\b[^>]*>|`, "gi"); re.lastIndex = open.lastIndex; let mm; while ((mm = re.exec(html)) !== null) { if (mm[0].startsWith(" entries inside an HTML fragment containing list items. */ function listItems(ulInner) { const items = []; let pos = 0; for (;;) { const li = innerOf(ulInner, "li", pos); if (!li) break; items.push(li.inner); pos = li.end; } return items; } /** * FAQ entries, taken ONLY from the visitor-visible troubleshooting list. * * The question is the page's own list label, verbatim (minus the trailing list * punctuation); the answer is the page's own explanation, verbatim. Nothing is * rephrased, and no question is synthesised — if the label is not visible on the * page, it does not get marked up. Pages whose troubleshooting entry has no bold * label are skipped. */ function extractFaqs(html) { const h = html.indexOf("

Troubleshooting

"); if (h === -1) return []; const ul = innerOf(html, "ul", h); if (!ul) return []; const faqs = []; for (const li of listItems(ul.inner)) { // Label: answer const m = /([\s\S]*?)<\/strong>([\s\S]*)/.exec(li); if (!m) continue; const q = text(m[1]).replace(/[:\s]+$/, ""); const a = text(m[2]); if (!q || !a) continue; faqs.push({ question: q, answer: a }); } return faqs; } function extractSteps(html, appName) { const h = html.indexOf("

Install via the DashCaddy dashboard

"); if (h === -1) return []; const ol = innerOf(html, "ol", h); if (!ol) return []; const steps = listItems(ol.inner) .map((li) => text(li)) .filter((s) => s.length > 0); return steps.length >= 3 ? steps : []; } function extractMeta(html) { const title = /([\s\S]*?)<\/title>/.exec(html); let appName = title ? text(title[1]) : ""; appName = appName.replace(/\s*[—–-]\s*DashCaddy Docs\s*$/i, "").replace(/^Install\s+/i, "").trim(); let description = ""; const intro = /<p class="mt-4 max-w-3xl[^"]*">([\s\S]*?)<\/p>/.exec(html); if (intro) description = text(intro[1]); const cat = /Category:\s*([^<]+)</.exec(html); const diff = /Difficulty:\s*([^<]+)</.exec(html); const img = /Docker image:[\s\S]*?<code[^>]*>([\s\S]*?)<\/code>/.exec(html); return { appName, description, category: cat ? text(cat[1]) : null, difficulty: diff ? text(diff[1]) : null, dockerImage: img ? text(img[1]) : null, }; } /* ------------------------------------------------------------ blog posts --- */ /** * Blog FAQ entries, taken only from a real, visible "Frequently asked questions" * section: each `### question` heading plus the paragraphs that follow it, both * verbatim. A post with no such section (or fewer than two entries) gets no * FAQPage node. */ function extractBlogFaqs(html) { const m = /<h2>(?:Frequently asked questions|FAQ)<\/h2>/i.exec(html); if (!m) return []; const rest = html.slice(m.index + m[0].length); let end = rest.length; for (const marker of ["<h2", "<footer", "<nav", "<aside", "<script", "<main"]) { const i = rest.indexOf(marker); if (i !== -1 && i < end) end = i; } const region = rest.slice(0, end); const faqs = []; const parts = region.split(/<h3>([\s\S]*?)<\/h3>/); for (let i = 1; i < parts.length; i += 2) { const q = text(parts[i]); const a = text(parts[i + 1] || ""); if (q && a) faqs.push({ question: q, answer: a }); } // Fallback shape: "**Question?** answer" rendered as <p><strong>…</strong>…</p>. if (faqs.length < 2) { const re = /<p><strong>([\s\S]*?)<\/strong>([\s\S]*?)<\/p>/g; let m2; while ((m2 = re.exec(region)) !== null) { const q = text(m2[1]); const a = text(m2[2]); if (q && q.endsWith("?") && a) faqs.push({ question: q, answer: a }); } } return faqs.length >= 2 ? faqs : []; } function buildBlogGraph(slug, html, faqs) { const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/.exec(html); const title = h1 ? text(h1[1]) : slug; const page = `${ORIGIN}/blog/${slug}/`; const graph = [ { "@type": "BreadcrumbList", "@id": `${page}#breadcrumb`, itemListElement: [ { "@type": "ListItem", position: 1, name: "DashCaddy", item: `${ORIGIN}/` }, { "@type": "ListItem", position: 2, name: "Blog", item: `${ORIGIN}/blog/` }, { "@type": "ListItem", position: 3, name: title, item: page }, ], }, ]; if (faqs.length >= 2) { graph.push({ "@type": "FAQPage", "@id": `${page}#faq`, mainEntity: faqs.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer }, })), }); } return { "@context": "https://schema.org", "@graph": graph }; } /* ------------------------------------------------------------------ build --- */ function buildGraph(appId, meta, faqs, steps) { const page = `${ORIGIN}/docs/catalog/${appId}/`; const graph = []; graph.push({ "@type": "BreadcrumbList", "@id": `${page}#breadcrumb`, itemListElement: [ { "@type": "ListItem", position: 1, name: "DashCaddy", item: `${ORIGIN}/` }, { "@type": "ListItem", position: 2, name: "Documentation", item: `${ORIGIN}/docs/` }, { "@type": "ListItem", position: 3, name: "App Catalog", item: `${ORIGIN}/docs/catalog/` }, { "@type": "ListItem", position: 4, name: `Install ${meta.appName}`, item: page }, ], }); const app = { "@type": "SoftwareApplication", "@id": `${page}#software`, name: meta.appName, url: page, operatingSystem: "Linux", applicationCategory: meta.category || "UtilitiesApplication", }; if (meta.description) app.description = meta.description; if (meta.dockerImage) app.softwareRequirements = `Docker image ${meta.dockerImage}`; graph.push(app); if (faqs.length >= 2) { graph.push({ "@type": "FAQPage", "@id": `${page}#faq`, mainEntity: faqs.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answer }, })), }); } if (steps.length >= 3) { graph.push({ "@type": "HowTo", "@id": `${page}#howto`, name: `Install ${meta.appName} with DashCaddy`, description: `Deploy ${meta.appName} on a DashCaddy host from the dashboard.`, step: steps.map((s, i) => ({ "@type": "HowToStep", position: i + 1, name: `Step ${i + 1}`, text: s, })), }); } return { "@context": "https://schema.org", "@graph": graph }; } /* ------------------------------------------------------------------- main --- */ function main() { if (!statSync(OUT, { throwIfNoEntry: false })) { console.error("inject-structured-data: out/ not found — run next build first"); process.exit(1); } const appIds = readdirSync(CATALOG).filter((d) => { try { return statSync(join(CATALOG, d)).isDirectory(); } catch { return false; } }); if (appIds.length < 50) { console.error(`inject-structured-data: only ${appIds.length} app dirs found; refusing`); process.exit(1); } const report = { pages: 0, injected: 0, skipped: 0, faq: 0, howto: 0, image: 0, missing: [], ungrounded: 0 }; for (const appId of appIds) { const file = join(CATALOG, appId, "index.html"); if (!statSync(file, { throwIfNoEntry: false })) { report.missing.push(`${appId}: no index.html`); continue; } report.pages += 1; let html = readFileSync(file, "utf8"); if (html.includes(MARKER)) { report.skipped += 1; continue; } const meta = extractMeta(html); const vis = visibleText(html); const faqs = extractFaqs(html).filter((f) => { if (vis.includes(f.question) && vis.includes(f.answer)) return true; report.ungrounded += 1; return false; }); const steps = extractSteps(html, meta.appName).filter((s) => { if (vis.includes(s)) return true; report.ungrounded += 1; return false; }); if (!meta.appName) { report.missing.push(`${appId}: no app name`); continue; } const gaps = []; if (!meta.category) gaps.push("category"); if (!meta.difficulty) gaps.push("difficulty"); if (!meta.dockerImage) gaps.push("dockerImage"); if (gaps.length) report.missing.push(`${appId}: ${gaps.join(",")}`); if (faqs.length >= 2) report.faq += 1; if (steps.length >= 3) report.howto += 1; if (meta.dockerImage) report.image += 1; const json = JSON.stringify(buildGraph(appId, meta, faqs, steps)) .replace(/</g, "\\u003c") .replace(/>/g, "\\u003e") .replace(/&/g, "\\u0026"); const block = `<script type="application/ld+json" ${MARKER}>${json}</script>\n`; if (!html.includes("</head>")) { report.missing.push(`${appId}: no </head>`); continue; } html = html.replace("</head>", `${block}</head>`); writeFileSync(file, html); report.injected += 1; } console.log("inject-structured-data: app guide pages"); console.log(` pages_seen=${report.pages}`); console.log(` pages_injected=${report.injected}`); console.log(` pages_skipped_already=${report.skipped}`); console.log(` pages_with_faqpage=${report.faq}`); console.log(` pages_with_howto=${report.howto}`); console.log(` pages_with_docker_image=${report.image}`); console.log(` pages_with_gaps=${report.missing.length}`); console.log(` ungrounded_strings_dropped=${report.ungrounded}`); for (const m of report.missing) console.log(` gap: ${m}`); if (report.injected + report.skipped !== report.pages) { console.error("inject-structured-data: not every page was injected or skipped"); process.exit(1); } injectBlogPosts(); } /* ------------------------------------------------------------- blog pass --- */ function injectBlogPosts() { const blogDir = join(OUT, "blog"); if (!statSync(blogDir, { throwIfNoEntry: false })) { console.log("inject-structured-data: no out/blog dir — skipping blog pass"); return; } const slugs = readdirSync(blogDir).filter((d) => { try { return statSync(join(blogDir, d)).isDirectory(); } catch { return false; } }); let seen = 0; let injected = 0; let skipped = 0; let withFaq = 0; let questions = 0; let ungrounded = 0; const gaps = []; for (const slug of slugs) { const file = join(blogDir, slug, "index.html"); if (!statSync(file, { throwIfNoEntry: false })) { gaps.push(`${slug}: no index.html`); continue; } seen += 1; let html = readFileSync(file, "utf8"); if (html.includes(BLOG_MARKER)) { skipped += 1; continue; } const vis = visibleText(html); const faqs = extractBlogFaqs(html).filter((f) => { if (vis.includes(f.question) && vis.includes(f.answer)) return true; ungrounded += 1; return false; }); if (faqs.length >= 2) { withFaq += 1; questions += faqs.length; } else { gaps.push(`${slug}: no visible FAQ section`); } const json = JSON.stringify(buildBlogGraph(slug, html, faqs)) .replace(/</g, "\\u003c") .replace(/>/g, "\\u003e") .replace(/&/g, "\\u0026"); const block = `<script type="application/ld+json" ${BLOG_MARKER}>${json}</script>\n`; if (!html.includes("</head>")) { gaps.push(`${slug}: no </head>`); continue; } writeFileSync(file, html.replace("</head>", `${block}</head>`)); injected += 1; } console.log("inject-structured-data: blog posts"); console.log(` posts_seen=${seen}`); console.log(` posts_injected=${injected}`); console.log(` posts_skipped_already=${skipped}`); console.log(` posts_with_faqpage=${withFaq}`); console.log(` faq_questions=${questions}`); console.log(` ungrounded_strings_dropped=${ungrounded}`); console.log(` notes=${gaps.length}`); for (const g of gaps) console.log(` note: ${g}`); } main();