#!/usr/bin/env tsx /** * Internal implementation detail of generate.ts (the unified orchestrator) * -- invoke `generate` instead; direct invocation remains possible but * undocumented. */ /** * Reads .deco/blocks/*.json and emits blocksManifest.gen.ts — a module that * STATICALLY imports every block file and re-exports them as one * `Record` keyed exactly like * `@decocms/blocks/cms/loadDecofileDirectory`: `parseBlockId()`, * the filename stem URL-decoded ONCE (classic deco stores blocks as * `encodeURIComponent().json`; content references saved blocks by * the decoded id, and the Studio editor writes back to * `encodeURIComponent().json`). No renaming beyond that decode — the * `pages-` filename prefix on page blocks is load-bearing, see * loadDecofileDirectory's doc comment. * * Why static imports instead of the runtime fs read: a plain * `loadDecofileDirectory(".deco/blocks")` is invisible to the bundler, so in * `next dev` editing a block JSON invalidates nothing (no CMS content * hot-reload) and production deploys need `outputFileTracingIncludes` hacks * to ship the directory. With the manifest, Next's own module graph owns the * files: editing an imported JSON re-evaluates the server module graph * (~120–165ms measured), which also resets module-scope memos like * `createNextSetup`'s bootstrap cache — content edits reload naturally, and * the JSON is bundled into the build output. The trade-off: adding or * removing a block FILE requires re-running this generator (content edits do * not), so wire it into the site's `generate` chain. * * Import specifiers use the RAW on-disk filename. Verified against webpack, * Turbopack, and Vite: specifiers are opaque strings to all three — `%2520`, * `%20`, parentheses, `%C3%A7`, `%2F` etc. pass through verbatim and nothing * URL-decodes, so JSON.stringify-quoting the specifier (and the key) is all * the escaping needed. * * Usage (from site root): * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts * * CLI: * --blocks-dir override input (default: .deco/blocks) * --out-file override output (default: .deco/blocksManifest.gen.ts) * * Programmatic: * import { generateBlocksManifest } from "@decocms/blocks-cli/generate-blocks-manifest"; * await generateBlocksManifest({ blocksDir, outFile }); */ import fs from "node:fs"; import path from "node:path"; import { parseBlockId } from "@decocms/blocks/cms/loadDecofileDirectory"; // Header of the emitted module. Kept as line comments and deliberately free // of interpolated filenames: block filenames can contain almost any // character, and a filename containing the sequence `*` + `/` inside a // generated /** ... */ block would terminate the comment early and corrupt // the module (a past incident). Filenames only ever appear inside // JSON.stringify-quoted string literals below. const HEADER = [ "// Auto-generated by @decocms/blocks-cli/scripts/generate-blocks-manifest.ts — do not edit.", "//", "// Static-import manifest of every JSON decofile in the blocks directory,", "// keyed by parseBlockId() — the stem URL-decoded once — the same", "// key format @decocms/blocks/cms/loadDecofileDirectory produces (page", "// blocks keep their load-bearing `pages-` filename prefix).", "//", "// Because every block file is a static import, the bundler's module graph", "// owns them: in `next dev`, editing a block JSON re-evaluates the server", "// module graph (CMS content hot-reload), and production builds bundle the", "// content (no outputFileTracingIncludes needed). Adding or removing a", "// block FILE requires re-running the generator; editing content does not.", "//", "// Regenerate:", "// npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks-manifest.ts", "", ].join("\n"); export interface GenerateBlocksManifestOptions { blocksDir: string; outFile: string; /** Suppress the per-run summary log. Defaults to false. */ silent?: boolean; } export interface GenerateBlocksManifestResult { count: number; outFile: string; /** True when the blocks dir was missing and an empty manifest was emitted. */ empty: boolean; /** * True when the manifest on disk actually changed. Regeneration is * idempotent: an unchanged block set rewrites nothing, so watchers (and * Next's module graph) are not tickled by no-op runs. */ written: boolean; } function renderManifest(blocksDir: string, outFile: string, files: string[]): string { const lines: string[] = [HEADER]; const sorted = [...files].sort(); // code-unit sort — locale-independent, diff-stable // Two filenames can decode to the same block id (`A B.json` + `A%20B.json`). // Mirror loadDecofileDirectory: last sorted filename wins, warn about the // shadowed one. Emitting both would also be a TS error (duplicate object // key), so the shadowed file is not imported at all. const keyToIndex = new Map(); for (let i = 0; i < sorted.length; i++) { const key = parseBlockId(sorted[i]); const prev = keyToIndex.get(key); if (prev !== undefined) { console.warn( `generate-blocks-manifest: block id ${JSON.stringify(key)} maps to multiple files; ${JSON.stringify(sorted[i])} wins`, ); } keyToIndex.set(key, i); } const emitted = [...keyToIndex.entries()]; // insertion order = sorted order for (const [, i] of emitted) { // Import specifier: the RAW filename, relative to the emitted module. // JSON.stringify provides all necessary escaping — bundlers do not // URL-decode specifiers, so no percent-escaping/normalizing here. let spec = path .relative(path.dirname(outFile), path.join(blocksDir, sorted[i])) .replace(/\\/g, "/"); if (!spec.startsWith(".")) spec = `./${spec}`; lines.push(`import _b${i} from ${JSON.stringify(spec)};`); } lines.push(""); lines.push("const blocks: Record = {"); for (const [key, i] of emitted) { lines.push(` ${JSON.stringify(key)}: _b${i},`); } lines.push("};"); lines.push(""); lines.push("export default blocks;"); lines.push(""); return lines.join("\n"); } async function writeIfChanged(outFile: string, content: string): Promise { let existing: string | undefined; try { existing = await fs.promises.readFile(outFile, "utf-8"); } catch {} if (existing === content) return false; await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); await fs.promises.writeFile(outFile, content); return true; } export async function generateBlocksManifest( options: GenerateBlocksManifestOptions, ): Promise { const blocksDir = path.resolve(options.blocksDir); const outFile = path.resolve(options.outFile); const silent = options.silent ?? false; if (!fs.existsSync(blocksDir)) { if (!silent) { console.warn(`Blocks directory not found: ${blocksDir} — generating empty manifest.`); } const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, [])); return { count: 0, outFile, empty: true, written }; } // Top-level *.json files only — mirrors loadDecofileDirectory, which // filters `entry.isFile()` and never recurses (nested paths live encoded // in the FILENAME, e.g. `collections%2Fblog%2F....json`). const files = (await fs.promises.readdir(blocksDir, { withFileTypes: true })) .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map((entry) => entry.name); const written = await writeIfChanged(outFile, renderManifest(blocksDir, outFile, files)); if (!silent) { console.log( `Generated static-import manifest for ${files.length} blocks → ` + `${path.relative(process.cwd(), outFile)}${written ? "" : " (unchanged)"}`, ); } return { count: files.length, outFile, empty: false, written }; } // --------------------------------------------------------------------------- // CLI shim — same pattern as generate-blocks.ts. // --------------------------------------------------------------------------- function isMainModule(): boolean { // tsx/node ESM: import.meta.url matches process.argv[1] when invoked directly. // Use a forgiving comparison so it works under both `tsx script.ts` and // `node --import tsx script.ts`. const entry = process.argv[1]; if (!entry) return false; try { const entryUrl = new URL(`file://${path.resolve(entry)}`).href; return import.meta.url === entryUrl; } catch { return false; } } if (isMainModule()) { const args = process.argv.slice(2); const arg = (name: string, fallback: string): string => { const idx = args.indexOf(`--${name}`); return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; }; const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks")); const outFile = path.resolve(process.cwd(), arg("out-file", ".deco/blocksManifest.gen.ts")); generateBlocksManifest({ blocksDir, outFile }).catch((err) => { console.error(err); process.exit(1); }); }