Files
station-master/scripts/build-web.ts
T

133 lines
4.8 KiB
TypeScript

/**
* 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(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
writeFileSync(join(dist, 'index.html'), page);
// A tiny note for whoever unzips this later and wonders what it needs.
writeFileSync(
join(dist, 'README.txt'),
[
'Station Master — solitaire, static build.',
'',
'Upload the contents of this folder to any static host and open index.html.',
'There is no server component. The game runs entirely in the browser and saves',
'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
'',
'Note: ES modules require the files to be SERVED over http(s). Opening',
'index.html directly from the filesystem will be blocked by the browser.',
'To try it locally: npx http-server dist (or any static file server)',
'',
].join('\n'),
);
console.log(`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}`);