492 lines
15 KiB
JavaScript
492 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* inject-structured-data.mjs
|
|
*
|
|
* Adds JSON-LD structured data to every built DashCaddy app-guide page
|
|
* (out/docs/catalog/<app>/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(/<br\s*\/?>/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(/<script\b[\s\S]*?<\/script>/gi, " ")
|
|
.replace(/<style\b[\s\S]*?<\/style>/gi, " "),
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------- extraction --- */
|
|
|
|
/** Return the inner HTML of the first <tag ...> ... </tag> 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[^>]*>|</${tag}>`, "gi");
|
|
re.lastIndex = open.lastIndex;
|
|
let mm;
|
|
while ((mm = re.exec(html)) !== null) {
|
|
if (mm[0].startsWith("</")) depth -= 1;
|
|
else depth += 1;
|
|
if (depth === 0) {
|
|
return { inner: html.slice(open.lastIndex, mm.index), end: re.lastIndex };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** All top-level <li> 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("<h2>Troubleshooting</h2>");
|
|
if (h === -1) return [];
|
|
const ul = innerOf(html, "ul", h);
|
|
if (!ul) return [];
|
|
const faqs = [];
|
|
for (const li of listItems(ul.inner)) {
|
|
// <strong>Label:</strong> answer
|
|
const m = /<strong>([\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("<h2>Install via the DashCaddy dashboard</h2>");
|
|
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 = /<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(); |