#!/usr/bin/env node // Build a phone-sized standalone copy of the game carrying one campaign. // // node scripts/build-phone.mjs [--campaign=c50] [--out=build/phone] // // The repository's own static build ships every edition, and v0.5 alone is a // 9.2 MB module holding a thousand levels — fine over localhost, not over a // phone connection. This swaps the campaign registry for one that names a // single edition, then follows the import graph from the entry module, so the // editions nobody asked for are never reached and never copied. // // Two shapes of page, because hosts differ. --standalone emits a complete // document for an ordinary static host. The default emits body content only, // for Claude Artifacts, whose publisher supplies , , and // and rejects a page that brings its own. Either way the stylesheets are // inlined rather than linked, so the page has no render-blocking round trips. import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; })); const CAMPAIGN = String(args.campaign ?? 'c50'); const STANDALONE = Boolean(args.standalone); const OUT = resolve(args.out ?? 'build/phone'); const ROOT = resolve('.'); // The registry, rewritten to one edition. Same exports, so nothing downstream // knows the difference. const registry = `// Generated by scripts/build-phone.mjs — one edition only. import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js'; export const CAMPAIGN_VERSIONS = [{ id: C50_CAMPAIGN.id, name: C50_CAMPAIGN.name, description: C50_CAMPAIGN.description, chapters: C50_CAMPAIGN.chapters, chapterSize: C50_CAMPAIGN.chapterSize, levels: C50_LEVELS, legacyStorageKeys: [], }]; export const DEFAULT_CAMPAIGN_VERSION = '${CAMPAIGN}'; export function campaignVersion(id) { return CAMPAIGN_VERSIONS.find(c => c.id === id) || CAMPAIGN_VERSIONS[0]; } `; const OVERRIDES = new Map([[resolve('src/campaign-versions.js'), registry]]); // Follow every static import from the entry. Dynamic imports are not followed; // the game has none, and a miss would show up as a 404 rather than silently. const IMPORT = /(?:^|\n)\s*(?:import|export)\s[^'"]*from\s*['"]([^'"]+)['"]|(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g; const seen = new Map(); function walk(file) { if (seen.has(file)) return; const source = OVERRIDES.get(file) ?? readFileSync(file, 'utf8'); seen.set(file, source); for (const m of source.matchAll(IMPORT)) { const spec = m[1] ?? m[2]; if (!spec.startsWith('.')) continue; // bare specifiers: none in this project walk(resolve(dirname(file), spec)); } } walk(resolve('src/app.js')); // One `; // viewport-fit=cover lets the board sit under a phone's notch; the Artifact // skeleton supplies its own viewport meta, so only the standalone page needs it. writeFileSync(join(OUT, 'index.html'), STANDALONE ? `\n\n\n\n` + `\n` + `\n${head}\n\n\n${body}\n\n\n` : `${head}\n${body}\n`); for (const asset of assets) { mkdirSync(join(OUT, dirname(asset)), { recursive: true }); copyFileSync(resolve(asset), join(OUT, asset)); } for (const [file, source] of seen) { const rel = relative(ROOT, file); mkdirSync(join(OUT, dirname(rel)), { recursive: true }); writeFileSync(join(OUT, rel), source); } const size = dir => readdirSync(dir, { withFileTypes: true }) .reduce((n, e) => n + (e.isDirectory() ? size(join(dir, e.name)) : readFileSync(join(dir, e.name)).length), 0); const kb = n => (n / 1024).toFixed(0) + ' KB'; console.log(`入口 src/app.js → ${seen.size} 个模块`); console.log(`样式 ${sheets.length} 个文件已内联(${kb(cssFixed.length)})`); console.log(`资源 ${assets.size} 个:${[...assets].map(a => `${a.replace('assets/', '')} ${kb(readFileSync(resolve(a)).length)}`).join(' · ')}`); console.log(`产物 ${OUT}:${kb(size(OUT))}(${STANDALONE ? '完整 HTML 文档,适合普通静态托管' : '仅 body 内容,适合 Claude Artifact'})`); const heavy = [...seen].map(([f, s]) => [relative(ROOT, f), s.length]).sort((a, b) => b[1] - a[1]).slice(0, 3); console.log(`最大的模块 ${heavy.map(([f, n]) => `${f} ${kb(n)}`).join(' · ')}`);