// Build the static bundle into out/ (deploy target: Cloudflare Pages). // // 1. generate public/config.json (env or mock) // 2. STATIC_EXPORT=1 next build -> out/ (no API routes exist; the mock is // a standalone dev-only server, so the export is clean) // 3. lay down clean-path files: every non-param route becomes a real // /index.html so any static host serves the clean URL directly // 4. the English home page at the bundle root (it IS "/"), /en retired // 5. emit out/_redirects (Cloudflare Pages 200-rewrites for the param URLs — // the only routes that can't exist as pre-built files) plus // out/_routing-spec.json, the host-agnostic form of the same list // // `next dev` is untouched: the mock + rewrites still power local testing. import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { routes, isParam, isIndexable } from "./routes.mjs"; const OUT = "out"; function copyInto(srcFile, urlPath) { const destDir = path.join(OUT, urlPath); fs.mkdirSync(destDir, { recursive: true }); fs.copyFileSync(srcFile, path.join(destDir, "index.html")); } // 1. config.json execSync("node scripts/build-config.mjs", { stdio: "inherit" }); // 2. export execSync("next build", { stdio: "inherit", env: { ...process.env, STATIC_EXPORT: "1" }, }); // 3. clean-path files for every non-param route let laid = 0; const paramRoutes = []; for (const r of routes) { if (isParam(r.url)) { paramRoutes.push(r); continue; } if (r.next) continue; // Next already exported it at its path const srcFile = path.join(OUT, r.file); // e.g. out/landing/about/index.html if (!fs.existsSync(srcFile)) { console.warn(` ! missing built file for ${r.url}: ${r.file}`); continue; } copyInto(srcFile, r.url); laid++; } console.log(`laid down ${laid} clean-path files`); // 4. root 404: Cloudflare Pages serves out/404.html (with a 404 status) for // any URL that matches no file. The page itself is built by build-pages // like every other (nav, footer, GA, locale mirror at /ja/404); this copy // replaces the bare default that `next build` exports. fs.copyFileSync(path.join(OUT, "landing/404/index.html"), path.join(OUT, "404.html")); console.log("wrote 404.html (from /landing/404/)"); // 4b. sitemap.xml — the indexable page list (public/robots.txt points here). // Same origin as the canonical tags build-pages.mjs writes. // // Two things this file has to get right or it works against us: // - every URL carries a TRAILING SLASH, because that is what the host // serves. The export is built with `trailingSlash: true`, so /displays // 308-redirects to /displays/, and a sitemap full of redirecting URLs // is reported back as "Page with redirect" and indexes nothing. Same // rule as seoUrl() in build-pages.mjs, which writes the canonicals. // - every entry declares its locale counterparts with xhtml:link, matching // the hreflang tags in the page head. Google wants the pairing in both // places and treats a one-sided declaration as unconfirmed. // // Deliberately no : every build rewrites every file, so a mtime // would claim "everything changed" on each deploy. Google discards a // lastmod it cannot trust, and an absent one costs nothing. const SITE_ORIGIN = "https://bigscreenvr.com"; // canonical host: bare apex (Max + Brandon 2026-08-27); www 301s to it const withSlash = (u) => (u.endsWith("/") ? u : `${u}/`); const indexable = new Set(routes.filter((r) => isIndexable(r.url)).map((r) => r.url)); // The English home serves at the bare root and its Japanese twin at /ja; every // other page keeps its path and gains a /ja prefix. const jaOf = (en) => (en === "/" ? "/ja" : `/ja${en}`); const sitemapEntries = []; for (const en of [...indexable].filter((u) => !u.startsWith("/ja")).sort()) { const ja = indexable.has(jaOf(en)) ? jaOf(en) : null; const links = [["en", en], ...(ja ? [["ja", ja]] : []), ["x-default", en]] .map( ([hl, u]) => `\n `, ) .join(""); for (const u of ja ? [en, ja] : [en]) sitemapEntries.push(` ${SITE_ORIGIN}${withSlash(u)}${links}\n `); } fs.writeFileSync( path.join(OUT, "sitemap.xml"), '\n' + '\n' + sitemapEntries.join("\n") + "\n\n", ); console.log(`wrote sitemap.xml (${sitemapEntries.length} URLs)`); // 5. the home page IS the root. Step 3 has already laid down out/index.html // from the built English mirror, so the bare domain serves the real page // with a 200 instead of the meta-refresh stub it used to carry. That stub // was a 1.7 KB document returning 200, which Google indexed as a page in // its own right and ranked ahead of the real home page under a title it // invented; every inbound link and every nav logo points at "/" too. // // What is left is /en, which Next still exports from src/app/[locale] and // which must not shadow the 301 written into _redirects below. Its /en/v2 // sibling (the redesign scaffold) is left alone — it is in neither routes // nor the sitemap. if (!fs.existsSync(path.join(OUT, "index.html"))) throw new Error('out/index.html is missing — the "/" route should have laid it down in step 3'); for (const f of ["en/index.html", "en/index.txt"]) { const abs = path.join(OUT, f); if (fs.existsSync(abs)) fs.rmSync(abs); } console.log("home page serves at / (200); /en cleared for its 301"); // 6. routing for the param URLs (they can't be pre-generated per value, so the // host must map each pattern to its file) const spec = paramRoutes.map((r) => ({ urlPattern: r.url, serveFile: r.next ? `${r.file}/index.html` : r.file, })); fs.writeFileSync( path.join(OUT, "_routing-spec.json"), JSON.stringify(spec, null, 2) + "\n", ); // Cloudflare Pages consumes these as _redirects 200-rewrites. Trailing param // segments become a splat (/purchase/:id/:url -> /purchase/*); interior params // stay as Pages placeholders (/account/order/:id/start). Destinations MUST be // the clean directory path, not .../index.html — Pages 308-normalizes // index.html URLs, which would rewrite the browser URL and lose the :token. // The old English home. Both forms are listed because Pages 308-normalizes a // bare path to its slashed form when the directory exists, and out/en/ still // does (it holds /en/v2) — without the second line a visitor would take two // hops. 301, not a rewrite: the URL must actually retire. const LEGACY_REDIRECTS = ["/en / 301", "/en/ / 301"]; const redirects = spec .map((s) => { const src = s.urlPattern.replace(/(\/:[^/]+)+$/, "/*"); const dest = s.serveFile.replace(/index\.html$/, ""); return `${src} ${dest} 200`; }) .join("\n"); fs.writeFileSync( path.join(OUT, "_redirects"), [...LEGACY_REDIRECTS, redirects].join("\n") + "\n", ); console.log( `\n${spec.length} dynamic-URL patterns written to out/_redirects ` + `(Cloudflare Pages) and out/_routing-spec.json (host-agnostic):`, ); for (const s of spec) console.log(` ${s.urlPattern} -> ${s.serveFile}`); console.log("\nout/ is ready. Preview: npm run preview | Deploy: npm run deploy");