123/**4 * Applies the demo overlay (apps/demo/overlay) onto a generated lofi app.5 *6 * Every overlay file replaces its counterpart in the generated project; a file7 * with no counterpart is an error unless it is listed in {@link NEW_FILES}.8 * Text files may carry the `__LOFI_DEMO_VERSION__` placeholder, which is9 * replaced with the released package version.10 *11 * Usage: deno run -A tools/demo_overlay.ts --app <path> [--version <semver>]12 */1314import { dirname, join, relative, resolve } from "node:path";15import { fileURLToPath } from "node:url";16import { LOFI_VERSION } from "../package/version.ts";1718/** Overlay files that intentionally have no counterpart in the starter. */19export const NEW_FILES: readonly string[] = [20 "src/islands/IncidentBoard.tsx",21 "src/islands/MagmaBackdrop.tsx",22 "src/islands/StatusStrip.tsx",23 "src/islands/use-incidents.ts",24 "public/fonts/chakra-petch-700.woff2",25 "public/fonts/OFL.txt",26];2728/** The placeholder substituted with the released version. */29export const VERSION_PLACEHOLDER = "__LOFI_DEMO_VERSION__";3031const TEXT_EXTENSIONS = [32 ".ts",33 ".tsx",34 ".astro",35 ".css",36 ".json",37 ".webmanifest",38 ".svg",39 ".md",40];4142const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");4344/** The overlay source directory, resolved from the repository checkout. */45export const OVERLAY_ROOT = join(repositoryRoot, "apps", "demo", "overlay");4647function isTextFile(path: string): boolean {48 return TEXT_EXTENSIONS.some((extension) => path.endsWith(extension));49}5051/** Lists overlay file paths relative to the overlay root, sorted. */52export async function listOverlayFiles(root: string = OVERLAY_ROOT): Promise<string[]> {53 const files: string[] = [];54 async function walk(directory: string): Promise<void> {55 for await (const entry of Deno.readDir(directory)) {56 const path = join(directory, entry.name);57 if (entry.isDirectory) await walk(path);58 else if (entry.isFile) files.push(relative(root, path));59 }60 }61 await walk(root);62 return files.sort();63}6465/** Copies the overlay onto the generated app and substitutes the version. */66export async function applyOverlay(appDirectory: string, version: string): Promise<string[]> {67 if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {68 throw new Error(`--version must be an exact semantic version, got ${JSON.stringify(version)}`);69 }70 const appRoot = resolve(appDirectory);71 const appStat = await Deno.stat(appRoot).catch(() => null);72 if (appStat === null || !appStat.isDirectory) {73 throw new Error(`generated app directory not found: ${appRoot}`);74 }7576 const overlayFiles = await listOverlayFiles();77 if (overlayFiles.length === 0) throw new Error(`overlay is empty: ${OVERLAY_ROOT}`);7879 for (const relativePath of overlayFiles) {80 const target = join(appRoot, relativePath);81 const counterpart = await Deno.stat(target).catch(() => null);82 if (counterpart === null && !NEW_FILES.includes(relativePath)) {83 throw new Error(84 `overlay file ${relativePath} has no counterpart in the generated app; ` +85 `the starter layout changed, or the file belongs in NEW_FILES`,86 );87 }88 await Deno.mkdir(dirname(target), { recursive: true });89 const source = join(OVERLAY_ROOT, relativePath);90 if (isTextFile(relativePath)) {91 const contents = await Deno.readTextFile(source);92 await Deno.writeTextFile(target, contents.replaceAll(VERSION_PLACEHOLDER, version));93 } else {94 const bytes = await Deno.readFile(source);95 if (new TextDecoder("utf-8", { fatal: false }).decode(bytes).includes(VERSION_PLACEHOLDER)) {96 throw new Error(97 `overlay file ${relativePath} carries the version placeholder ` +98 `but is not recognized as a text file, so it would ship unsubstituted`,99 );100 }101 await Deno.writeFile(target, bytes);102 }103 }104 return overlayFiles;105}106107if (import.meta.main) {108 let appDirectory = "";109 let version = LOFI_VERSION;110 const args = [...Deno.args];111 for (let index = 0; index < args.length; index++) {112 const argument = args[index];113 if (argument === "--app") appDirectory = args[++index] ?? "";114 else if (argument === "--version") version = args[++index] ?? "";115 else throw new Error(`unknown demo-overlay argument ${JSON.stringify(argument)}`);116 }117 if (appDirectory === "") throw new Error("--app <path to generated app> is required");118 const applied = await applyOverlay(appDirectory, version);119 console.log(`applied ${applied.length} overlay files onto ${resolve(appDirectory)}:`);120 for (const path of applied) console.log(` ${path}`);121}