@decocms/blocks-cli 7.0.0

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.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Pure helpers used by `generate-blocks.ts` to choose between multiple files
3
+ * that decode to the same logical CMS block key.
4
+ *
5
+ * Background: the live decofile snapshot lives under `.deco/blocks/`, with
6
+ * one file per block. The filename is `encodeURIComponent(<rawProdKey>) +
7
+ * ".json"`. The Deco admin sometimes serves URL-encoded keys (e.g.
8
+ * `pages-Home%20-%20LB-618509`), so a single block can land on disk with
9
+ * different filenames depending on which writer produced it:
10
+ *
11
+ * - The `deco-sync-bot` (CI) encodes the raw prod key as-is, producing
12
+ * `pages-Home%2520-%2520LB-618509.json` (two decode passes back to the
13
+ * literal key).
14
+ * - The legacy manual `sync-decofile.ts` decoded keys to literal first,
15
+ * so it wrote `pages-Home%20-%20LB-618509.json` (one decode pass).
16
+ *
17
+ * Both files decode to the same logical key, so the block generator must
18
+ * pick one. Picking by file size is wrong (a shrunk live page gives a
19
+ * smaller JSON than the stale older snapshot, so size silently prefers
20
+ * stale); picking by mtime alone is wrong (fresh `git clone` writes all
21
+ * files with the clone time and erases temporal ordering).
22
+ */
23
+
24
+ export interface Candidate {
25
+ file: string;
26
+ passes: number;
27
+ mtimeMs: number;
28
+ hasPath: boolean;
29
+ parsed: unknown;
30
+ }
31
+
32
+ /**
33
+ * Repeatedly URL-decode the basename of `filename` until no `%` sequence
34
+ * remains. Returns the fully-decoded canonical key plus the number of
35
+ * decode rounds it took. Higher pass count = the writer encoded a key
36
+ * that itself contained `%XX` sequences = bot scheme. See module-level
37
+ * comment for why this matters.
38
+ */
39
+ export function decodeBlockNameWithPasses(filename: string): {
40
+ name: string;
41
+ passes: number;
42
+ } {
43
+ let name = filename.replace(/\.json$/, "");
44
+ let passes = 0;
45
+ while (name.includes("%")) {
46
+ try {
47
+ const next = decodeURIComponent(name);
48
+ if (next === name) break;
49
+ name = next;
50
+ passes++;
51
+ } catch {
52
+ break;
53
+ }
54
+ }
55
+ return { name, passes };
56
+ }
57
+
58
+ export function decodeBlockName(filename: string): string {
59
+ return decodeBlockNameWithPasses(filename).name;
60
+ }
61
+
62
+ /**
63
+ * Decode the filename stem exactly once — matches the Deno runtime's
64
+ * `parseBlockId`. Use this for the decofile key so that the CMS editor's
65
+ * `encodeURIComponent(blockKey)` round-trips back to the original filename.
66
+ */
67
+ export function singleDecodeBlockName(filename: string): string {
68
+ const stem = filename.replace(/\.json$/, "");
69
+ try {
70
+ return decodeURIComponent(stem);
71
+ } catch {
72
+ return stem;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Tie-break two candidates that decode to the same key. Priority:
78
+ * 1. Block has a non-null `path` — beats zombie/orphan entries.
79
+ * 2. More decode passes — bot's "encode raw prod key"
80
+ * scheme wins over legacy
81
+ * "decode-then-encode" leftovers
82
+ * when prod uses URL-encoded keys
83
+ * (the only case that collides).
84
+ * 3. Newer mtime — last-write-wins for same scheme.
85
+ * 4. Lexicographic filename — deterministic last resort.
86
+ */
87
+ export function pickWinner(a: Candidate, b: Candidate): Candidate {
88
+ if (a.hasPath !== b.hasPath) return a.hasPath ? a : b;
89
+ if (a.passes !== b.passes) return a.passes > b.passes ? a : b;
90
+ if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs > b.mtimeMs ? a : b;
91
+ return a.file < b.file ? a : b;
92
+ }
93
+
94
+ /** True iff a parsed block JSON looks like a live page (non-empty `.path`). */
95
+ export function blockHasPath(parsed: unknown): boolean {
96
+ return (
97
+ typeof parsed === "object" &&
98
+ parsed !== null &&
99
+ "path" in parsed &&
100
+ typeof (parsed as { path?: unknown }).path === "string" &&
101
+ (parsed as { path: string }).path.length > 0
102
+ );
103
+ }
104
+
105
+ export interface CollisionRecord {
106
+ key: string;
107
+ files: string[];
108
+ winner: string;
109
+ }
110
+
111
+ export interface MergeResult {
112
+ winners: Record<string, Candidate>;
113
+ collisions: CollisionRecord[];
114
+ }
115
+
116
+ /**
117
+ * Reduce a list of candidates into one winner per decoded key, recording
118
+ * every collision so the caller can surface it as a build warning.
119
+ */
120
+ export function mergeCandidates(
121
+ candidates: Array<{ candidate: Candidate; key: string }>,
122
+ ): MergeResult {
123
+ const winners: Record<string, Candidate> = {};
124
+ // Track every file that decoded to a given key so three-way (and beyond)
125
+ // collisions don't lose the eventual winner from the file list.
126
+ const filesByKey: Record<string, string[]> = {};
127
+ for (const { candidate, key } of candidates) {
128
+ if (!filesByKey[key]) filesByKey[key] = [];
129
+ const list = filesByKey[key];
130
+ if (!list.includes(candidate.file)) list.push(candidate.file);
131
+
132
+ const existing = winners[key];
133
+ winners[key] = existing ? pickWinner(existing, candidate) : candidate;
134
+ }
135
+
136
+ const collisions: CollisionRecord[] = [];
137
+ for (const [key, files] of Object.entries(filesByKey)) {
138
+ if (files.length < 2) continue;
139
+ collisions.push({ key, files, winner: winners[key].file });
140
+ }
141
+ return { winners, collisions };
142
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Minimal Cloudflare KV REST API client for CI.
3
+ *
4
+ * CI has no Worker KV binding, so the fast-deploy sync/migrate scripts write to
5
+ * KV over the REST API instead. Only the two operations the scripts need —
6
+ * single-key GET and PUT — are implemented.
7
+ *
8
+ * Auth/config via env (read by the scripts, passed to `createKvRestClient`):
9
+ * - CF_ACCOUNT_ID Cloudflare account id
10
+ * - CF_KV_NAMESPACE_ID target KV namespace id
11
+ * - CF_API_TOKEN API token with "Workers KV Storage:Edit"
12
+ *
13
+ * `fetch` is injectable so the client is unit-testable without network.
14
+ */
15
+
16
+ export interface KvRestConfig {
17
+ accountId: string;
18
+ namespaceId: string;
19
+ token: string;
20
+ /** Injectable for tests; defaults to global fetch. */
21
+ fetchImpl?: typeof fetch;
22
+ /** Override API base (tests). Defaults to the Cloudflare API. */
23
+ baseUrl?: string;
24
+ }
25
+
26
+ export interface KvRestClient {
27
+ get(key: string): Promise<string | null>;
28
+ put(key: string, value: string): Promise<void>;
29
+ }
30
+
31
+ const DEFAULT_BASE = "https://api.cloudflare.com/client/v4";
32
+
33
+ /** Resolve the three required env vars or throw a clear error. */
34
+ export function kvConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Omit<KvRestConfig, "fetchImpl" | "baseUrl"> {
35
+ const accountId = env.CF_ACCOUNT_ID;
36
+ const namespaceId = env.CF_KV_NAMESPACE_ID;
37
+ const token = env.CF_API_TOKEN;
38
+ const missing = [
39
+ !accountId && "CF_ACCOUNT_ID",
40
+ !namespaceId && "CF_KV_NAMESPACE_ID",
41
+ !token && "CF_API_TOKEN",
42
+ ].filter(Boolean);
43
+ if (missing.length) {
44
+ throw new Error(`missing required env var(s): ${missing.join(", ")}`);
45
+ }
46
+ return { accountId: accountId!, namespaceId: namespaceId!, token: token! };
47
+ }
48
+
49
+ export function createKvRestClient(config: KvRestConfig): KvRestClient {
50
+ const fetchImpl = config.fetchImpl ?? fetch;
51
+ const base = config.baseUrl ?? DEFAULT_BASE;
52
+ const root = `${base}/accounts/${config.accountId}/storage/kv/namespaces/${config.namespaceId}`;
53
+ const authHeaders = { Authorization: `Bearer ${config.token}` };
54
+
55
+ return {
56
+ async get(key) {
57
+ const res = await fetchImpl(`${root}/values/${encodeURIComponent(key)}`, {
58
+ headers: authHeaders,
59
+ });
60
+ if (res.status === 404) return null;
61
+ if (!res.ok) {
62
+ throw new Error(`KV GET ${key} failed: ${res.status} ${await res.text()}`);
63
+ }
64
+ return res.text();
65
+ },
66
+
67
+ async put(key, value) {
68
+ const res = await fetchImpl(`${root}/values/${encodeURIComponent(key)}`, {
69
+ method: "PUT",
70
+ headers: { ...authHeaders, "Content-Type": "text/plain" },
71
+ body: value,
72
+ });
73
+ if (!res.ok) {
74
+ throw new Error(`KV PUT ${key} failed: ${res.status} ${await res.text()}`);
75
+ }
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared JSONC helpers.
3
+ *
4
+ * Vendored mini-parser used by the observability codemod
5
+ * (`migrate-to-cf-observability.ts`) and the audit
6
+ * (`audit-observability-config.ts`). Kept here so a single bugfix
7
+ * lands in both call sites.
8
+ *
9
+ * - `stripJsoncComments` removes line + block comments while:
10
+ * - preserving quoted strings (handles `\"` and `\\` escapes),
11
+ * - preserving newlines (so JSON.parse error line numbers stay
12
+ * aligned with the original source file).
13
+ *
14
+ * - `stripJsoncTrailingCommas` removes trailing commas before `}` and
15
+ * `]`. JSONC allows them; vanilla `JSON.parse` does not. Real
16
+ * `wrangler.jsonc` files commonly have trailing commas — audits that
17
+ * call `JSON.parse` directly fail surprisingly otherwise.
18
+ *
19
+ * - `parseJsonc` is the convenience wrapper: strip comments + trailing
20
+ * commas, then `JSON.parse`. Throws on malformed input.
21
+ */
22
+ export function stripJsoncComments(src: string): string {
23
+ let out = "";
24
+ let i = 0;
25
+ let inString = false;
26
+ let stringQuote = "";
27
+ while (i < src.length) {
28
+ const ch = src[i];
29
+ const next = src[i + 1];
30
+ if (inString) {
31
+ out += ch;
32
+ if (ch === "\\" && i + 1 < src.length) {
33
+ out += next;
34
+ i += 2;
35
+ continue;
36
+ }
37
+ if (ch === stringQuote) {
38
+ inString = false;
39
+ }
40
+ i++;
41
+ continue;
42
+ }
43
+ if (ch === '"' || ch === "'") {
44
+ inString = true;
45
+ stringQuote = ch;
46
+ out += ch;
47
+ i++;
48
+ continue;
49
+ }
50
+ if (ch === "/" && next === "/") {
51
+ while (i < src.length && src[i] !== "\n") i++;
52
+ continue;
53
+ }
54
+ if (ch === "/" && next === "*") {
55
+ i += 2;
56
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
57
+ if (src[i] === "\n") out += "\n";
58
+ i++;
59
+ }
60
+ i += 2;
61
+ continue;
62
+ }
63
+ out += ch;
64
+ i++;
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Strip trailing commas from a JSONC string — the comma between the
71
+ * last value and its closing `}` or `]`. String-aware so commas inside
72
+ * string literals are preserved verbatim.
73
+ */
74
+ export function stripJsoncTrailingCommas(src: string): string {
75
+ let out = "";
76
+ let i = 0;
77
+ let inString = false;
78
+ let stringQuote = "";
79
+ while (i < src.length) {
80
+ const ch = src[i];
81
+ if (inString) {
82
+ out += ch;
83
+ if (ch === "\\" && i + 1 < src.length) {
84
+ out += src[i + 1];
85
+ i += 2;
86
+ continue;
87
+ }
88
+ if (ch === stringQuote) inString = false;
89
+ i++;
90
+ continue;
91
+ }
92
+ if (ch === '"' || ch === "'") {
93
+ inString = true;
94
+ stringQuote = ch;
95
+ out += ch;
96
+ i++;
97
+ continue;
98
+ }
99
+ if (ch === ",") {
100
+ // Look ahead through whitespace for the next non-space character.
101
+ let j = i + 1;
102
+ while (j < src.length && /\s/.test(src[j])) j++;
103
+ if (j < src.length && (src[j] === "}" || src[j] === "]")) {
104
+ // Drop the comma, keep the whitespace so line numbers stay aligned.
105
+ i++;
106
+ continue;
107
+ }
108
+ }
109
+ out += ch;
110
+ i++;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Parse a JSONC string. Strips comments + trailing commas before
117
+ * handing to `JSON.parse`. Throws the same `SyntaxError` as JSON.parse
118
+ * for inputs that remain malformed after stripping.
119
+ */
120
+ export function parseJsonc<T = unknown>(src: string): T {
121
+ return JSON.parse(stripJsoncTrailingCommas(stripJsoncComments(src))) as T;
122
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared fast-deploy snapshot helpers for the CI scripts.
3
+ *
4
+ * Both `migrate-blocks-to-kv.ts` and `sync-blocks-to-kv.ts` write the same two
5
+ * keys (`decofile:current` + `index:revision`). The revision is computed with
6
+ * the SAME `computeRevision` the runtime uses (`src/cms/blockSource.ts`) so a
7
+ * hydrating isolate computes a matching revision and the poller doesn't loop.
8
+ */
9
+
10
+ import { computeRevision, KV_KEYS } from "@decocms/blocks/cms";
11
+ import type { KvRestClient } from "./cf-kv-rest";
12
+
13
+ export interface Snapshot {
14
+ /** Serialized decofile written to `decofile:current`. */
15
+ snapshot: string;
16
+ /** DJB2 revision written to `index:revision`. */
17
+ revision: string;
18
+ /** Block count, for logging. */
19
+ count: number;
20
+ }
21
+
22
+ export function buildSnapshot(blocks: Record<string, unknown>): Snapshot {
23
+ return {
24
+ snapshot: JSON.stringify(blocks),
25
+ revision: computeRevision(blocks),
26
+ count: Object.keys(blocks).length,
27
+ };
28
+ }
29
+
30
+ /** Write the snapshot + revision to KV. Snapshot first, then revision, so a
31
+ * poller never sees a new revision pointing at an old snapshot. */
32
+ export async function writeSnapshotToKv(client: KvRestClient, snap: Snapshot): Promise<void> {
33
+ await client.put(KV_KEYS.SNAPSHOT, snap.snapshot);
34
+ await client.put(KV_KEYS.REVISION, snap.revision);
35
+ }
36
+
37
+ /** Read both keys back and confirm the revision matches what we wrote. */
38
+ export async function verifySnapshotInKv(
39
+ client: KvRestClient,
40
+ expectedRevision: string,
41
+ ): Promise<{ ok: boolean; reason?: string }> {
42
+ const [snapshot, revision] = await Promise.all([
43
+ client.get(KV_KEYS.SNAPSHOT),
44
+ client.get(KV_KEYS.REVISION),
45
+ ]);
46
+ if (snapshot === null) return { ok: false, reason: `${KV_KEYS.SNAPSHOT} missing` };
47
+ if (revision !== expectedRevision) {
48
+ return { ok: false, reason: `${KV_KEYS.REVISION} is "${revision}", expected "${expectedRevision}"` };
49
+ }
50
+ return { ok: true };
51
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Read a `.deco/blocks/*.json` directory into a single decofile map.
3
+ *
4
+ * This is the same merge the block generator performs (filename → decoded
5
+ * block key, with collision tie-breaking via `pickWinner`), factored out so
6
+ * the fast-deploy CI scripts (`migrate-blocks-to-kv.ts`, `sync-blocks-to-kv.ts`)
7
+ * produce a decofile byte-identical to the bundled `blocks.gen` snapshot.
8
+ *
9
+ * Reuses `scripts/lib/blocks-dedupe.ts`.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import {
15
+ blockHasPath,
16
+ type Candidate,
17
+ type CollisionRecord,
18
+ decodeBlockNameWithPasses,
19
+ mergeCandidates,
20
+ } from "./blocks-dedupe";
21
+
22
+ export interface ReadDecofileResult {
23
+ /** Decoded block key → parsed block JSON. */
24
+ blocks: Record<string, unknown>;
25
+ /** Filename collisions (same decoded key from multiple files). */
26
+ collisions: CollisionRecord[];
27
+ }
28
+
29
+ /**
30
+ * Read and merge every `*.json` under `blocksDir` into a decofile map.
31
+ * Throws if the directory does not exist. Unparseable files are skipped
32
+ * (logged unless `silent`).
33
+ */
34
+ export function readDecofileFromDir(blocksDir: string, opts: { silent?: boolean } = {}): ReadDecofileResult {
35
+ if (!fs.existsSync(blocksDir)) {
36
+ throw new Error(`blocks directory not found: ${blocksDir}`);
37
+ }
38
+
39
+ const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
40
+ const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
41
+
42
+ for (const file of files) {
43
+ const { name, passes } = decodeBlockNameWithPasses(file);
44
+ const fp = path.join(blocksDir, file);
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
48
+ } catch (e) {
49
+ if (!opts.silent) console.warn(`Failed to parse ${file}:`, e);
50
+ continue;
51
+ }
52
+ candidatesWithKeys.push({
53
+ key: name,
54
+ candidate: {
55
+ file,
56
+ passes,
57
+ mtimeMs: fs.statSync(fp).mtimeMs,
58
+ hasPath: blockHasPath(parsed),
59
+ parsed,
60
+ },
61
+ });
62
+ }
63
+
64
+ const { winners, collisions } = mergeCandidates(candidatesWithKeys);
65
+ const blocks: Record<string, unknown> = {};
66
+ for (const [key, candidate] of Object.entries(winners)) {
67
+ blocks[key] = candidate.parsed;
68
+ }
69
+ return { blocks, collisions };
70
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure helpers for `sync-blocks-to-kv.ts` — kept separate from the CLI so they
3
+ * can be unit-tested without executing the script's `main()`.
4
+ */
5
+
6
+ import { decodeBlockName } from "./blocks-dedupe";
7
+
8
+ /**
9
+ * Parse `git diff --name-only` output into the set of changed block files that
10
+ * live under `blocksDir`. Paths are POSIX (git always emits forward slashes).
11
+ */
12
+ export function changedBlockFiles(gitOutput: string, blocksDir: string): string[] {
13
+ const prefix = blocksDir.replace(/\\/g, "/").replace(/\/$/, "");
14
+ return gitOutput
15
+ .split("\n")
16
+ .map((l) => l.trim())
17
+ .filter(Boolean)
18
+ .filter((l) => l.startsWith(`${prefix}/`) && l.endsWith(".json"));
19
+ }
20
+
21
+ /** Map changed block-file paths to their decoded block keys. */
22
+ export function changedBlockKeys(files: string[]): string[] {
23
+ return files.map((f) => decodeBlockName(f.split("/").pop() ?? f));
24
+ }
25
+
26
+ /**
27
+ * Derive the page paths to purge from the changed block keys. Only page blocks
28
+ * carrying a string `path` contribute; "/" is always included so the home page
29
+ * (and anything edge-cached under the static profile) is refreshed.
30
+ */
31
+ export function purgePathsForChangedKeys(
32
+ blocks: Record<string, unknown>,
33
+ changedKeys: string[],
34
+ ): string[] {
35
+ const paths = new Set<string>(["/"]);
36
+ for (const key of changedKeys) {
37
+ const block = blocks[key];
38
+ if (block && typeof block === "object" && !Array.isArray(block)) {
39
+ const p = (block as { path?: unknown }).path;
40
+ if (typeof p === "string" && p.length > 0) paths.add(p);
41
+ }
42
+ }
43
+ return [...paths];
44
+ }