133 lines
4.8 KiB
TypeScript
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}`);
|