create-website-build-kit 0.1.17 → 0.1.19

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.17",
3
+ "version": "0.1.19",
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",
@@ -20,6 +20,7 @@
20
20
  "check:copy": "node scripts/check-copy.mjs",
21
21
  "check:drift": "node scripts/check-drift.mjs",
22
22
  "check:form": "node scripts/check-form.mjs",
23
+ "check:redirects": "node scripts/check-redirects.mjs",
23
24
  "check:secrets": "node scripts/check-secrets.mjs",
24
25
  "check:sitemap": "node scripts/check-sitemap.mjs",
25
26
  "console": "node scripts/check-console.mjs",
@@ -122,10 +122,22 @@ step(process.execPath, ['scripts/check-env.mjs']);
122
122
 
123
123
  if (env === 'production') {
124
124
  step(process.execPath, ['scripts/check-sitemap.mjs']);
125
+ /* A redirect map is the one migration artefact edited by hand, in bulk, about
126
+ URLs nobody can see any more. Every failure it has is invisible at deploy. */
127
+ step(process.execPath, ['scripts/check-redirects.mjs']);
125
128
  /* Production only: it measures the GENERATED images, and a staging build is
126
129
  often run before `npm run media` has caught up. A no-op until a project
127
130
  declares regions — the template has no design and therefore none. */
128
131
  step(process.execPath, ['scripts/check-contrast.mjs']);
132
+
133
+ /*
134
+ * Advisory: it exits 0 whatever it finds, because drift is a decision and not
135
+ * an error. It runs here because a site is current on the day it is scaffolded
136
+ * and behind some months later — and the build is the only moment anybody is
137
+ * reliably looking. A check nobody remembers to run is the failure it exists
138
+ * to catch, applied to itself.
139
+ */
140
+ step(process.execPath, ['scripts/check-drift.mjs']);
129
141
  }
130
142
 
131
143
  /* A sanity line, so the log says which environment actually ran rather than
@@ -237,12 +237,18 @@ for (const source of media) {
237
237
  * with that media source's `output`. Convert the field and migrate the data
238
238
  * in the same change.
239
239
  */
240
+ /** Dotted field path → the `options.path` it is scoped to, when it declares one. */
241
+ const scopedPaths = new Map();
242
+
240
243
  function imageFields(fields, prefix = '') {
241
244
  const out = [];
242
245
  for (const field of fields ?? []) {
243
246
  if (!field?.name) continue;
244
247
  const path = prefix ? `${prefix}.${field.name}` : field.name;
245
- if (field.type === 'image') out.push({ path, media: field.options?.media });
248
+ if (field.type === 'image') {
249
+ out.push({ path, media: field.options?.media });
250
+ if (field.options?.path) scopedPaths.set(path, String(field.options.path).replace(/^\.?\//, ''));
251
+ }
246
252
  if (Array.isArray(field.fields)) out.push(...imageFields(field.fields, path));
247
253
  }
248
254
  return out;
@@ -290,6 +296,24 @@ for (const entry of entries) {
290
296
  for (const { path: fieldPath, media: mediaName } of fields) {
291
297
  const source =
292
298
  (mediaName && mediaByName.get(mediaName)) ?? (media.length === 1 ? media[0] : null);
299
+
300
+ /*
301
+ * ⚠ A PICKER SCOPED TO A FOLDER THAT DOES NOT EXIST OPENS ON NOTHING.
302
+ * `options.path` narrows which directory the media browser shows. Point it
303
+ * at `public/img/brand` when the files live in `brand-v2/` and the editor
304
+ * gets an empty folder — no error, no warning, and the build does not
305
+ * care. It is visible only to the person trying to choose an image, which
306
+ * is the one person who cannot fix it.
307
+ */
308
+ if (fieldPath && typeof entry.fields === 'object') {
309
+ const scoped = scopedPaths.get(fieldPath);
310
+ if (scoped && !existsSync(scoped)) {
311
+ problems.push({
312
+ label: `${entry.name ?? entry.label} → ${fieldPath}`,
313
+ why: `\`options.path\` is ${JSON.stringify(scoped)}, which does not exist — the picker opens on an empty folder`,
314
+ });
315
+ }
316
+ }
293
317
  const output = typeof source === 'object' ? source?.output : null;
294
318
  if (!output) continue; // nothing declared to measure against
295
319
  /* `output: /` makes "starts with the output" true of every absolute path, so
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Refuse a redirect map that is silently wrong.
3
+ *
4
+ * npm run check:redirects
5
+ *
6
+ * Runs in `build:production`. A no-op when there is no `public/_redirects`.
7
+ *
8
+ * ── WHY THIS IS ITS OWN CHECK ──────────────────────────────────────────────
9
+ * `redirects.mjs` PROPOSES a map from the old site's inventory. Nothing has
10
+ * ever checked the map that a human then edited — and the editing is where the
11
+ * mistakes are, because a redirect file is the one artefact in a migration
12
+ * that is written by hand, in bulk, under time pressure, about URLs nobody can
13
+ * see any more.
14
+ *
15
+ * ⚠ EVERY FAILURE BELOW IS INVISIBLE AT DEPLOY. The file parses, the site
16
+ * builds, the pages are fine. What breaks is a URL that used to rank, weeks
17
+ * later, in somebody else's analytics.
18
+ *
19
+ * ── WHAT IT CATCHES, AND WHY EACH ONE MATTERS ──────────────────────────────
20
+ * **A duplicate source.** Cloudflare takes the FIRST match and ignores the
21
+ * rest, silently. So the second rule — usually the one somebody added later,
22
+ * on purpose, to fix something — never fires at all, and the fix appears not
23
+ * to work for reasons nothing explains.
24
+ *
25
+ * **A self-redirect.** `/a → /a` is a loop the browser stops after ~20 hops
26
+ * with ERR_TOO_MANY_REDIRECTS. The page is simply gone, and it is gone only in
27
+ * production, because nobody clicks the old URL in development.
28
+ *
29
+ * **A loop.** `/a → /b → /a`. The same, with an extra step to hide it.
30
+ *
31
+ * **A chain.** `/a → /b → /c` costs a redundant round trip on every visit and
32
+ * leaks a little PageRank at each hop. Cloudflare resolves only one hop per
33
+ * request, so a chain is also slower than it looks.
34
+ *
35
+ * **An unsupported status.** Cloudflare `_redirects` accepts only
36
+ * 200/301/302/303/307/308. Anything else makes the platform reject the rule —
37
+ * see `traps.md`, where a trailing comment rejected the whole FILE.
38
+ */
39
+
40
+ import { existsSync, readFileSync } from 'node:fs';
41
+
42
+ const RESET = '\x1b[0m';
43
+ const RED = '\x1b[31m';
44
+ const GREEN = '\x1b[32m';
45
+ const YELLOW = '\x1b[33m';
46
+ const DIM = '\x1b[2m';
47
+
48
+ const FILE = 'public/_redirects';
49
+ const ALLOWED = new Set([200, 301, 302, 303, 307, 308]);
50
+
51
+ if (!existsSync(FILE)) {
52
+ console.log(`${DIM}·${RESET} no ${FILE} — nothing to validate`);
53
+ process.exit(0);
54
+ }
55
+
56
+ const problems = [];
57
+ const warnings = [];
58
+ const rules = [];
59
+
60
+ const lines = readFileSync(FILE, 'utf8').split('\n');
61
+
62
+ lines.forEach((raw, index) => {
63
+ const line = raw.trim();
64
+ if (!line || line.startsWith('#')) return;
65
+
66
+ /* ⚠ SPLIT ON WHITESPACE, NOT ON A SINGLE SPACE. Columns in a hand-edited
67
+ file are aligned with runs of spaces, and a naive split reports every
68
+ aligned rule as malformed — which on a real migration is all of them. */
69
+ const parts = line.split(/\s+/);
70
+ const [from, to, status] = parts;
71
+
72
+ if (!from || !to) {
73
+ problems.push({ line: index + 1, why: `is not a rule: ${JSON.stringify(line)}`, raw: line });
74
+ return;
75
+ }
76
+
77
+ const code = status === undefined ? 301 : Number(status);
78
+ if (!Number.isInteger(code) || !ALLOWED.has(code)) {
79
+ problems.push({
80
+ line: index + 1,
81
+ why: `status ${JSON.stringify(status)} is not one Cloudflare accepts (200, 301, 302, 303, 307, 308)`,
82
+ raw: line,
83
+ });
84
+ return;
85
+ }
86
+
87
+ rules.push({ line: index + 1, from, to, code, raw: line });
88
+ });
89
+
90
+ /* ── a duplicate source ───────────────────────────────────────────────────── */
91
+
92
+ const bySource = new Map();
93
+ for (const rule of rules) {
94
+ const key = rule.from;
95
+ if (bySource.has(key)) {
96
+ problems.push({
97
+ line: rule.line,
98
+ why: `duplicate source ${key} — first declared on line ${bySource.get(key).line}. Cloudflare takes the FIRST match, so this rule never fires`,
99
+ raw: rule.raw,
100
+ });
101
+ } else {
102
+ bySource.set(key, rule);
103
+ }
104
+ }
105
+
106
+ /* ── self-redirects, loops and chains ─────────────────────────────────────── */
107
+
108
+ /* Compare with and without a trailing slash: `/a` and `/a/` are the same page
109
+ to a reader and to Cloudflare's matcher, and a loop written across the two
110
+ forms is the one nobody spots by eye. */
111
+ const norm = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
112
+
113
+ for (const rule of rules) {
114
+ if (norm(rule.from) === norm(rule.to)) {
115
+ problems.push({
116
+ line: rule.line,
117
+ why: `redirects to itself — a browser stops after about twenty hops and the page is simply gone`,
118
+ raw: rule.raw,
119
+ });
120
+ }
121
+ }
122
+
123
+ const target = new Map(rules.map((r) => [norm(r.from), r]));
124
+
125
+ /*
126
+ * ⚠ WALK FIRST, THEN DECIDE. Reporting a hop as a chain the moment it is seen
127
+ * means a LOOP is announced as a chain and then as a loop — two messages, the
128
+ * first of them wrong, and the wrong one arrives first. Collect the walk, and
129
+ * only call it a chain if it actually terminates.
130
+ */
131
+ for (const rule of rules) {
132
+ if (norm(rule.from) === norm(rule.to)) continue; // already reported
133
+
134
+ const path = [norm(rule.from)];
135
+ const hops = [];
136
+ let cursor = target.get(norm(rule.to));
137
+ let looped = false;
138
+
139
+ while (cursor && hops.length < 20) {
140
+ const here = norm(cursor.from);
141
+ if (path.includes(here)) {
142
+ looped = true;
143
+ path.push(here);
144
+ break;
145
+ }
146
+ path.push(here);
147
+ hops.push(cursor);
148
+ cursor = target.get(norm(cursor.to));
149
+ }
150
+
151
+ if (looped) {
152
+ problems.push({
153
+ line: rule.line,
154
+ why: `is part of a redirect LOOP: ${path.join(' → ')}`,
155
+ raw: rule.raw,
156
+ });
157
+ } else if (hops.length) {
158
+ const last = hops[hops.length - 1];
159
+ warnings.push(
160
+ `line ${rule.line}: ${rule.from} reaches ${last.to} in ${hops.length + 1} hops. ` +
161
+ `Cloudflare resolves one per request, so point this rule straight at ${last.to}`,
162
+ );
163
+ }
164
+ }
165
+
166
+ /* ── report ──────────────────────────────────────────────────────────────── */
167
+
168
+ const unique = [...new Set(warnings)];
169
+ for (const w of unique) console.log(` ${YELLOW}!${RESET} ${w}`);
170
+
171
+ if (!problems.length) {
172
+ console.log(
173
+ `${GREEN}✓${RESET} ${rules.length} redirect rule(s): no duplicates, no loops, every status supported`,
174
+ );
175
+ process.exit(0);
176
+ }
177
+
178
+ console.error(`\n${RED}✗ ${problems.length} problem(s) in ${FILE}${RESET}\n`);
179
+ for (const p of problems) {
180
+ console.error(` line ${p.line} ${p.why}`);
181
+ console.error(` ${DIM}${p.raw}${RESET}`);
182
+ }
183
+ console.error(
184
+ `\n ${DIM}None of these stop the file parsing or the site building. They break a URL\n` +
185
+ ` that used to rank, weeks later, in somebody else's analytics.${RESET}\n`,
186
+ );
187
+ process.exit(1);