create-website-build-kit 0.1.13 → 0.1.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
5
5
  "keywords": [
6
6
  "astro",
@@ -95,6 +95,19 @@ The rules whose failure looks like success — double-counted pageviews, a conve
95
95
  two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
96
96
  Read it before adding any tag.
97
97
 
98
+ ⚠ **A CMS DELETES EVERY KEY ITS SCHEMA FORGOT.** It rewrites the whole file from the schema, so
99
+ anything undeclared is absent from what it writes back — the client changes one field, saves, and
100
+ the rest is gone, looking like an ordinary content commit. `npm run check:cms` refuses a
101
+ `.pages.yml` that does not declare every key in the files it edits. **Declare keys the client will
102
+ never touch**, or move them out of a CMS-managed file.
103
+
104
+ ⚠ **UPLOADS GO TO `media/source/`, NEVER `public/img/`.** The direction is the whole bug:
105
+ `optimize-media.mjs` **reads** `media/source/` and **writes** `public/img/`. A CMS media source
106
+ pointed at the output produces files with no variants, no width/height and no manifest entry, so
107
+ `<Img>` throws and the client's own edit turns the build red. `<Img>` accepts a picker path like
108
+ `/img/photos/hero-1200.webp` and normalises it back to the key, so an image field can be a real
109
+ picker instead of asking the client to type a manifest key from memory.
110
+
98
111
  ⚠ **THE HONEYPOT IS CALLED `company`.** `api/contact.ts` discards any submission that fills it in,
99
112
  silently and with a 200, so a bot learns nothing. Add a real "Company" field to that form — an
100
113
  ordinary client request — and every enquiry from a company that types its name is thrown away, with
@@ -131,6 +131,36 @@ how.
131
131
  once — the header, the footer, every button, the notification emails, and what Google reads.
132
132
  Ask for them to be changed in that one file rather than page by page, or they will drift.
133
133
 
134
+ ### Editing it yourself
135
+
136
+ ⚠ *Delete this whole subsection if the site has no content editor.*
137
+
138
+ You sign in at ⚠ **editor URL** with your ⚠ **GitHub / provider** account. Ask ⚠ **name** to add
139
+ anyone else who needs access.
140
+
141
+ | Section | What it changes |
142
+ | --- | --- |
143
+ | ⚠ Site settings | ⚠ business name, contact details, the main button |
144
+ | ⚠ Pages | ⚠ the words on Home, About, Contact |
145
+ | ⚠ Services | ⚠ each service, its photograph and description |
146
+ | ⚠ Blog | ⚠ posts |
147
+
148
+ **A change is live a few minutes after you save.** ⚠ *Confirm the real timing before sending
149
+ this.* To check it worked, open the page in a private window — an ordinary window may show you
150
+ the version you had before.
151
+
152
+ **Photographs.** Upload them in the ⚠ **Photographs** area, then choose one in the field on the
153
+ page you are editing. Always fill in the short description beside the image: it is what a
154
+ blind visitor hears and what Google reads. Describe what is in the picture, not the file name.
155
+
156
+ **You cannot break the website from here.** If a change is wrong, the version before it stays
157
+ live until the new one is ready — so a mistake means your change does not appear, not that the
158
+ site goes down. Tell ⚠ **name** what you changed and it can be put back.
159
+
160
+ **Things to leave alone.** ⚠ *List anything the editor can reach that it should not — otherwise
161
+ delete this line.* If a screen asks for something that looks technical rather than something a
162
+ customer would read, stop and ask.
163
+
134
164
  ---
135
165
 
136
166
  ## 7. Accessibility
@@ -21,7 +21,8 @@
21
21
  "sharp": "^0.35.3",
22
22
  "turndown": "^7.2.4",
23
23
  "typescript": "^6.0.3",
24
- "wrangler": "^4.118.0"
24
+ "wrangler": "^4.118.0",
25
+ "yaml": "^2.9.0"
25
26
  },
26
27
  "engines": {
27
28
  "node": ">=22.12.0"
@@ -1337,7 +1338,6 @@
1337
1338
  "cpu": [
1338
1339
  "arm64"
1339
1340
  ],
1340
- "dev": true,
1341
1341
  "license": "Apache-2.0",
1342
1342
  "optional": true,
1343
1343
  "os": [
@@ -1360,7 +1360,6 @@
1360
1360
  "cpu": [
1361
1361
  "x64"
1362
1362
  ],
1363
- "dev": true,
1364
1363
  "license": "Apache-2.0",
1365
1364
  "optional": true,
1366
1365
  "os": [
@@ -1380,7 +1379,6 @@
1380
1379
  "version": "0.35.3",
1381
1380
  "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
1382
1381
  "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
1383
- "dev": true,
1384
1382
  "license": "Apache-2.0",
1385
1383
  "optional": true,
1386
1384
  "os": [
@@ -1403,7 +1401,6 @@
1403
1401
  "cpu": [
1404
1402
  "arm64"
1405
1403
  ],
1406
- "dev": true,
1407
1404
  "license": "LGPL-3.0-or-later",
1408
1405
  "optional": true,
1409
1406
  "os": [
@@ -1420,7 +1417,6 @@
1420
1417
  "cpu": [
1421
1418
  "x64"
1422
1419
  ],
1423
- "dev": true,
1424
1420
  "license": "LGPL-3.0-or-later",
1425
1421
  "optional": true,
1426
1422
  "os": [
@@ -1437,7 +1433,6 @@
1437
1433
  "cpu": [
1438
1434
  "arm"
1439
1435
  ],
1440
- "dev": true,
1441
1436
  "license": "LGPL-3.0-or-later",
1442
1437
  "optional": true,
1443
1438
  "os": [
@@ -1454,7 +1449,6 @@
1454
1449
  "cpu": [
1455
1450
  "arm64"
1456
1451
  ],
1457
- "dev": true,
1458
1452
  "license": "LGPL-3.0-or-later",
1459
1453
  "optional": true,
1460
1454
  "os": [
@@ -1471,7 +1465,6 @@
1471
1465
  "cpu": [
1472
1466
  "ppc64"
1473
1467
  ],
1474
- "dev": true,
1475
1468
  "license": "LGPL-3.0-or-later",
1476
1469
  "optional": true,
1477
1470
  "os": [
@@ -1488,7 +1481,6 @@
1488
1481
  "cpu": [
1489
1482
  "riscv64"
1490
1483
  ],
1491
- "dev": true,
1492
1484
  "license": "LGPL-3.0-or-later",
1493
1485
  "optional": true,
1494
1486
  "os": [
@@ -1505,7 +1497,6 @@
1505
1497
  "cpu": [
1506
1498
  "s390x"
1507
1499
  ],
1508
- "dev": true,
1509
1500
  "license": "LGPL-3.0-or-later",
1510
1501
  "optional": true,
1511
1502
  "os": [
@@ -1522,7 +1513,6 @@
1522
1513
  "cpu": [
1523
1514
  "x64"
1524
1515
  ],
1525
- "dev": true,
1526
1516
  "license": "LGPL-3.0-or-later",
1527
1517
  "optional": true,
1528
1518
  "os": [
@@ -1539,7 +1529,6 @@
1539
1529
  "cpu": [
1540
1530
  "arm64"
1541
1531
  ],
1542
- "dev": true,
1543
1532
  "license": "LGPL-3.0-or-later",
1544
1533
  "optional": true,
1545
1534
  "os": [
@@ -1556,7 +1545,6 @@
1556
1545
  "cpu": [
1557
1546
  "x64"
1558
1547
  ],
1559
- "dev": true,
1560
1548
  "license": "LGPL-3.0-or-later",
1561
1549
  "optional": true,
1562
1550
  "os": [
@@ -1573,7 +1561,6 @@
1573
1561
  "cpu": [
1574
1562
  "arm"
1575
1563
  ],
1576
- "dev": true,
1577
1564
  "license": "Apache-2.0",
1578
1565
  "optional": true,
1579
1566
  "os": [
@@ -1596,7 +1583,6 @@
1596
1583
  "cpu": [
1597
1584
  "arm64"
1598
1585
  ],
1599
- "dev": true,
1600
1586
  "license": "Apache-2.0",
1601
1587
  "optional": true,
1602
1588
  "os": [
@@ -1619,7 +1605,6 @@
1619
1605
  "cpu": [
1620
1606
  "ppc64"
1621
1607
  ],
1622
- "dev": true,
1623
1608
  "license": "Apache-2.0",
1624
1609
  "optional": true,
1625
1610
  "os": [
@@ -1642,7 +1627,6 @@
1642
1627
  "cpu": [
1643
1628
  "riscv64"
1644
1629
  ],
1645
- "dev": true,
1646
1630
  "license": "Apache-2.0",
1647
1631
  "optional": true,
1648
1632
  "os": [
@@ -1665,7 +1649,6 @@
1665
1649
  "cpu": [
1666
1650
  "s390x"
1667
1651
  ],
1668
- "dev": true,
1669
1652
  "license": "Apache-2.0",
1670
1653
  "optional": true,
1671
1654
  "os": [
@@ -1688,7 +1671,6 @@
1688
1671
  "cpu": [
1689
1672
  "x64"
1690
1673
  ],
1691
- "dev": true,
1692
1674
  "license": "Apache-2.0",
1693
1675
  "optional": true,
1694
1676
  "os": [
@@ -1711,7 +1693,6 @@
1711
1693
  "cpu": [
1712
1694
  "arm64"
1713
1695
  ],
1714
- "dev": true,
1715
1696
  "license": "Apache-2.0",
1716
1697
  "optional": true,
1717
1698
  "os": [
@@ -1734,7 +1715,6 @@
1734
1715
  "cpu": [
1735
1716
  "x64"
1736
1717
  ],
1737
- "dev": true,
1738
1718
  "license": "Apache-2.0",
1739
1719
  "optional": true,
1740
1720
  "os": [
@@ -1754,7 +1734,6 @@
1754
1734
  "version": "0.35.3",
1755
1735
  "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
1756
1736
  "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
1757
- "dev": true,
1758
1737
  "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
1759
1738
  "optional": true,
1760
1739
  "dependencies": {
@@ -1774,7 +1753,6 @@
1774
1753
  "cpu": [
1775
1754
  "wasm32"
1776
1755
  ],
1777
- "dev": true,
1778
1756
  "license": "Apache-2.0",
1779
1757
  "optional": true,
1780
1758
  "dependencies": {
@@ -1794,7 +1772,6 @@
1794
1772
  "cpu": [
1795
1773
  "arm64"
1796
1774
  ],
1797
- "dev": true,
1798
1775
  "license": "Apache-2.0 AND LGPL-3.0-or-later",
1799
1776
  "optional": true,
1800
1777
  "os": [
@@ -1814,7 +1791,6 @@
1814
1791
  "cpu": [
1815
1792
  "ia32"
1816
1793
  ],
1817
- "dev": true,
1818
1794
  "license": "Apache-2.0 AND LGPL-3.0-or-later",
1819
1795
  "optional": true,
1820
1796
  "os": [
@@ -1834,7 +1810,6 @@
1834
1810
  "cpu": [
1835
1811
  "x64"
1836
1812
  ],
1837
- "dev": true,
1838
1813
  "license": "Apache-2.0 AND LGPL-3.0-or-later",
1839
1814
  "optional": true,
1840
1815
  "os": [
@@ -36,7 +36,8 @@
36
36
  "deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
37
37
  "check:secrets": "node scripts/check-secrets.mjs",
38
38
  "check:copy": "node scripts/check-copy.mjs",
39
- "check:form": "node scripts/check-form.mjs"
39
+ "check:form": "node scripts/check-form.mjs",
40
+ "check:cms": "node scripts/check-cms.mjs"
40
41
  },
41
42
  "dependencies": {
42
43
  "@astrojs/cloudflare": "^14.1.7",
@@ -51,6 +52,7 @@
51
52
  "sharp": "^0.35.3",
52
53
  "turndown": "^7.2.4",
53
54
  "typescript": "^6.0.3",
54
- "wrangler": "^4.118.0"
55
+ "wrangler": "^4.118.0",
56
+ "yaml": "^2.9.0"
55
57
  }
56
58
  }
@@ -101,6 +101,12 @@ if (env === 'production') {
101
101
  environments — nobody ever meant two controls to share a name. */
102
102
  step(process.execPath, ['scripts/check-form.mjs']);
103
103
 
104
+ /* Same reasoning, and the same timing: a CMS config that does not declare every
105
+ key in the file it edits will delete content the first time the client saves.
106
+ That is a source bug, it is invisible in a build, and it costs nothing to
107
+ check. A no-op on projects with no `.pages.yml`. */
108
+ step(process.execPath, ['scripts/check-cms.mjs']);
109
+
104
110
  run(['build']);
105
111
 
106
112
  if (env === 'staging') {
@@ -0,0 +1,444 @@
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
+
45
+ const RESET = '\x1b[0m';
46
+ const RED = '\x1b[31m';
47
+ const GREEN = '\x1b[32m';
48
+ const YELLOW = '\x1b[33m';
49
+ const DIM = '\x1b[2m';
50
+
51
+ const CONFIG = '.pages.yml';
52
+
53
+ if (!existsSync(CONFIG)) {
54
+ console.log(`${DIM}·${RESET} no ${CONFIG} — no CMS to check`);
55
+ process.exit(0);
56
+ }
57
+
58
+ let config;
59
+ try {
60
+ config = parse(readFileSync(CONFIG, 'utf8')) ?? {};
61
+ } catch (err) {
62
+ console.error(`\n${RED}✗ ${CONFIG} does not parse${RESET}\n\n ${err.message}\n`);
63
+ process.exit(1);
64
+ }
65
+
66
+ const problems = [];
67
+ const warnings = [];
68
+
69
+ /* PagesCMS nests with `items`, not `content`. Getting this wrong reports every
70
+ grouped config as having zero entries — which looks like a clean pass. */
71
+ const flatten = (entries) =>
72
+ (entries ?? []).flatMap((entry) =>
73
+ entry?.type === 'group' ? flatten(entry.items ?? entry.content ?? []) : [entry],
74
+ );
75
+
76
+ /** Every dotted path the schema declares. Arrays reuse the parent prefix. */
77
+ function schemaPaths(fields, prefix = '') {
78
+ const out = new Set();
79
+ for (const field of fields ?? []) {
80
+ if (!field?.name) continue;
81
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
82
+ out.add(path);
83
+ if (Array.isArray(field.fields)) for (const p of schemaPaths(field.fields, path)) out.add(p);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Every dotted path the DATA contains. An array's items sit at its own prefix. */
89
+ function dataPaths(value, prefix = '') {
90
+ const out = new Set();
91
+ if (Array.isArray(value)) {
92
+ for (const item of value) for (const p of dataPaths(item, prefix)) out.add(p);
93
+ } else if (value && typeof value === 'object') {
94
+ for (const [key, inner] of Object.entries(value)) {
95
+ const path = prefix ? `${prefix}.${key}` : key;
96
+ out.add(path);
97
+ for (const p of dataPaths(inner, path)) out.add(p);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** Frontmatter keys actually used across a collection, as dotted paths. */
104
+ function collectionPaths(dir) {
105
+ const out = new Set();
106
+ const walk = (d) =>
107
+ readdirSync(d).flatMap((e) => {
108
+ const full = join(d, e);
109
+ return statSync(full).isDirectory() ? walk(full) : [full];
110
+ });
111
+ for (const file of walk(dir).filter((f) => /\.mdx?$/.test(f))) {
112
+ const raw = readFileSync(file, 'utf8');
113
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
114
+ if (!match) continue;
115
+ try {
116
+ for (const p of dataPaths(parse(match[1]) ?? {})) out.add(p);
117
+ } catch {
118
+ /* A collection item with unparseable frontmatter is the content
119
+ collection's problem, and astro check reports it properly. */
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const rel = (p) => relative(process.cwd(), p).split(sep).join('/');
126
+
127
+ /* ── content entries ─────────────────────────────────────────────────────── */
128
+
129
+ const entries = flatten(config.content);
130
+
131
+ if (!entries.length) {
132
+ warnings.push(`${CONFIG} declares no content entries — the editor sees an empty CMS`);
133
+ }
134
+
135
+ for (const entry of entries) {
136
+ const label = entry?.name ?? entry?.label ?? '(unnamed)';
137
+ const path = entry?.path;
138
+ if (!path) {
139
+ problems.push({ label, why: 'has no `path`' });
140
+ continue;
141
+ }
142
+ if (!existsSync(path)) {
143
+ problems.push({ label, why: `path does not exist: ${path}` });
144
+ continue;
145
+ }
146
+
147
+ const declared = schemaPaths(entry.fields);
148
+
149
+ if (entry.type === 'collection') {
150
+ const used = collectionPaths(path);
151
+ const undeclared = [...used].filter((p) => !declared.has(p));
152
+ if (undeclared.length) {
153
+ problems.push({ label, why: `frontmatter keys the schema does not declare`, keys: undeclared, path });
154
+ }
155
+ continue;
156
+ }
157
+
158
+ if (/\.json$/.test(path)) {
159
+ let data;
160
+ try {
161
+ data = JSON.parse(readFileSync(path, 'utf8'));
162
+ } catch (err) {
163
+ problems.push({ label, why: `${path} is not valid JSON — ${err.message}` });
164
+ continue;
165
+ }
166
+ const undeclared = [...dataPaths(data)].filter((p) => !declared.has(p));
167
+ if (undeclared.length) {
168
+ problems.push({ label, why: 'keys in the file the schema does not declare', keys: undeclared, path });
169
+ }
170
+ }
171
+ }
172
+
173
+ /* ── media ───────────────────────────────────────────────────────────────── */
174
+
175
+ /* Declared here, not beside the image checks that read it: the media loop below
176
+ fills it, and a `const` used above its own declaration is a TDZ
177
+ ReferenceError that `node --check` cannot see. This file shipped that way for
178
+ exactly one run. */
179
+ const mediaByName = new Map();
180
+
181
+ /* Where optimize-media.mjs writes. An upload here is not an input, it is a
182
+ file dropped into generated output. */
183
+ const GENERATED = ['public/img', 'dist', '.astro'];
184
+
185
+ const media = config.media ? (Array.isArray(config.media) ? config.media : [config.media]) : [];
186
+
187
+ for (const source of media) {
188
+ const input = typeof source === 'string' ? source : source?.input;
189
+ const name = (typeof source === 'object' && source?.name) || input || '(unnamed)';
190
+ if (!input) {
191
+ problems.push({ label: `media ${name}`, why: 'has no `input`' });
192
+ continue;
193
+ }
194
+ if (typeof source === 'object' && source.name) mediaByName.set(source.name, source);
195
+ const normalised = input.replace(/^\.?\//, '').replace(/\/$/, '');
196
+ if (GENERATED.some((g) => normalised === g || normalised.startsWith(`${g}/`))) {
197
+ problems.push({
198
+ label: `media ${name}`,
199
+ why: `uploads into ${input}, which is GENERATED output`,
200
+ direction: true,
201
+ });
202
+ continue;
203
+ }
204
+ if (!existsSync(input)) {
205
+ problems.push({ label: `media ${name}`, why: `input directory does not exist: ${input}` });
206
+ continue;
207
+ }
208
+ if (typeof source === 'object' && !source.extensions) {
209
+ warnings.push(
210
+ `media "${name}" declares no \`extensions\` — a bad format is accepted in the UI and ` +
211
+ `fails the build twenty minutes later instead of being refused at the door`,
212
+ );
213
+ }
214
+ }
215
+
216
+ /* ── image fields: is the stored VALUE the shape the field type needs? ───── */
217
+
218
+ /*
219
+ * ⚠ THIS IS THE CHECK THE TOLERANT READER MADE NECESSARY.
220
+ *
221
+ * `<Img>` accepts a manifest key OR a picker path, which is what lets an
222
+ * image field be a real picker. But a reader that accepts two formats will
223
+ * never tell you which one you stored — so converting a field to
224
+ * `type: image` without migrating its values leaves a site where:
225
+ *
226
+ * the build is green, astro check is clean, pa11y is clean, and the
227
+ * rendered HTML is BYTE-IDENTICAL — and every picker in the CMS is broken.
228
+ *
229
+ * That happened on a real site: eighteen grey squares in the editor and a
230
+ * GitHub link 404ing, while every automated check said the site was fine.
231
+ * Nothing rendered by the site can see it, because the CMS is not a page.
232
+ *
233
+ * `type: image` is built around the PATH — the picker returns one, the
234
+ * thumbnail loads one, the repo link resolves one. So the value has to start
235
+ * with that media source's `output`. Convert the field and migrate the data
236
+ * in the same change.
237
+ */
238
+ function imageFields(fields, prefix = '') {
239
+ const out = [];
240
+ for (const field of fields ?? []) {
241
+ if (!field?.name) continue;
242
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
243
+ if (field.type === 'image') out.push({ path, media: field.options?.media });
244
+ if (Array.isArray(field.fields)) out.push(...imageFields(field.fields, path));
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /** Every value stored at a dotted path, walking through arrays. */
250
+ function valuesAt(value, parts) {
251
+ if (value == null) return [];
252
+ if (!parts.length) return Array.isArray(value) ? value : [value];
253
+ if (Array.isArray(value)) return value.flatMap((v) => valuesAt(v, parts));
254
+ if (typeof value !== 'object') return [];
255
+ const [head, ...rest] = parts;
256
+ return valuesAt(value[head], rest);
257
+ }
258
+
259
+ for (const entry of entries) {
260
+ const fields = imageFields(entry?.fields);
261
+ if (!fields.length || !existsSync(entry.path ?? '')) continue;
262
+
263
+ const documents = [];
264
+ if (entry.type === 'collection') {
265
+ /* Frontmatter only; a body image is markdown, not a field. */
266
+ const walk = (d) =>
267
+ readdirSync(d).flatMap((e) => {
268
+ const full = join(d, e);
269
+ return statSync(full).isDirectory() ? walk(full) : [full];
270
+ });
271
+ for (const file of walk(entry.path).filter((f) => /\.mdx?$/.test(f))) {
272
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
273
+ if (!m) continue;
274
+ try {
275
+ documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
276
+ } catch {
277
+ /* astro check reports unparseable frontmatter properly. */
278
+ }
279
+ }
280
+ } else if (/\.json$/.test(entry.path)) {
281
+ try {
282
+ documents.push({ where: rel(entry.path), data: JSON.parse(readFileSync(entry.path, 'utf8')) });
283
+ } catch {
284
+ /* Already reported above. */
285
+ }
286
+ }
287
+
288
+ for (const { path: fieldPath, media: mediaName } of fields) {
289
+ const source =
290
+ (mediaName && mediaByName.get(mediaName)) ?? (media.length === 1 ? media[0] : null);
291
+ const output = typeof source === 'object' ? source?.output : null;
292
+ if (!output) continue; // nothing declared to measure against
293
+ /* `output: /` makes "starts with the output" true of every absolute path, so
294
+ it only distinguishes a path from a non-path. Still worth reporting — a
295
+ `type: image` field holding a bare word is a picker showing nothing — but
296
+ do not pretend the test was stronger than it was. */
297
+
298
+ /* ⚠ ONE PROBLEM PER FIELD, NOT PER VALUE. A collection of thirty items with
299
+ one bad field produced thirty identical lines on a real project — 78 in
300
+ total for three fields. A gate that floods is a gate that gets switched
301
+ off, and the fix is always the same edit for the whole field. */
302
+ const wrong = [];
303
+ for (const doc of documents) {
304
+ for (const value of valuesAt(doc.data, fieldPath.split('.'))) {
305
+ if (typeof value !== 'string' || !value) continue;
306
+ if (value.startsWith(output)) continue;
307
+ wrong.push({ value, where: doc.where });
308
+ }
309
+ }
310
+ if (wrong.length) {
311
+ const samples = [...new Set(wrong.map((w) => w.value))].slice(0, 3);
312
+ problems.push({
313
+ label: `${entry.name ?? entry.label} → ${fieldPath}`,
314
+ why:
315
+ `is \`type: image\` but ${wrong.length} value(s) are not a path under ${JSON.stringify(output)}` +
316
+ ` — e.g. ${samples.map((v) => JSON.stringify(v)).join(', ')}`,
317
+ picker: true,
318
+ path: wrong[0].where,
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ /* ── coverage, and secrets ───────────────────────────────────────────────── */
325
+
326
+ /*
327
+ * ⚠ WARNINGS, NEVER FAILURES. What belongs in a CMS is a judgement — a single
328
+ * -location business has no business needing a Locations collection, and a
329
+ * gate that insists otherwise gets switched off. But "the client says whole
330
+ * sections are missing" was the actual complaint from five delivered sites,
331
+ * and it is checkable: content exists in the repo that no CMS entry points at.
332
+ *
333
+ * Audited across those five, navigation was absent from ALL FIVE, and
334
+ * testimonials from four. Nothing reported it, because nothing looked.
335
+ */
336
+ const covered = new Set(
337
+ entries.map((e) => (e?.path ?? '').replace(/^\.?\//, '').replace(/\/$/, '')).filter(Boolean),
338
+ );
339
+
340
+ /*
341
+ * Generated files — a CMS editing these would be editing build output.
342
+ *
343
+ * ⚠ MATCHED BY SHAPE, NOT BY NAME. This began as a two-name list and
344
+ * immediately produced a false positive on a real project's
345
+ * `media-manifest.json`, which is the denylist problem in miniature: it knows
346
+ * only the files already thought of. Anything `*manifest.json` is written by
347
+ * a build step, and `lastmod.json` is named because dates are generated too.
348
+ */
349
+ const isGenerated = (file) => /manifest\.json$/.test(file) || file === 'lastmod.json';
350
+
351
+ const uncovered = [];
352
+
353
+ if (existsSync('src/content')) {
354
+ for (const dir of readdirSync('src/content', { withFileTypes: true })) {
355
+ if (!dir.isDirectory()) continue;
356
+ const path = `src/content/${dir.name}`;
357
+ if (![...covered].some((c) => c === path || c.startsWith(`${path}/`))) uncovered.push(path);
358
+ }
359
+ }
360
+
361
+ if (existsSync('src/data')) {
362
+ for (const file of readdirSync('src/data')) {
363
+ if (!file.endsWith('.json') || isGenerated(file)) continue;
364
+ const path = `src/data/${file}`;
365
+ if (!covered.has(path)) uncovered.push(path);
366
+ }
367
+ }
368
+
369
+ if (uncovered.length) {
370
+ warnings.push(
371
+ `${uncovered.length} content source(s) exist that no CMS entry points at — the client cannot ` +
372
+ `edit them, and "whole sections are missing" is how that gets reported:\n` +
373
+ uncovered.map((u) => ` ${u}`).join('\n') +
374
+ `\n Each is either a deliberate developer-controlled file or a gap. Decide which.`,
375
+ );
376
+ }
377
+
378
+ /*
379
+ * ⚠ A SECRET IN A CMS IS A SECRET THE CLIENT CAN READ AND CHANGE. Analytics
380
+ * IDs, tokens and keys are technical configuration: their failure mode is
381
+ * silent (tracking stops, mail stops) and no editor can diagnose it.
382
+ */
383
+ const SECRET_SHAPED = /(^|[._-])(api|secret|token|key|password|credential|apikey)([._-]|$)|(ga4|gtm|analytics|measurement)/i;
384
+
385
+ for (const entry of entries) {
386
+ const risky = [...schemaPaths(entry?.fields)].filter((f) => SECRET_SHAPED.test(f));
387
+ if (risky.length) {
388
+ warnings.push(
389
+ `"${entry?.name ?? entry?.label}" exposes field(s) that look like technical configuration ` +
390
+ `rather than content: ${risky.join(', ')}. A client cannot diagnose what breaks when one ` +
391
+ `is changed, and the failure is silent.`,
392
+ );
393
+ }
394
+ }
395
+
396
+ /* ── report ──────────────────────────────────────────────────────────────── */
397
+
398
+ for (const w of warnings) console.log(` ${YELLOW}!${RESET} ${w}`);
399
+
400
+ if (!problems.length) {
401
+ console.log(
402
+ `${GREEN}✓${RESET} ${CONFIG}: ${entries.length} entrie(s), every key declared` +
403
+ (media.length ? `, ${media.length} media source(s)` : ''),
404
+ );
405
+ process.exit(0);
406
+ }
407
+
408
+ console.error(`\n${RED}✗ ${problems.length} problem(s) in ${CONFIG}${RESET}\n`);
409
+
410
+ for (const p of problems) {
411
+ console.error(` ${p.label} — ${p.why}`);
412
+ if (p.keys) {
413
+ for (const k of p.keys.slice(0, 12)) console.error(` ${DIM}${k}${RESET}`);
414
+ if (p.keys.length > 12) console.error(` ${DIM}…and ${p.keys.length - 12} more${RESET}`);
415
+ console.error(
416
+ ` ${DIM}These exist in ${rel(p.path)} and are NOT in the schema, so the first\n` +
417
+ ` save from this screen DELETES them. Declare every key — including ones\n` +
418
+ ` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
419
+ );
420
+ }
421
+ if (p.picker) {
422
+ console.error(
423
+ ` ${DIM}The site still renders this: <Img> accepts a manifest key as well as a\n` +
424
+ ` picker path. The CMS does not — \`type: image\` is built around the path, so\n` +
425
+ ` the picker shows an empty square and the repo link 404s, while the build,\n` +
426
+ ` the types and the rendered HTML all stay clean.\n\n` +
427
+ ` A reader that accepts two formats cannot tell you which one you stored.\n` +
428
+ ` Convert the field and migrate the values in the same change.${RESET}`,
429
+ );
430
+ }
431
+ if (p.direction) {
432
+ console.error(
433
+ ` ${DIM}The direction is the bug. optimize-media.mjs READS media/source/ and\n` +
434
+ ` WRITES public/img/. An upload into the output has no variants, no\n` +
435
+ ` width/height and no manifest entry, so <Img> throws and the client's\n` +
436
+ ` own edit turns the build red.\n\n` +
437
+ ` input: media/source/uploads what the pipeline reads\n` +
438
+ ` output: /img/uploads what it writes, once processed${RESET}`,
439
+ );
440
+ }
441
+ console.error('');
442
+ }
443
+
444
+ process.exit(1);
@@ -16,9 +16,18 @@
16
16
  import sharp from 'sharp';
17
17
  import fs from 'node:fs/promises';
18
18
  import path from 'node:path';
19
- import { fileURLToPath } from 'node:url';
20
19
 
21
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ /*
21
+ * ⚠ THE PROJECT ROOT IS THE CWD, as it is in every other script here.
22
+ *
23
+ * This resolved from the SCRIPT's own location. In every supported flow the
24
+ * two are the same directory — `npm run media` runs with the cwd at the
25
+ * package root — so the change is a no-op in use. What it was not was
26
+ * testable: the script read its own media/source/ no matter where it was
27
+ * pointed, so a fixture could not exercise it at all, and the run that added
28
+ * a non-zero exit had nothing proving the exit fires.
29
+ */
30
+ const ROOT = process.cwd();
22
31
  const SRC = path.join(ROOT, 'media/source');
23
32
  const OUT = path.join(ROOT, 'public/img');
24
33
  const MANIFEST = path.join(ROOT, 'src/data/image-manifest.json');
@@ -56,7 +65,25 @@ const WEBP = { quality: 78, effort: 6 };
56
65
  const AVIF = { quality: 55, effort: 4 };
57
66
  const FORMATS = ['avif', 'webp'];
58
67
 
59
- const RASTER = /\.(jpe?g|png|webp|tiff?)$/i;
68
+ const RESET = '\x1b[0m';
69
+ const RED = '\x1b[31m';
70
+ const YELLOW = '\x1b[33m';
71
+ const DIM = '\x1b[2m';
72
+
73
+ /*
74
+ * ⚠ heic/heif ARE HERE ON PURPOSE. libvips in the sharp this kit already ships
75
+ * reads them — check with `node -e "console.log(require('sharp').format.heif)"`.
76
+ * Leaving them out meant the single likeliest wrong format, a photo straight
77
+ * off an iPhone, was discarded by our own regex while the library underneath
78
+ * handled it fine. It produced no output, no warning and no manifest entry.
79
+ */
80
+ const RASTER = /\.(jpe?g|png|webp|tiff?|heic|heif)$/i;
81
+
82
+ /* Sources are committed forever. Output is capped by the width ladder, so a
83
+ huge original costs visitors nothing and costs the REPO permanently — say so
84
+ in those words, or someone "fixes" a page-speed problem that does not exist. */
85
+ const BIG_PIXELS = 24_000_000;
86
+ const BIG_BYTES = 8 * 1024 * 1024;
60
87
 
61
88
  /*
62
89
  * ── DO NOT RESET THIS TO {} ────────────────────────────────────────────────
@@ -362,11 +389,45 @@ const before = await bytes(SRC);
362
389
  console.log('optimizing media…');
363
390
  await copyBrand(files.filter((f) => f.includes(`${path.sep}brand${path.sep}`)));
364
391
 
365
- for (const file of files.filter((f) => RASTER.test(f))) {
392
+ /*
393
+ * ⚠ THE RUN MUST NAME EVERYTHING IT DID NOT PRODUCE.
394
+ *
395
+ * This loop used to be `files.filter(RASTER)`. Anything else was dropped with
396
+ * no output, no warning and no manifest entry — so a `.heic` in media/source/
397
+ * simply was not on the site, and the failure surfaced much later as <Img>
398
+ * throwing "no manifest entry for …" against a file plainly sitting in the
399
+ * repo. That reads as a bug in the kit rather than a rejected upload.
400
+ *
401
+ * And there was no try/catch, so one corrupt file aborted the run midway —
402
+ * after outputs were written and the manifest was partly updated, leaving the
403
+ * manifest describing a state on disk that no longer matched it.
404
+ */
405
+ const skipped = [];
406
+ const failed = [];
407
+ const oversized = [];
408
+
409
+ for (const file of files) {
366
410
  if (file.includes(`${path.sep}brand${path.sep}`)) continue;
367
- if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
368
- else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
369
- else await emitResponsive(file, PHOTO_WIDTHS);
411
+ const rel = path.relative(SRC, file);
412
+
413
+ if (!RASTER.test(file)) {
414
+ skipped.push(rel);
415
+ continue;
416
+ }
417
+
418
+ try {
419
+ const { size } = await fs.stat(file);
420
+ const meta = await sharp(file).metadata();
421
+ if (size > BIG_BYTES || (meta.width ?? 0) * (meta.height ?? 0) > BIG_PIXELS) {
422
+ oversized.push(`${rel} ${meta.width}x${meta.height}, ${mb(size)}`);
423
+ }
424
+
425
+ if (file.includes(`${path.sep}certifications${path.sep}`)) await emitSingle(file);
426
+ else if (file.includes(`${path.sep}blog${path.sep}`)) await emitResponsive(file, BLOG_WIDTHS);
427
+ else await emitResponsive(file, PHOTO_WIDTHS);
428
+ } catch (err) {
429
+ failed.push(`${rel} ← ${String(err.message).split('\n')[0]}`);
430
+ }
370
431
  }
371
432
 
372
433
  await emitFavicons();
@@ -378,3 +439,29 @@ await fs.writeFile(MANIFEST, JSON.stringify(manifest, null, 2) + '\n');
378
439
  const after = await bytes(OUT);
379
440
  console.log(`\nsource ${mb(before)} → dist ${mb(after)} (${Math.round((1 - after / before) * 100)}% smaller)`);
380
441
  console.log(`${Object.keys(manifest).length} images in manifest`);
442
+
443
+ /* A report, not an error. A PDF or a .txt in media/source/ is legitimate — the
444
+ kit's own comp lives there — so this says what produced no image and lets the
445
+ reader judge. Only a genuine processing failure exits non-zero. */
446
+ if (skipped.length) {
447
+ console.log(`\n${YELLOW}!${RESET} ${skipped.length} file(s) produced no image (not a raster format):`);
448
+ for (const f of skipped) console.log(` ${DIM}${f}${RESET}`);
449
+ }
450
+
451
+ if (oversized.length) {
452
+ console.log(`\n${YELLOW}!${RESET} ${oversized.length} oversized source(s) — the SITE is unaffected:`);
453
+ for (const f of oversized) console.log(` ${DIM}${f}${RESET}`);
454
+ console.log(
455
+ ` ${DIM}Output is capped by the width ladder, so visitors never download these.\n` +
456
+ ` The cost is the repository, which carries them forever.${RESET}`,
457
+ );
458
+ }
459
+
460
+ if (failed.length) {
461
+ console.error(`\n${RED}✗ ${failed.length} file(s) failed to process:${RESET}`);
462
+ for (const f of failed) console.error(` ${f}`);
463
+ console.error(
464
+ `\n ${DIM}Every other file was still written and the manifest is complete for them.${RESET}\n`,
465
+ );
466
+ process.exit(1);
467
+ }
@@ -158,13 +158,17 @@ const notes = [];
158
158
  */
159
159
  const refusals = new Set();
160
160
 
161
- function refuse(reason, url) {
161
+ function refuse(reason, url, kind) {
162
162
  if (!refusals.has(reason)) {
163
163
  refusals.add(reason);
164
164
  console.log(` ${YELLOW}refused${RESET} ${reason}`);
165
+ /* ⚠ ONLY A HOST REFUSAL IS SOMETHING --allow-internal CAN EXCUSE. Offering
166
+ the flag for a `file:` redirect is a dead hint — the same mistake this
167
+ script already fixed once in the startup message, still living here. */
165
168
  notes.push(
166
169
  `Refused to fetch ${url} — ${reason}. This was NOT a network error: the crawl skipped it ` +
167
- `deliberately, so the inventory is incomplete. Re-run with --allow-internal if that host is yours.`,
170
+ `deliberately, so the inventory is incomplete.` +
171
+ (kind === 'host' ? ' Re-run with --allow-internal if that host is yours.' : ''),
168
172
  );
169
173
  }
170
174
  return null;
@@ -172,7 +176,7 @@ function refuse(reason, url) {
172
176
 
173
177
  async function req(url, options = {}) {
174
178
  const refusal = blockedReason(url, { allowInternal });
175
- if (refusal) return refuse(refusal.reason, url);
179
+ if (refusal) return refuse(refusal.reason, url, refusal.kind);
176
180
 
177
181
  const controller = new AbortController();
178
182
  const timer = setTimeout(() => controller.abort(), 20000);
@@ -193,7 +197,7 @@ async function req(url, options = {}) {
193
197
  for (let hops = 0; follow && res.status >= 300 && res.status < 400 && res.headers.get('location') && hops < MAX_HOPS; hops++) {
194
198
  const next = new URL(res.headers.get('location'), url).toString();
195
199
  const hopRefusal = blockedReason(next, { allowInternal });
196
- if (hopRefusal) return refuse(`${hopRefusal.reason} — reached by a redirect from ${url}`, next);
200
+ if (hopRefusal) return refuse(`${hopRefusal.reason} — reached by a redirect from ${url}`, next, hopRefusal.kind);
197
201
  url = next;
198
202
  res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
199
203
  }
@@ -9,6 +9,7 @@
9
9
  * content migration.
10
10
  */
11
11
  import manifest from '../data/image-manifest.json';
12
+ import { isPickerPath, toImageKey } from '../lib/image-key';
12
13
 
13
14
  interface Props {
14
15
  /** Manifest key, e.g. "photos/hero-home". */
@@ -36,13 +37,29 @@ const {
36
37
  style,
37
38
  } = Astro.props;
38
39
 
39
- const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[name];
40
+ /* A CMS picker returns `/img/photos/hero-1200.webp`, never `photos/hero`.
41
+ Normalising here is what lets an image field be a real picker instead of a
42
+ free-text box asking the client to type a manifest key from memory. */
43
+ const key = toImageKey(name);
44
+ const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[key];
40
45
 
41
46
  if (!entry) {
42
47
  // Fail the build, not the page. A missing image is a content bug and should
43
48
  // never reach a deploy as a silently broken <img>.
49
+ //
50
+ // ⚠ THE THROWN TEXT IS PURE ASCII ON PURPOSE. The Cloudflare adapter puts a
51
+ // prerender failure into the `x-astro-prerender-error` HTTP HEADER, and a
52
+ // header carrying non-ASCII warns loudly and arrives mangled: an em dash
53
+ // came back as `â`. Comments may use the house style; anything inside a
54
+ // thrown string may not. See docs/traps.md.
44
55
  throw new Error(
45
- `Img: no manifest entry for "${name}". Run \`npm run media\` after adding it to media/source/.`,
56
+ isPickerPath(name)
57
+ ? `Img: "${name}" was uploaded straight into public/img/, which is where the media pipeline ` +
58
+ `WRITES, so it has no responsive variants, no width/height and no manifest entry.\n\n` +
59
+ `Point the CMS media source at media/source/ (what the pipeline READS), re-upload, ` +
60
+ `then run \`npm run media\`.\n\n` +
61
+ `The last good deploy is still live; this change just will not appear.`
62
+ : `Img: no manifest entry for "${name}" (resolved to "${key}"). Run \`npm run media\` after adding it to media/source/.`,
46
63
  );
47
64
  }
48
65
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Turn a public image PATH back into the manifest KEY it came from.
3
+ *
4
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
5
+ * `<Img>` resolves a manifest key — `photos/hero`. A CMS image picker cannot
6
+ * return one: it browses files and returns the public path of what it found,
7
+ * `/img/photos/hero-1200.webp`, because it has no idea a manifest exists.
8
+ *
9
+ * Without this, every image field in a CMS-managed site has to be a plain
10
+ * string with a description asking a non-technical editor to type a key from
11
+ * memory. That is not an editable field, it is a quiz — and it is why image
12
+ * editing was the part of the CMS that clients could never actually use.
13
+ *
14
+ * The two can meet because `optimize-media.mjs` writes exactly one shape:
15
+ *
16
+ * /img/ + <key> -<width>.<ext>
17
+ *
18
+ * so the mapping back is exact rather than a guess.
19
+ *
20
+ * ── THREE PROPERTIES THAT MAKE IT SAFE ─────────────────────────────────────
21
+ * 1. **Which variant the editor clicks does not matter.** `-480` and `-1800`
22
+ * normalise to the same key and render the identical full srcset.
23
+ * 2. **A key ending in a digit survives.** The width strip is anchored to the
24
+ * extension, so `photos/gift-card-slider-1`, reached via `…-1-480.webp`,
25
+ * comes back whole. An unanchored `-\d+` would eat the `-1`.
26
+ * 3. ⚠ **It must not assume `.webp`.** The kit emits AVIF alongside WebP and
27
+ * social cards are `.jpg`, so the extension strip stays generic.
28
+ */
29
+
30
+ /** `/img/photos/hero-1200.webp` → `photos/hero`. Anything else is returned as-is. */
31
+ export const toImageKey = (nameOrPath: string): string =>
32
+ isPickerPath(nameOrPath)
33
+ ? nameOrPath
34
+ .replace(/^\/img\//, '')
35
+ .replace(/-\d+(?=\.[a-z0-9]+$)/i, '')
36
+ .replace(/\.[a-z0-9]+$/i, '')
37
+ : nameOrPath;
38
+
39
+ /**
40
+ * Did this come from a media picker rather than being a manifest key?
41
+ *
42
+ * Used to tell an editor what actually went wrong. `/img/` is where the
43
+ * pipeline WRITES, so a value pointing there is either a processed variant
44
+ * (fine — `toImageKey` handles it) or a file uploaded straight into the output
45
+ * directory, which has no variants and no manifest entry at all.
46
+ */
47
+ export const isPickerPath = (nameOrPath: string): boolean => nameOrPath.startsWith('/img/');