// 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(/</g, "<"); const title = unesc( (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? p.title ?? "Bigscreen").trim(), ); const desc = unesc( (html.match(/<meta name="description" content="([^"]*)"/i)?.[1] ?? p.desc ?? ""), ); const image = SITE_ORIGIN + (p.og ?? OG_DEFAULT_IMAGE); const abs = seoUrl(locale, url); return [ `<link rel="canonical" href="${abs}">`, `<meta property="og:type" content="website">`, `<meta property="og:site_name" content="Bigscreen">`, `<meta property="og:locale" content="${OG_LOCALE[locale] ?? "en_US"}">`, `<meta property="og:title" content="${esc(title)}">`, `<meta property="og:description" content="${esc(desc)}">`, `<meta property="og:url" content="${abs}">`, `<meta property="og:image" content="${image}">`, `<meta property="og:image:width" content="1200">`, `<meta property="og:image:height" content="630">`, `<meta property="og:image:alt" content="${esc(title)}">`, `<meta name="twitter:card" content="summary_large_image">`, `<meta name="twitter:title" content="${esc(title)}">`, `<meta name="twitter:description" content="${esc(desc)}">`, `<meta name="twitter:image" content="${image}">`, ].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 <title> 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) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, "\\u003c")}</script>`; 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 <title> 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(/<title[^>]*>([\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(/<meta (?:property="og:|name="twitter:)[^>]*>\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("</head>")) { localized = stripSocialMeta(localized).replace( "</head>", hreflangTags(url) + "\n" + socialTags(p, localized, url, locale) + "\n" + structuredData(p, localized, url, locale) + "\n" + gaTag + "\n" + pixelTags + (p.active ? "\n" + klaviyoTag : "") + "\n</head>", ); } 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("<a ", labelIdx); const tag = s.slice(aStart, labelIdx); const newTag = tag .replace('class="header-link', 'class="header-link is-active') .replace("<a ", '<a aria-current="page" '); if (newTag === tag) throw new Error(`could not activate nav link: ${active.label}`); s = s.slice(0, aStart) + newTag + s.slice(labelIdx); // dropdown "current" dot on an exact-href match (e.g. Overview on the homepage) s = s.replace( `class="nav-menu-link" href="${active.href}"`, `class="nav-menu-link is-current" href="${active.href}"`, ); return s; } // Shared chrome stylesheet — single source of truth for nav + footer styling. fs.copyFileSync(path.join(P, "chrome.css"), path.join(OUT, "chrome.css")); console.log("built /landing/chrome.css"); // Shared setup-guide stylesheet — the unified guide design system (My Beyond / // Halo Mount / Audio Strap). Linked per-page via the page `css` field. fs.copyFileSync(path.join(P, "guide.css"), path.join(OUT, "guide.css")); console.log("built /landing/guide.css"); // Shared legal-document stylesheet (/terms, /privacy, /hardwareterms). fs.copyFileSync(path.join(P, "legal.css"), path.join(OUT, "legal.css")); console.log("built /landing/legal.css"); // Account system: shared stylesheet + the Bigscreen API client the account / // orders / remote-desktop pages load (source of truth: src/site/partials/). fs.copyFileSync(path.join(P, "account.css"), path.join(OUT, "account.css")); console.log("built /landing/account.css"); fs.copyFileSync(path.join(P, "bigapi.js"), path.join(OUT, "bigapi.js")); console.log("built /landing/bigapi.js"); for (const p of PAGES) { let body = read(path.join(SRC, p.src)); if (p.faq) { const faqItems = read(path.join(FAQ, p.faq)); body = body .split("{{GUIDE_SUPPORT}}") .join( guideSupport .split("{{FAQ_ITEMS}}").join(faqItems) .split("{{PRODUCT_NAME}}").join(p.product || "product") .split("{{SAFETY_GUIDE_URL}}").join(p.safetyGuide), ); } const pageNav = activateNav(nav, p.active); let html; if (p.full) { html = body.split("{{NAV}}").join(pageNav).split("{{FOOTER}}").join(footer); html = html.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + BANNER); } else { const headExtra = p.css ? `<link rel="stylesheet" href="/landing/${p.css}.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<main class="page-enter">\n' + body + "\n</main>\n" + footer + "\n</body>\n</html>\n"; } // Shared components available to every page. for (const [token, value] of Object.entries(reviewTokens)) html = html.split(token).join(value); html = html.split("{{CHOOSER}}").join(chooser); html = html.split("{{PREFOOTER}}").join(prefooter); html = html.split("{{PREFOOTER_LIGHT}}").join(prefooterLight); html = html.split("{{PRESS_GALLERY_RENDERS}}").join(gallery.renders); html = html.split("{{PRESS_GALLERY_PHOTOS}}").join(gallery.photos); for (const m of ["{{NAV}}", "{{FOOTER}}", "{{CHOOSER}}", "{{PREFOOTER}}", "{{PREFOOTER_LIGHT}}", "{{PRESS_GALLERY_RENDERS}}", "{{PRESS_GALLERY_PHOTOS}}", "{{GUIDE_SUPPORT}}", "{{FAQ_ITEMS}}", "{{PRODUCT_NAME}}", "{{SAFETY_GUIDE_URL}}", "{{RV_COUNT}}", "{{RV_AVG}}", "{{RV_VERIFIED}}", "{{RV_METER_ARIA}}"]) if (html.includes(m)) throw new Error(`unresolved marker ${m} in ${p.src}`); emit(p, html); }