// Build "embed shells" from a static export: for each exported page, emit a // minimal HTML file containing ONLY the metadata a link-unfurler reads // (title, description, Open Graph, Twitter cards) plus the image/video assets // those tags reference. Nothing else ships — a leaked shell URL shows a blank // page, not the site. // // Used by scripts/embed-check.mjs to test how pages unfurl on Discord/Slack/ // Twitter without exposing staging content. See docs/STAGE-TEST-RELEASE.md §4. // // Usage: // node scripts/build-embed-shells.mjs --host https://check-ab12cd..pages.dev [--pages /en,/10years] // // --host Absolute base URL the shells will be served from. Unfurlers // require absolute og:image / og:video URLs, so local asset // references are rewritten onto this host. Known before deploy // because embed-check deploys to a chosen branch alias. // --pages Comma-separated clean URL paths to shell (default: every page // in out/ that carries og: metadata). // // Input: out/ (run `npm run build:static` first) // Output: embed-shells/ (same directory layout: /index.html + assets) import fs from "node:fs"; import path from "node:path"; const OUT = "out"; const DEST = "embed-shells"; const args = process.argv.slice(2); function argValue(flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : null; } const host = argValue("--host"); const pagesArg = argValue("--pages"); if (!host || !/^https?:\/\//.test(host)) { console.error("--host https://... is required (unfurlers need absolute asset URLs)"); process.exit(1); } const hostBase = host.replace(/\/$/, ""); if (!fs.existsSync(OUT)) { console.error(`${OUT}/ not found - run \`npm run build:static\` first`); process.exit(1); } // Tags an unfurler reads. Everything else in is dropped. const KEEP_META = [ /^og:/, // Open Graph (Discord, Slack, iMessage, Facebook) /^twitter:/, // Twitter cards (also read by Discord) /^description$/, /^theme-color$/, // Discord uses it for the embed accent bar ]; // Meta properties whose content is a URL to a local asset we must carry along. const ASSET_PROPS = new Set([ "og:image", "og:image:url", "og:image:secure_url", "og:video", "og:video:url", "og:video:secure_url", "twitter:image", "twitter:player", "twitter:player:stream", ]); function* findPages(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) yield* findPages(p); else if (entry.name === "index.html") yield p; } } function extractHead(html) { const m = html.match(/]*>([\s\S]*?)<\/head>/i); return m ? m[1] : ""; } // Minimal attribute parser for tags — the export is our own // well-formed output, so regex parsing is safe here. function parseMetaTags(head) { const tags = []; const re = /]*?)\/?>(?:<\/meta>)?/gi; let m; while ((m = re.exec(head))) { const attrs = {}; const attrRe = /([a-zA-Z:-]+)\s*=\s*"([^"]*)"/g; let a; while ((a = attrRe.exec(m[1]))) attrs[a[1].toLowerCase()] = a[2]; tags.push(attrs); } return tags; } function escapeAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """).replace(/ "/" + p.trim().replace(/^\/|\/$/g, ""))) : null; fs.rmSync(DEST, { recursive: true, force: true }); fs.mkdirSync(DEST, { recursive: true }); let shelled = 0; const copiedAssets = new Set(); for (const file of findPages(OUT)) { const urlPath = "/" + path.relative(OUT, path.dirname(file)).split(path.sep).join("/"); const cleanUrl = urlPath === "/." ? "/" : urlPath; if (wantedPages && !wantedPages.has(cleanUrl)) continue; const head = extractHead(fs.readFileSync(file, "utf8")); const metas = parseMetaTags(head); const kept = metas.filter((t) => { const key = t.property || t.name || ""; return KEEP_META.some((re) => re.test(key)); }); const hasOg = kept.some((t) => (t.property || "").startsWith("og:")); // Without an explicit page list, only shell pages that opted into OG tags. if (!wantedPages && !hasOg) continue; const titleMatch = head.match(/]*>([\s\S]*?)<\/title>/i); const title = titleMatch ? titleMatch[1].trim() : "Bigscreen"; const lines = [`${title}`]; for (const t of kept) { const key = t.property || t.name; let content = t.content || ""; if (ASSET_PROPS.has(key) && content) { // Root-relative asset, or absolute onto one of our own hosts: carry the // file into the shell bundle and point the tag at the shell host. const own = content.match( /^https?:\/\/(?:www\.)?(?:bigscreenvr\.com|[a-z0-9-]+\.bigscreencloud\.com|[a-z0-9-]+\.pages\.dev)(\/.*)$/i, ); const localPath = own ? own[1] : content.startsWith("/") ? content : null; if (localPath) { const src = path.join(OUT, localPath.replace(/^\//, "").split("?")[0]); if (fs.existsSync(src)) { const destAsset = path.join(DEST, localPath.replace(/^\//, "").split("?")[0]); if (!copiedAssets.has(destAsset)) { fs.mkdirSync(path.dirname(destAsset), { recursive: true }); fs.copyFileSync(src, destAsset); copiedAssets.add(destAsset); } content = hostBase + localPath; } else { console.warn(` ! ${cleanUrl}: asset not in export, tag kept as-is: ${content}`); } } } const attr = t.property ? "property" : "name"; lines.push( ``, ); } // og:url should be the shell page itself so unfurlers treat it as canonical. if (!kept.some((t) => t.property === "og:url")) { lines.push(``); } const shell = ` ${lines.join("\n")} `; const destDir = path.join(DEST, cleanUrl.replace(/^\//, "")); fs.mkdirSync(destDir, { recursive: true }); fs.writeFileSync(path.join(destDir, "index.html"), shell); shelled++; console.log(` shelled ${cleanUrl}`); } // Keep crawlers out wholesale; unfurlers ignore robots.txt for direct links. fs.writeFileSync(path.join(DEST, "robots.txt"), "User-agent: *\nDisallow: /\n"); if (!shelled) { console.error( pagesArg ? "no pages matched --pages; paths must match exported clean URLs (e.g. /en)" : "no pages with og: metadata found in out/", ); process.exit(1); } console.log( `\n${shelled} shell(s) + ${copiedAssets.size} asset(s) in ${DEST}/ for ${hostBase}`, );