// 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}`,
);