#!/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 `. // 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;