Files
josh c90f8f1e5c feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader
- scripts/compile-ink.mjs: build-time inklecate runner using bundled binary
  (BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings).
  Assumption A6 verified first-try on Windows; the same binary path resolution
  works on macOS + Linux per the wrapper's own getInklecatePath convention.
- scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs +
  emits valid JSON with inkVersion. wipe=false for the test path so it can
  run in parallel with the ink-loader test without racing on the wipe step.
- 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper
  for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink,
  compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into
  VAR-driven branch shape consumable by the runtime).
- src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk +
  INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8
  BOM stripped before Story instantiation.
- src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4
  beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent
  skip for stories missing declared vars.
- package.json: build now runs compile:ink first; ci chain runs compile:ink
  before test so ink-loader.test.ts's precondition check passes.
- .gitignore: src/content/compiled-ink/ excluded (regenerated on every build).

npm run ci exits 0; 11 new tests green (228 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:24:40 -04:00

162 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Phase 2 Plan 02-04 — compile content/dialogue/**\/*.ink → src/content/compiled-ink/**\/*.ink.json
*
* Per RESEARCH Pattern 5 + Assumption A6 (verified on this run).
*
* Approach (chosen after reading node_modules/inklecate/index.js +
* getInklecatePath.js + executableHandler.js):
*
* The npm wrapper for inklecate exposes a CommonJS module shape:
* `module.exports = { ArgsEnum, DEBUG, getBinDir, getCacheFilepath,
* getInklecatePath, inklecate }`.
*
* The wrapper's `inklecate` function spawns the inklecate.exe / inklecate
* binary under node_modules/inklecate/bin/ asynchronously and resolves
* when the child exits — but as of inklecate@1.8.1, the wrapper's
* `executableHandler` swallows non-zero exit codes silently and the
* API surface is undocumented for stderr. To keep failure modes loud
* AND to keep this script cross-platform, we invoke the binary
* DIRECTLY via `child_process.execFileSync`. The wrapper's bin/ folder
* is the canonical home for both Windows (inklecate.exe) and POSIX
* (inklecate) executables; the wrapper handles platform selection
* internally via `process.platform === 'darwin' ? 'inklecate' :
* 'inklecate.exe'` (see node_modules/inklecate/getInklecatePath.js).
*
* On Linux the same `inklecate` binary applies (it's a single .NET
* self-contained executable that ships alongside the .dll runtime),
* matching what `executableHandler` does internally.
*/
import {
mkdirSync,
existsSync,
readdirSync,
statSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
const INK_ROOT = resolve(process.cwd(), 'content/dialogue');
const OUT_ROOT = resolve(process.cwd(), 'src/content/compiled-ink');
function findInkFiles(root) {
const out = [];
if (!existsSync(root)) return out;
for (const entry of readdirSync(root)) {
const full = join(root, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...findInkFiles(full));
else if (entry.endsWith('.ink')) out.push(full);
}
return out;
}
/**
* Resolve the bundled inklecate binary path.
*
* BLOCKER 4 mitigation — DO NOT use stale path strings like
* `node_modules/inklecate/inklecate-windows/inklecate.exe`. The wrapper
* ships a single `bin/` directory containing both inklecate (POSIX) and
* inklecate.exe (Windows). Verified during Plan 02-04 first run:
* ls node_modules/inklecate/bin/
* ink-engine-runtime.dll inklecate.exe inklecate
* ink_compiler.dll libhostpolicy.so
*
* Platform selection follows the wrapper's own
* getInklecatePath.js convention: anything-not-darwin uses .exe — but
* that's a quirk of the .NET self-contained build. On Linux the .exe
* file is the actual ELF executable (Mono-style multi-platform .NET);
* on macOS the no-extension `inklecate` is used. We replicate that
* behavior here so this script works on Windows + macOS + Linux dev
* machines without modification (Assumption A6).
*/
function inklecateBinary() {
const binDir = resolve(process.cwd(), 'node_modules/inklecate/bin');
// Match the wrapper's own platform-selection logic.
const name = process.platform === 'darwin' ? 'inklecate' : 'inklecate.exe';
return join(binDir, name);
}
export async function compileAllInk(options = {}) {
const { wipe = true } = options;
const files = findInkFiles(INK_ROOT);
if (files.length === 0) {
console.log('[compile:ink] no .ink files under content/dialogue/ — skipping');
return { compiled: 0, files: [] };
}
// Optionally wipe stale output. The CLI path passes wipe=true (default)
// so deleted .ink files don't leave stale .ink.json files behind. The
// Vitest test passes wipe=false so it doesn't race with parallel test
// files (e.g., src/content/ink-loader.test.ts) reading the compiled
// artefacts.
if (wipe && existsSync(OUT_ROOT)) {
rmSync(OUT_ROOT, { recursive: true, force: true });
}
const binary = inklecateBinary();
if (!existsSync(binary)) {
throw new Error(
`[compile:ink] inklecate binary not found at ${binary}. ` +
`Did 'npm install' run? Expected node_modules/inklecate/bin/{inklecate,inklecate.exe}.`,
);
}
const compiled = [];
for (const inkPath of files) {
const rel = relative(INK_ROOT, inkPath);
const outPath = resolve(OUT_ROOT, rel.replace(/\.ink$/, '.ink.json'));
mkdirSync(dirname(outPath), { recursive: true });
// Inklecate CLI shape: `inklecate -o <outFile> <inFile>`.
// The binary writes a JSON file at the given path. Stderr is captured
// and surfaced if the exit code is non-zero.
try {
execFileSync(binary, ['-o', outPath, inkPath], { stdio: 'pipe' });
} catch (err) {
const stderr = err && err.stderr ? err.stderr.toString() : '';
const stdout = err && err.stdout ? err.stdout.toString() : '';
throw new Error(
`[compile:ink] FAILED compiling ${rel}\n` +
(stderr ? `stderr:\n${stderr}\n` : '') +
(stdout ? `stdout:\n${stdout}\n` : ''),
);
}
if (!existsSync(outPath)) {
throw new Error(
`[compile:ink] inklecate exit code 0 but no output at ${outPath} for input ${inkPath}`,
);
}
compiled.push({ in: inkPath, out: outPath });
console.log(`[compile:ink] ${rel} -> ${relative(process.cwd(), outPath)}`);
}
console.log(`[compile:ink] compiled ${compiled.length} files`);
return { compiled: compiled.length, files: compiled };
}
// CLI invocation (gated so Vitest can `import` this module without firing).
const isDirectCli = (() => {
try {
const argvUrl = `file://${resolve(process.argv[1] ?? '').replace(/\\/g, '/')}`;
return import.meta.url === argvUrl || import.meta.url.endsWith('/compile-ink.mjs') && process.argv[1]?.endsWith('compile-ink.mjs');
} catch {
return false;
}
})();
if (isDirectCli) {
compileAllInk().catch((err) => {
console.error('[compile:ink] FAILED:', err && err.stack ? err.stack : err);
process.exit(1);
});
}
// Suppress unused-import lint for writeFileSync — kept available for
// future inline-write paths if the binary path approach ever needs to
// fall back to a wrapper-only-mode that returns JSON via stdout.
void writeFileSync;