/** * Build the static solitaire site into `dist/`. * * Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that * serves static content and the game runs entirely in the visitor's browser. No server, no API, no * network call at runtime. * * The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature. * So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`. */ import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const dist = join(root, 'dist'); rmSync(dist, { recursive: true, force: true }); mkdirSync(dist, { recursive: true }); execFileSync( 'npx', [ 'tsc', '--ignoreConfig', 'src/web/main.ts', '--outDir', dist, '--rootDir', 'src', '--target', 'es2022', '--module', 'es2022', '--moduleResolution', 'bundler', '--lib', 'es2022,dom', '--types', '', '--strict', '--allowImportingTsExtensions', '--rewriteRelativeImportExtensions', ], { cwd: root, stdio: 'inherit' }, ); /** * Stamp the build into the page. * * Once this is published, "what is actually deployed?" stops being answerable by looking at the * source. The stamp is written into index.html at copy time rather than generated into `src/`, so * no generated file has to be ignored, imported, or kept in step. * * `-dirty` marks a build made from an uncommitted tree — the honest state of most test deploys, and * exactly the thing you want to know when a fix appears to have had no effect. */ function buildStamp(): string { const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }; let git = 'nogit'; try { const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }) .toString() .trim(); const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim().length > 0; git = sha + (dirty ? '-dirty' : ''); } catch { // A build from a tarball with no git history is still a valid build. } const when = new Date().toISOString().replace('T', ' ').slice(0, 16); return `v${pkg.version} · ${git} · ${when}Z`; } const stamp = buildStamp(); /** * Cache-bust every module. * * A returning visitor gets a fresh index.html and STALE JavaScript, because a static host caches * .js and the filenames never change. That is not a cosmetic staleness: it mixes new HTML with old * code, and the first mismatch is fatal. It happened on the first real deploy — index.html had * dropped an element that the cached main.js still asked for, so the page threw * `missing element: target` and the game never started. * * Appending the build tag to every relative import makes each deploy a new set of URLs, so a * browser cannot serve half of one build and half of another. */ const tag = encodeURIComponent(stamp.split(' · ')[1] ?? stamp); function bustImports(dir: string): number { let count = 0; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { count += bustImports(full); continue; } if (!entry.name.endsWith('.js')) continue; const src = readFileSync(full, 'utf8'); const next = src.replace( /^(\s*(?:import|export)[^'"\n]*?from\s+['"])(\.[^'"]+\.js)(['"])/gm, (_m, head: string, spec: string, tail: string) => `${head}${spec}?v=${tag}${tail}`, ); if (next !== src) { writeFileSync(full, next); count++; } } return count; } const busted = bustImports(dist); const page = readFileSync(join(root, 'src/web/index.html'), 'utf8') .replaceAll('__BUILD__', stamp) .replace(/(