@decocms/blocks-cli 7.20.6 → 7.20.8

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.6",
3
+ "version": "7.20.8",
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.6",
33
+ "@decocms/blocks": "7.20.8",
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
  // ---------------------------------------------------------------------------
@@ -178,8 +178,13 @@ Selection:
178
178
 
179
179
  Platform:
180
180
  --platform <name> Forwarded to schema. With "eitri": runs ONLY schema +
181
- blocks (skips manifest/sections/loaders/invoke) and makes
182
- the schema self-contained (composeMeta at gen time).
181
+ blocks (skips manifest/sections/loaders/invoke) and tags
182
+ the composed meta's framework field with "eitri".
183
+
184
+ The generated meta.gen.json is always self-contained (composeMeta runs at gen
185
+ time for every platform), so FS-only consumers (FS-based Studio, Eitri) and the
186
+ committed artifact all carry the framework block types. The tanstack runtime
187
+ re-composes on load idempotently, so it sees the same shape either way.
183
188
 
184
189
  Forwarded to the individual generators:
185
190
  --blocks-dir <dir> blocks + manifest input (default .deco/blocks)
@@ -653,9 +658,19 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
653
658
  ...(opts.namespace ? ["--namespace", opts.namespace] : []),
654
659
  ...(opts.platform ? ["--platform", opts.platform] : []),
655
660
  ...(opts.skipApps ? ["--skip-apps"] : []),
656
- // Eitri (and any FS-only consumer) needs a self-contained meta.gen.json:
657
- // bake composeMeta's framework block types in at generation time.
658
- ...(opts.platform === "eitri" ? ["--compose", "--framework", "eitri"] : []),
661
+ // Always bake composeMeta's framework block types (Page, matchers,
662
+ // __SECTION_REF__, Resolvable) into meta.gen.json so the file is
663
+ // SELF-CONTAINED for every consumer that reads it straight from disk —
664
+ // the FS-based Studio and the Eitri stack, but also anyone diffing the
665
+ // committed artifact against /live/_meta. The tanstack runtime re-runs
666
+ // composeMeta on load, which is now idempotent (it returns an
667
+ // already-composed meta unchanged — see composeMeta's `framework`
668
+ // sentinel), so this is a no-op there rather than a double-compose.
669
+ "--compose",
670
+ // Non-React stacks (Eitri) tag the composed meta with their own
671
+ // framework name; everyone else gets composeMeta's default
672
+ // ("tanstack-start").
673
+ ...(opts.platform === "eitri" ? ["--framework", "eitri"] : []),
659
674
  ],
660
675
  stage: 2,
661
676
  ...enabledIf(
@@ -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
+ }