diff --git a/packages/blocks-cli/scripts/fast-deploy-kv.test.ts b/packages/blocks-cli/scripts/fast-deploy-kv.test.ts index c8659074..910bfeb9 100644 --- a/packages/blocks-cli/scripts/fast-deploy-kv.test.ts +++ b/packages/blocks-cli/scripts/fast-deploy-kv.test.ts @@ -10,6 +10,7 @@ import { snapshotKey, } from "@decocms/blocks/cms"; import { createKvRestClient, type KvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest"; +import { readDecofileFromDir } from "./lib/read-decofile"; import { kvNamespaceIdFromToml, kvNamespaceIdFromWrangler } from "./lib/wrangler-config"; import { buildSnapshot, @@ -341,3 +342,71 @@ describe("sync-helpers", () => { expect(paths).not.toContain(undefined); }); }); + +describe("readDecofileFromDir — CSV redirects", () => { + /** A site tree: `.deco/blocks/*.json` + `public/*.csv`. Returns the blocks dir. */ + function makeSite(blocks: Record, csv: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "decofile-csv-")); + const blocksDir = path.join(root, ".deco", "blocks"); + fs.mkdirSync(blocksDir, { recursive: true }); + fs.mkdirSync(path.join(root, "public"), { recursive: true }); + for (const [name, value] of Object.entries(blocks)) { + fs.writeFileSync(path.join(blocksDir, name), JSON.stringify(value)); + } + for (const [name, value] of Object.entries(csv)) { + fs.writeFileSync(path.join(root, "public", name), value); + } + return blocksDir; + } + + it("materializes a CSV loader nested under site.routes[] into a top-level block", () => { + // The shape that broke production: the CSV loader is nested inside + // `site.json -> routes[]`, where `loadRedirects` (top-level only) can't see + // it, and the CSV itself was only ever read by the unported Fresh loader. + const blocksDir = makeSite( + { + "site.json": { + __resolveType: "site/apps/site.ts", + routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }], + }, + }, + { "r.csv": "from,to,type\n/old,/new,permanent\n/tmp,/other\n" }, + ); + + const { blocks } = readDecofileFromDir(blocksDir, { silent: true }); + + expect(blocks["__csv_redirects__r.csv"]).toEqual({ + __resolveType: "website/loaders/redirects.ts", + redirects: [ + { from: "/old", to: "/new", type: "permanent" }, + { from: "/tmp", to: "/other", type: "temporary" }, + ], + }); + // The real blocks are untouched. + expect(blocks.site).toBeDefined(); + }); + + it("lets a real block win over the synthetic one on a key collision", () => { + const blocksDir = makeSite( + { + "site.json": { + routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "r.csv" }], + }, + // Decodes to exactly the synthetic key the CSV materializer produces. + "__csv_redirects__r.csv.json": { curated: true }, + }, + { "r.csv": "/old,/new\n" }, + ); + + const { blocks } = readDecofileFromDir(blocksDir, { silent: true }); + + // `{ ...csv, ...blocks }` — the curated block wins, never the synthetic one. + expect(blocks["__csv_redirects__r.csv"]).toEqual({ curated: true }); + }); + + it("is a no-op when no CSV loader is referenced", () => { + const blocksDir = makeSite({ "site.json": { __resolveType: "site/apps/site.ts" } }, {}); + const { blocks } = readDecofileFromDir(blocksDir, { silent: true }); + expect(Object.keys(blocks)).toEqual(["site"]); + }); +}); diff --git a/packages/blocks-cli/scripts/lib/read-decofile.ts b/packages/blocks-cli/scripts/lib/read-decofile.ts index fab18a40..5aca0fce 100644 --- a/packages/blocks-cli/scripts/lib/read-decofile.ts +++ b/packages/blocks-cli/scripts/lib/read-decofile.ts @@ -6,7 +6,7 @@ * the fast-deploy CI scripts (`migrate-blocks-to-kv.ts`, `sync-blocks-to-kv.ts`) * produce a decofile byte-identical to the bundled `blocks.gen` snapshot. * - * Reuses `scripts/lib/blocks-dedupe.ts`. + * Reuses `scripts/lib/blocks-dedupe.ts` and `scripts/lib/csv-redirects.ts`. */ import * as fs from "node:fs"; @@ -18,6 +18,7 @@ import { decodeBlockNameWithPasses, mergeCandidates, } from "./blocks-dedupe"; +import { buildCsvRedirectBlocks } from "./csv-redirects"; export interface ReadDecofileResult { /** Decoded block key → parsed block JSON. */ @@ -66,5 +67,15 @@ export function readDecofileFromDir(blocksDir: string, opts: { silent?: boolean for (const [key, candidate] of Object.entries(winners)) { blocks[key] = candidate.parsed; } - return { blocks, collisions }; + + // Synthetic `__csv_redirects__*` blocks, exactly as `generate-blocks` builds + // them. Without this the KV snapshot is NOT byte-identical to `blocks.gen`: + // a site whose redirects come from a CSV would lose every one of them the + // moment the bundled snapshot stops being the fallback (the fastDeploy stub + // makes KV the only source), turning migration 301s into silent 404s. + // Same precedence as the generator — a curated CMS redirect wins over CSV. + const csvBlocks = buildCsvRedirectBlocks(blocks, { blocksDir, silent: opts.silent }); + const merged = Object.keys(csvBlocks).length ? { ...csvBlocks, ...blocks } : blocks; + + return { blocks: merged, collisions }; }