create-website-build-kit 0.1.18 → 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.
- package/package.json +1 -1
- package/template/CLAUDE.md +3 -1
- package/template/docs/dependencies.md +14 -5
- package/template/package-lock.json +509 -982
- package/template/package.json +1 -0
- package/template/scripts/a11y-evidence.mjs +31 -4
- package/template/scripts/check-a11y.mjs +16 -1
- package/template/scripts/check-cms.mjs +692 -26
- package/template/scripts/check-drift.mjs +154 -8
- package/template/scripts/lib/literal-content.mjs +233 -0
- package/template/scripts/lib/literal-images.mjs +35 -0
- package/template/scripts/lib/schemes.mjs +98 -10
- package/template/scripts/md-to-pdf.mjs +19 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
const undeclared = [...
|
|
378
|
+
const values = new Map();
|
|
379
|
+
const undeclared = [...collectionPaths(path, values, entry.fields, entry.exclude)];
|
|
154
380
|
if (undeclared.length) {
|
|
155
|
-
problems.push({
|
|
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 = [...
|
|
401
|
+
const undeclared = [...undeclaredPaths(data, entry.fields)];
|
|
169
402
|
if (undeclared.length) {
|
|
170
|
-
problems.push({
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
}
|
|
@@ -237,13 +547,28 @@ for (const source of media) {
|
|
|
237
547
|
* with that media source's `output`. Convert the field and migrate the data
|
|
238
548
|
* in the same change.
|
|
239
549
|
*/
|
|
550
|
+
/** Dotted field path → the `options.path` it is scoped to, when it declares one. */
|
|
551
|
+
const scopedPaths = new Map();
|
|
552
|
+
|
|
240
553
|
function imageFields(fields, prefix = '') {
|
|
241
554
|
const out = [];
|
|
242
555
|
for (const field of fields ?? []) {
|
|
243
556
|
if (!field?.name) continue;
|
|
244
557
|
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
245
|
-
|
|
246
|
-
|
|
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') {
|
|
567
|
+
out.push({ path, media: field.options?.media });
|
|
568
|
+
if (field.options?.path) scopedPaths.set(path, String(field.options.path).replace(/^\.?\//, ''));
|
|
569
|
+
}
|
|
570
|
+
const sub = fieldsOf(field);
|
|
571
|
+
if (sub) out.push(...imageFields(sub, path));
|
|
247
572
|
}
|
|
248
573
|
return out;
|
|
249
574
|
}
|
|
@@ -270,13 +595,20 @@ for (const entry of entries) {
|
|
|
270
595
|
const full = join(d, e);
|
|
271
596
|
return statSync(full).isDirectory() ? walk(full) : [full];
|
|
272
597
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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');
|
|
276
603
|
try {
|
|
277
|
-
|
|
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
|
+
}
|
|
278
610
|
} catch {
|
|
279
|
-
/* astro check reports unparseable
|
|
611
|
+
/* astro check reports unparseable content properly. */
|
|
280
612
|
}
|
|
281
613
|
}
|
|
282
614
|
} else if (/\.json$/.test(entry.path)) {
|
|
@@ -290,7 +622,26 @@ for (const entry of entries) {
|
|
|
290
622
|
for (const { path: fieldPath, media: mediaName } of fields) {
|
|
291
623
|
const source =
|
|
292
624
|
(mediaName && mediaByName.get(mediaName)) ?? (media.length === 1 ? media[0] : null);
|
|
293
|
-
|
|
625
|
+
|
|
626
|
+
/*
|
|
627
|
+
* ⚠ A PICKER SCOPED TO A FOLDER THAT DOES NOT EXIST OPENS ON NOTHING.
|
|
628
|
+
* `options.path` narrows which directory the media browser shows. Point it
|
|
629
|
+
* at `public/img/brand` when the files live in `brand-v2/` and the editor
|
|
630
|
+
* gets an empty folder — no error, no warning, and the build does not
|
|
631
|
+
* care. It is visible only to the person trying to choose an image, which
|
|
632
|
+
* is the one person who cannot fix it.
|
|
633
|
+
*/
|
|
634
|
+
if (fieldPath && typeof entry.fields === 'object') {
|
|
635
|
+
const scoped = scopedPaths.get(fieldPath);
|
|
636
|
+
if (scoped && !existsSync(scoped)) {
|
|
637
|
+
problems.push({
|
|
638
|
+
label: `${entry.name ?? entry.label} → ${fieldPath}`,
|
|
639
|
+
why: `\`options.path\` is ${JSON.stringify(scoped)}, which does not exist — the picker opens on an empty folder`,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const output = typeof source === 'object' ? source?.output : null;
|
|
294
645
|
if (!output) continue; // nothing declared to measure against
|
|
295
646
|
/* `output: /` makes "starts with the output" true of every absolute path, so
|
|
296
647
|
it only distinguishes a path from a non-path. Still worth reporting — a
|
|
@@ -321,8 +672,167 @@ for (const entry of entries) {
|
|
|
321
672
|
});
|
|
322
673
|
}
|
|
323
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
|
+
}
|
|
324
833
|
}
|
|
325
834
|
|
|
835
|
+
|
|
326
836
|
/* ── internal links a client can type ────────────────────────────────────── */
|
|
327
837
|
|
|
328
838
|
/*
|
|
@@ -518,6 +1028,35 @@ if (literals.length) {
|
|
|
518
1028
|
);
|
|
519
1029
|
}
|
|
520
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
|
+
|
|
521
1060
|
/*
|
|
522
1061
|
* ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
|
|
523
1062
|
* IDs, tokens and keys are technical configuration: their failure mode is
|
|
@@ -548,6 +1087,122 @@ if (!problems.length) {
|
|
|
548
1087
|
process.exit(0);
|
|
549
1088
|
}
|
|
550
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
|
+
|
|
551
1206
|
console.error(`\n${RED}✗ ${problems.length} problem(s) in ${CONFIG}${RESET}\n`);
|
|
552
1207
|
|
|
553
1208
|
for (const p of problems) {
|
|
@@ -560,6 +1215,17 @@ for (const p of problems) {
|
|
|
560
1215
|
` save from this screen DELETES them. Declare every key — including ones\n` +
|
|
561
1216
|
` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
|
|
562
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
|
+
}
|
|
563
1229
|
}
|
|
564
1230
|
if (p.links) {
|
|
565
1231
|
for (const l of p.links.slice(0, 8)) {
|