@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.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- 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
|
+
}
|