@blocofy/cli 0.8.0 → 0.10.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/README.md +359 -38
- package/bin/blocofy.mjs +1260 -234
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +375 -48
- package/lib/credentials.mjs +216 -60
- package/lib/http.mjs +60 -0
- package/lib/local-theme.mjs +7 -2
- package/lib/media-uses.mjs +27 -16
- package/lib/messages.mjs +42 -2
- package/lib/output.mjs +53 -0
- package/lib/page-files.mjs +318 -0
- package/lib/page-path-codec.mjs +193 -0
- package/lib/secret-store.mjs +133 -0
- package/lib/site-migrate.mjs +132 -0
- package/lib/site-state-client.mjs +92 -0
- package/lib/site-state-fs.mjs +137 -0
- package/lib/site-state.mjs +682 -0
- package/lib/target.mjs +427 -0
- package/lib/theme-sync.mjs +113 -80
- package/package.json +1 -1
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { canonicalizeLocaleCode, pageFilePath, parsePageFilePath } from "./page-path-codec.mjs";
|
|
4
|
+
import { classifyPageFile } from "./page-files.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CF-T4 — Declarative Site State, the PURE half. Mirror of the platform's `packages/cms/src/site-state/*`
|
|
8
|
+
* (paths.ts, canonical.ts, manifest.ts, diff.ts, validate.ts), one file because the CLI keeps its mirrors
|
|
9
|
+
* flat (see `lib/page-path-codec.mjs`). No database, no filesystem, no network — `test/fixtures/site-state-vectors.json`
|
|
10
|
+
* is the shared oracle with the platform; a vector changed on one side and not the other is a drift.
|
|
11
|
+
*
|
|
12
|
+
* Page-path pieces (`pageFilePath`, `parsePageFilePath`, `canonicalizeLocaleCode`, `classifyPageFile`) are
|
|
13
|
+
* NOT re-implemented here — they are the CLI's existing PS-19 mirrors (`page-path-codec.mjs`, `page-files.mjs`),
|
|
14
|
+
* reused so there is exactly one page-path grammar in this codebase.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const SITE_STATE_SCHEMA_VERSION = 1;
|
|
18
|
+
export const SITE_STATE_KIND = "blocofy-site-state";
|
|
19
|
+
export const MANIFEST_PATH = "blocofy-site.json";
|
|
20
|
+
|
|
21
|
+
export const SITE_STATE_OWNERS = [
|
|
22
|
+
"locales",
|
|
23
|
+
"globals",
|
|
24
|
+
"media_policy",
|
|
25
|
+
"content_model",
|
|
26
|
+
"translations",
|
|
27
|
+
"navigation",
|
|
28
|
+
"theme",
|
|
29
|
+
"settings",
|
|
30
|
+
"chrome",
|
|
31
|
+
"pages",
|
|
32
|
+
"media_decisions",
|
|
33
|
+
"assets",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const SITE_LOCALES_PATH = "site/locales.json";
|
|
37
|
+
export const SITE_GLOBALS_PATH = "site/globals.json";
|
|
38
|
+
export const SITE_MEDIA_POLICY_PATH = "site/media-policy.json";
|
|
39
|
+
export const SITE_CONTENT_MODEL_PATH = "site/content-model.json";
|
|
40
|
+
export const SITE_TRANSLATIONS_PATH = "site/translations.json";
|
|
41
|
+
export const THEME_SETTINGS_PATH = "theme/config/settings.json";
|
|
42
|
+
export const THEME_CHROME_HEADER_PATH = "theme/chrome/header_group.json";
|
|
43
|
+
export const THEME_CHROME_FOOTER_PATH = "theme/chrome/footer_group.json";
|
|
44
|
+
export const ASSETS_INDEX_PATH = "media/assets.json";
|
|
45
|
+
export const ASSET_FILES_PREFIX = "media/files/";
|
|
46
|
+
export const PAGE_FILE_NAME = "index.json";
|
|
47
|
+
export const PAGE_MEDIA_DECISIONS_NAME = "media-decisions.json";
|
|
48
|
+
|
|
49
|
+
export const SITE_STATE_LIMITS = {
|
|
50
|
+
pageFiles: 500,
|
|
51
|
+
pageBytes: 4 * 1024 * 1024,
|
|
52
|
+
themeFiles: 256,
|
|
53
|
+
themeBytes: 4 * 1024 * 1024,
|
|
54
|
+
navigationMenus: 50,
|
|
55
|
+
navigationItems: 500,
|
|
56
|
+
assetFiles: 200,
|
|
57
|
+
assetBytes: 50 * 1024 * 1024,
|
|
58
|
+
assetTotalBytes: 256 * 1024 * 1024,
|
|
59
|
+
requestBytes: 16 * 1024 * 1024,
|
|
60
|
+
segmentBytes: 200,
|
|
61
|
+
pathBytes: 400,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const utf8 = new TextEncoder();
|
|
65
|
+
const byteLength = (s) => utf8.encode(s).length;
|
|
66
|
+
|
|
67
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
68
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
69
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
|
70
|
+
const WINDOWS_FORBIDDEN = /[<>:"|?*]/;
|
|
71
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
72
|
+
const LOCALE_SEGMENT = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
73
|
+
const NAV_HANDLE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
74
|
+
|
|
75
|
+
// ── paths ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** Why this path may not appear in a site-state tree, or null when it may. */
|
|
78
|
+
export function checkSiteStatePath(path) {
|
|
79
|
+
const invalid = (reason) => ({ code: "SITE_STATE_INVALID_PATH", reason });
|
|
80
|
+
if (typeof path !== "string" || path === "") return invalid("empty path");
|
|
81
|
+
if (byteLength(path) > SITE_STATE_LIMITS.pathBytes) return invalid(`path is longer than ${SITE_STATE_LIMITS.pathBytes} bytes`);
|
|
82
|
+
if (path.startsWith("/") || /^[A-Za-z]:/.test(path)) return invalid("absolute path");
|
|
83
|
+
if (path.includes("\\")) return invalid("backslash in path");
|
|
84
|
+
if (CONTROL.test(path)) return invalid("control character in path");
|
|
85
|
+
if (LONE_SURROGATE.test(path)) return invalid("path is not well-formed Unicode");
|
|
86
|
+
if (path.normalize("NFC") !== path) return invalid("path is not in Unicode NFC form");
|
|
87
|
+
for (const segment of path.split("/")) {
|
|
88
|
+
if (segment === "") return invalid("empty path segment");
|
|
89
|
+
if (segment === "." || segment === "..") return invalid("'.' or '..' path segment");
|
|
90
|
+
if (segment.startsWith(".")) return invalid("leading-dot path segment");
|
|
91
|
+
if (segment.endsWith(".") || segment.endsWith(" ") || segment.startsWith(" ")) {
|
|
92
|
+
return invalid("path segment ends with a dot or space");
|
|
93
|
+
}
|
|
94
|
+
if (WINDOWS_FORBIDDEN.test(segment)) return invalid("path segment contains a character Windows refuses");
|
|
95
|
+
if (WINDOWS_RESERVED.test(segment.split(".")[0] ?? "")) return invalid("Windows reserved path segment");
|
|
96
|
+
if (byteLength(segment) > SITE_STATE_LIMITS.segmentBytes) {
|
|
97
|
+
return invalid(`path segment is longer than ${SITE_STATE_LIMITS.segmentBytes} bytes`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (path === MANIFEST_PATH) return null;
|
|
101
|
+
return ownerOfPath(path) === null ? { code: "SITE_STATE_UNKNOWN_PATH", reason: "not a site-state file" } : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which owner this path belongs to, or null when nothing owns it. */
|
|
105
|
+
export function ownerOfPath(path) {
|
|
106
|
+
if (path === MANIFEST_PATH) return null;
|
|
107
|
+
if (path === SITE_LOCALES_PATH) return "locales";
|
|
108
|
+
if (path === SITE_GLOBALS_PATH) return "globals";
|
|
109
|
+
if (path === SITE_MEDIA_POLICY_PATH) return "media_policy";
|
|
110
|
+
if (path === SITE_CONTENT_MODEL_PATH) return "content_model";
|
|
111
|
+
if (path === SITE_TRANSLATIONS_PATH) return "translations";
|
|
112
|
+
if (path === ASSETS_INDEX_PATH) return "assets";
|
|
113
|
+
if (path === THEME_SETTINGS_PATH) return "settings";
|
|
114
|
+
if (path === THEME_CHROME_HEADER_PATH || path === THEME_CHROME_FOOTER_PATH) return "chrome";
|
|
115
|
+
|
|
116
|
+
const parts = path.split("/");
|
|
117
|
+
if (parts[0] === "site" && parts[1] === "navigation") {
|
|
118
|
+
if (parts.length !== 4 || !LOCALE_SEGMENT.test(parts[2]) || !parts[3].endsWith(".json")) return null;
|
|
119
|
+
return NAV_HANDLE.test(parts[3].slice(0, -".json".length)) ? "navigation" : null;
|
|
120
|
+
}
|
|
121
|
+
if (parts[0] === "pages") {
|
|
122
|
+
const last = parts[parts.length - 1];
|
|
123
|
+
if (last === PAGE_FILE_NAME) return parsePageFilePath(path).kind === "invalid" ? null : "pages";
|
|
124
|
+
if (last === PAGE_MEDIA_DECISIONS_NAME) {
|
|
125
|
+
const sibling = [...parts.slice(0, -1), PAGE_FILE_NAME].join("/");
|
|
126
|
+
return parsePageFilePath(sibling).kind === "invalid" ? null : "media_decisions";
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (path.startsWith(ASSET_FILES_PREFIX)) {
|
|
131
|
+
return SHA256_HEX.test(path.slice(ASSET_FILES_PREFIX.length)) ? "assets" : null;
|
|
132
|
+
}
|
|
133
|
+
if (parts[0] === "theme" && parts.length >= 2) {
|
|
134
|
+
if (parts[1] === "chrome") return null;
|
|
135
|
+
return "theme";
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function pageStatePaths(locale, slug) {
|
|
141
|
+
const page = pageFilePath(locale, slug);
|
|
142
|
+
return { page, mediaDecisions: page.slice(0, -PAGE_FILE_NAME.length) + PAGE_MEDIA_DECISIONS_NAME };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function navigationPath(locale, handle) {
|
|
146
|
+
return `site/navigation/${locale}/${handle}.json`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function chromePath(area) {
|
|
150
|
+
return area === "header" ? THEME_CHROME_HEADER_PATH : THEME_CHROME_FOOTER_PATH;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const foldPath = (path) => path.normalize("NFC").toLowerCase();
|
|
154
|
+
|
|
155
|
+
/** Whole-tree path checks: every path's own grammar, plus casefold + file/folder collisions. */
|
|
156
|
+
export function checkSiteStateTreePaths(paths) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const path of paths) {
|
|
159
|
+
const refusal = checkSiteStatePath(path);
|
|
160
|
+
if (refusal) out.push({ level: "error", code: refusal.code, message: refusal.reason, path });
|
|
161
|
+
}
|
|
162
|
+
const byFold = new Map();
|
|
163
|
+
for (const path of [...paths].sort()) {
|
|
164
|
+
const fold = foldPath(path);
|
|
165
|
+
const seen = byFold.get(fold);
|
|
166
|
+
if (seen !== undefined && seen !== path) {
|
|
167
|
+
out.push({
|
|
168
|
+
level: "error",
|
|
169
|
+
code: "SITE_STATE_PATH_CASEFOLD_COLLISION",
|
|
170
|
+
message: `${path} and ${seen} are the same file on a case-insensitive filesystem`,
|
|
171
|
+
path,
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
byFold.set(fold, path);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const folders = new Set();
|
|
178
|
+
for (const path of paths) {
|
|
179
|
+
const parts = path.split("/");
|
|
180
|
+
for (let i = 1; i < parts.length; i++) folders.add(foldPath(parts.slice(0, i).join("/")));
|
|
181
|
+
}
|
|
182
|
+
for (const path of [...paths].sort()) {
|
|
183
|
+
if (folders.has(foldPath(path))) {
|
|
184
|
+
out.push({
|
|
185
|
+
level: "error",
|
|
186
|
+
code: "SITE_STATE_INVALID_PATH",
|
|
187
|
+
message: `${path} is both a file and a folder in this tree`,
|
|
188
|
+
path,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── canonical (semantic view + asset-ref codec) ─────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export const ASSET_REF_PREFIX = "asset:sha256:";
|
|
198
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
199
|
+
const PLATFORM_ASSET_RE = /\/(?:cdn|assets)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i;
|
|
200
|
+
export const PLATFORM_METADATA_KEY = "platform";
|
|
201
|
+
export const MEDIA_DECISION_AUDIT_FIELDS = ["decided_by", "decided_at", "policy_version"];
|
|
202
|
+
|
|
203
|
+
export const assetRef = (sha256) => `${ASSET_REF_PREFIX}${sha256}`;
|
|
204
|
+
|
|
205
|
+
export function parseAssetRef(value) {
|
|
206
|
+
if (typeof value !== "string" || !value.startsWith(ASSET_REF_PREFIX)) return null;
|
|
207
|
+
const sha = value.slice(ASSET_REF_PREFIX.length);
|
|
208
|
+
return SHA256_HEX.test(sha) ? sha : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Mirror of the platform's `normalizeAssetRef` (asset-ref.ts): a platform asset value → its raw uuid. */
|
|
212
|
+
export function normalizeAssetRef(value) {
|
|
213
|
+
if (typeof value !== "string") return value;
|
|
214
|
+
const v = value.trim();
|
|
215
|
+
if (!v) return value;
|
|
216
|
+
if (UUID_RE.test(v)) return v.toLowerCase();
|
|
217
|
+
const m = v.match(PLATFORM_ASSET_RE);
|
|
218
|
+
if (m) return m[1].toLowerCase();
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function emptyAssetReport() {
|
|
223
|
+
return { used: new Set(), foreign: new Set(), missing: new Set() };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mapStrings(value, fn) {
|
|
227
|
+
if (typeof value === "string") return fn(value);
|
|
228
|
+
if (Array.isArray(value)) return value.map((v) => mapStrings(v, fn));
|
|
229
|
+
if (value && typeof value === "object") {
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const [k, v] of Object.entries(value)) out[k] = mapStrings(v, fn);
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function encodeAssetRefs(value, shaOf, report) {
|
|
238
|
+
return mapStrings(value, (s) => {
|
|
239
|
+
const ref = normalizeAssetRef(s);
|
|
240
|
+
if (typeof ref !== "string" || !UUID_RE.test(ref)) return s;
|
|
241
|
+
const uuid = ref.toLowerCase();
|
|
242
|
+
const sha = shaOf(uuid);
|
|
243
|
+
if (sha === null) {
|
|
244
|
+
report.foreign.add(uuid);
|
|
245
|
+
return s;
|
|
246
|
+
}
|
|
247
|
+
report.used.add(sha);
|
|
248
|
+
return assetRef(sha);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function decodeAssetRefs(value, uuidOf, report) {
|
|
253
|
+
return mapStrings(value, (s) => {
|
|
254
|
+
const sha = parseAssetRef(s);
|
|
255
|
+
if (sha === null) return s;
|
|
256
|
+
const uuid = uuidOf(sha);
|
|
257
|
+
if (uuid === null) {
|
|
258
|
+
report.missing.add(sha);
|
|
259
|
+
return s;
|
|
260
|
+
}
|
|
261
|
+
report.used.add(sha);
|
|
262
|
+
return uuid;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const DROPPED_TOP_LEVEL = ["exported_at", "source_site", "base_revision"];
|
|
267
|
+
const DROPPED_FROM_PAGE = ["status", "dynamic_collection"];
|
|
268
|
+
|
|
269
|
+
const isRecord = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
270
|
+
|
|
271
|
+
/** Text normalization for a non-JSON file (a theme source file): NFC + LF. */
|
|
272
|
+
export const normalizeText = (content) => content.normalize("NFC").replace(/\r\n?/g, "\n");
|
|
273
|
+
|
|
274
|
+
function withoutKeys(record, keys) {
|
|
275
|
+
const out = { ...record };
|
|
276
|
+
for (const key of keys) delete out[key];
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function canonicalValue(path, parsed) {
|
|
281
|
+
if (!isRecord(parsed)) {
|
|
282
|
+
if (Array.isArray(parsed) && path.endsWith("media-decisions.json")) {
|
|
283
|
+
return parsed.map((entry) => (isRecord(entry) ? withoutKeys(entry, MEDIA_DECISION_AUDIT_FIELDS) : entry));
|
|
284
|
+
}
|
|
285
|
+
return parsed;
|
|
286
|
+
}
|
|
287
|
+
const out = withoutKeys(parsed, path.endsWith("/index.json") && path.startsWith("pages/") ? [...DROPPED_TOP_LEVEL, ...DROPPED_FROM_PAGE] : DROPPED_TOP_LEVEL);
|
|
288
|
+
if (isRecord(out.data)) out.data = withoutKeys(out.data, [PLATFORM_METADATA_KEY]);
|
|
289
|
+
if (Array.isArray(out.decisions)) {
|
|
290
|
+
out.decisions = out.decisions.map((e) => (isRecord(e) ? withoutKeys(e, MEDIA_DECISION_AUDIT_FIELDS) : e));
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The comparable value of one file: parsed + provenance-stripped JSON, or normalized text. */
|
|
296
|
+
export function canonicalFileView(path, content) {
|
|
297
|
+
if (!path.endsWith(".json")) return normalizeText(content);
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(content);
|
|
301
|
+
} catch {
|
|
302
|
+
return normalizeText(content);
|
|
303
|
+
}
|
|
304
|
+
return canonicalValue(path, parsed);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function fileDigest(path, content) {
|
|
308
|
+
return sha256Hex(stableStringify(canonicalFileView(path, content)));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function sha256Hex(value) {
|
|
312
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Key-order-insensitive JSON (mirror of page-files/base-revision.ts). */
|
|
316
|
+
export function stableStringify(value) {
|
|
317
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
318
|
+
if (isRecord(value)) {
|
|
319
|
+
return `{${Object.keys(value)
|
|
320
|
+
.sort()
|
|
321
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
|
322
|
+
.join(",")}}`;
|
|
323
|
+
}
|
|
324
|
+
return JSON.stringify(value) ?? "null";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── manifest ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/** Per-owner file lists + digests for a tree (the manifest file itself is never an owner's file). */
|
|
330
|
+
export function computeOwners(files) {
|
|
331
|
+
const byOwner = new Map();
|
|
332
|
+
for (const path of Object.keys(files).sort()) {
|
|
333
|
+
if (path === MANIFEST_PATH) continue;
|
|
334
|
+
const owner = ownerOfPath(path);
|
|
335
|
+
if (owner === null) continue;
|
|
336
|
+
byOwner.set(owner, [...(byOwner.get(owner) ?? []), path]);
|
|
337
|
+
}
|
|
338
|
+
const owners = {};
|
|
339
|
+
for (const owner of SITE_STATE_OWNERS) {
|
|
340
|
+
const paths = byOwner.get(owner);
|
|
341
|
+
if (!paths || paths.length === 0) continue;
|
|
342
|
+
owners[owner] = {
|
|
343
|
+
files: paths,
|
|
344
|
+
digest: sha256Hex(stableStringify(paths.map((p) => [p, fileDigest(p, files[p])]))),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return owners;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function manifestDigestOf(owners) {
|
|
351
|
+
return sha256Hex(
|
|
352
|
+
stableStringify(
|
|
353
|
+
Object.keys(owners)
|
|
354
|
+
.sort()
|
|
355
|
+
.map((owner) => [owner, owners[owner].digest]),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function buildManifest({ files, platformOrigin, sourceSite, exportedAt }) {
|
|
361
|
+
const owners = computeOwners(files);
|
|
362
|
+
return {
|
|
363
|
+
schema_version: SITE_STATE_SCHEMA_VERSION,
|
|
364
|
+
kind: SITE_STATE_KIND,
|
|
365
|
+
platform_origin: platformOrigin,
|
|
366
|
+
source_site: sourceSite,
|
|
367
|
+
exported_at: exportedAt,
|
|
368
|
+
manifest_digest: manifestDigestOf(owners),
|
|
369
|
+
owners,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Is this manifest one this build understands, and does it describe exactly these files? */
|
|
374
|
+
export function verifyManifest(manifest, files) {
|
|
375
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
376
|
+
return { code: "SITE_STATE_UNSUPPORTED_SCHEMA", message: "manifest must be a JSON object" };
|
|
377
|
+
}
|
|
378
|
+
const m = manifest;
|
|
379
|
+
if (m.kind !== SITE_STATE_KIND) {
|
|
380
|
+
return { code: "SITE_STATE_UNSUPPORTED_SCHEMA", message: `kind ${JSON.stringify(m.kind)} is not ${SITE_STATE_KIND}` };
|
|
381
|
+
}
|
|
382
|
+
if (m.schema_version !== SITE_STATE_SCHEMA_VERSION) {
|
|
383
|
+
return {
|
|
384
|
+
code: "SITE_STATE_UNSUPPORTED_SCHEMA",
|
|
385
|
+
message: `schema_version ${JSON.stringify(m.schema_version)} is not supported (this CLI reads ${SITE_STATE_SCHEMA_VERSION})`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const owners = computeOwners(files);
|
|
389
|
+
const digest = manifestDigestOf(owners);
|
|
390
|
+
if (m.manifest_digest !== digest) {
|
|
391
|
+
return { code: "SITE_STATE_MANIFEST_DIGEST_MISMATCH", message: "the files do not match the manifest digest; re-export the site state" };
|
|
392
|
+
}
|
|
393
|
+
const declared = stableStringify(m.owners ?? {});
|
|
394
|
+
if (declared !== stableStringify(owners)) {
|
|
395
|
+
return { code: "SITE_STATE_MANIFEST_DIGEST_MISMATCH", message: "the manifest's owner list does not match the files" };
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The instance name an apply targets for a manifest: stable, so a resume finds its own work. */
|
|
401
|
+
export function siteStateInstanceName(manifestDigest) {
|
|
402
|
+
return `Site State · ${manifestDigest.slice(0, 12)}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function themeSourceIdempotencyKey(manifestDigest) {
|
|
406
|
+
return `site-state:${manifestDigest.slice(0, 12)}:theme`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── diff ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
function comparable(path) {
|
|
412
|
+
return path !== MANIFEST_PATH && !path.startsWith(ASSET_FILES_PREFIX) && ownerOfPath(path) !== null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function diffSiteState(a, b) {
|
|
416
|
+
const paths = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(comparable).sort();
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const path of paths) {
|
|
419
|
+
const left = a[path];
|
|
420
|
+
const right = b[path];
|
|
421
|
+
const owner = ownerOfPath(path);
|
|
422
|
+
if (left === undefined) out.push({ owner, key: path, change: "added" });
|
|
423
|
+
else if (right === undefined) out.push({ owner, key: path, change: "removed" });
|
|
424
|
+
else if (stableStringify(canonicalFileView(path, left)) !== stableStringify(canonicalFileView(path, right))) {
|
|
425
|
+
out.push({ owner, key: path, change: "changed" });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function diffOwner(owner, a, b) {
|
|
432
|
+
return diffSiteState(a, b).filter((d) => d.owner === owner);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── validate (structural, offline) ──────────────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
const error = (code, message, path) => ({ level: "error", code, message, ...(path ? { path } : {}) });
|
|
438
|
+
|
|
439
|
+
function parsedJson(path, content, out) {
|
|
440
|
+
try {
|
|
441
|
+
return JSON.parse(content);
|
|
442
|
+
} catch {
|
|
443
|
+
out.push(error("SITE_STATE_INVALID_FILE", "file is not valid JSON", path));
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Every structural refusal a tree can earn offline: paths, the path↔content binding, duplicate
|
|
450
|
+
* identities and the limits. `files` maps every tree path (media/files/<sha> included, content unused
|
|
451
|
+
* for those) to its string content.
|
|
452
|
+
*/
|
|
453
|
+
export function validateSiteStateTree(files) {
|
|
454
|
+
const out = [];
|
|
455
|
+
const paths = Object.keys(files).sort();
|
|
456
|
+
out.push(...checkSiteStateTreePaths(paths));
|
|
457
|
+
|
|
458
|
+
const pagePaths = paths.filter((p) => ownerOfPath(p) === "pages");
|
|
459
|
+
const identities = new Map();
|
|
460
|
+
for (const path of pagePaths) {
|
|
461
|
+
const at = parsePageFilePath(path);
|
|
462
|
+
if (at.kind !== "canonical" && at.kind !== "hashed") {
|
|
463
|
+
out.push(error("SITE_STATE_INVALID_PATH", "page files use the canonical layout", path));
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const classified = classifyPageFile(path, files[path], { defaultLocale: at.locale });
|
|
467
|
+
if (!classified.ok) {
|
|
468
|
+
const code = classified.code === "PAGES_INVALID_JSON" ? "SITE_STATE_INVALID_FILE" : "SITE_STATE_IDENTITY_MISMATCH";
|
|
469
|
+
out.push(error(code, `${classified.code}: ${classified.message}`, path));
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (classified.layout !== "v2") {
|
|
473
|
+
out.push(error("SITE_STATE_IDENTITY_MISMATCH", "a site state carries format_version 2 page files only", path));
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const key = `${classified.locale}\u0000${classified.slug}`;
|
|
477
|
+
const seen = identities.get(key);
|
|
478
|
+
if (seen) out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `${path} and ${seen} are the same page`, path));
|
|
479
|
+
else identities.set(key, path);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for (const path of paths.filter((p) => ownerOfPath(p) === "media_decisions")) {
|
|
483
|
+
const sibling = path.slice(0, -PAGE_MEDIA_DECISIONS_NAME.length) + PAGE_FILE_NAME;
|
|
484
|
+
if (files[sibling] === undefined) {
|
|
485
|
+
out.push(error("SITE_STATE_IDENTITY_MISMATCH", "media decisions without their page file", path));
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const value = parsedJson(path, files[path], out);
|
|
489
|
+
if (value === undefined) continue;
|
|
490
|
+
if (!Array.isArray(value)) {
|
|
491
|
+
out.push(error("SITE_STATE_INVALID_FILE", "media decisions must be a JSON array", path));
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const uses = new Set();
|
|
495
|
+
for (const entry of value) {
|
|
496
|
+
if (!isRecord(entry) || typeof entry.path !== "string" || typeof entry.facet !== "string") {
|
|
497
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a media decision needs `path` and `facet`", path));
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const key = `${entry.path}\u0000${entry.facet}`;
|
|
501
|
+
if (uses.has(key)) out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `two decisions for ${key.replace("\u0000", " ")}`, path));
|
|
502
|
+
uses.add(key);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const navPaths = paths.filter((p) => ownerOfPath(p) === "navigation");
|
|
507
|
+
if (navPaths.length > SITE_STATE_LIMITS.navigationMenus) {
|
|
508
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${navPaths.length} menus; at most ${SITE_STATE_LIMITS.navigationMenus}`));
|
|
509
|
+
}
|
|
510
|
+
for (const path of navPaths) {
|
|
511
|
+
const value = parsedJson(path, files[path], out);
|
|
512
|
+
if (value === undefined) continue;
|
|
513
|
+
if (!isRecord(value)) {
|
|
514
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a navigation file must be a JSON object", path));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const segments = path.split("/");
|
|
518
|
+
const locale = segments[2];
|
|
519
|
+
const handle = segments[3].slice(0, -".json".length);
|
|
520
|
+
if (value.locale !== locale || value.handle !== handle) {
|
|
521
|
+
out.push(error("SITE_STATE_IDENTITY_MISMATCH", `this file is ${locale} "${handle}"`, path));
|
|
522
|
+
}
|
|
523
|
+
const items = value.items;
|
|
524
|
+
if (!Array.isArray(items)) {
|
|
525
|
+
out.push(error("SITE_STATE_INVALID_FILE", "navigation items must be an array", path));
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (items.length > SITE_STATE_LIMITS.navigationItems) {
|
|
529
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${items.length} items; at most ${SITE_STATE_LIMITS.navigationItems}`, path));
|
|
530
|
+
}
|
|
531
|
+
const ids = new Set();
|
|
532
|
+
for (const item of items) {
|
|
533
|
+
if (!isRecord(item) || typeof item.id !== "string" || typeof item.label !== "string" || !isRecord(item.link)) {
|
|
534
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a navigation item needs `id`, `label` and `link`", path));
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (ids.has(item.id)) out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `two navigation items are "${item.id}"`, path));
|
|
538
|
+
ids.add(item.id);
|
|
539
|
+
const link = item.link;
|
|
540
|
+
if (link.type === "page") {
|
|
541
|
+
if (typeof link.locale !== "string" || typeof link.slug !== "string") {
|
|
542
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a page link needs `locale` and `slug`", path));
|
|
543
|
+
}
|
|
544
|
+
} else if (link.type !== "custom" || typeof link.url !== "string") {
|
|
545
|
+
out.push(error("SITE_STATE_UNSUPPORTED_LINK", `link type ${JSON.stringify(link.type)} is not carried by a site state`, path));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const item of items) {
|
|
549
|
+
if (isRecord(item) && item.parent != null && !ids.has(String(item.parent))) {
|
|
550
|
+
out.push(error("SITE_STATE_INVALID_FILE", `parent ${JSON.stringify(item.parent)} is not an item of this menu`, path));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
for (const path of [THEME_CHROME_HEADER_PATH, THEME_CHROME_FOOTER_PATH]) {
|
|
556
|
+
const content = files[path];
|
|
557
|
+
if (content === undefined) continue;
|
|
558
|
+
const value = parsedJson(path, content, out);
|
|
559
|
+
if (value === undefined) continue;
|
|
560
|
+
const area = path === THEME_CHROME_HEADER_PATH ? "header" : "footer";
|
|
561
|
+
if (!isRecord(value) || value.area !== area) {
|
|
562
|
+
out.push(error("SITE_STATE_IDENTITY_MISMATCH", `this file is the ${area} group`, path));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const translations = files[SITE_TRANSLATIONS_PATH];
|
|
567
|
+
if (translations !== undefined) {
|
|
568
|
+
const value = parsedJson(SITE_TRANSLATIONS_PATH, translations, out);
|
|
569
|
+
const groups = isRecord(value) ? value.groups : undefined;
|
|
570
|
+
if (!Array.isArray(groups)) {
|
|
571
|
+
if (value !== undefined) out.push(error("SITE_STATE_INVALID_FILE", "translations need a `groups` array", SITE_TRANSLATIONS_PATH));
|
|
572
|
+
} else {
|
|
573
|
+
const groupIds = new Set();
|
|
574
|
+
const members = new Set();
|
|
575
|
+
for (const group of groups) {
|
|
576
|
+
if (!isRecord(group) || typeof group.group !== "string" || !Array.isArray(group.members)) {
|
|
577
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a translation group needs `group` and `members`", SITE_TRANSLATIONS_PATH));
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (groupIds.has(group.group)) {
|
|
581
|
+
out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `two groups are "${group.group}"`, SITE_TRANSLATIONS_PATH));
|
|
582
|
+
}
|
|
583
|
+
groupIds.add(group.group);
|
|
584
|
+
const locales = new Set();
|
|
585
|
+
for (const member of group.members) {
|
|
586
|
+
if (!isRecord(member) || typeof member.locale !== "string" || typeof member.slug !== "string") {
|
|
587
|
+
out.push(error("SITE_STATE_INVALID_FILE", "a member needs `locale` and `slug`", SITE_TRANSLATIONS_PATH));
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const key = `${member.locale}\u0000${member.slug}`;
|
|
591
|
+
if (members.has(key)) {
|
|
592
|
+
out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `${member.locale} ${member.slug} is in two groups`, SITE_TRANSLATIONS_PATH));
|
|
593
|
+
}
|
|
594
|
+
members.add(key);
|
|
595
|
+
if (locales.has(member.locale)) {
|
|
596
|
+
out.push(error("SITE_STATE_TRANSLATION_REFUSED", `group ${group.group} has two ${member.locale} pages`, SITE_TRANSLATIONS_PATH));
|
|
597
|
+
}
|
|
598
|
+
locales.add(member.locale);
|
|
599
|
+
if (canonicalizeLocaleCode(member.locale) !== member.locale) {
|
|
600
|
+
out.push(error("SITE_STATE_INVALID_FILE", `locale ${JSON.stringify(member.locale)} is not canonical`, SITE_TRANSLATIONS_PATH));
|
|
601
|
+
}
|
|
602
|
+
if (!identities.has(key)) {
|
|
603
|
+
out.push(error("SITE_STATE_TRANSLATION_REFUSED", `${member.locale} ${member.slug} has no page file`, SITE_TRANSLATIONS_PATH));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (group.members.length < 2) {
|
|
607
|
+
out.push(error("SITE_STATE_TRANSLATION_REFUSED", `group ${group.group} has fewer than two members`, SITE_TRANSLATIONS_PATH));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const assetsIndex = files[ASSETS_INDEX_PATH];
|
|
614
|
+
const declaredShas = new Set();
|
|
615
|
+
if (assetsIndex !== undefined) {
|
|
616
|
+
const value = parsedJson(ASSETS_INDEX_PATH, assetsIndex, out);
|
|
617
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
618
|
+
out.push(error("SITE_STATE_INVALID_FILE", "media/assets.json must be a JSON array", ASSETS_INDEX_PATH));
|
|
619
|
+
} else if (Array.isArray(value)) {
|
|
620
|
+
if (value.length > SITE_STATE_LIMITS.assetFiles) {
|
|
621
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${value.length} assets; at most ${SITE_STATE_LIMITS.assetFiles}`, ASSETS_INDEX_PATH));
|
|
622
|
+
}
|
|
623
|
+
let total = 0;
|
|
624
|
+
for (const entry of value) {
|
|
625
|
+
if (!isRecord(entry) || typeof entry.sha256 !== "string" || parseAssetRef(`asset:sha256:${entry.sha256}`) === null) {
|
|
626
|
+
out.push(error("SITE_STATE_INVALID_FILE", "an asset needs a lowercase sha256", ASSETS_INDEX_PATH));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (declaredShas.has(entry.sha256)) {
|
|
630
|
+
out.push(error("SITE_STATE_DUPLICATE_IDENTITY", `${entry.sha256} is listed twice`, ASSETS_INDEX_PATH));
|
|
631
|
+
}
|
|
632
|
+
declaredShas.add(entry.sha256);
|
|
633
|
+
const size = typeof entry.bytes === "number" ? entry.bytes : 0;
|
|
634
|
+
total += size;
|
|
635
|
+
if (size > SITE_STATE_LIMITS.assetBytes) {
|
|
636
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${entry.sha256} is larger than ${SITE_STATE_LIMITS.assetBytes} bytes`, ASSETS_INDEX_PATH));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (total > SITE_STATE_LIMITS.assetTotalBytes) {
|
|
640
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `assets total more than ${SITE_STATE_LIMITS.assetTotalBytes} bytes`, ASSETS_INDEX_PATH));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const path of paths.filter((p) => p.startsWith(ASSET_FILES_PREFIX))) {
|
|
645
|
+
const sha = path.slice(ASSET_FILES_PREFIX.length);
|
|
646
|
+
if (!declaredShas.has(sha)) {
|
|
647
|
+
out.push(error("SITE_STATE_IDENTITY_MISMATCH", "this file is not listed in media/assets.json", path));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (pagePaths.length > SITE_STATE_LIMITS.pageFiles) {
|
|
652
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${pagePaths.length} page files; at most ${SITE_STATE_LIMITS.pageFiles}`));
|
|
653
|
+
}
|
|
654
|
+
const pageBytes = pagePaths.reduce((n, p) => n + byteLength(files[p]), 0);
|
|
655
|
+
if (pageBytes > SITE_STATE_LIMITS.pageBytes) {
|
|
656
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${pageBytes} bytes of page files; at most ${SITE_STATE_LIMITS.pageBytes}`));
|
|
657
|
+
}
|
|
658
|
+
const themePaths = paths.filter((p) => ownerOfPath(p) === "theme");
|
|
659
|
+
if (themePaths.length > SITE_STATE_LIMITS.themeFiles) {
|
|
660
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${themePaths.length} theme files; at most ${SITE_STATE_LIMITS.themeFiles}`));
|
|
661
|
+
}
|
|
662
|
+
const themeBytes = themePaths.reduce((n, p) => n + byteLength(files[p]), 0);
|
|
663
|
+
if (themeBytes > SITE_STATE_LIMITS.themeBytes) {
|
|
664
|
+
out.push(error("SITE_STATE_LIMIT_EXCEEDED", `${themeBytes} bytes of theme files; at most ${SITE_STATE_LIMITS.themeBytes}`));
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The HTTP status a refusal code answers with (frozen contract §A2) — used to shape CLI-side exit codes. */
|
|
670
|
+
export function siteStateStatusFor(code) {
|
|
671
|
+
if (code === "SITE_STATE_LIMIT_EXCEEDED") return 413;
|
|
672
|
+
if (
|
|
673
|
+
code === "SITE_STATE_PLAN_STALE" ||
|
|
674
|
+
code === "SITE_STATE_TARGET_IS_LIVE" ||
|
|
675
|
+
code === "SITE_STATE_REVISION_CONFLICT" ||
|
|
676
|
+
code === "SITE_STATE_APPLY_INCOMPLETE" ||
|
|
677
|
+
code === "SITE_STATE_TRANSLATION_REFUSED"
|
|
678
|
+
) {
|
|
679
|
+
return 409;
|
|
680
|
+
}
|
|
681
|
+
return 422;
|
|
682
|
+
}
|