// Build-time per-route social-share prerendering. // // WHY: artcraft-website is a Vite SPA. The single dist/index.html that every // route falls back to carries only homepage /<description> and no Open // Graph tags, so when any URL (e.g. /seedance-2) is shared on Discord/Slack/X, // the crawler — which does NOT run JS — sees the homepage preview. // // WHAT: after `nx build`, this reads dist/index.html and, for every route in // src/config/page-meta.ts, writes dist/<route>/index.html with that route's // <title>, <meta description>, Open Graph and Twitter Card tags injected into // <head>. It also rewrites the root dist/index.html with the default OG tags. // _redirects then points each route at its prerendered file so the crawler // gets the right tags; the SPA still boots from that HTML and React Router // takes over for users. // // HOW IT STAYS DRY: page metadata lives only in page-meta.ts. This script // transpiles that TS module on the fly (via the installed `typescript` // compiler — no extra dependency) and imports it, so titles/descriptions are // never duplicated here. import fs from "fs"; import os from "os"; import path from "path"; import { fileURLToPath, pathToFileURL } from "url"; import ts from "typescript"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const APP_DIR = path.resolve(__dirname, "../apps/artcraft-website"); const DIST_DIR = path.join(APP_DIR, "dist"); const ROOT_HTML = path.join(DIST_DIR, "index.html"); const META_TS = path.join(APP_DIR, "src/config/page-meta.ts"); const main = async () => { if (!fs.existsSync(ROOT_HTML)) { console.error(`prerender-meta: ${ROOT_HTML} not found — run the build first.`); process.exit(1); } const { PAGE_META, DEFAULT_META, DEFAULT_OG_IMAGE, getPageMeta, toAbsoluteUrl } = await importTsModule(META_TS); const baseHtml = fs.readFileSync(ROOT_HTML, "utf8"); // Rewrite the root index.html (the "/" route and SPA fallback) so the // homepage and any non-prerendered route at least carry default OG tags. const homeMeta = getPageMeta("/"); fs.writeFileSync(ROOT_HTML, injectMeta(baseHtml, homeMeta, "/", toAbsoluteUrl, DEFAULT_OG_IMAGE), "utf8"); console.log("prerender-meta: rewrote / (root index.html)"); // Emit dist/<route>/index.html for every configured route except "/". for (const route of Object.keys(PAGE_META)) { if (route === "/") continue; const meta = getPageMeta(route); const outDir = path.join(DIST_DIR, route.replace(/^\//, "")); fs.mkdirSync(outDir, { recursive: true }); const outFile = path.join(outDir, "index.html"); fs.writeFileSync(outFile, injectMeta(baseHtml, meta, route, toAbsoluteUrl, DEFAULT_OG_IMAGE), "utf8"); console.log(`prerender-meta: wrote ${route} -> ${path.relative(DIST_DIR, outFile)}`); } // Sanity: DEFAULT_META.title should still be the value baked into root. if (!DEFAULT_META.title) { console.warn("prerender-meta: DEFAULT_META.title is empty — check page-meta.ts"); } }; // Replace the homepage <title>/<meta description> in the source HTML and inject // a full Open Graph + Twitter Card block for the given route. Idempotent: an // existing injected block (between the marker comments) is replaced, so running // twice doesn't stack duplicate tags. const injectMeta = (html, meta, route, toAbsoluteUrl, defaultImage) => { const url = toAbsoluteUrl(route === "/" ? "/" : route); const image = toAbsoluteUrl(meta.ogImage ?? defaultImage); // Share card title may be a shorter override; <title> stays the full SEO one. const ogTitle = meta.ogTitle ?? meta.title; let out = html; // Swap the existing <title> and <meta name="description">. out = out.replace(/<title>[\s\S]*?<\/title>/i, `<title>${esc(meta.title)}`); out = out.replace( //i, ``, ); const block = [ "", ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, "", ].join("\n "); // Replace a previously-injected block if present (idempotent), else insert // right before . const existing = /[\s\S]*?/; if (existing.test(out)) { out = out.replace(existing, block); } else { out = out.replace(/<\/head>/i, ` ${block}\n `); } return out; }; // Minimal HTML-attribute escaping for values placed inside double-quoted // attributes / element text. const esc = (s) => String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); // Transpile a TS module to ESM via the installed `typescript` compiler and // dynamic-import it. Avoids adding tsx/esbuild just for the build step. const importTsModule = async (tsPath) => { const source = fs.readFileSync(tsPath, "utf8"); const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020, }, fileName: tsPath, }); const tmpFile = path.join( fs.mkdtempSync(path.join(os.tmpdir(), "prerender-meta-")), "page-meta.mjs", ); fs.writeFileSync(tmpFile, outputText, "utf8"); try { return await import(pathToFileURL(tmpFile).href); } finally { fs.rmSync(path.dirname(tmpFile), { recursive: true, force: true }); } }; main().catch((err) => { console.error("prerender-meta failed:", err); process.exit(1); });