Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/blocks-cli/scripts/fast-deploy-kv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>, csv: Record<string, string>): 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"]);
});
});
15 changes: 13 additions & 2 deletions packages/blocks-cli/scripts/lib/read-decofile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,6 +18,7 @@ import {
decodeBlockNameWithPasses,
mergeCandidates,
} from "./blocks-dedupe";
import { buildCsvRedirectBlocks } from "./csv-redirects";

export interface ReadDecofileResult {
/** Decoded block key → parsed block JSON. */
Expand Down Expand Up @@ -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 };
}