@decocms/blocks-cli 7.20.7 → 7.20.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.20.7",
3
+ "version": "7.20.9",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.20.7",
33
+ "@decocms/blocks": "7.20.9",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -48,6 +48,7 @@ import {
48
48
  mergeCandidates,
49
49
  singleDecodeBlockName,
50
50
  } from "./lib/blocks-dedupe";
51
+ import { buildCsvRedirectBlocks } from "./lib/csv-redirects";
51
52
  import { warnLegacyArtifact } from "./lib/legacyArtifact";
52
53
 
53
54
  const TS_STUB = [
@@ -175,11 +176,20 @@ export async function generateBlocks(
175
176
  // This matches the Deno runtime's `parseBlockId` (one decodeURIComponent)
176
177
  // so that studio's `encodeURIComponent(blockKey)` round-trips back to the
177
178
  // exact filename on disk.
178
- const blocks: Record<string, unknown> = {};
179
+ const mergedBlocks: Record<string, unknown> = {};
179
180
  for (const [_name, c] of Object.entries(winners)) {
180
- blocks[singleDecodeBlockName(c.file)] = c.parsed;
181
+ mergedBlocks[singleDecodeBlockName(c.file)] = c.parsed;
181
182
  }
182
183
 
184
+ // Read CSV-backed redirect loaders from public/ and emit synthetic top-level
185
+ // redirect blocks so `loadRedirects` can see them (it only scans top-level).
186
+ // Spread CSV blocks FIRST so a curated CMS redirect wins over a bulk CSV row
187
+ // for the same `from` (loadRedirects is last-write-wins over insertion order).
188
+ const csvRedirectBlocks = buildCsvRedirectBlocks(mergedBlocks, { blocksDir, silent });
189
+ const blocks: Record<string, unknown> = Object.keys(csvRedirectBlocks).length
190
+ ? { ...csvRedirectBlocks, ...mergedBlocks }
191
+ : mergedBlocks;
192
+
183
193
  await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
184
194
 
185
195
  // 1. Compact JSON — the real data (no pretty-printing to save ~40% size)
@@ -277,7 +287,11 @@ export function readBlockDelta(options: ReadBlockDeltaOptions): Record<string, u
277
287
  delta[key] = parsed;
278
288
  }
279
289
 
280
- return delta;
290
+ // If a changed block references a CSV redirect loader (e.g. site.json was
291
+ // edited), re-materialize the synthetic top-level redirect blocks into the
292
+ // delta so the live snapshot stays in sync. Curated blocks keep precedence.
293
+ const csvRedirectBlocks = buildCsvRedirectBlocks(delta, { blocksDir, silent });
294
+ return Object.keys(csvRedirectBlocks).length ? { ...csvRedirectBlocks, ...delta } : delta;
281
295
  }
282
296
 
283
297
  // ---------------------------------------------------------------------------
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Unit test for `buildCsvRedirectBlocks` — the generate-time materialization of
3
+ * `website/loaders/redirectsFromCsv.ts` blocks into synthetic top-level redirect
4
+ * blocks. Drives it against a tmp `public/` fixture and asserts the emitted
5
+ * blocks are what `loadRedirects` (runtime) actually consumes.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { buildCsvRedirectBlocks } from "./csv-redirects";
13
+
14
+ describe("buildCsvRedirectBlocks", () => {
15
+ let tmpDir: string;
16
+ let blocksDir: string;
17
+ let publicDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "csv-redirects-"));
21
+ blocksDir = path.join(tmpDir, ".deco", "blocks");
22
+ publicDir = path.join(tmpDir, "public");
23
+ fs.mkdirSync(blocksDir, { recursive: true });
24
+ fs.mkdirSync(publicDir, { recursive: true });
25
+ });
26
+
27
+ afterEach(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+
31
+ const writeCsv = (name: string, body: string) =>
32
+ fs.writeFileSync(path.join(publicDir, name), body);
33
+
34
+ it("materializes a CSV nested in site.routes into a top-level redirect block", () => {
35
+ writeCsv("redirects.csv", "from,to,type\n/old,/new,permanent\n/tmp-old,/tmp-new,temporary\n");
36
+ const blocks: Record<string, unknown> = {
37
+ site: {
38
+ __resolveType: "site/apps/site.ts",
39
+ routes: [
40
+ {
41
+ __resolveType: "website/loaders/redirectsFromCsv.ts",
42
+ from: "static/redirects.csv",
43
+ redirects: [],
44
+ },
45
+ ],
46
+ },
47
+ };
48
+
49
+ const csvBlocks = buildCsvRedirectBlocks(blocks, { blocksDir, silent: true });
50
+ expect(Object.keys(csvBlocks)).toEqual(["__csv_redirects__redirects.csv"]);
51
+
52
+ // The synthetic block is what loadRedirects consumes at runtime.
53
+ const map = loadRedirects({ ...csvBlocks, ...blocks });
54
+ expect(matchRedirect("/old", map)).toMatchObject({ to: "/new", status: 301 });
55
+ expect(matchRedirect("/tmp-old", map)).toMatchObject({ to: "/tmp-new", status: 302 });
56
+ });
57
+
58
+ it("drops the header row instead of turning it into a redirect", () => {
59
+ writeCsv("h.csv", "from,to,type\n/a,/b,permanent\n");
60
+ const blocks = {
61
+ x: { __resolveType: "website/loaders/redirectsFromCsv.ts", from: "public/h.csv" },
62
+ };
63
+ const map = loadRedirects(buildCsvRedirectBlocks(blocks, { blocksDir, silent: true }));
64
+ expect(matchRedirect("/from", map)).toBeNull();
65
+ expect(matchRedirect("/a", map)).toMatchObject({ to: "/b", status: 301 });
66
+ });
67
+
68
+ it("lets a curated redirect block win over a CSV row for the same `from`", () => {
69
+ writeCsv("c.csv", "from,to,type\n/dup,/from-csv,permanent\n");
70
+ const blocks: Record<string, unknown> = {
71
+ "redirect-curated": {
72
+ __resolveType: "website/loaders/redirect.ts",
73
+ redirect: { from: "/dup", to: "/from-cms", type: "permanent" },
74
+ },
75
+ site: {
76
+ __resolveType: "site/apps/site.ts",
77
+ routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "c.csv" }],
78
+ },
79
+ };
80
+ const csvBlocks = buildCsvRedirectBlocks(blocks, { blocksDir, silent: true });
81
+ // Merge CSV FIRST so curated wins (documented precedence).
82
+ const map = loadRedirects({ ...csvBlocks, ...blocks });
83
+ expect(matchRedirect("/dup", map)).toMatchObject({ to: "/from-cms" });
84
+ });
85
+
86
+ it("returns {} when no CSV loader is referenced", () => {
87
+ const blocks = { a: { __resolveType: "website/loaders/redirect.ts" } };
88
+ expect(buildCsvRedirectBlocks(blocks, { blocksDir, silent: true })).toEqual({});
89
+ });
90
+
91
+ it("does not throw when the referenced CSV is missing", () => {
92
+ const blocks = {
93
+ x: { __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/missing.csv" },
94
+ };
95
+ expect(() => buildCsvRedirectBlocks(blocks, { blocksDir, silent: true })).not.toThrow();
96
+ expect(buildCsvRedirectBlocks(blocks, { blocksDir, silent: true })).toEqual({});
97
+ });
98
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Materialize CSV-backed redirects at generate time.
3
+ *
4
+ * A `website/loaders/redirectsFromCsv.ts` block only carries a `from` CSV path
5
+ * plus an empty `redirects: []` — the CSV was read by a Fresh/Deno loader at
6
+ * runtime that was never ported to TanStack. On top of that, these blocks are
7
+ * usually nested inside `site.routes[]`, and the runtime `loadRedirects` only
8
+ * scans TOP-LEVEL decofile entries, so nested CSV loaders are invisible twice
9
+ * over.
10
+ *
11
+ * `generate-blocks` runs in Node with fs access to the site's `public/` dir, so
12
+ * here we read each referenced CSV and emit synthetic TOP-LEVEL redirect blocks
13
+ * (`__csv_redirects__<name>`) with the parsed rules. `loadRedirects` picks those
14
+ * up with no runtime I/O — it stays synchronous and unchanged.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { parseRedirectsCsv } from "@decocms/blocks/sdk/redirects";
19
+
20
+ const CSV_REDIRECT_RESOLVE_TYPE = "website/loaders/redirectsFromCsv.ts";
21
+ const REDIRECTS_RESOLVE_TYPE = "website/loaders/redirects.ts";
22
+
23
+ /** Recursively collect `from` CSV paths referenced by redirectsFromCsv nodes. */
24
+ function collectCsvRefs(node: unknown, out: Set<string>): void {
25
+ if (!node || typeof node !== "object") return;
26
+ if (Array.isArray(node)) {
27
+ for (const item of node) collectCsvRefs(item, out);
28
+ return;
29
+ }
30
+ const obj = node as Record<string, unknown>;
31
+ if (obj.__resolveType === CSV_REDIRECT_RESOLVE_TYPE && typeof obj.from === "string") {
32
+ out.add(obj.from);
33
+ }
34
+ for (const value of Object.values(obj)) collectCsvRefs(value, out);
35
+ }
36
+
37
+ /**
38
+ * Resolve a decofile CSV `from` to an absolute path under the site's `public/`.
39
+ * Accepts `static/foo.csv` (Fresh convention), `public/foo.csv`, or `foo.csv`.
40
+ */
41
+ function resolveCsvPath(from: string, publicDir: string): string {
42
+ const rel = from.replace(/^\/?(?:static|public)\//i, "").replace(/^\//, "");
43
+ return path.resolve(publicDir, rel);
44
+ }
45
+
46
+ export interface MaterializeCsvRedirectsOptions {
47
+ /** The `.deco/blocks` directory; `public/` is resolved relative to its root. */
48
+ blocksDir: string;
49
+ /** Suppress info/warn logs. Defaults to false. */
50
+ silent?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Scan `blocks` for CSV redirect loaders and return synthetic top-level redirect
55
+ * blocks (keyed `__csv_redirects__<name>`). Read-only over `blocks`; returns an
56
+ * empty object when no CSV loader is referenced. A missing CSV file warns but
57
+ * never throws, so it cannot break the build.
58
+ *
59
+ * Callers should merge the result with LOWER precedence than the real blocks
60
+ * (`{ ...csvBlocks, ...blocks }`) so a curated CMS redirect always wins over a
61
+ * bulk-migration CSV row for the same `from` (`loadRedirects` is last-write-wins
62
+ * over insertion order).
63
+ */
64
+ export function buildCsvRedirectBlocks(
65
+ blocks: Record<string, unknown>,
66
+ options: MaterializeCsvRedirectsOptions,
67
+ ): Record<string, unknown> {
68
+ const silent = options.silent ?? false;
69
+ const publicDir = path.resolve(options.blocksDir, "../../public");
70
+
71
+ const refs = new Set<string>();
72
+ for (const value of Object.values(blocks)) collectCsvRefs(value, refs);
73
+ if (refs.size === 0) return {};
74
+
75
+ const csvBlocks: Record<string, unknown> = {};
76
+ for (const from of refs) {
77
+ const csvPath = resolveCsvPath(from, publicDir);
78
+ let csv: string;
79
+ try {
80
+ csv = fs.readFileSync(csvPath, "utf-8");
81
+ } catch {
82
+ if (!silent) {
83
+ console.warn(`[redirects] CSV not found for "${from}" (looked at ${csvPath}) — skipping.`);
84
+ }
85
+ continue;
86
+ }
87
+
88
+ // `loadRedirects` reads `entry.type` ("permanent" | "temporary"), while
89
+ // `parseRedirectsCsv` returns a numeric `status` — map it back so 301s stay
90
+ // 301s (a raw status field would be ignored and default to 302).
91
+ const entries = parseRedirectsCsv(csv).map((r) => ({
92
+ from: r.from,
93
+ to: r.to,
94
+ type: r.status === 301 ? "permanent" : "temporary",
95
+ }));
96
+
97
+ const key = `__csv_redirects__${path.basename(csvPath)}`;
98
+ csvBlocks[key] = { redirects: entries, __resolveType: REDIRECTS_RESOLVE_TYPE };
99
+
100
+ if (!silent) {
101
+ console.log(
102
+ `[redirects] Materialized ${entries.length} redirects from ${path.basename(csvPath)}.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ return csvBlocks;
108
+ }