create-website-build-kit 0.1.19 → 0.1.20

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.
@@ -41,6 +41,7 @@
41
41
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
42
42
  import { join, relative, sep } from 'node:path';
43
43
  import { parse } from 'yaml';
44
+ import { literalContent } from './lib/literal-content.mjs';
44
45
  import { literalImages } from './lib/literal-images.mjs';
45
46
  import { routeExists, routesFromPages } from './lib/routes.mjs';
46
47
 
@@ -52,6 +53,23 @@ const DIM = '\x1b[2m';
52
53
 
53
54
  const CONFIG = '.pages.yml';
54
55
 
56
+ /*
57
+ * ⚠ `--fix` PRINTS. IT NEVER WRITES.
58
+ *
59
+ * The name is what people reach for, and the behaviour is what keeps this
60
+ * safe: for every undeclared key the check already knows the exact dotted
61
+ * path and the value sitting at it, so it can emit the field declaration to
62
+ * paste. What it cannot know is whether declaring the key is the RIGHT fix —
63
+ * for an analytics ID it is not, and this file's own other warning says so.
64
+ *
65
+ * So the fix is offered, never applied, and the one case where the other
66
+ * answer is usually correct is marked.
67
+ *
68
+ * It is off by default because this runs in `build:production`, where a gate
69
+ * that floods is a gate somebody switches off.
70
+ */
71
+ const SHOW_FIX = process.argv.includes('--fix');
72
+
55
73
  if (!existsSync(CONFIG)) {
56
74
  console.log(`${DIM}·${RESET} no ${CONFIG} — no CMS to check`);
57
75
  process.exit(0);
@@ -75,6 +93,36 @@ const flatten = (entries) =>
75
93
  entry?.type === 'group' ? flatten(entry.items ?? entry.content ?? []) : [entry],
76
94
  );
77
95
 
96
+ /*
97
+ * ⚠ A FIELD MAY BORROW ITS SHAPE FROM `components:`, AND MISSING THAT REPORTS
98
+ * DATA LOSS THAT IS NOT HAPPENING.
99
+ *
100
+ * PagesCMS lets a field say `component: image_field` instead of repeating a
101
+ * field list. This walk used to read only `field.fields`, so every key
102
+ * inside a borrowed shape looked undeclared — and undeclared, in this
103
+ * check, means "the client's first save DELETES it".
104
+ *
105
+ * Measured on a live trilingual site: six phantom problems across three
106
+ * home pages, every one of them `image.src` / `image.alt` / `image.isRender`
107
+ * reached through `component: image_field`, which declares exactly those
108
+ * three. The site was correct and the check was wrong — the worst direction
109
+ * for this particular check to fail in, because the fix it invites is
110
+ * pasting duplicate declarations into a config that was already right.
111
+ */
112
+ function fieldsOf(field, seen = new Set()) {
113
+ if (!field) return null;
114
+ if (field.component) {
115
+ /* A component referring to itself would otherwise recurse forever. */
116
+ if (seen.has(field.component)) return null;
117
+ seen.add(field.component);
118
+ const base = (config.components ?? {})[field.component];
119
+ /* The field's own keys win over the component's — a call site may
120
+ override the label, or supply its own `fields` outright. */
121
+ return base ? fieldsOf({ ...base, ...field, component: undefined }, seen) : null;
122
+ }
123
+ return Array.isArray(field.fields) ? field.fields : null;
124
+ }
125
+
78
126
  /** Every dotted path the schema declares. Arrays reuse the parent prefix. */
79
127
  function schemaPaths(fields, prefix = '') {
80
128
  const out = new Set();
@@ -82,7 +130,76 @@ function schemaPaths(fields, prefix = '') {
82
130
  if (!field?.name) continue;
83
131
  const path = prefix ? `${prefix}.${field.name}` : field.name;
84
132
  out.add(path);
85
- if (Array.isArray(field.fields)) for (const p of schemaPaths(field.fields, path)) out.add(p);
133
+ const sub = fieldsOf(field);
134
+ if (sub) for (const p of schemaPaths(sub, path)) out.add(p);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /*
140
+ * ⚠ A FLAT SET OF DECLARED PATHS IS WRONG FOR `type: block`, AND WRONG IN THE
141
+ * DIRECTION THAT LOSES DATA.
142
+ *
143
+ * Comparing `dataPaths(data)` against `schemaPaths(fields)` unions every
144
+ * variant of a block: `sections[].items[].path` reads as declared as long
145
+ * as ANY section type declares `path`, so a variant missing it is invisible
146
+ * behind a sibling that has it.
147
+ *
148
+ * That is the 2026-08-25 incident — PagesCMS deleted card links from a live
149
+ * `uz/services.json` while this check reported clean, because a different
150
+ * section type happened to declare the same field name.
151
+ *
152
+ * So the data is walked ALONGSIDE the schema instead, and each block item
153
+ * is matched to its OWN variant through the discriminator. A field name
154
+ * declared by a sibling variant no longer covers for it.
155
+ */
156
+ function undeclaredPaths(data, fields, prefix = '', out = new Set()) {
157
+ if (data === null || data === undefined) return out;
158
+ /* A list's items share their parent's prefix — `items[0].x` and
159
+ `items[1].x` are the same declared path. */
160
+ if (Array.isArray(data)) {
161
+ for (const item of data) undeclaredPaths(item, fields, prefix, out);
162
+ return out;
163
+ }
164
+ if (typeof data !== 'object') return out;
165
+ if (!fields) return out;
166
+
167
+ const byName = new Map(fields.filter((f) => f?.name).map((f) => [f.name, f]));
168
+
169
+ for (const [key, value] of Object.entries(data)) {
170
+ const here = prefix ? `${prefix}.${key}` : key;
171
+ const field = byName.get(key);
172
+ if (!field) {
173
+ out.add(here);
174
+ continue;
175
+ }
176
+
177
+ if (field.type === 'block') {
178
+ const discriminator = field.blockKey ?? '_block';
179
+ for (const item of Array.isArray(value) ? value : [value]) {
180
+ if (!item || typeof item !== 'object') continue;
181
+ const variant = (field.blocks ?? []).find((b) => b?.name === item[discriminator]);
182
+ if (!variant) {
183
+ /* An item whose type matches no declared variant has NO schema at
184
+ all, so every key in it would be dropped. */
185
+ out.add(`${here}[] (no block type "${item[discriminator]}")`);
186
+ continue;
187
+ }
188
+ const variantFields = fieldsOf(variant) ?? [];
189
+ for (const [k, v] of Object.entries(item)) {
190
+ if (k === discriminator) continue;
191
+ const vf = variantFields.find((x) => x?.name === k);
192
+ if (!vf) {
193
+ out.add(`${here}[type=${item[discriminator]}].${k}`);
194
+ continue;
195
+ }
196
+ undeclaredPaths(v, fieldsOf(vf), `${here}[type=${item[discriminator]}].${k}`, out);
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+
202
+ undeclaredPaths(value, fieldsOf(field), here, out);
86
203
  }
87
204
  return out;
88
205
  }
@@ -102,20 +219,78 @@ function dataPaths(value, prefix = '') {
102
219
  return out;
103
220
  }
104
221
 
222
+ /** The same walk as `dataPaths`, keeping one sample value per path. */
223
+ function pathValues(value, prefix = '', out = new Map()) {
224
+ if (Array.isArray(value)) {
225
+ for (const item of value) pathValues(item, prefix, out);
226
+ } else if (value && typeof value === 'object') {
227
+ for (const [key, inner] of Object.entries(value)) {
228
+ const path = prefix ? `${prefix}.${key}` : key;
229
+ /* First writer wins: across a collection the first document with a key is
230
+ as good a sample as any, and later ones must not overwrite a rich value
231
+ with a null from a document that happens to omit it. */
232
+ if (!out.has(path)) out.set(path, inner);
233
+ pathValues(inner, path, out);
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+
105
239
  /** Frontmatter keys actually used across a collection, as dotted paths. */
106
- function collectionPaths(dir) {
240
+ function collectionPaths(dir, values = new Map(), fields = null, exclude = []) {
107
241
  const out = new Set();
242
+ /*
243
+ * ⚠ `exclude` MUST BE HONOURED, or a file that legitimately sits inside a
244
+ * collection's directory is checked against the wrong schema.
245
+ *
246
+ * The real case: a home page living at `pages/ru/home.json` with its own
247
+ * `type: file` entry and its own fields, while `pages/ru` is a collection
248
+ * of interior pages. Reading it as a collection item reports every home
249
+ * page key as undeclared — six confident, wrong data-loss problems on a
250
+ * config that already handled this correctly by declaring
251
+ * `exclude: [home.json]`.
252
+ *
253
+ * A check that cries wolf gets switched off, so this is not cosmetic.
254
+ */
255
+ const excluded = new Set((exclude ?? []).map((e) => String(e)));
108
256
  const walk = (d) =>
109
257
  readdirSync(d).flatMap((e) => {
110
258
  const full = join(d, e);
111
259
  return statSync(full).isDirectory() ? walk(full) : [full];
112
260
  });
113
- for (const file of walk(dir).filter((f) => /\.mdx?$/.test(f))) {
261
+ /*
262
+ * ⚠ `.json` IS NOT OPTIONAL HERE, AND OMITTING IT SKIPS WHOLE COLLECTIONS
263
+ * IN SILENCE.
264
+ *
265
+ * This read only `.md`/`.mdx` and took the absence of frontmatter as
266
+ * "nothing to check". A collection whose items are JSON therefore passed
267
+ * without a single file being opened — no warning, no count, just a tick.
268
+ *
269
+ * Measured on a live site: 60 JSON items across six collections, none of
270
+ * them ever read, while the check reported clean. Astro content
271
+ * collections take JSON as readily as Markdown, so this is a normal shape
272
+ * and not an exotic one.
273
+ */
274
+ const withinCollection = (f) => relative(dir, f).split(sep).join('/');
275
+ for (const file of walk(dir).filter(
276
+ (f) => /\.(mdx?|json)$/.test(f) && !excluded.has(withinCollection(f)),
277
+ )) {
114
278
  const raw = readFileSync(file, 'utf8');
115
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
279
+ let match = null;
280
+ if (/\.json$/.test(file)) {
281
+ match = ['', raw];
282
+ } else {
283
+ match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
284
+ }
116
285
  if (!match) continue;
117
286
  try {
118
- for (const p of dataPaths(parse(match[1]) ?? {})) out.add(p);
287
+ const data = (/\.json$/.test(file) ? JSON.parse(match[1]) : parse(match[1])) ?? {};
288
+ /* With `fields`, report only what the schema cannot round-trip — walked
289
+ per file so a block variant is matched to itself. Without it, fall
290
+ back to every path, which is what the callers that only want values
291
+ expect. */
292
+ for (const p of fields ? undeclaredPaths(data, fields) : dataPaths(data)) out.add(p);
293
+ pathValues(data, '', values);
119
294
  } catch {
120
295
  /* A collection item with unparseable frontmatter is the content
121
296
  collection's problem, and astro check reports it properly. */
@@ -124,6 +299,10 @@ function collectionPaths(dir) {
124
299
  return out;
125
300
  }
126
301
 
302
+ /** The extensions a content collection item can have — the same set the
303
+ collection walker filters on, kept here so the two cannot disagree. */
304
+ const CONTENT_EXT = /\.(mdx?|json)$/;
305
+
127
306
  const rel = (p) => relative(process.cwd(), p).split(sep).join('/');
128
307
 
129
308
  /* ── content entries ─────────────────────────────────────────────────────── */
@@ -146,13 +325,67 @@ for (const entry of entries) {
146
325
  continue;
147
326
  }
148
327
 
328
+ /*
329
+ * ⚠ A COLLECTION WHOSE ITEMS ALL SIT ONE LEVEL DOWN.
330
+ *
331
+ * `existsSync` is satisfied by the directory and the walk below recurses,
332
+ * so a config left pointing at the PARENT of its content reports clean —
333
+ * every file gets read and every key checked, but against an entry the
334
+ * editor may not be seeing at all.
335
+ *
336
+ * Measured on ngbif 2026-09-02: a locale migration moved six collections
337
+ * into `en/` and `uz/` and nobody updated `.pages.yml`. This check caught
338
+ * the two singletons, whose paths had genuinely vanished, and MISSED all
339
+ * four collections — two of six real findings.
340
+ *
341
+ * ⚠ A WARNING, NOT A PROBLEM, AND DELIBERATELY SO. Whether Pages CMS
342
+ * lists a collection's subfolders is not something this script has
343
+ * verified, and a guard that asserts unmeasured behaviour is how a check
344
+ * earns a reputation for crying wolf. What IS certain is that the config
345
+ * no longer has the shape the content does, and that a person should look
346
+ * at it. Sites that legitimately nest will see this once and can add
347
+ * `exclude` or split the entry.
348
+ */
349
+ if (entry.type === 'collection') {
350
+ const direct = readdirSync(path, { withFileTypes: true }).some(
351
+ (e) => e.isFile() && CONTENT_EXT.test(e.name),
352
+ );
353
+ if (!direct) {
354
+ const holders = readdirSync(path, { withFileTypes: true })
355
+ .filter((e) => e.isDirectory())
356
+ .filter((e) => {
357
+ try {
358
+ return readdirSync(join(path, e.name)).some((f) => CONTENT_EXT.test(f));
359
+ } catch {
360
+ return false;
361
+ }
362
+ })
363
+ .map((e) => `${e.name}/`);
364
+ if (holders.length) {
365
+ warnings.push(
366
+ `${label}: no content directly in ${path}, but ${holders.join(', ')} ` +
367
+ `underneath ${holders.length === 1 ? 'holds' : 'hold'} some — ` +
368
+ 'usually a locale or structure move the config was not updated for. ' +
369
+ 'Verify what the CMS actually lists; the fix is one entry per subdirectory.',
370
+ );
371
+ }
372
+ }
373
+ }
374
+
149
375
  const declared = schemaPaths(entry.fields);
150
376
 
151
377
  if (entry.type === 'collection') {
152
- const used = collectionPaths(path);
153
- const undeclared = [...used].filter((p) => !declared.has(p));
378
+ const values = new Map();
379
+ const undeclared = [...collectionPaths(path, values, entry.fields, entry.exclude)];
154
380
  if (undeclared.length) {
155
- problems.push({ label, why: `frontmatter keys the schema does not declare`, keys: undeclared, path });
381
+ problems.push({
382
+ label,
383
+ why: `frontmatter keys the schema does not declare`,
384
+ keys: undeclared,
385
+ path,
386
+ values,
387
+ declared,
388
+ });
156
389
  }
157
390
  continue;
158
391
  }
@@ -165,9 +398,16 @@ for (const entry of entries) {
165
398
  problems.push({ label, why: `${path} is not valid JSON — ${err.message}` });
166
399
  continue;
167
400
  }
168
- const undeclared = [...dataPaths(data)].filter((p) => !declared.has(p));
401
+ const undeclared = [...undeclaredPaths(data, entry.fields)];
169
402
  if (undeclared.length) {
170
- problems.push({ label, why: 'keys in the file the schema does not declare', keys: undeclared, path });
403
+ problems.push({
404
+ label,
405
+ why: 'keys in the file the schema does not declare',
406
+ keys: undeclared,
407
+ path,
408
+ values: pathValues(data),
409
+ declared,
410
+ });
171
411
  }
172
412
  }
173
413
  }
@@ -184,6 +424,71 @@ const mediaByName = new Map();
184
424
  file dropped into generated output. */
185
425
  const GENERATED = ['public/img', 'dist', '.astro'];
186
426
 
427
+ /*
428
+ * ⚠ POINTING A PICKER AT THE GENERATED OUTPUT IS NOT AUTOMATICALLY THE BUG,
429
+ * AND THIS CHECK USED TO SAY IT WAS.
430
+ *
431
+ * `src/lib/image-key.ts` exists in this very template BECAUSE a CMS picker
432
+ * "browses files and returns the public path of what it found,
433
+ * /img/photos/hero-1200.webp". That only happens when the media source's
434
+ * input IS the output directory. So the check was telling people to break
435
+ * the feature the kit ships to make image fields usable at all — follow it
436
+ * and the picker returns /media/source/..., which `toImageKey` does not map,
437
+ * and <Img> throws.
438
+ *
439
+ * Measured on a delivered site with the mapping in place: 386 files under
440
+ * public/img, 95 manifest keys, and ZERO files with no manifest entry after
441
+ * months of use. The design holds.
442
+ *
443
+ * What the original trap was actually about is a project that CANNOT resolve a
444
+ * picked path — no mapping, so anything the editor chooses is unusable, and an
445
+ * upload into the output is unusable twice over. That is what this now reports.
446
+ *
447
+ * The residual risk where a mapping DOES exist — an editor uploading a raw
448
+ * JPEG that never gets processed — is covered by the `extensions` warning
449
+ * below, which is the lever that actually refuses it at the door.
450
+ */
451
+ /*
452
+ * ⚠ AND THE DIRECTION CANNOT BITE AT ALL IF NOTHING STORES A PICKED PATH.
453
+ *
454
+ * A third delivered site declares TWO media sources on purpose — "Image
455
+ * library (published)" pointed at public/img so an editor can see what is
456
+ * live, and "Originals (need `npm run media`)" pointed at media/source where
457
+ * uploads belong. It has no `type: image` field at all: seven fields use a
458
+ * `select` of manifest keys instead, so a picked path can never become a
459
+ * field value.
460
+ *
461
+ * The trap needs all three - a source pointed at generated output, a field
462
+ * that can store what the picker returns, and nothing able to resolve it.
463
+ * Miss any one and this reports a design somebody thought harder about than
464
+ * the check did.
465
+ */
466
+ const declaresImageFields = (() => {
467
+ const walk = (fields) =>
468
+ (fields ?? []).some((f) => {
469
+ if (!f?.name) return false;
470
+ const effective = f.type ?? (config.components ?? {})[f.component]?.type;
471
+ if (effective === 'image') return true;
472
+ return walk(f.fields ?? (config.components ?? {})[f.component]?.fields);
473
+ });
474
+ return entries.some((e) => walk(e?.fields));
475
+ })();
476
+
477
+ const resolvesPickerPaths = (() => {
478
+ if (existsSync(join('src', 'lib', 'image-key.ts'))) return true;
479
+ try {
480
+ const walk = (d) =>
481
+ readdirSync(d, { withFileTypes: true }).flatMap((e) =>
482
+ e.isDirectory() ? walk(join(d, e.name)) : [join(d, e.name)],
483
+ );
484
+ return walk('src')
485
+ .filter((f) => /\.(ts|js|mjs|astro)$/.test(f))
486
+ .some((f) => readFileSync(f, 'utf8').includes('toImageKey'));
487
+ } catch {
488
+ return false;
489
+ }
490
+ })();
491
+
187
492
  const media = config.media ? (Array.isArray(config.media) ? config.media : [config.media]) : [];
188
493
 
189
494
  for (const source of media) {
@@ -196,14 +501,19 @@ for (const source of media) {
196
501
  if (typeof source === 'object' && source.name) mediaByName.set(source.name, source);
197
502
  const normalised = input.replace(/^\.?\//, '').replace(/\/$/, '');
198
503
  if (GENERATED.some((g) => normalised === g || normalised.startsWith(`${g}/`))) {
199
- problems.push({
200
- label: `media ${name}`,
201
- why: `uploads into ${input}, which is GENERATED output`,
202
- direction: true,
203
- });
204
- continue;
205
- }
206
- if (!existsSync(input)) {
504
+ if (declaresImageFields && !resolvesPickerPaths) {
505
+ problems.push({
506
+ label: `media ${name}`,
507
+ why:
508
+ `uploads into ${input}, which is GENERATED output. A \`type: image\` field stores ` +
509
+ `what the picker returns and nothing here maps that path back to a manifest key`,
510
+ direction: true,
511
+ });
512
+ continue;
513
+ }
514
+ /* Mapping present: browsing the output is the intended design. The upload
515
+ risk is the `extensions` question, checked below like any other source. */
516
+ } else if (!existsSync(input)) {
207
517
  problems.push({ label: `media ${name}`, why: `input directory does not exist: ${input}` });
208
518
  continue;
209
519
  }
@@ -245,11 +555,20 @@ function imageFields(fields, prefix = '') {
245
555
  for (const field of fields ?? []) {
246
556
  if (!field?.name) continue;
247
557
  const path = prefix ? `${prefix}.${field.name}` : field.name;
248
- if (field.type === 'image') {
558
+ /* ⚠ RESOLVE THE COMPONENT FIRST. A picture field is very often a shared
559
+ `component: image_field`, and reading only `field.type` misses every
560
+ one of them — so the check that catches an unpickable path went quiet
561
+ on exactly the fields most likely to have one. Observed live: a home
562
+ page whose picture showed an empty square in the CMS and whose "View on
563
+ GitHub" link 404'd, while this check reported the page clean. */
564
+ const effectiveType =
565
+ field.type ?? (config.components ?? {})[field.component]?.type;
566
+ if (effectiveType === 'image') {
249
567
  out.push({ path, media: field.options?.media });
250
568
  if (field.options?.path) scopedPaths.set(path, String(field.options.path).replace(/^\.?\//, ''));
251
569
  }
252
- if (Array.isArray(field.fields)) out.push(...imageFields(field.fields, path));
570
+ const sub = fieldsOf(field);
571
+ if (sub) out.push(...imageFields(sub, path));
253
572
  }
254
573
  return out;
255
574
  }
@@ -276,13 +595,20 @@ for (const entry of entries) {
276
595
  const full = join(d, e);
277
596
  return statSync(full).isDirectory() ? walk(full) : [full];
278
597
  });
279
- for (const file of walk(entry.path).filter((f) => /\.mdx?$/.test(f))) {
280
- const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
281
- if (!m) continue;
598
+ /* JSON items count too see the note on `collectionPaths`. Filtering to
599
+ markdown here meant image fields in a JSON collection were never
600
+ type-checked at all. */
601
+ for (const file of walk(entry.path).filter((f) => /\.(mdx?|json)$/.test(f))) {
602
+ const raw = readFileSync(file, 'utf8');
282
603
  try {
283
- documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
604
+ if (/\.json$/.test(file)) {
605
+ documents.push({ where: rel(file), data: JSON.parse(raw) ?? {} });
606
+ } else {
607
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
608
+ if (m) documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
609
+ }
284
610
  } catch {
285
- /* astro check reports unparseable frontmatter properly. */
611
+ /* astro check reports unparseable content properly. */
286
612
  }
287
613
  }
288
614
  } else if (/\.json$/.test(entry.path)) {
@@ -314,7 +640,8 @@ for (const entry of entries) {
314
640
  });
315
641
  }
316
642
  }
317
- const output = typeof source === 'object' ? source?.output : null;
643
+
644
+ const output = typeof source === 'object' ? source?.output : null;
318
645
  if (!output) continue; // nothing declared to measure against
319
646
  /* `output: /` makes "starts with the output" true of every absolute path, so
320
647
  it only distinguishes a path from a non-path. Still worth reporting — a
@@ -345,8 +672,167 @@ for (const entry of entries) {
345
672
  });
346
673
  }
347
674
  }
675
+
676
+ /*
677
+ * ⚠ A BLANK IMAGE SLOT THE CMS WROTE, WHICH BREAKS THE BUILD ON SAVE.
678
+ *
679
+ * PagesCMS writes an object for every declared object field whether or not
680
+ * the editor filled it in. A picture left empty therefore arrives as
681
+ * `{ "isRender": false }` — the boolean has a default, the required
682
+ * strings do not — and that is NOT a missing optional. It is a present
683
+ * object failing validation, so `.optional()` on the schema does not save
684
+ * you.
685
+ *
686
+ * Observed: the first save by a non-developer broke a production deploy
687
+ * this way, and the client saw only a failed build in a log they cannot
688
+ * read. It is the single most likely way a CMS commit takes a site down.
689
+ *
690
+ * The durable fix is in the SCHEMA, not here — strip a src-less object
691
+ * before validating:
692
+ *
693
+ * z.preprocess(
694
+ * (v) => (v && typeof v === 'object' && !('src' in v) ? undefined : v),
695
+ * imageObject.optional(),
696
+ * )
697
+ *
698
+ * This reports the state so it is caught before the deploy rather than by
699
+ * the client.
700
+ */
701
+ for (const { path: fieldPath } of fields) {
702
+ if (!fieldPath.includes('.')) continue; // a bare image field has no wrapper
703
+ const parts = fieldPath.split('.');
704
+ const key = parts.pop();
705
+ const blanks = [];
706
+ for (const doc of documents) {
707
+ for (const parent of valuesAt(doc.data, parts)) {
708
+ if (!parent || typeof parent !== 'object' || Array.isArray(parent)) continue;
709
+ if (key in parent) continue;
710
+ /*
711
+ * ⚠ AN OMITTED OPTIONAL FIELD LOOKS EXACTLY LIKE A BLANK SLOT, AND
712
+ * REPORTING IT IS THIS CHECK'S WHOLE FALSE-POSITIVE CLASS.
713
+ *
714
+ * Caught on a delivered site: a video list where the first item is
715
+ * `{ src: 'a-video.mp4' }` and the others also carry a `poster`. The
716
+ * poster is `.optional()` in the content schema, the page does
717
+ * `poster: v.poster ?? d.image`, and the component guards on it.
718
+ * Nothing is wrong, and this reported two problems on a clean site.
719
+ *
720
+ * What the check is actually for is the object the CMS WROTE with
721
+ * nothing in it - `{ isRender: false }`, where the boolean has a
722
+ * default and every string is absent. The signal that separates
723
+ * them is whether the object carries any real string at all: a
724
+ * blank slot carries none, an author's omission carries the rest of
725
+ * the record.
726
+ */
727
+ const substance = Object.values(parent).some(
728
+ (v) => typeof v === 'string' && v.trim() !== '',
729
+ );
730
+ if (substance) continue;
731
+ blanks.push(doc.where);
732
+ }
733
+ }
734
+ if (blanks.length) {
735
+ problems.push({
736
+ label: `${entry.name ?? entry.label} → ${parts.join('.')}`,
737
+ why:
738
+ `is an image slot the CMS filled in PARTIALLY — the object exists but has no \`${key}\`, ` +
739
+ `so it is a present object that fails validation rather than a missing optional. ` +
740
+ `${blanks.length} file(s), e.g. ${blanks[0]}`,
741
+ });
742
+ }
743
+ }
744
+ }
745
+
746
+ /* ── a select of manifest keys, against the manifest ─────────────────────── */
747
+
748
+ /*
749
+ * ⚠ A HAND-MAINTAINED LIST OF IMAGE KEYS DRIFTS, AND BOTH DIRECTIONS HURT.
750
+ *
751
+ * Where a project has no way to map a picked path back to a manifest key,
752
+ * the workable alternative is a `select` of the keys themselves. A delivered
753
+ * site does exactly that: 110 photograph keys and 9 blog keys written out in
754
+ * `.pages.yml`. Its own comment states the hazard:
755
+ *
756
+ * "a key missing here simply cannot be chosen, and a key here that the
757
+ * manifest lost will fail the build the moment it is selected"
758
+ *
759
+ * That is a promise a person has to keep every time `npm run media` runs. It
760
+ * was being kept - 110 and 9, nothing dead, nothing unlisted - and nothing
761
+ * was enforcing it.
762
+ *
763
+ * A key OFFERED that the manifest lacks is a problem: choosing it is an
764
+ * ordinary editorial action that turns the build red. A key the manifest HAS
765
+ * that the list omits is a warning, because which images belong in a given
766
+ * field is a judgement - a blog header list is right to exclude the team
767
+ * photographs.
768
+ *
769
+ * ⚠ ONLY LISTS THAT ARE ALREADY KEY LISTS. A select of "texas, nevada,
770
+ * arizona" is not a broken image list, and saying so would be noise on every
771
+ * project. A list qualifies only when one of its values is a real manifest
772
+ * key, and the unlisted half is scoped to prefixes the list already uses, so
773
+ * a blog list is measured against `blog/` and never against every photograph
774
+ * on the site.
775
+ */
776
+ {
777
+ const manifestFile = ['src/data/image-manifest.json', 'src/data/media-manifest.json'].find(existsSync);
778
+ let keys = null;
779
+ if (manifestFile) {
780
+ try {
781
+ keys = new Set(Object.keys(JSON.parse(readFileSync(manifestFile, 'utf8'))));
782
+ } catch {
783
+ /* A malformed manifest is the media pipeline's problem, not this one. */
784
+ }
785
+ }
786
+
787
+ if (keys?.size) {
788
+ /* PagesCMS accepts a bare string or `{ name, label }`. */
789
+ const valueOf = (v) => (typeof v === 'string' ? v : (v?.name ?? v?.value));
790
+
791
+ const selects = [];
792
+ for (const [name, def] of Object.entries(config.components ?? {})) {
793
+ if (def?.type === 'select') selects.push([`component ${name}`, def]);
794
+ }
795
+ const collect = (fields, where) => {
796
+ for (const f of fields ?? []) {
797
+ if (!f?.name) continue;
798
+ if (f.type === 'select') selects.push([`${where} → ${f.name}`, f]);
799
+ collect(f.fields, where);
800
+ }
801
+ };
802
+ for (const entry of entries) collect(entry?.fields, entry?.name ?? entry?.label ?? '?');
803
+
804
+ for (const [label, def] of selects) {
805
+ const values = (def.options?.values ?? []).map(valueOf).filter((v) => typeof v === 'string');
806
+ if (!values.some((v) => keys.has(v))) continue; // not a key list
807
+
808
+ const dead = values.filter((v) => !keys.has(v));
809
+ if (dead.length) {
810
+ problems.push({
811
+ label,
812
+ why:
813
+ `offers ${dead.length} image key(s) the manifest does not have - choosing one fails ` +
814
+ `the build: ${dead.slice(0, 4).map((d) => JSON.stringify(d)).join(', ')}` +
815
+ (dead.length > 4 ? `, and ${dead.length - 4} more` : ''),
816
+ });
817
+ }
818
+
819
+ const prefixes = new Set(values.map((v) => v.slice(0, v.lastIndexOf('/') + 1)).filter(Boolean));
820
+ const unlisted = [...keys].filter(
821
+ (k) => !values.includes(k) && [...prefixes].some((pre) => k.startsWith(pre)),
822
+ );
823
+ if (unlisted.length) {
824
+ warnings.push(
825
+ `"${label}" omits ${unlisted.length} image(s) the manifest has, so the editor cannot ` +
826
+ `choose them: ${unlisted.slice(0, 5).join(', ')}${unlisted.length > 5 ? ', …' : ''}\n` +
827
+ ` A hand-written list goes stale the next time \`npm run media\` adds an image. ` +
828
+ `Either add them, or write down why the list is curated.`,
829
+ );
830
+ }
831
+ }
832
+ }
348
833
  }
349
834
 
835
+
350
836
  /* ── internal links a client can type ────────────────────────────────────── */
351
837
 
352
838
  /*
@@ -542,6 +1028,35 @@ if (literals.length) {
542
1028
  );
543
1029
  }
544
1030
 
1031
+ /*
1032
+ * ⚠ AND THE SAME FAILURE ONE LEVEL IN: THE COPY ITSELF.
1033
+ *
1034
+ * A page whose content is a `const` array in its own frontmatter renders
1035
+ * correctly, types correctly, and has no field anywhere. Measured across
1036
+ * seven delivered sites that all had a working CMS the client was using:
1037
+ * nine such blocks on three of them, including a twelve-item FAQ about
1038
+ * post-operative medication and a page of seven treatments with their
1039
+ * patient-facing copy.
1040
+ *
1041
+ * A warning for the same reason as the images above — an inline list is
1042
+ * sometimes right. It must be a decision, not an oversight.
1043
+ */
1044
+ const inline = literalContent();
1045
+ if (inline.length) {
1046
+ const strings = inline.reduce((n, b) => n + b.strings, 0);
1047
+ warnings.push(
1048
+ `${inline.length} block(s) of page copy declared inline, holding ${strings} sentence(s) ` +
1049
+ `the CMS cannot reach:\n` +
1050
+ inline
1051
+ .slice(0, 8)
1052
+ .map((b) => ` ${b.file} ${b.name}[${b.items}] ${b.strings} sentences "${b.sample}…"`)
1053
+ .join('\n') +
1054
+ (inline.length > 8 ? `\n …and ${inline.length - 8} more` : '') +
1055
+ `\n This is what "required sections cannot be edited" looks like in the source. ` +
1056
+ `Each block is either deliberately fixed or a page the client cannot touch.`,
1057
+ );
1058
+ }
1059
+
545
1060
  /*
546
1061
  * ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
547
1062
  * IDs, tokens and keys are technical configuration: their failure mode is
@@ -572,6 +1087,122 @@ if (!problems.length) {
572
1087
  process.exit(0);
573
1088
  }
574
1089
 
1090
+ /*
1091
+ * ── The field declarations to paste, built from the data itself ────────────
1092
+ *
1093
+ * ⚠ THE TYPE COMES FROM THE VALUE, NOT FROM THE NAME. `openingHours` is an
1094
+ * array of objects on one site and a string on another, and guessing from
1095
+ * the key is how a generator produces confident nonsense. Every type below
1096
+ * is read off the value actually sitting at that path.
1097
+ *
1098
+ * PagesCMS spells a repeated field `list: true` on the field itself rather
1099
+ * than as a distinct type, so an array becomes its element's type plus that
1100
+ * flag. An empty array cannot say what it holds, and is emitted as a string
1101
+ * list with a comment rather than silently picking one.
1102
+ */
1103
+ function fieldType(value) {
1104
+ if (Array.isArray(value)) {
1105
+ const sample = value.find((v) => v != null);
1106
+ if (sample === undefined) return { type: 'string', list: true, unknown: true };
1107
+ return { ...fieldType(sample), list: true };
1108
+ }
1109
+ if (value === null) return { type: 'string', unknown: true };
1110
+ if (typeof value === 'number') return { type: 'number' };
1111
+ if (typeof value === 'boolean') return { type: 'boolean' };
1112
+ if (typeof value === 'object') return { type: 'object' };
1113
+ /* A long string is a textarea; a short one is an input. The threshold is the
1114
+ point past which a single-line box stops being usable, not a rule. */
1115
+ return { type: String(value).length > 80 ? 'text' : 'string' };
1116
+ }
1117
+
1118
+ /** Nest a flat list of dotted paths back into a tree. */
1119
+ function tree(paths) {
1120
+ const root = new Map();
1121
+ for (const path of paths) {
1122
+ let node = root;
1123
+ for (const part of path.split('.')) {
1124
+ if (!node.has(part)) node.set(part, new Map());
1125
+ node = node.get(part);
1126
+ }
1127
+ }
1128
+ return root;
1129
+ }
1130
+
1131
+ function emitFields(node, values, prefix, indent) {
1132
+ const pad = ' '.repeat(indent);
1133
+ const lines = [];
1134
+ for (const [name, children] of node) {
1135
+ const path = prefix ? `${prefix}.${name}` : name;
1136
+ const { type, list, unknown } = fieldType(values.get(path));
1137
+ const note = unknown ? ' # value was null or empty — check this one' : '';
1138
+ if (children.size) {
1139
+ lines.push(`${pad}- name: ${name}`);
1140
+ lines.push(`${pad} type: object`);
1141
+ if (list) lines.push(`${pad} list: true`);
1142
+ lines.push(`${pad} fields:`);
1143
+ lines.push(...emitFields(children, values, path, indent + 4));
1144
+ } else if (list) {
1145
+ lines.push(`${pad}- name: ${name}`);
1146
+ lines.push(`${pad} type: ${type}`);
1147
+ lines.push(`${pad} list: true${note}`);
1148
+ } else {
1149
+ lines.push(`${pad}- { name: ${name}, type: ${type} }${note}`);
1150
+ }
1151
+ }
1152
+ return lines;
1153
+ }
1154
+
1155
+ /**
1156
+ * The fix for one undeclared-keys problem, as lines to print.
1157
+ *
1158
+ * ⚠ A KEY WHOSE PARENT IS ALREADY DECLARED CANNOT BE PASTED AT THE TOP LEVEL.
1159
+ * `analytics` declared and `analytics.gtmId` missing means the field exists
1160
+ * and its `fields:` is short — emitting a second `analytics` field would
1161
+ * give the editor two screens for one object. Those are reported separately,
1162
+ * naming the field to open.
1163
+ */
1164
+ function fixFor(problem) {
1165
+ const { keys, values, declared, label, path } = problem;
1166
+ const roots = [];
1167
+ const nested = new Map();
1168
+
1169
+ for (const key of keys) {
1170
+ const parent = key.slice(0, key.lastIndexOf('.'));
1171
+ if (key.includes('.') && declared.has(parent)) {
1172
+ if (!nested.has(parent)) nested.set(parent, []);
1173
+ nested.get(parent).push(key);
1174
+ } else if (!keys.some((k) => k !== key && key.startsWith(`${k}.`))) {
1175
+ roots.push(key);
1176
+ }
1177
+ }
1178
+
1179
+ const out = [];
1180
+ if (roots.length) {
1181
+ const own = keys.filter((k) => roots.some((r) => k === r || k.startsWith(`${r}.`)));
1182
+ out.push(` ${DIM}── paste into \`fields:\` for \`${label}\` ${'─'.repeat(30)}${RESET}`);
1183
+ out.push(...emitFields(tree(own), values, '', 6).map((l) => `${DIM}${l}${RESET}`));
1184
+ }
1185
+ for (const [parent, children] of nested) {
1186
+ out.push('');
1187
+ out.push(` ${DIM}── add under the existing \`${parent}\` field's \`fields:\` ──${RESET}`);
1188
+ const relative_ = children.map((c) => c.slice(parent.length + 1));
1189
+ const scoped = new Map(children.map((c) => [c.slice(parent.length + 1), values.get(c)]));
1190
+ out.push(...emitFields(tree(relative_), scoped, '', 6).map((l) => `${DIM}${l}${RESET}`));
1191
+ }
1192
+
1193
+ const risky = keys.filter((k) => SECRET_SHAPED.test(k));
1194
+ if (risky.length) {
1195
+ out.push('');
1196
+ out.push(
1197
+ ` ${YELLOW}⚠${RESET} ${DIM}${risky.slice(0, 4).join(', ')}${risky.length > 4 ? ', …' : ''} ` +
1198
+ `look like technical configuration.\n` +
1199
+ ` Moving them OUT of ${rel(path)} is usually the better fix — a client\n` +
1200
+ ` cannot diagnose what breaks when one is changed, and it fails silently.${RESET}`,
1201
+ );
1202
+ }
1203
+ return out;
1204
+ }
1205
+
575
1206
  console.error(`\n${RED}✗ ${problems.length} problem(s) in ${CONFIG}${RESET}\n`);
576
1207
 
577
1208
  for (const p of problems) {
@@ -584,6 +1215,17 @@ for (const p of problems) {
584
1215
  ` save from this screen DELETES them. Declare every key — including ones\n` +
585
1216
  ` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
586
1217
  );
1218
+ if (!SHOW_FIX) {
1219
+ console.error(
1220
+ ` ${DIM}Run \`npm run check:cms -- --fix\` to print the field declarations\n` +
1221
+ ` to paste, with each type read off the value actually stored there.${RESET}`,
1222
+ );
1223
+ }
1224
+ if (SHOW_FIX) {
1225
+ console.error('');
1226
+ for (const line of fixFor(p)) console.error(line);
1227
+ console.error('');
1228
+ }
587
1229
  }
588
1230
  if (p.links) {
589
1231
  for (const l of p.links.slice(0, 8)) {