@decocms/blocks-cli 7.20.7 → 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.
|
|
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.
|
|
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
|
|
179
|
+
const mergedBlocks: Record<string, unknown> = {};
|
|
179
180
|
for (const [_name, c] of Object.entries(winners)) {
|
|
180
|
-
|
|
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
|
-
|
|
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
|
+
}
|