create-website-build-kit 0.1.14 → 0.1.16

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.
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Refuse a CMS config that will silently destroy content.
3
+ *
4
+ * npm run check:cms
5
+ *
6
+ * A no-op when the project has no `.pages.yml` — the kit ships no CMS, and a
7
+ * check that cannot run says so rather than printing a tick it did not earn.
8
+ *
9
+ * ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
10
+ * ⚠ A CMS REWRITES THE WHOLE FILE FROM ITS SCHEMA. Any key the schema does not
11
+ * declare is absent from what it writes back — not merged, not flagged. The
12
+ * editor changes one field, hits save, and everything the config forgot is
13
+ * gone from the repo. In the diff it reads as an ordinary content commit.
14
+ *
15
+ * This is not hypothetical. Audited across five shipped sites, two were losing
16
+ * data on the client's first save:
17
+ *
18
+ * site.json analytics.ga4MeasurementId, analytics.gtmId,
19
+ * analytics.googleTagId, analytics.cloudflareToken,
20
+ * businessType, openingHours, socials.google
21
+ * home_{en,ru,uz}.json cta.image.src/alt/isRender,
22
+ * quote.image.src/alt/isRender
23
+ *
24
+ * Read the first one again: the moment the client opens Site Settings and saves,
25
+ * every analytics ID is deleted. Tracking stops, opening hours vanish from the
26
+ * JSON-LD, and nobody is told. On the multilingual site, all three homepages
27
+ * lose their CTA and quote images at once.
28
+ *
29
+ * 27 keys were at risk across those two projects. Every one was invisible to
30
+ * `astro check`, to the build, and to a reviewer reading the config — because
31
+ * the config is *valid*. It just describes less than the file contains.
32
+ *
33
+ * ── AND MEDIA POINTED THE WRONG WAY ────────────────────────────────────────
34
+ * Two of the five declared their upload directory as `public/img`, which is
35
+ * where `optimize-media.mjs` WRITES. An upload there is servable but has no
36
+ * variants, no width/height and no manifest entry, so `<Img>` throws and the
37
+ * client's own edit turns the build red. Uploads belong in the pipeline's
38
+ * INPUT — `media/source/` — and the direction is the whole bug.
39
+ */
40
+
41
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
42
+ import { join, relative, sep } from 'node:path';
43
+ import { parse } from 'yaml';
44
+ import { literalImages } from './lib/literal-images.mjs';
45
+
46
+ const RESET = '\x1b[0m';
47
+ const RED = '\x1b[31m';
48
+ const GREEN = '\x1b[32m';
49
+ const YELLOW = '\x1b[33m';
50
+ const DIM = '\x1b[2m';
51
+
52
+ const CONFIG = '.pages.yml';
53
+
54
+ if (!existsSync(CONFIG)) {
55
+ console.log(`${DIM}·${RESET} no ${CONFIG} — no CMS to check`);
56
+ process.exit(0);
57
+ }
58
+
59
+ let config;
60
+ try {
61
+ config = parse(readFileSync(CONFIG, 'utf8')) ?? {};
62
+ } catch (err) {
63
+ console.error(`\n${RED}✗ ${CONFIG} does not parse${RESET}\n\n ${err.message}\n`);
64
+ process.exit(1);
65
+ }
66
+
67
+ const problems = [];
68
+ const warnings = [];
69
+
70
+ /* PagesCMS nests with `items`, not `content`. Getting this wrong reports every
71
+ grouped config as having zero entries — which looks like a clean pass. */
72
+ const flatten = (entries) =>
73
+ (entries ?? []).flatMap((entry) =>
74
+ entry?.type === 'group' ? flatten(entry.items ?? entry.content ?? []) : [entry],
75
+ );
76
+
77
+ /** Every dotted path the schema declares. Arrays reuse the parent prefix. */
78
+ function schemaPaths(fields, prefix = '') {
79
+ const out = new Set();
80
+ for (const field of fields ?? []) {
81
+ if (!field?.name) continue;
82
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
83
+ out.add(path);
84
+ if (Array.isArray(field.fields)) for (const p of schemaPaths(field.fields, path)) out.add(p);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** Every dotted path the DATA contains. An array's items sit at its own prefix. */
90
+ function dataPaths(value, prefix = '') {
91
+ const out = new Set();
92
+ if (Array.isArray(value)) {
93
+ for (const item of value) for (const p of dataPaths(item, prefix)) out.add(p);
94
+ } else if (value && typeof value === 'object') {
95
+ for (const [key, inner] of Object.entries(value)) {
96
+ const path = prefix ? `${prefix}.${key}` : key;
97
+ out.add(path);
98
+ for (const p of dataPaths(inner, path)) out.add(p);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Frontmatter keys actually used across a collection, as dotted paths. */
105
+ function collectionPaths(dir) {
106
+ const out = new Set();
107
+ const walk = (d) =>
108
+ readdirSync(d).flatMap((e) => {
109
+ const full = join(d, e);
110
+ return statSync(full).isDirectory() ? walk(full) : [full];
111
+ });
112
+ for (const file of walk(dir).filter((f) => /\.mdx?$/.test(f))) {
113
+ const raw = readFileSync(file, 'utf8');
114
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
115
+ if (!match) continue;
116
+ try {
117
+ for (const p of dataPaths(parse(match[1]) ?? {})) out.add(p);
118
+ } catch {
119
+ /* A collection item with unparseable frontmatter is the content
120
+ collection's problem, and astro check reports it properly. */
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ const rel = (p) => relative(process.cwd(), p).split(sep).join('/');
127
+
128
+ /* ── content entries ─────────────────────────────────────────────────────── */
129
+
130
+ const entries = flatten(config.content);
131
+
132
+ if (!entries.length) {
133
+ warnings.push(`${CONFIG} declares no content entries — the editor sees an empty CMS`);
134
+ }
135
+
136
+ for (const entry of entries) {
137
+ const label = entry?.name ?? entry?.label ?? '(unnamed)';
138
+ const path = entry?.path;
139
+ if (!path) {
140
+ problems.push({ label, why: 'has no `path`' });
141
+ continue;
142
+ }
143
+ if (!existsSync(path)) {
144
+ problems.push({ label, why: `path does not exist: ${path}` });
145
+ continue;
146
+ }
147
+
148
+ const declared = schemaPaths(entry.fields);
149
+
150
+ if (entry.type === 'collection') {
151
+ const used = collectionPaths(path);
152
+ const undeclared = [...used].filter((p) => !declared.has(p));
153
+ if (undeclared.length) {
154
+ problems.push({ label, why: `frontmatter keys the schema does not declare`, keys: undeclared, path });
155
+ }
156
+ continue;
157
+ }
158
+
159
+ if (/\.json$/.test(path)) {
160
+ let data;
161
+ try {
162
+ data = JSON.parse(readFileSync(path, 'utf8'));
163
+ } catch (err) {
164
+ problems.push({ label, why: `${path} is not valid JSON — ${err.message}` });
165
+ continue;
166
+ }
167
+ const undeclared = [...dataPaths(data)].filter((p) => !declared.has(p));
168
+ if (undeclared.length) {
169
+ problems.push({ label, why: 'keys in the file the schema does not declare', keys: undeclared, path });
170
+ }
171
+ }
172
+ }
173
+
174
+ /* ── media ───────────────────────────────────────────────────────────────── */
175
+
176
+ /* Declared here, not beside the image checks that read it: the media loop below
177
+ fills it, and a `const` used above its own declaration is a TDZ
178
+ ReferenceError that `node --check` cannot see. This file shipped that way for
179
+ exactly one run. */
180
+ const mediaByName = new Map();
181
+
182
+ /* Where optimize-media.mjs writes. An upload here is not an input, it is a
183
+ file dropped into generated output. */
184
+ const GENERATED = ['public/img', 'dist', '.astro'];
185
+
186
+ const media = config.media ? (Array.isArray(config.media) ? config.media : [config.media]) : [];
187
+
188
+ for (const source of media) {
189
+ const input = typeof source === 'string' ? source : source?.input;
190
+ const name = (typeof source === 'object' && source?.name) || input || '(unnamed)';
191
+ if (!input) {
192
+ problems.push({ label: `media ${name}`, why: 'has no `input`' });
193
+ continue;
194
+ }
195
+ if (typeof source === 'object' && source.name) mediaByName.set(source.name, source);
196
+ const normalised = input.replace(/^\.?\//, '').replace(/\/$/, '');
197
+ if (GENERATED.some((g) => normalised === g || normalised.startsWith(`${g}/`))) {
198
+ problems.push({
199
+ label: `media ${name}`,
200
+ why: `uploads into ${input}, which is GENERATED output`,
201
+ direction: true,
202
+ });
203
+ continue;
204
+ }
205
+ if (!existsSync(input)) {
206
+ problems.push({ label: `media ${name}`, why: `input directory does not exist: ${input}` });
207
+ continue;
208
+ }
209
+ if (typeof source === 'object' && !source.extensions) {
210
+ warnings.push(
211
+ `media "${name}" declares no \`extensions\` — a bad format is accepted in the UI and ` +
212
+ `fails the build twenty minutes later instead of being refused at the door`,
213
+ );
214
+ }
215
+ }
216
+
217
+ /* ── image fields: is the stored VALUE the shape the field type needs? ───── */
218
+
219
+ /*
220
+ * ⚠ THIS IS THE CHECK THE TOLERANT READER MADE NECESSARY.
221
+ *
222
+ * `<Img>` accepts a manifest key OR a picker path, which is what lets an
223
+ * image field be a real picker. But a reader that accepts two formats will
224
+ * never tell you which one you stored — so converting a field to
225
+ * `type: image` without migrating its values leaves a site where:
226
+ *
227
+ * the build is green, astro check is clean, pa11y is clean, and the
228
+ * rendered HTML is BYTE-IDENTICAL — and every picker in the CMS is broken.
229
+ *
230
+ * That happened on a real site: eighteen grey squares in the editor and a
231
+ * GitHub link 404ing, while every automated check said the site was fine.
232
+ * Nothing rendered by the site can see it, because the CMS is not a page.
233
+ *
234
+ * `type: image` is built around the PATH — the picker returns one, the
235
+ * thumbnail loads one, the repo link resolves one. So the value has to start
236
+ * with that media source's `output`. Convert the field and migrate the data
237
+ * in the same change.
238
+ */
239
+ function imageFields(fields, prefix = '') {
240
+ const out = [];
241
+ for (const field of fields ?? []) {
242
+ if (!field?.name) continue;
243
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
244
+ if (field.type === 'image') out.push({ path, media: field.options?.media });
245
+ if (Array.isArray(field.fields)) out.push(...imageFields(field.fields, path));
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /** Every value stored at a dotted path, walking through arrays. */
251
+ function valuesAt(value, parts) {
252
+ if (value == null) return [];
253
+ if (!parts.length) return Array.isArray(value) ? value : [value];
254
+ if (Array.isArray(value)) return value.flatMap((v) => valuesAt(v, parts));
255
+ if (typeof value !== 'object') return [];
256
+ const [head, ...rest] = parts;
257
+ return valuesAt(value[head], rest);
258
+ }
259
+
260
+ for (const entry of entries) {
261
+ const fields = imageFields(entry?.fields);
262
+ if (!fields.length || !existsSync(entry.path ?? '')) continue;
263
+
264
+ const documents = [];
265
+ if (entry.type === 'collection') {
266
+ /* Frontmatter only; a body image is markdown, not a field. */
267
+ const walk = (d) =>
268
+ readdirSync(d).flatMap((e) => {
269
+ const full = join(d, e);
270
+ return statSync(full).isDirectory() ? walk(full) : [full];
271
+ });
272
+ for (const file of walk(entry.path).filter((f) => /\.mdx?$/.test(f))) {
273
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
274
+ if (!m) continue;
275
+ try {
276
+ documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
277
+ } catch {
278
+ /* astro check reports unparseable frontmatter properly. */
279
+ }
280
+ }
281
+ } else if (/\.json$/.test(entry.path)) {
282
+ try {
283
+ documents.push({ where: rel(entry.path), data: JSON.parse(readFileSync(entry.path, 'utf8')) });
284
+ } catch {
285
+ /* Already reported above. */
286
+ }
287
+ }
288
+
289
+ for (const { path: fieldPath, media: mediaName } of fields) {
290
+ const source =
291
+ (mediaName && mediaByName.get(mediaName)) ?? (media.length === 1 ? media[0] : null);
292
+ const output = typeof source === 'object' ? source?.output : null;
293
+ if (!output) continue; // nothing declared to measure against
294
+ /* `output: /` makes "starts with the output" true of every absolute path, so
295
+ it only distinguishes a path from a non-path. Still worth reporting — a
296
+ `type: image` field holding a bare word is a picker showing nothing — but
297
+ do not pretend the test was stronger than it was. */
298
+
299
+ /* ⚠ ONE PROBLEM PER FIELD, NOT PER VALUE. A collection of thirty items with
300
+ one bad field produced thirty identical lines on a real project — 78 in
301
+ total for three fields. A gate that floods is a gate that gets switched
302
+ off, and the fix is always the same edit for the whole field. */
303
+ const wrong = [];
304
+ for (const doc of documents) {
305
+ for (const value of valuesAt(doc.data, fieldPath.split('.'))) {
306
+ if (typeof value !== 'string' || !value) continue;
307
+ if (value.startsWith(output)) continue;
308
+ wrong.push({ value, where: doc.where });
309
+ }
310
+ }
311
+ if (wrong.length) {
312
+ const samples = [...new Set(wrong.map((w) => w.value))].slice(0, 3);
313
+ problems.push({
314
+ label: `${entry.name ?? entry.label} → ${fieldPath}`,
315
+ why:
316
+ `is \`type: image\` but ${wrong.length} value(s) are not a path under ${JSON.stringify(output)}` +
317
+ ` — e.g. ${samples.map((v) => JSON.stringify(v)).join(', ')}`,
318
+ picker: true,
319
+ path: wrong[0].where,
320
+ });
321
+ }
322
+ }
323
+ }
324
+
325
+ /* ── coverage, and secrets ───────────────────────────────────────────────── */
326
+
327
+ /*
328
+ * ⚠ WARNINGS, NEVER FAILURES. What belongs in a CMS is a judgement — a single
329
+ * -location business has no business needing a Locations collection, and a
330
+ * gate that insists otherwise gets switched off. But "the client says whole
331
+ * sections are missing" was the actual complaint from five delivered sites,
332
+ * and it is checkable: content exists in the repo that no CMS entry points at.
333
+ *
334
+ * Audited across those five, navigation was absent from ALL FIVE, and
335
+ * testimonials from four. Nothing reported it, because nothing looked.
336
+ */
337
+ const covered = new Set(
338
+ entries.map((e) => (e?.path ?? '').replace(/^\.?\//, '').replace(/\/$/, '')).filter(Boolean),
339
+ );
340
+
341
+ /*
342
+ * Generated files — a CMS editing these would be editing build output.
343
+ *
344
+ * ⚠ MATCHED BY SHAPE, NOT BY NAME. This began as a two-name list and
345
+ * immediately produced a false positive on a real project's
346
+ * `media-manifest.json`, which is the denylist problem in miniature: it knows
347
+ * only the files already thought of. Anything `*manifest.json` is written by
348
+ * a build step, and `lastmod.json` is named because dates are generated too.
349
+ */
350
+ const isGenerated = (file) => /manifest\.json$/.test(file) || file === 'lastmod.json';
351
+
352
+ const uncovered = [];
353
+
354
+ if (existsSync('src/content')) {
355
+ for (const dir of readdirSync('src/content', { withFileTypes: true })) {
356
+ if (!dir.isDirectory()) continue;
357
+ const path = `src/content/${dir.name}`;
358
+ if (![...covered].some((c) => c === path || c.startsWith(`${path}/`))) uncovered.push(path);
359
+ }
360
+ }
361
+
362
+ if (existsSync('src/data')) {
363
+ for (const file of readdirSync('src/data')) {
364
+ if (!file.endsWith('.json') || isGenerated(file)) continue;
365
+ const path = `src/data/${file}`;
366
+ if (!covered.has(path)) uncovered.push(path);
367
+ }
368
+ }
369
+
370
+ if (uncovered.length) {
371
+ warnings.push(
372
+ `${uncovered.length} content source(s) exist that no CMS entry points at — the client cannot ` +
373
+ `edit them, and "whole sections are missing" is how that gets reported:\n` +
374
+ uncovered.map((u) => ` ${u}`).join('\n') +
375
+ `\n Each is either a deliberate developer-controlled file or a gap. Decide which.`,
376
+ );
377
+ }
378
+
379
+ /*
380
+ * ⚠ A CLIENT GUIDE DOES NOT GO OUT OF DATE GRACEFULLY. IT STARTS LYING.
381
+ *
382
+ * `docs/handover.md` is the only document written for the client. One
383
+ * project's was written when the CMS had six entries; it had thirteen by the
384
+ * time anyone looked, and nothing noticed. That is the mild half.
385
+ *
386
+ * The serious half is that it still said the address and phone number "are
387
+ * not editable" — which stopped being true the day those moved into the CMS.
388
+ * A client reading that either asks you to do something she can do herself,
389
+ * or assumes her address updates everywhere on its own because the document
390
+ * told her the site owned it.
391
+ *
392
+ * Only the client ever finds out. So: every entry the CMS shows should be
393
+ * named in the guide. A warning, not a failure — what the guide says is a
394
+ * judgement, and a section deliberately left out is a decision.
395
+ */
396
+ const GUIDE = join('docs', 'handover.md');
397
+
398
+ if (existsSync(GUIDE) && entries.length) {
399
+ const guide = readFileSync(GUIDE, 'utf8').toLowerCase();
400
+ const unmentioned = entries
401
+ .map((e) => e?.label ?? e?.name)
402
+ .filter(Boolean)
403
+ .filter((label) => !guide.includes(String(label).toLowerCase()));
404
+ if (unmentioned.length) {
405
+ warnings.push(
406
+ `${unmentioned.length} CMS section(s) the client guide never mentions: ` +
407
+ `${unmentioned.join(', ')}.\n` +
408
+ ` ${GUIDE} is the only document written for the client. A section it omits is one ` +
409
+ `they will not know they can edit — and a claim it makes that the CMS has since ` +
410
+ `contradicted is worse, because they will believe it.`,
411
+ );
412
+ }
413
+ }
414
+
415
+ /*
416
+ * ⚠ A SENTENCE SAYING "PHOTOGRAPHS ARE CHOSEN IN CODE" COVERS THE FIELDS THAT
417
+ * EXIST AND EXCUSES THE ONES THAT DO NOT.
418
+ *
419
+ * On a real build that left a header band, four class tiles and a gift-card
420
+ * picture as string literals in `.astro`, while the config claimed images
421
+ * were deliberately developer-controlled. The client opened the page, saw a
422
+ * photograph, and had no way to change it. Nothing was broken; the only
423
+ * symptom was someone looking for a field that was never there.
424
+ *
425
+ * A warning, because a fixed image IS sometimes right — a logo, an
426
+ * illustration that belongs to the layout. The rule is that it must be a
427
+ * decision, not an oversight.
428
+ */
429
+ const literals = literalImages();
430
+ if (literals.length) {
431
+ warnings.push(
432
+ `${literals.length} image(s) hardcoded in pages, which the CMS cannot change:\n` +
433
+ literals
434
+ .slice(0, 10)
435
+ .map((l) => ` ${l.file} ${l.value}`)
436
+ .join('\n') +
437
+ (literals.length > 10 ? `\n …and ${literals.length - 10} more` : '') +
438
+ `\n Each is a field the client does not have. Either give it one, or write down ` +
439
+ `why it is fixed — "chosen in code" stops being true the moment the next one is added.`,
440
+ );
441
+ }
442
+
443
+ /*
444
+ * ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
445
+ * IDs, tokens and keys are technical configuration: their failure mode is
446
+ * silent (tracking stops, mail stops) and no editor can diagnose it.
447
+ */
448
+ const SECRET_SHAPED = /(^|[._-])(api|secret|token|key|password|credential|apikey)([._-]|$)|(ga4|gtm|analytics|measurement)/i;
449
+
450
+ for (const entry of entries) {
451
+ const risky = [...schemaPaths(entry?.fields)].filter((f) => SECRET_SHAPED.test(f));
452
+ if (risky.length) {
453
+ warnings.push(
454
+ `"${entry?.name ?? entry?.label}" exposes field(s) that look like technical configuration ` +
455
+ `rather than content: ${risky.join(', ')}. A client cannot diagnose what breaks when one ` +
456
+ `is changed, and the failure is silent.`,
457
+ );
458
+ }
459
+ }
460
+
461
+ /* ── report ──────────────────────────────────────────────────────────────── */
462
+
463
+ for (const w of warnings) console.log(` ${YELLOW}!${RESET} ${w}`);
464
+
465
+ if (!problems.length) {
466
+ console.log(
467
+ `${GREEN}✓${RESET} ${CONFIG}: ${entries.length} entrie(s), every key declared` +
468
+ (media.length ? `, ${media.length} media source(s)` : ''),
469
+ );
470
+ process.exit(0);
471
+ }
472
+
473
+ console.error(`\n${RED}✗ ${problems.length} problem(s) in ${CONFIG}${RESET}\n`);
474
+
475
+ for (const p of problems) {
476
+ console.error(` ${p.label} — ${p.why}`);
477
+ if (p.keys) {
478
+ for (const k of p.keys.slice(0, 12)) console.error(` ${DIM}${k}${RESET}`);
479
+ if (p.keys.length > 12) console.error(` ${DIM}…and ${p.keys.length - 12} more${RESET}`);
480
+ console.error(
481
+ ` ${DIM}These exist in ${rel(p.path)} and are NOT in the schema, so the first\n` +
482
+ ` save from this screen DELETES them. Declare every key — including ones\n` +
483
+ ` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
484
+ );
485
+ }
486
+ if (p.picker) {
487
+ console.error(
488
+ ` ${DIM}The site still renders this: <Img> accepts a manifest key as well as a\n` +
489
+ ` picker path. The CMS does not — \`type: image\` is built around the path, so\n` +
490
+ ` the picker shows an empty square and the repo link 404s, while the build,\n` +
491
+ ` the types and the rendered HTML all stay clean.\n\n` +
492
+ ` A reader that accepts two formats cannot tell you which one you stored.\n` +
493
+ ` Convert the field and migrate the values in the same change.${RESET}`,
494
+ );
495
+ }
496
+ if (p.direction) {
497
+ console.error(
498
+ ` ${DIM}The direction is the bug. optimize-media.mjs READS media/source/ and\n` +
499
+ ` WRITES public/img/. An upload into the output has no variants, no\n` +
500
+ ` width/height and no manifest entry, so <Img> throws and the client's\n` +
501
+ ` own edit turns the build red.\n\n` +
502
+ ` input: media/source/uploads what the pipeline reads\n` +
503
+ ` output: /img/uploads what it writes, once processed${RESET}`,
504
+ );
505
+ }
506
+ console.error('');
507
+ }
508
+
509
+ process.exit(1);