wilddragon-site/build-pages.mjs

203 lines
17 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// Renders crawlable static pages from services-data.js + projects-data.js:
// /services/index.html, /services/<slug>/index.html, /projects/index.html,
// /projects/<slug>/index.html, plus sitemap.xml. Node stdlib only.
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const ROOT = path.dirname(new URL(import.meta.url).pathname);
const SITE = "https://wilddragon.net";
const TODAY = new Date().toISOString().slice(0, 10);
const win = { __R: (p) => "/" + p };
vm.runInNewContext(fs.readFileSync(path.join(ROOT, "projects-data.js"), "utf8"), { window: win, __R: win.__R });
vm.runInNewContext(fs.readFileSync(path.join(ROOT, "services-data.js"), "utf8"), { window: win });
const { PROJECTS, SERVICE_PAGES, SERVICE_AREAS, LOCATION_PAGES } = win;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const abs = (p) => (p.startsWith("http") ? p : SITE + (p.startsWith("/") ? p : "/" + p));
const write = (rel, html) => {
const f = path.join(ROOT, rel);
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, html);
};
const NAV = `<header class="sp-nav"><div class="container sp-nav-inner">
<a class="sp-brand" href="/"><img src="/images/dragon-mark.png" alt="" width="28" height="28"/><span>Wild Dragon</span></a>
<nav aria-label="Primary"><a href="/services/">Services</a><a href="/projects/">Projects</a><a href="/#about">About</a><a href="mailto:zgaetano@wilddragon.net">Contact</a></nav>
</div></header>`;
const FOOTER = `<footer class="footer"><div class="container">
<div class="footer-top">
<div><div class="footer-brand"><img src="/images/dragon-mark.png" alt="Wild Dragon"/><span>Wild Dragon</span></div>
<p class="about-line">Broadcast systems and commercial AV design, integration, and commissioning — Washington, DC and the DMV.</p></div>
<div class="footer-col"><h4>Services</h4><ul>${SERVICE_PAGES.map((s) => `<li><a href="/services/${s.slug}/">${esc(s.title)}</a></li>`).join("")}</ul></div>
<div class="footer-col"><h4>Service Areas</h4><ul>${LOCATION_PAGES.map((l) => `<li><a href="/${l.slug}/">${esc(l.title)}</a></li>`).join("")}</ul></div>
<div class="footer-col"><h4>Projects</h4><ul>${PROJECTS.map((p) => `<li><a href="/projects/${p.slug}/">${esc(p.client)}</a></li>`).join("")}</ul></div>
</div>
<div class="footer-bottom"><span>© ${new Date().getFullYear()} Zachary Gaetano · Wild Dragon</span><span>Washington, DC</span></div>
</div></footer>`;
// ponytail: page-local CSS instead of touching styles.css — the SPA never renders these pages.
const STYLE = `<style>
.sp-nav{background:var(--color-primary);padding:1.1rem 0}.sp-nav-inner{display:flex;align-items:center;justify-content:space-between}
.sp-brand{display:flex;align-items:center;gap:.6rem;color:#fff;font-family:var(--font-mono);font-size:.8rem;letter-spacing:.18em;text-transform:uppercase}.sp-brand img{filter:brightness(0) invert(1)}
.sp-nav nav{display:flex;gap:1.5rem}.sp-nav nav a{color:rgba(255,255,255,.75);font-size:.8rem;font-weight:500}.sp-nav nav a:hover{color:var(--color-accent)}
.sp-hero{background:var(--color-primary);color:#fff;padding:4.5rem 0 4rem;position:relative;overflow:hidden}
.sp-hero img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.22}.sp-hero .container{position:relative}
.sp-hero h1{font-family:var(--font-display);font-size:clamp(2rem,4.4vw,3.25rem);font-weight:600;line-height:1.05;letter-spacing:-.02em;margin:0 0 1.25rem;max-width:52rem}
.sp-hero .lede{color:rgba(255,255,255,.72);font-size:1.125rem;line-height:1.7;max-width:44rem}
.sp-main{padding:4rem 0 5rem}.sp-prose{max-width:52rem;color:var(--color-muted);line-height:1.8;font-size:1.0625rem}.sp-prose p{margin:0 0 1.25rem}
.sp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));gap:1.25rem;margin:2.5rem 0}
.sp-card{border:1px solid var(--neutral-200,#e5e5e5);border-radius:.75rem;padding:1.5rem}.sp-card h3{margin:0 0 .5rem;font-size:1rem}.sp-card p{margin:0;color:var(--color-muted);font-size:.95rem;line-height:1.6}
.sp-h2{font-family:var(--font-display);font-size:1.6rem;font-weight:600;letter-spacing:-.01em;margin:3rem 0 1rem}
.sp-faq dt{font-weight:600;margin-top:1.25rem}.sp-faq dd{margin:.35rem 0 0;color:var(--color-muted);line-height:1.7}
.sp-tags{display:flex;flex-wrap:wrap;gap:.5rem;margin:1rem 0}.sp-tags span{font-family:var(--font-mono);font-size:.7rem;letter-spacing:.08em;border:1px solid var(--neutral-200,#e5e5e5);border-radius:999px;padding:.3rem .7rem;color:var(--color-muted)}
.sp-areas{color:var(--color-muted);font-size:.95rem}.sp-cta{margin-top:3rem}
ul.sp-list{padding-left:1.25rem;color:var(--color-muted);line-height:1.8}
.sp-related{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}.sp-related a{display:block;border:1px solid var(--neutral-200,#e5e5e5);border-radius:.75rem;padding:1.25rem;color:var(--color-primary)}.sp-related a:hover{border-color:var(--color-accent)}
.sp-related small{display:block;color:var(--color-muted);margin-top:.35rem}
@media(max-width:640px){.sp-nav nav{gap:1rem}.sp-nav nav a:nth-child(3){display:none}}
</style>`;
function page({ url, title, description, image, ld, body, breadcrumb }) {
const crumbs = { "@type": "BreadcrumbList", itemListElement: breadcrumb.map(([name, item], i) => ({ "@type": "ListItem", position: i + 1, name, item: abs(item) })) };
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${esc(title)}</title>
<meta name="description" content="${esc(description)}" />
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large" />
<meta name="geo.region" content="US-DC" /><meta name="geo.placename" content="Washington, DC" />
<link rel="canonical" href="${abs(url)}" />
<meta property="og:type" content="website" /><meta property="og:site_name" content="Wild Dragon" /><meta property="og:locale" content="en_US" />
<meta property="og:url" content="${abs(url)}" /><meta property="og:title" content="${esc(title)}" />
<meta property="og:description" content="${esc(description)}" /><meta property="og:image" content="${abs(image)}" />
<meta name="twitter:card" content="summary_large_image" /><meta name="twitter:title" content="${esc(title)}" /><meta name="twitter:description" content="${esc(description)}" /><meta name="twitter:image" content="${abs(image)}" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" href="/images/dragon-mark.png" type="image/png" />
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<style>:root{--font-inter:"Inter";--font-mono-stack:"JetBrains Mono"}</style>
${STYLE}
<script type="application/ld+json">${JSON.stringify({ "@context": "https://schema.org", "@graph": [...ld, crumbs] })}</script>
</head>
<body>
${NAV}
${body}
${FOOTER}
</body>
</html>
`;
}
const BUSINESS_ID = SITE + "/#organization";
const projectCard = (p) => `<a href="/projects/${p.slug}/"><strong>${esc(p.client)}</strong><small>${esc(p.title)}</small></a>`;
// ── service pages ──────────────────────────────────────────────────────────
for (const s of SERVICE_PAGES) {
const url = `/services/${s.slug}/`;
const related = s.related.map((slug) => PROJECTS.find((p) => p.slug === slug)).filter(Boolean);
const ld = [
{ "@type": "Service", "@id": abs(url) + "#service", name: s.title, description: s.metaDescription, url: abs(url),
provider: { "@id": BUSINESS_ID }, areaServed: SERVICE_AREAS.map((n) => ({ "@type": "Place", name: n })), serviceType: s.title,
hasOfferCatalog: { "@type": "OfferCatalog", name: s.title, itemListElement: s.services.map(([n, d]) => ({ "@type": "Offer", itemOffered: { "@type": "Service", name: n, description: d } })) } },
{ "@type": "FAQPage", mainEntity: s.faq.map(([q, a]) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a } })) },
];
const body = `
<section class="sp-hero"><img src="/${s.hero}" alt="" loading="eager" fetchpriority="high"/><div class="container">
<div class="eyebrow">${esc(s.eyebrow)}</div><h1>${esc(s.title)}</h1><p class="lede">${esc(s.lede)}</p>
<p style="margin-top:2rem"><a class="btn btn-primary" href="mailto:zgaetano@wilddragon.net?subject=${encodeURIComponent(s.title)}">Request a consultation</a></p>
</div></section>
<main class="sp-main"><div class="container">
<div class="sp-prose">${s.body.map((p) => `<p>${esc(p)}</p>`).join("")}</div>
<h2 class="sp-h2">What's included</h2>
<div class="sp-grid">${s.services.map(([n, d]) => `<div class="sp-card"><h3>${esc(n)}</h3><p>${esc(d)}</p></div>`).join("")}</div>
<h2 class="sp-h2">Service area</h2>
<p class="sp-areas">${SERVICE_AREAS.map(esc).join(" · ")}</p>
<p class="sp-areas">${LOCATION_PAGES.map((l) => `<a href="/${l.slug}/">${esc(l.title)}</a>`).join(" · ")}</p>
<h2 class="sp-h2">Frequently asked questions</h2>
<dl class="sp-faq">${s.faq.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>
${related.length ? `<h2 class="sp-h2">Related projects</h2><div class="sp-related">${related.map(projectCard).join("")}</div>` : ""}
<div class="sp-cta"><a class="btn btn-primary" href="mailto:zgaetano@wilddragon.net?subject=${encodeURIComponent(s.title)}">Request a consultation</a></div>
</div></main>`;
write(url + "index.html", page({ url, title: s.metaTitle, description: s.metaDescription, image: s.hero, ld, body, breadcrumb: [["Home", "/"], ["Services", "/services/"], [s.title, url]] }));
}
// ── project pages ──────────────────────────────────────────────────────────
for (const p of PROJECTS) {
const url = `/projects/${p.slug}/`;
const desc = p.summary.length > 158 ? p.summary.slice(0, 155).replace(/\s+\S*$/, "") + "…" : p.summary;
const ld = [{ "@type": "Article", "@id": abs(url) + "#article", headline: p.title, description: p.summary, image: abs(p.thumbnail), url: abs(url),
author: { "@id": SITE + "/#person" }, publisher: { "@id": BUSINESS_ID }, about: { "@type": "Organization", name: p.client }, keywords: [p.category, ...(p.technologies || [])].join(", ") }];
const body = `
<section class="sp-hero"><img src="${p.thumbnail}" alt="${esc(p.title)}" loading="eager" fetchpriority="high"/><div class="container">
<div class="eyebrow">${esc(p.category)} · ${esc(p.year)}</div><h1>${esc(p.title)}</h1><p class="lede">${esc(p.summary)}</p>
</div></section>
<main class="sp-main"><div class="container">
<div class="sp-prose">${p.description.map((t) => `<p>${esc(t)}</p>`).join("")}</div>
${p.highlights ? `<h2 class="sp-h2">Highlights</h2><ul class="sp-list">${p.highlights.map((h) => `<li>${esc(h)}</li>`).join("")}</ul>` : ""}
${p.scope ? `<h2 class="sp-h2">Scope</h2><div class="sp-tags">${p.scope.map((t) => `<span>${esc(t)}</span>`).join("")}</div>` : ""}
${p.technologies ? `<h2 class="sp-h2">Technologies</h2><div class="sp-tags">${p.technologies.map((t) => `<span>${esc(t)}</span>`).join("")}</div>` : ""}
<div class="sp-cta"><a class="btn btn-primary" href="/#/projects/${p.slug}">Open interactive case study</a></div>
</div></main>`;
write(url + "index.html", page({ url, title: `${p.title} | Case Study | Wild Dragon`, description: desc, image: p.thumbnail, ld, body, breadcrumb: [["Home", "/"], ["Projects", "/projects/"], [p.title, url]] }));
}
// ── index pages ────────────────────────────────────────────────────────────
write("services/index.html", page({ url: "/services/", title: "AV & Broadcast Integration Services | Washington DC | Wild Dragon",
description: "Commercial AV installation, broadcast systems integration, LED video walls, and conference room AV — designed, installed, and commissioned in Washington, DC and the DMV.",
image: "images/wild-dragon-logo.jpg", ld: [], breadcrumb: [["Home", "/"], ["Services", "/services/"]],
body: `<section class="sp-hero"><div class="container"><div class="eyebrow">Services · Washington, DC</div><h1>AV &amp; broadcast integration services</h1><p class="lede">Design, installation, and commissioning for commercial AV and broadcast facilities across the DMV.</p></div></section>
<main class="sp-main"><div class="container"><div class="sp-related">${SERVICE_PAGES.map((s) => `<a href="/services/${s.slug}/"><strong>${esc(s.title)}</strong><small>${esc(s.lede)}</small></a>`).join("")}</div></div></main>` }));
write("projects/index.html", page({ url: "/projects/", title: "Broadcast & AV Integration Case Studies | Wild Dragon",
description: "Case studies: broadcast facilities, cloud production, XR stages, and AV integration for the Washington Commanders, BetMGM, CVS/Aetna, UBS, Monumental Sports, Intuit, and Red Sands.",
image: "images/photos/production-switcher.jpg", ld: [], breadcrumb: [["Home", "/"], ["Projects", "/projects/"]],
body: `<section class="sp-hero"><div class="container"><div class="eyebrow">Selected work</div><h1>Case studies</h1><p class="lede">Facilities designed, integrated, and commissioned by Wild Dragon.</p></div></section>
<main class="sp-main"><div class="container"><div class="sp-related">${PROJECTS.map(projectCard).join("")}</div></div></main>` }));
// ── location / keyword pages ───────────────────────────────────────────────
for (const l of LOCATION_PAGES) {
const url = `/${l.slug}/`;
const svcs = l.services.map((slug) => SERVICE_PAGES.find((s) => s.slug === slug)).filter(Boolean);
const related = l.related.map((slug) => PROJECTS.find((p) => p.slug === slug)).filter(Boolean);
const ld = [
{ "@type": "Service", "@id": abs(url) + "#service", name: l.title, description: l.metaDescription, url: abs(url),
provider: { "@id": BUSINESS_ID }, areaServed: SERVICE_AREAS.map((n) => ({ "@type": "Place", name: n })), serviceType: l.title },
{ "@type": "FAQPage", mainEntity: l.faq.map(([q, a]) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a } })) },
];
const body = `
<section class="sp-hero"><img src="/${l.hero}" alt="" loading="eager" fetchpriority="high"/><div class="container">
<div class="eyebrow">${esc(l.eyebrow)}</div><h1>${esc(l.title)}</h1><p class="lede">${esc(l.lede)}</p>
<p style="margin-top:2rem"><a class="btn btn-primary" href="mailto:zgaetano@wilddragon.net?subject=${encodeURIComponent(l.title)}">Request a consultation</a></p>
</div></section>
<main class="sp-main"><div class="container">
<div class="sp-prose">${l.body.map((p) => `<p>${esc(p)}</p>`).join("")}</div>
<h2 class="sp-h2">Services</h2>
<div class="sp-related">${svcs.map((s) => `<a href="/services/${s.slug}/"><strong>${esc(s.title)}</strong><small>${esc(s.lede)}</small></a>`).join("")}</div>
<h2 class="sp-h2">Service area</h2>
<p class="sp-areas">${SERVICE_AREAS.map(esc).join(" · ")}</p>
<h2 class="sp-h2">Frequently asked questions</h2>
${l.subpages ? `<p class="sp-areas">See also: ${l.subpages.map((sl) => LOCATION_PAGES.find((x) => x.slug === sl)).filter(Boolean).map((x) => `<a href="/${x.slug}/">${esc(x.title)}</a>`).join(" · ")}</p>` : ""}
<dl class="sp-faq">${l.faq.map(([q, a]) => `<dt>${esc(q)}</dt><dd>${esc(a)}</dd>`).join("")}</dl>
${related.length ? `<h2 class="sp-h2">Related projects</h2><div class="sp-related">${related.map(projectCard).join("")}</div>` : ""}
<div class="sp-cta"><a class="btn btn-primary" href="mailto:zgaetano@wilddragon.net?subject=${encodeURIComponent(l.title)}">Request a consultation</a></div>
</div></main>`;
write(url + "index.html", page({ url, title: l.metaTitle, description: l.metaDescription, image: l.hero, ld, body, breadcrumb: [["Home", "/"], [l.title, url]] }));
}
// ── sitemap ────────────────────────────────────────────────────────────────
const urls = [["/", "1.0"], ["/services/", "0.9"], ...SERVICE_PAGES.map((s) => [`/services/${s.slug}/`, "0.9"]), ...LOCATION_PAGES.map((l) => [`/${l.slug}/`, "0.9"]), ["/projects/", "0.7"], ...PROJECTS.map((p) => [`/projects/${p.slug}/`, "0.7"])];
write("sitemap.xml", `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(([u, pr]) => ` <url><loc>${abs(u)}</loc><lastmod>${TODAY}</lastmod><priority>${pr}</priority></url>`).join("\n")}
</urlset>
`);
console.log(`built ${SERVICE_PAGES.length} service + ${LOCATION_PAGES.length} location + ${PROJECTS.length} project pages, sitemap ${urls.length} urls`);