// Assemble the static pages from ONE shared chrome (nav + footer) + per-page bodies.
//
// partials: src/site/partials/
// nav.html — the site nav (pill nav + dropdowns + mobile overlay + JS)
// footer.html — the site footer (bs-footer)
// chrome.css — ALL nav/footer styling -> copied to public/landing/chrome.css
// head.html —
template for standard pages ({{TITLE}}/{{DESC}})
// pages: src/site/pages/.html
// home.html — full document with {{NAV}}/{{FOOTER}} markers (the /en mirror)
// others — body-only (s); wrapped in head/nav/main/footer
// output: public/landing/... (served via rewrites in next.config.mjs)
//
// EDIT THE SOURCES, NOT THE OUTPUT. public/landing/index.html and
// public/landing//index.html are generated by this script.
import fs from "node:fs";
import path from "node:path";
import { buildPressGallery } from "./build-press-gallery.mjs";
// Press gallery: markup generated from the actual files in
// public/landing/press/images/ (previews + truthful meta lines).
const gallery = await buildPressGallery();
const P = "src/site/partials";
const SRC = "src/site/pages";
const FAQ = "src/site/faqs";
const OUT = "public/landing";
const read = (f) => fs.readFileSync(f, "utf8");
/* ---- Blog badge ------------------------------------------------------------
* The nav's Blog chip shows how many posts went live on the Shopify blog in
* the last 60 days ("the last 2 months"). The store theme computes the same
* count live in Liquid on every request (sections/header.liquid — the nav
* twin); this side is static and the feed sends no CORS headers, so the count
* is read from the blog's public Atom feed at BUILD time and baked into the
* chrome. It refreshes on every rebuild, like everything else in the mirror.
* The last good count is committed in blog-badge.json so an offline build
* keeps the previous number instead of dropping the badge; zero posts renders
* no badge at all.
*/
const BADGE_FEED = "https://store.bigscreenvr.com/blogs/beyond.atom";
const BADGE_WINDOW_DAYS = 60;
const BADGE_CACHE = "src/site/blog-badge.json";
async function blogBadgeCount() {
const cached = fs.existsSync(BADGE_CACHE) ? JSON.parse(read(BADGE_CACHE)) : null;
try {
const res = await fetch(BADGE_FEED, { signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const xml = await res.text();
const cutoff = Date.now() - BADGE_WINDOW_DAYS * 86400e3;
// Entry publish dates only — the feed-level stamp never matches.
const count = [...xml.matchAll(/([^<]+)<\/published>/g)].filter(
(m) => Date.parse(m[1]) >= cutoff,
).length;
if (count !== cached?.count)
fs.writeFileSync(
BADGE_CACHE,
JSON.stringify({ count, fetchedAt: new Date().toISOString() }, null, 2) + "\n",
);
return count;
} catch (e) {
console.warn(
`blog badge: feed fetch failed (${e.message}); ` +
(cached ? `using cached count ${cached.count} from ${cached.fetchedAt}` : "rendering no badge"),
);
return cached ? cached.count : 0;
}
}
const badgeCount = await blogBadgeCount();
/* ---- Owner review stats ----------------------------------------------------
* The owner-reviews band on the homepage quotes the store's real numbers: the
* average, the review count, the star distribution and how many came from
* verified buyers. Those move every week, so nothing here is typed by hand —
* they are read from Yotpo (the store's review app) at BUILD time and baked in,
* exactly like the blog badge above. This is the same endpoint the widget on
* store.bigscreenvr.com calls, so the homepage and the product page can never
* disagree. Note the v3 storefront route, not the older /v1/widget one: v1
* reports only this product's own reviews (252) while the store shows the whole
* grouped total (283).
*
* The verified-buyer share needs the reviews themselves, so the pages are
* walked at 100 a time — three requests today, and it stops as soon as the
* pagination total is covered.
*
* Last good numbers are committed in owner-reviews.json so an offline build
* keeps the previous figures instead of shipping a section full of zeroes.
*/
const YOTPO_APP_KEY = "H7qaWLITLrW7sdzzaPLGD5p1zgJPhThD8JnjJ6iD"; // public storefront key, same one the store page embeds
const YOTPO_PRODUCT = "9130508157145"; // Bigscreen Beyond 2
const REVIEWS_CACHE = "src/site/owner-reviews.json";
async function ownerReviewStats() {
const cached = fs.existsSync(REVIEWS_CACHE) ? JSON.parse(read(REVIEWS_CACHE)) : null;
const url = (page) =>
`https://api-cdn.yotpo.com/v3/storefront/store/${YOTPO_APP_KEY}/product/${YOTPO_PRODUCT}/reviews?page=${page}&perPage=100`;
try {
let bottomline = null;
let total = 0;
let seen = 0;
let verified = 0;
for (let page = 1; page <= 20; page++) {
const res = await fetch(url(page), { signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (page === 1) {
bottomline = json.bottomline;
total = json.pagination?.total ?? 0;
}
const reviews = json.reviews ?? [];
if (!reviews.length) break;
seen += reviews.length;
verified += reviews.filter((r) => r.verifiedBuyer).length;
if (seen >= total) break;
}
const count = bottomline?.totalReview ?? 0;
const average = bottomline?.averageScore ?? 0;
if (!count) throw new Error("no reviews in response");
const dist = bottomline.starDistribution ?? {};
const stats = {
count,
average,
verified,
stars: Object.fromEntries([5, 4, 3, 2, 1].map((s) => [s, dist[s] ?? 0])),
fetchedAt: new Date().toISOString(),
};
const same =
cached &&
cached.count === stats.count &&
cached.verified === stats.verified &&
cached.average === stats.average &&
JSON.stringify(cached.stars) === JSON.stringify(stats.stars);
if (!same) fs.writeFileSync(REVIEWS_CACHE, JSON.stringify(stats, null, 2) + "\n");
return stats;
} catch (e) {
if (!cached) throw new Error(`owner reviews: fetch failed (${e.message}) and no cached figures to fall back on`);
console.warn(
`owner reviews: fetch failed (${e.message}); using cached figures (${cached.count} reviews) from ${cached.fetchedAt}`,
);
return cached;
}
}
const reviewStats = await ownerReviewStats();
// Bars are each star's share of the total, so the five widths read as one bar
// split five ways rather than five independent meters.
const reviewPct = (n) => `${((n / reviewStats.count) * 100).toFixed(1)}%`;
const starWord = { 5: "five", 4: "four", 3: "three", 2: "two", 1: "one" };
const reviewTokens = {
"{{RV_COUNT}}": String(reviewStats.count),
"{{RV_AVG}}": reviewStats.average.toFixed(1),
"{{RV_AVG_EXACT}}": reviewStats.average.toFixed(2),
"{{RV_VERIFIED}}": String(reviewStats.verified),
"{{RV_METER_ARIA}}":
"Star distribution: " +
[5, 4, 3, 2, 1]
.map((s, i) => `${i === 4 ? "and " : ""}${reviewStats.stars[s]} ${starWord[s]}-star`)
.join(", ")
.replace(", and ", " and ") +
` reviews. Average ${reviewStats.average.toFixed(2)} out of 5, ` +
`${reviewStats.verified} verified purchases.`,
...Object.fromEntries(
[5, 4, 3, 2, 1].flatMap((s) => [
[`{{RV_${s}}}`, String(reviewStats.stars[s])],
[`{{RV_${s}_PCT}}`, reviewPct(reviewStats.stars[s])],
]),
),
};
console.log(
`owner reviews: ${reviewStats.count} reviews, ${reviewStats.average.toFixed(2)} average, ${reviewStats.verified} verified`,
);
const badgeHtml =
badgeCount > 0
? `${badgeCount}`
: "";
const nav = read(path.join(P, "nav.html")).replace("{{BLOG_BADGE}}", badgeHtml);
const footer = read(path.join(P, "footer.html"));
const head = read(path.join(P, "head.html"));
const chooser = read(path.join(P, "model-chooser.html"));
const guideSupport = read(path.join(P, "guide-support.html"));
const prefooter = read(path.join(P, "prefooter-signal.html"));
const prefooterLight = read(path.join(P, "prefooter-light.html"));
const BANNER =
"";
/* ---- i18n ------------------------------------------------------------------
* English is the source and stays at the canonical path (/displays). Every
* other locale gets a prefixed mirror built from the SAME assembled HTML
* (/ja/displays), so a locale can never drift structurally from English — only
* its strings differ.
*
* Strings are swapped by their English value rather than by markup
* annotation. That is a deliberate trade: it means nav.html and footer.html
* stay byte-comparable with the store theme's header/footer, which are their
* twins and must be diffable by eye (see the store repo's CLAUDE.md). The cost
* is that the English in dictionaries/en.json must match the partial exactly —
* which the check below enforces at build time rather than leaving it to be
* discovered as a half-translated page in production.
*
* Only keys whose Japanese actually differs from the English are swapped, so an
* untranslated key renders English rather than blank. Chrome only for now; page
* bodies are the next tranche and will need a real extraction pass.
*/
const LOCALES = ["en", "ja"];
const DEFAULT_LOCALE = "en";
const dict = Object.fromEntries(
LOCALES.map((l) => [l, JSON.parse(read(`src/i18n/dictionaries/${l}.json`))]),
);
// [englishValue, translatedValue] for every chrome key that has a translation
function swapPairs(locale) {
const base = dict[DEFAULT_LOCALE];
const target = dict[locale];
const pairs = [];
for (const group of ["nav", "footer"]) {
for (const [key, en] of Object.entries(base[group] ?? {})) {
const tr = target[group]?.[key];
if (typeof en === "string" && typeof tr === "string" && tr !== en) pairs.push([en, tr]);
}
}
// Longest first: "About Us" must not be eaten by a shorter overlapping key.
return pairs.sort((a, b) => b[0].length - a[0].length);
}
/* Body copy harvested from the old Builder site's own shipped Japanese (plus a
* few marked drafts) — see the file's _note for provenance and the mispairing
* caveat. Same whole-element swap discipline as the chrome: a pair whose
* English has been rewritten simply stops matching, so stale pairs retire
* silently instead of mistranslating. */
const bodyPairs = {};
for (const locale of LOCALES) {
if (locale === DEFAULT_LOCALE) continue;
// harvested first (the old site's own shipped copy wins), drafts second
const merged = {};
for (const name of ["harvested-body", "drafted-body"]) {
const f = `src/i18n/${name}.${locale}.json`;
if (fs.existsSync(f)) {
const { pairs } = JSON.parse(read(f));
for (const [en, ja] of Object.entries(pairs)) if (!(en in merged)) merged[en] = ja;
}
}
// Compiled to whitespace-tolerant regexes: the same sentence often exists
// twice in a page (desktop + mobile variants) with different indentation, and
// an exact-string swap translated one copy and left its twin English. Each
// whitespace run in the key matches any whitespace run in the source; "&" is
// tried both literal and entity-escaped, since the sources mix the two.
// Longest first so a fragment can never eat part of a longer sentence.
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const WS = "(?:\\s|\\u00a0| )";
bodyPairs[locale] = Object.entries(merged)
.sort((a, b) => b[0].length - a[0].length)
.map(([en, ja]) => {
// edges trimmed and matched as OPTIONAL whitespace: the same sentence can
// sit flush against its tags in one copy and padded by a newline in the
// other, and a key captured from the padded copy must still match both
const core = en.replace(/^(?:\s|\u00a0| )+|(?:\s|\u00a0| )+$/g, "");
const alt = [core, core.replace(/&/g, "&"), core.replace(/&(?![a-z]+;)/g, "&")]
.filter((v, i, a) => a.indexOf(v) === i)
.map((v) => esc(v).replace(/(?:\s|\u00a0| )+/g, `${WS}+`))
.join("|");
return [new RegExp(`>${WS}*(?:${alt})${WS}*<`, "g"), `>${ja}<`];
});
}
/* Meta descriptions live in an ATTRIBUTE, and translate() below only ever
* swaps element text — deliberately, so it can never touch an href or a class
* name. The consequence was that every /ja page shipped an English meta
* description, i.e. an English snippet under a Japanese title in Google's
* results. This is the attribute-side dictionary for them: English
* description -> localized description, applied right after translate() and
* before the social tags are read back out of the document. Same retire-
* silently discipline as the body pairs: a key whose English has been
* rewritten stops matching and the page falls back to English. */
const metaDict = Object.fromEntries(
LOCALES.filter((l) => l !== DEFAULT_LOCALE).map((l) => {
const f = `src/i18n/meta.${l}.json`;
return [l, fs.existsSync(f) ? (JSON.parse(read(f)).descriptions ?? {}) : {}];
}),
);
function translateMeta(html, locale) {
const d = metaDict[locale];
if (!d) return html;
return html.replace(
/()/i,
(m, open, desc, close) => (d[desc] ? open + d[desc] + close : m),
);
}
function translate(html, locale) {
if (locale === DEFAULT_LOCALE) return html;
let out = html;
for (const [en, tr] of swapPairs(locale)) {
// element text only — never attributes, hrefs or class names
out = out.split(`>${en}<`).join(`>${tr}<`);
}
for (const [re, tr] of bodyPairs[locale] ?? []) {
out = out.replace(re, tr);
}
return out
.replace(/]*)\slang="[^"]*"/i, `]*\slang=)/i, `${en}<`)) missing.push(`${group}.${key} = ${en}`);
}
}
if (missing.length)
throw new Error(
"dictionaries/en.json no longer matches the chrome partials — these keys match nothing:\n " +
missing.join("\n ") +
"\nUpdate en.json (and ja.json) to the current nav/footer wording.",
);
}
// "index.html" -> "/", "displays/index.html" -> "/displays"
const outToUrl = (out) => "/" + out.replace(/index\.html$/, "").replace(/\/$/, "");
const localeUrl = (locale, url) =>
locale === DEFAULT_LOCALE ? url || "/" : `/${locale}${url === "/" ? "" : url}`;
/* Absolute URL for SEO tags (canonical / og:url / hreflang).
*
* Every path carries a TRAILING SLASH, because that is what the host serves:
* the static export is built with `trailingSlash: true`, so /displays
* 308-redirects to /displays/. A canonical, hreflang or sitemap entry written
* on the slashless form therefore points at a redirect, and Google discards a
* canonical whose target redirects and picks its own instead — which, together
* with the root being a meta-refresh stub, is how the bare domain came to be
* indexed under a Google-invented title. Keep this in step with
* scripts/build-static.mjs, which writes the sitemap from the same rule.
*
* English needs no special case: it serves at the bare root (routes.mjs), so
* localeUrl already returns "/" for it. */
const withSlash = (p) => (p.endsWith("/") ? p : `${p}/`);
const seoUrl = (locale, url) => SITE_ORIGIN + withSlash(localeUrl(locale, url));
const LOCALE_LABEL = { en: "English", ja: "日本語" };
/* Two locales, so two links rather than a dropdown: a select for a binary
* choice is more machinery than the choice needs, and plain anchors are
* crawlable and carry hreflang semantics for free. Lives in the legal bar
* because that is where a language control is conventionally looked for and
* because it must not compete with the one violet CTA above it. */
function langSwitch(currentLocale, url) {
const links = LOCALES.map((loc) => {
const href = localeUrl(loc, url);
const current = loc === currentLocale;
return (
`${LOCALE_LABEL[loc]}`
);
}).join("");
return ``;
}
// Every locale of a page declares every other, plus x-default on English —
// without these a search engine has no way to learn the pages are counterparts.
function hreflangTags(url) {
// Absolute URLs: Google requires fully-qualified hreflang hrefs and commonly
// ignores relative ones, which would break the EN/JA counterpart signal.
const tags = LOCALES.map(
(loc) => ``,
);
tags.push(
``,
);
return tags.join("\n");
}
/* On a localized page, internal links must stay inside the locale — without
* this, tapping Experiences from /ja lands on English /experiences and the
* visitor silently loses their language choice. Skipped: routes with no locale
* mirror (account + transactional, matching routes.mjs NO_MIRROR), real file
* paths, and anything external. Runs BEFORE the switcher is injected, so the
* switcher's own cross-locale hrefs are never rewritten. */
const NO_MIRROR_HREF =
/^\/(?:account|purchase|browser|scans?|bset|token2|email|email_verify|email_update|reset|forgot|api|landing)(?:[/?#]|$)/;
function localizeHrefs(html, locale) {
if (locale === DEFAULT_LOCALE) return html;
return html.replace(/href="(\/[^"]*)"/g, (m, u) => {
if (u === `/${locale}` || u.startsWith(`/${locale}/`)) return m;
if (NO_MIRROR_HREF.test(u)) return m;
if (/\.[a-z0-9]{2,5}(?:[?#]|$)/i.test(u)) return m; // real files keep their path
return `href="/${locale}${u === "/" ? "" : u}"`;
});
}
/* ---- Social embed cards (Open Graph / Twitter) ----------------------------
* Every page gets a card so a shared link unfurls with a title, description,
* and image on Discord/Slack/iMessage/X/LinkedIn. URLs are ABSOLUTE (the OG
* spec requires it; several unfurlers drop relative images). Images are
* 1200×630 JPGs from scripts/build-og-cards.mjs: pages default to the branded
* wordmark card; a page can override with `og: "/path.jpg"` in PAGES. Any
* og:/twitter: tags already in a page source are stripped first so this stays
* the single authority. */
/* Canonical host decision (Max + Brandon 2026-08-27, reversing the earlier
* www call): the BARE APEX, matching the team's historical preference. At
* cutover www gets a 301 to the apex (Cloudflare redirect rule). Two
* follow-ups this creates: Google's current index sits on www, so expect a
* consolidation period after the flip; and the Klaviyo onsite form's URL
* whitelist was scoped to www and must be updated to the apex. */
const SITE_ORIGIN = "https://bigscreenvr.com";
const OG_DEFAULT_IMAGE = "/landing/og/card-default.jpg";
const OG_LOCALE = { en: "en_US", ja: "ja_JP" };
/* ---- Google Analytics ------------------------------------------------------
* Same GA4 property the Builder-era site reported to (its tag lived in a
* Builder symbol; the rip kept only the comment). Injected into every emitted
* page here; the Next route tree carries the same ID in
* src/app/[locale]/layout.tsx — change both together. */
const GA_ID = "G-MRCR4SZ7NL";
const gaTag = [
``,
``,
].join("\n");
/* Ad pixels carried over from the Builder-era site's shell, same IDs (Max
* 2026-08-25: keep retargeting working through the cutover): Facebook, Reddit,
* Twitter/X, AdSense. Injected on every emitted page alongside gaTag; the Next
* tree mirrors these in src/app/[locale]/layout.tsx — change both together. */
const pixelTags = [
``,
``,
``,
``,
``,
].join("\n");
/* Klaviyo onsite (company VAD48N): renders the SAME newsletter signup popup
* the store runs, and Klaviyo's own display rules handle show-once-per-visitor
* (its cookie, its frequency caps — configured in the Klaviyo dashboard, one
* place for both sites). Injected on nav-bearing marketing pages only
* (p.active); account/transactional flows and the 404 never get a popup. */
const klaviyoTag =
``;
/* Title and description are read back out of the LOCALIZED document rather
* than taken from the English PAGES entry: p.title / p.desc are only the
* source of what is already in that document, so this is identical for /en
* and finally correct for /ja, whose og:title used to be English under a
* Japanese . */
function socialTags(p, html, url, locale) {
const unesc = (s) => s.replace(/&/g, "&");
const esc = (s) =>
s.replace(/&/g, "&").replace(/"/g, """).replace(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? p.title ?? "Bigscreen").trim(),
);
const desc = unesc(
(html.match(/`,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
``,
].join("\n");
}
/* ---- Structured data (JSON-LD) ---------------------------------------------
* Three graphs, each answering something Google asks for by name:
* - Organization (home only). The entity behind the site. Its `name` is a
* primary input to the site name Google prints above a result: with no
* Organization to read, Google guesses from the and prints
* "Bigscreen Beyond" where the company is called Bigscreen. Its `logo` is
* what puts the wordmark beside the result.
* - WebSite (home only). The site-name declaration proper.
* - BreadcrumbList (every other public page). Turns the bare
* "bigscreenvr.com > ..." ellipsis in a result into a readable trail.
*
* Nothing is asserted here that the page does not show, and deliberately no
* Offer and no aggregateRating: price and stock live on the store, and marked
* up claims a page cannot back are what earns a structured-data manual action.
*/
const ORG_ID = `${SITE_ORIGIN}/#organization`;
// The profiles the footer already links. sameAs is an identity claim, so it
// lists only accounts we actually run.
const ORG_SAME_AS = [
"https://x.com/bigscreenvr",
"https://youtube.com/bigscreenvr",
"https://www.facebook.com/bigscreenvr",
"https://www.reddit.com/r/bigscreenbeyond",
"https://discord.gg/bigscreenbeyond",
];
// The press kit's raster wordmark: Google wants a crawlable bitmap here, not
// an SVG, and reads it at its natural size.
const ORG_LOGO = "/landing/press/assets/bigscreen-logo-black.png";
const ldJson = (obj) =>
``;
function homeLd(locale) {
return [
ldJson({
"@context": "https://schema.org",
"@type": "Organization",
"@id": ORG_ID,
name: "Bigscreen",
alternateName: "Bigscreen VR",
url: `${SITE_ORIGIN}/`,
logo: {
"@type": "ImageObject",
url: SITE_ORIGIN + ORG_LOGO,
width: 2611,
height: 361,
},
foundingDate: "2014",
sameAs: ORG_SAME_AS,
}),
ldJson({
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${SITE_ORIGIN}/#website`,
name: "Bigscreen",
url: `${SITE_ORIGIN}/`,
inLanguage: locale === "ja" ? "ja-JP" : "en-US",
publisher: { "@id": ORG_ID },
}),
].join("\n");
}
/* A page's crumb is the part of its title before the " · " suffix
* ("Displays · Bigscreen Beyond" -> "Displays"), so the trail reads the way
* the page names itself. The leaf label comes from the already-localized
* document; an ancestor's English title is put through the same dictionary by
* wrapping it as element text, which is exactly how its own was
* translated. */
const crumbLabel = (title) => title.split(" · ")[0].split(" | ")[0].trim();
const crumbLocalized = (title, locale) =>
crumbLabel(translate(`>${title}<`, locale).slice(1, -1));
function breadcrumbLd(html, url, locale) {
const leaf = crumbLabel(
(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? "").trim(),
);
if (!leaf) return "";
const byUrl = new Map(PAGES.map((q) => [outToUrl(q.out), q]));
const segs = url.split("/").filter(Boolean);
const trail = [];
for (let i = 1; i < segs.length; i++) {
const ancestor = `/${segs.slice(0, i).join("/")}`;
const t = byUrl.get(ancestor)?.title;
if (t) trail.push([crumbLocalized(t, locale), seoUrl(locale, ancestor)]);
}
trail.push([leaf, seoUrl(locale, url)]);
return ldJson({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [["Bigscreen", seoUrl(locale, "/")], ...trail].map(
([name, item], i) => ({ "@type": "ListItem", position: i + 1, name, item }),
),
});
}
/* Skipped on the transactional surfaces (robots.txt keeps those out of the
* index anyway) and on the 404, where a breadcrumb would describe a page that
* is not there. `p.active` is the nav-highlight key, carried by exactly the
* pages that are part of the public site; the legal and setup-guide pages sit
* outside the nav and are named by their stylesheet instead. */
const structuredData = (p, html, url, locale) =>
url === "/"
? homeLd(locale)
: p.active || p.css === "legal" || p.css === "guide"
? breadcrumbLd(html, url, locale)
: "";
const stripSocialMeta = (html) =>
html.replace(/]*>\s*/g, "");
function emit(p, html) {
const url = outToUrl(p.out);
for (const locale of LOCALES) {
let localized = localizeHrefs(translateMeta(translate(html, locale), locale), locale)
.split("{{LANG_SWITCH}}")
.join(langSwitch(locale, url));
if (localized.includes("")) {
localized = stripSocialMeta(localized).replace(
"",
hreflangTags(url) + "\n" + socialTags(p, localized, url, locale) + "\n" +
structuredData(p, localized, url, locale) + "\n" + gaTag + "\n" + pixelTags +
(p.active ? "\n" + klaviyoTag : "") + "\n",
);
}
const rel = locale === DEFAULT_LOCALE ? p.out : path.join(locale, p.out);
const outPath = path.join(OUT, rel);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, localized);
console.log(`built /landing/${rel} (${(localized.length / 1024).toFixed(1)} KB)`);
}
}
assertDictionaryMatchesChrome(nav + footer);
const PAGES = [
{
out: "index.html",
src: "home.html",
full: true, // complete document with {{NAV}}/{{FOOTER}} markers
og: "/landing/og/card-home.jpg",
active: { label: "Beyond-2", href: "/" },
},
{
out: "experiences/index.html",
src: "experiences.html",
title: "Experiences · Bigscreen Beyond 2",
desc: "Racing and flight simulation, high-end PC VR gaming, movies, and VRChat on Bigscreen Beyond 2: 5120×2560, 116° field of view, 107 grams.",
active: { label: "Experiences", href: "/experiences" },
},
{
out: "experiences/enterprise/index.html",
src: "enterprise.html",
title: "Bigscreen for Business · Beyond 2",
desc: "Beyond 2 at work: robotics data capture, motorsport simulation, flight rehearsal, clinical training, and architecture at 1:1. Fleet ordering, SDK, and direct support for teams.",
og: "/landing/og/card-enterprise.jpg",
active: { label: "Experiences", href: "/experiences/enterprise" },
},
{
out: "press/index.html",
src: "press.html",
title: "Press & Branding · Bigscreen",
desc: "Bigscreen press resources: the latest announcements, official logos and product imagery, usage guidelines, and a direct line to the team.",
og: "/landing/og/card-press.jpg",
active: { label: "About Us", href: "/press" },
},
{
out: "press/images/index.html",
src: "press-images.html",
title: "Product Images · Bigscreen Press",
desc: "Downloadable Bigscreen Beyond 2 product imagery for editorial use: studio renders and real-world photography, full resolution.",
active: { label: "About Us", href: "/press" },
},
{
out: "about/index.html",
src: "about.html",
title: "About Us · Bigscreen",
desc: "Founded in 2014, Bigscreen builds the VR platform used by millions and the world's smallest VR headsets.",
active: { label: "About Us", href: "/about" },
},
{
out: "displays/index.html",
src: "displays.html",
title: "Displays · Bigscreen Beyond",
desc: "A deep dive into Bigscreen Beyond's dual OLED displays: 5120×2560 resolution, 500,000:1 contrast, and through-the-lens photography at 75Hz and 90Hz.",
active: { label: "Beyond-2", href: "/displays" },
},
{
out: "affiliate/index.html",
src: "affiliate.html",
title: "Affiliate Program · Bigscreen",
desc: "Join the Bigscreen affiliate program: share Beyond 2 with your audience and earn 1.5% commission on every sale made through your link.",
},
// 404 — Cloudflare Pages serves this for every unmatched URL (build-static
// copies /landing/404/index.html to out/404.html). No `active` nav item.
{
out: "404/index.html",
src: "404.html",
title: "Page not found · Bigscreen",
desc: "There's no page at this address.",
},
// Legal documents — one shared document system, legal.css (light register).
// Copy ripped from the Builder non-shopify entries; sources are now the
// src/site/pages/ files. No `active` nav item: legal lives in the footer.
{
out: "terms/index.html",
src: "terms.html",
title: "Terms of Service · Bigscreen",
desc: "The terms that govern your use of Bigscreen's software, sites, and services.",
css: "legal",
},
{
out: "privacy/index.html",
src: "privacy.html",
title: "Privacy Policy · Bigscreen",
desc: "How Bigscreen collects, uses, and protects your information across our products and services.",
css: "legal",
},
{
out: "hardwareterms/index.html",
src: "hardwareterms.html",
title: "Hardware Warranty & Terms · Bigscreen",
desc: "Order, shipping, cancellation, warranty, and data terms for Bigscreen Beyond hardware.",
css: "legal",
},
// The limited-warranty contract itself (with the arbitration agreement) —
// distinct from hardwareterms' order/shipping policies. The /warranty URL is
// a legal reference: printed materials and the old site link it directly.
{
out: "warranty/index.html",
src: "warranty.html",
title: "Hardware Limited Warranty · Bigscreen",
desc: "The Bigscreen hardware limited warranty and agreement: coverage period, service and return instructions, exclusions, limitations, and dispute resolution.",
css: "legal",
},
// Setup guides (Resources) — one shared visual system, guide.css. Top-level
// routes printed on product boxes: /mybeyond, /myhalomount, /myaudiostrap.
// (Their images still live under /landing/mybeyond/assets — an internal path.)
{
out: "mybeyond/index.html",
src: "mybeyond.html",
title: "My Beyond · Setup Guide · Bigscreen",
desc: "Set up your Bigscreen Beyond 1 & 2: pre-installation checklist, unboxing, IPD and strap adjustments, SteamVR, the Beyond Utility, cleaning, and FAQ.",
css: "guide",
faq: "mybeyond.html",
product: "Beyond",
safetyGuide: "/landing/mybeyond/assets/beyond-safety-guide-main.pdf",
active: { label: "Beyond-2", href: "/mybeyond" },
},
{
out: "myhalomount/index.html",
src: "halo-mount.html",
title: "My Halo Mount · Setup Guide · Bigscreen",
desc: "Install and fine-tune the Bigscreen Beyond Halo Mount: clip-on, strap attachment, height, tilt and angle, flip-up, and cable routing.",
css: "guide",
faq: "halo-mount.html",
product: "Halo Mount",
safetyGuide: "/landing/mybeyond/assets/beyond-safety-guide-main.pdf",
active: { label: "Beyond-2", href: "/myhalomount" },
},
{
out: "myaudiostrap/index.html",
src: "audio-strap.html",
title: "My Audio Strap · Setup Guide · Bigscreen",
desc: "Set up the Bigscreen Beyond Audio Strap: cable routing, headset attachment, speaker-arm adjustments, and maintenance.",
css: "guide",
faq: "audio-strap.html",
product: "Audio Strap",
safetyGuide: "/landing/mybeyond/assets/beyond-safety-guide.pdf",
active: { label: "Beyond-2", href: "/myaudiostrap" },
},
// Bigscreen-software pages (DARK register — black stage, unlike the light
// corporate/account family). Ported from the old React site.
{
out: "remotedesktop/index.html",
src: "remotedesktop.html",
title: "Remote Desktop · Bigscreen",
desc: "Stream your PC desktop to your Quest with Bigscreen Remote Desktop: download, setup steps, and troubleshooting.",
// guide.css powers the shared FAQ + support block (the .guide section at
// the page foot); the page's own dark hero/steps live in inline rdc-css.
css: "guide",
active: { label: "Beyond-2", href: "/remotedesktop" },
},
{
out: "purchase/index.html",
src: "purchase.html",
title: "Buy a ticket · Bigscreen",
desc: "Purchase a movie ticket to watch in the Bigscreen VR app.",
},
// Ticket Policy — legal document referenced by the purchase flow.
{
out: "ticketpolicy/index.html",
src: "ticketpolicy.html",
title: "Ticket Policy · Bigscreen",
desc: "Bigscreen's movie ticket policy: refunds, exchanges, payment methods, and pricing.",
css: "legal",
},
// Account system — the dynamic features ported from the old React site
// (login/orders/IPD talk to the central Bigscreen API via /landing/bigapi.js;
// see src/site/partials/bigapi.js). Shared stylesheet: account.css (light
// register). Dynamic URL segments (/account/order/:id/…, /token2/login/:t)
// are parameterized rewrites in next.config.mjs onto these static files.
// No `active` nav item: account pages live outside the marketing nav.
{
out: "account/login/index.html",
src: "account-login.html",
title: "Log in · Bigscreen",
desc: "Log in to your Bigscreen account.",
css: "account",
},
{
out: "account/signup/index.html",
src: "account-signup.html",
title: "Create your account · Bigscreen",
desc: "Create a Bigscreen account for the VR platform, Beyond orders, and purchases.",
css: "account",
},
{
out: "account/forgot/index.html",
src: "account-forgot.html",
title: "Reset your password · Bigscreen",
desc: "Request a password-reset link for your Bigscreen account.",
css: "account",
},
{
out: "account/reset/index.html",
src: "account-reset.html",
title: "Choose a new password · Bigscreen",
desc: "Set a new password for your Bigscreen account.",
css: "account",
},
{
out: "account/home/index.html",
src: "account-home.html",
title: "Your account · Bigscreen",
desc: "Your Bigscreen account: Beyond orders, store orders, and tickets.",
css: "account",
},
{
out: "account/orders/index.html",
src: "account-orders.html",
title: "Your Beyond order · Bigscreen",
desc: "Your Bigscreen Beyond hardware orders: face scan, IPD, and line items.",
css: "account",
},
{
out: "account/order-start/index.html",
src: "account-order-start.html",
title: "Scan complete · Bigscreen",
desc: "Your face scan is complete — review the measurements your Beyond is built to.",
css: "account",
},
{
out: "account/order-ipd/index.html",
src: "account-order-ipd.html",
title: "Set your IPD · Bigscreen",
desc: "Choose the IPD your Bigscreen Beyond's optics are built to.",
css: "account",
},
{
out: "account/bridge/index.html",
src: "account-bridge.html",
title: "One moment · Bigscreen",
desc: "Signing you in.",
css: "account",
},
{
out: "account/outcome/index.html",
src: "account-outcome.html",
title: "Email confirmation · Bigscreen",
desc: "Your email confirmation result.",
css: "account",
},
// Face-scan flow + eyetracking token hand-off — the Beyond hardware
// utility pages customers reach from emails / the app clip / the
// eyetracking client (ported from the legacy app; exact URLs load-bearing).
{
out: "scans/start/index.html",
src: "scan-start.html",
title: "Face scan · Bigscreen",
desc: "Start your Bigscreen Beyond face scan.",
css: "account",
},
{
out: "scans/process/index.html",
src: "scan-process.html",
title: "Processing your scan · Bigscreen",
desc: "Submitting your Bigscreen Beyond face scan.",
css: "account",
},
{
out: "scans/failedtoken/index.html",
src: "scan-failedtoken.html",
title: "Link expired · Bigscreen",
desc: "This face-scan link has expired.",
css: "account",
},
{
out: "bset/token/index.html",
src: "bset-token.html",
title: "Eyetracking setup · Bigscreen",
desc: "Connect the Beyond Eyetracking app to your account.",
css: "account",
},
];
/** Mark the current page in the shared nav: top-level link by label
* (is-active + aria-current) and exact-href dropdown entry (is-current). */
function activateNav(navHtml, active) {
if (!active) return navHtml;
let s = navHtml;
const labelIdx = s.indexOf(`>${active.label}<`);
if (labelIdx < 0) throw new Error(`nav label not found: ${active.label}`);
const aStart = s.lastIndexOf("", "" + BANNER);
} else {
const headExtra = p.css
? ``
: "";
html =
head
.replace("{{TITLE}}", p.title)
.replace("{{DESC}}", p.desc)
.replace("{{HEADEXTRA}}", headExtra) +
BANNER + "\n" +
pageNav +
// .page-enter: content softly rises/fades in on load (nav + footer stay put).
'\n\n' + body + "\n\n" +
footer +
"\n\n