create-website-build-kit 0.1.0

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.
Files changed (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Propose a redirect map from the old site's URLs to the new site's routes.
3
+ *
4
+ * npm run redirects # new routes from dist/
5
+ * npm run redirects -- https://new.example.com # new routes from a deployed sitemap
6
+ *
7
+ * Reads recon/urls.txt (from `npm run recon`) and writes recon/redirects.proposed.
8
+ *
9
+ * ── IT NEVER WRITES public/_redirects ──────────────────────────────────────
10
+ * Deliberately, and this is the whole design. Slug similarity is a guess. A
11
+ * generator that edited the live map would turn a guess into a decision nobody
12
+ * made, and a wrong 301 is worse than a 404: the 404 shows up in the log and
13
+ * gets fixed, while the wrong redirect looks like it works and quietly sends
14
+ * people and link equity to the wrong page for years.
15
+ *
16
+ * You copy the lines you agree with. That is the point.
17
+ *
18
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
19
+ * The redirect map is the highest-traffic-risk step of a migration and it was
20
+ * built by hand, late, from a list of a few hundred URLs. A missed one is a
21
+ * page that 404s with its backlinks pointing at nothing.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+
26
+ import { discoverRoutes } from './lib/routes.mjs';
27
+ import { readInventory } from './lib/inventory.mjs';
28
+
29
+ const RESET = '[0m';
30
+ const RED = '[31m';
31
+ const GREEN = '[32m';
32
+ const YELLOW = '[33m';
33
+ const DIM = '[2m';
34
+ const BOLD = '[1m';
35
+
36
+ const origin = process.argv.slice(2).find((a) => a.startsWith('http'))?.replace(/\/$/, '');
37
+
38
+ const INVENTORY = 'recon/urls.txt';
39
+ const OUT = 'recon/redirects.proposed';
40
+
41
+ if (!existsSync(INVENTORY)) {
42
+ console.error(
43
+ `${RED}✗${RESET} ${INVENTORY} not found.\n` +
44
+ ' Run `npm run recon -- https://old-site.com` first — that is what builds the inventory.',
45
+ );
46
+ process.exit(1);
47
+ }
48
+
49
+ /*
50
+ * Parsed by lib/inventory.mjs, not here. This used to keep every line that was
51
+ * not blank or a comment, which is correct only while the file holds bare
52
+ * paths — a real inventory of ABSOLUTE URLs made it compare
53
+ * `https://site.com/about/` against `/about/`, match nothing, and propose an
54
+ * empty map with no complaint.
55
+ *
56
+ * Already-dead URLs are included deliberately: a path that was 404 on the old
57
+ * site still holds backlinks, so it is exactly the kind of thing that wants a
58
+ * redirect. They are the best candidates here, not noise.
59
+ */
60
+ const inventory = readInventory(readFileSync, INVENTORY);
61
+ const oldPaths = inventory ? [...inventory.live, ...inventory.gone] : [];
62
+
63
+ /* ── The new site's routes ─────────────────────────────────────────────── */
64
+
65
+ const base = origin ?? 'http://localhost';
66
+ const { routes, source } = await discoverRoutes(base);
67
+ const newPaths = [...new Set(routes.map((r) => new URL(r).pathname))];
68
+
69
+ if (!newPaths.length) {
70
+ console.error(
71
+ `${RED}✗${RESET} no routes for the new site.\n` +
72
+ ' Build first (`npm run build:staging`), or pass a deployed host.',
73
+ );
74
+ process.exit(1);
75
+ }
76
+
77
+ console.log(`${BOLD}── Matching ${'─'.repeat(46)}${RESET}`);
78
+ console.log(` ${DIM}${oldPaths.length} old path(s) · ${newPaths.length} new route(s) from ${source}${RESET}\n`);
79
+
80
+ /* ── Similarity ────────────────────────────────────────────────────────────
81
+ *
82
+ * Compares the SLUG, not the whole path, because a migration usually reshapes
83
+ * the directory structure and keeps the leaf: /blog/2019/duct-cleaning-tips/
84
+ * becomes /blog/duct-cleaning-tips/. Comparing full paths scores that pair low
85
+ * for a difference nobody cares about.
86
+ */
87
+
88
+ /*
89
+ * Words that carry no meaning in a slug.
90
+ *
91
+ * `us` and `we` matter more than they look: without them `/about-us/` scores
92
+ * 54% against `/about/` and lands in the needs-a-human pile — a pair anyone
93
+ * would take instantly. A reviewer handed two hundred obvious lines to confirm
94
+ * stops reading them, which defeats the whole point of the pile.
95
+ */
96
+ const NOISE = new Set([
97
+ 'a', 'an', 'the', 'and', 'or', 'of', 'for', 'to', 'in', 'on',
98
+ 'your', 'our', 'us', 'we',
99
+ ]);
100
+
101
+ const tokens = (path) =>
102
+ path
103
+ .toLowerCase()
104
+ .replace(/\.(html?|php|aspx?)$/, '')
105
+ .split('/')
106
+ .filter(Boolean)
107
+ .pop()
108
+ ?.split(/[-_.]+/)
109
+ .filter((t) => t && !NOISE.has(t) && !/^\d{4}$/.test(t)) ?? [];
110
+
111
+ function levenshtein(a, b) {
112
+ if (a === b) return 0;
113
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
114
+ for (let i = 1; i <= a.length; i++) {
115
+ let last = prev[0];
116
+ prev[0] = i;
117
+ for (let j = 1; j <= b.length; j++) {
118
+ const tmp = prev[j];
119
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
120
+ last = tmp;
121
+ }
122
+ }
123
+ return prev[b.length];
124
+ }
125
+
126
+ /**
127
+ * 0..1. Token overlap carries most of it — word order changes constantly and
128
+ * should not cost anything — with edit distance breaking ties and rescuing
129
+ * near-misses like `sevices` for `services`.
130
+ */
131
+ function similarity(a, b) {
132
+ const ta = tokens(a);
133
+ const tb = tokens(b);
134
+ if (!ta.length || !tb.length) return 0;
135
+
136
+ const setA = new Set(ta);
137
+ const setB = new Set(tb);
138
+ const shared = [...setA].filter((t) => setB.has(t)).length;
139
+ const jaccard = shared / new Set([...setA, ...setB]).size;
140
+
141
+ const strA = ta.join('-');
142
+ const strB = tb.join('-');
143
+ const edit = 1 - levenshtein(strA, strB) / Math.max(strA.length, strB.length);
144
+
145
+ return jaccard * 0.7 + Math.max(0, edit) * 0.3;
146
+ }
147
+
148
+ /* ── Classify every old path ───────────────────────────────────────────── */
149
+
150
+ const HIGH = 0.85;
151
+ const REVIEW = 0.5;
152
+
153
+ /*
154
+ * Admin paths must 404, never 301. A redirect from an admin path tells a
155
+ * scanner the site MOVED rather than that the path is gone — free
156
+ * reconnaissance, and it invites the follow-up scan. stacks.md §1d.
157
+ */
158
+ const MUST_404 = /^\/(wp-admin|wp-login\.php|xmlrpc\.php|wp-json|wp-includes|administrator|admin)\b/i;
159
+
160
+ /*
161
+ * A machine-readable path needs the FORMAT at that path, not a redirect to
162
+ * HTML. A directory that fetches and parses a feed gets a marketing page.
163
+ */
164
+ const MACHINE = /\.(xml|json|txt|kml|rss|atom|csv)$/i;
165
+
166
+ const kept = [];
167
+ const exact = [];
168
+ const high = [];
169
+ const review = [];
170
+ const none = [];
171
+ const must404 = [];
172
+ const machine = [];
173
+
174
+ const newSet = new Set(newPaths);
175
+
176
+ for (const path of oldPaths) {
177
+ if (newSet.has(path)) {
178
+ kept.push(path);
179
+ continue;
180
+ }
181
+ if (MUST_404.test(path)) {
182
+ must404.push(path);
183
+ continue;
184
+ }
185
+ if (MACHINE.test(path)) {
186
+ machine.push(path);
187
+ continue;
188
+ }
189
+
190
+ let best = null;
191
+ let bestScore = 0;
192
+ for (const candidate of newPaths) {
193
+ /* Never propose the homepage for a specific page. Google reads a mass of
194
+ deep URLs landing on `/` as a soft 404 across the whole site, and it is
195
+ the single most common bad redirect map. */
196
+ if (candidate === '/') continue;
197
+ const score = similarity(path, candidate);
198
+ if (score > bestScore) {
199
+ bestScore = score;
200
+ best = candidate;
201
+ }
202
+ }
203
+
204
+ const row = { path, best, score: bestScore };
205
+ if (bestScore >= HIGH) (path.replace(/\/$/, '') === best?.replace(/\/$/, '') ? exact : high).push(row);
206
+ else if (bestScore >= REVIEW) review.push(row);
207
+ else none.push(row);
208
+ }
209
+
210
+ const pct = (n) => `${(n * 100).toFixed(0)}%`;
211
+
212
+ console.log(` ${GREEN}✓${RESET} ${kept.length.toString().padStart(4)} already exist — no redirect needed`);
213
+ console.log(` ${GREEN}✓${RESET} ${high.length.toString().padStart(4)} high confidence`);
214
+ console.log(` ${YELLOW}!${RESET} ${review.length.toString().padStart(4)} need a human`);
215
+ console.log(` ${RED}✗${RESET} ${none.length.toString().padStart(4)} no candidate`);
216
+ if (must404.length) console.log(` ${DIM}·${RESET} ${must404.length.toString().padStart(4)} admin paths — must 404, never redirect`);
217
+ if (machine.length) console.log(` ${DIM}·${RESET} ${machine.length.toString().padStart(4)} machine-readable — regenerate, do not redirect`);
218
+
219
+ /* ── Write the proposal ────────────────────────────────────────────────── */
220
+
221
+ const lines = [
222
+ '# PROPOSED redirects — not applied. Copy the lines you agree with into',
223
+ '# public/_redirects. Nothing here has been written to the live map.',
224
+ '#',
225
+ '# Matched on slug similarity, which is a guess. A wrong 301 is worse than a',
226
+ '# 404: the 404 shows up in the log and gets fixed, the wrong redirect looks',
227
+ '# like it works and sends people to the wrong page for years.',
228
+ '#',
229
+ `# ${oldPaths.length} old paths against ${newPaths.length} new routes.`,
230
+ '',
231
+ ];
232
+
233
+ if (high.length) {
234
+ lines.push(`# ── High confidence (>= ${pct(HIGH)}) — read them, then paste them ────────────`, '');
235
+ for (const r of high) lines.push(`${r.path.padEnd(48)} ${r.best.padEnd(40)} 301 # ${pct(r.score)}`);
236
+ lines.push('');
237
+ }
238
+
239
+ if (review.length) {
240
+ lines.push(
241
+ `# ── NEEDS A HUMAN (${pct(REVIEW)}–${pct(HIGH)}) ─────────────────────────────────────`,
242
+ '# Commented out on purpose. Uncomment only after opening both pages.',
243
+ '',
244
+ );
245
+ for (const r of review) lines.push(`# ⚠ ${pct(r.score)} ${r.path.padEnd(44)} ${r.best} 301`);
246
+ lines.push('');
247
+ }
248
+
249
+ if (none.length) {
250
+ lines.push(
251
+ '# ── No candidate ───────────────────────────────────────────────────────',
252
+ '# Decide each one: is there an equivalent page, or is this content gone?',
253
+ '# Gone is a legitimate answer — 410 says so honestly. Never send these to',
254
+ '# the homepage to make the list shorter.',
255
+ '',
256
+ );
257
+ for (const r of none) lines.push(`# ⚠ no candidate ${r.path}`);
258
+ lines.push('');
259
+ }
260
+
261
+ if (must404.length) {
262
+ lines.push(
263
+ '# ── Do NOT redirect: admin paths ───────────────────────────────────────',
264
+ '# These must return a real 404. A 301 from an admin path tells a scanner',
265
+ '# the site moved rather than that the path is gone.',
266
+ '',
267
+ );
268
+ for (const p of must404) lines.push(`# ${p}`);
269
+ lines.push('');
270
+ }
271
+
272
+ if (machine.length) {
273
+ lines.push(
274
+ '# ── Do NOT redirect: machine-readable ──────────────────────────────────',
275
+ '# Whatever fetches these expects the FORMAT, not a redirect to HTML.',
276
+ '# Regenerate them at the same path. stacks.md §1d.',
277
+ '',
278
+ );
279
+ for (const p of machine) lines.push(`# ${p}`);
280
+ lines.push('');
281
+ }
282
+
283
+ mkdirSync('recon', { recursive: true });
284
+ writeFileSync(OUT, lines.join('\n'));
285
+
286
+ console.log(`\n${GREEN}✓${RESET} ${OUT}`);
287
+ console.log(
288
+ `${DIM} Nothing was written to public/_redirects. Read the file, paste what you agree\n` +
289
+ ` with, and verify after deploying: \`npm run verify -- https://…\` checks that every\n` +
290
+ ` rule returns its declared status AND that its target actually resolves.${RESET}`,
291
+ );
292
+
293
+ if (review.length || none.length) {
294
+ console.log(
295
+ `\n${YELLOW}⚠${RESET} ${review.length + none.length} path(s) need a decision. ` +
296
+ `${DIM}They are the ones that lose traffic silently.${RESET}`,
297
+ );
298
+ }