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,317 @@
1
+ /**
2
+ * Turn the captured HTML in recon/html/ into clean markdown, one file per page.
3
+ *
4
+ * npm run extract # every page recon captured
5
+ * npm run extract -- --only=about # one, substring match on the slug
6
+ * npm run extract -- --dir=recon/html # a different capture directory
7
+ *
8
+ * Writes recon/extracted/<slug>.md — frontmatter plus body — and prints a table
9
+ * of what came out, with the pages worth re-reading flagged.
10
+ *
11
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
12
+ * `recon` captured the rendered HTML and NOTHING CONSUMED IT. `build.md` phase
13
+ * 1 said "pull copy, media and metadata into structured files" and named no
14
+ * tool, so every migration hand-rolled an extractor at the point in the project
15
+ * where there is least time to write one carefully.
16
+ *
17
+ * On one build that left 18 pages of captured HTML sitting in recon/html/ while
18
+ * the site shipped with a home page and nothing else.
19
+ *
20
+ * ── IT DOES NOT DECIDE THE CONTENT MODEL ───────────────────────────────────
21
+ * Output goes to recon/extracted/, not src/content/. What the collections are,
22
+ * which pages collapse into template + data, and which of these should exist at
23
+ * all are phase-2 decisions and project-shaped — `build.md` §2. This produces
24
+ * reviewable markdown; a person places it.
25
+ *
26
+ * ── WHY A DEPENDENCY ───────────────────────────────────────────────────────
27
+ * `traps.md` has the entry: `html.replace(/<[^>]+>/g, '')` glues the text
28
+ * either side of every tag it removes, so a heading runs into its paragraph and
29
+ * a sentence into its link — but only where the source markup had no newline
30
+ * between the tags, which is every page builder's minified output. So the
31
+ * hand-rolled version is right on the pretty-printed pages and wrong on the
32
+ * rest, and it reads as a content problem rather than a converter one.
33
+ *
34
+ * Getting whitespace, nesting and list indentation right is the entire job, and
35
+ * turndown already does. It is 30 KB with no dependencies of its own. This is
36
+ * the dependency `build.md` §2 means by "reach for the heavy option when the
37
+ * task needs it".
38
+ */
39
+
40
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
41
+
42
+ import TurndownService from 'turndown';
43
+
44
+ const RESET = '\x1b[0m';
45
+ const RED = '\x1b[31m';
46
+ const GREEN = '\x1b[32m';
47
+ const YELLOW = '\x1b[33m';
48
+ const DIM = '\x1b[2m';
49
+ const BOLD = '\x1b[1m';
50
+
51
+ const args = process.argv.slice(2);
52
+ const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
53
+ const SRC = (args.find((a) => a.startsWith('--dir=')) ?? '').replace('--dir=', '') || 'recon/html';
54
+ const OUT = 'recon/extracted';
55
+
56
+ if (!existsSync(SRC)) {
57
+ console.error(
58
+ `${RED}✗${RESET} ${SRC} not found.\n` +
59
+ ' Run `npm run recon -- https://old-site.com` first — that is what captures the HTML.',
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ /*
65
+ * Remove an element and everything inside it, innermost first.
66
+ *
67
+ * A single non-greedy regex stops at the FIRST closing tag, so a <nav> holding
68
+ * a nested <nav> leaves the outer half behind — visible as a stray fragment of
69
+ * menu at the top of the extracted copy. Matching only spans that contain no
70
+ * further opening tag of the same name, repeatedly, unwinds nesting correctly.
71
+ */
72
+ function stripElement(html, tag) {
73
+ const inner = new RegExp(`<${tag}\\b[^>]*>(?:(?!<${tag}\\b)[\\s\\S])*?<\\/${tag}>`, 'gi');
74
+ let out = html;
75
+ for (let pass = 0; pass < 20; pass++) {
76
+ const next = out.replace(inner, '');
77
+ if (next === out) break;
78
+ out = next;
79
+ }
80
+ /* Self-closing and unclosed leftovers. */
81
+ return out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '');
82
+ }
83
+
84
+ /** Chrome, not content. Removed before the region is chosen and after. */
85
+ const FURNITURE = ['script', 'style', 'noscript', 'svg', 'nav', 'header', 'footer', 'aside', 'form', 'iframe'];
86
+
87
+ const attr = (html, re) => re.exec(html)?.[1]?.trim() ?? '';
88
+ const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
89
+
90
+ const decode = (s) =>
91
+ s
92
+ .replace(/&nbsp;/g, ' ')
93
+ .replace(/&amp;/g, '&')
94
+ .replace(/&lt;/g, '<')
95
+ .replace(/&gt;/g, '>')
96
+ .replace(/&quot;/g, '"')
97
+ .replace(/&#0?39;|&apos;|&rsquo;/g, "'")
98
+ .replace(/&ldquo;|&rdquo;/g, '"')
99
+ .replace(/&mdash;/g, '—')
100
+ .replace(/&ndash;/g, '–');
101
+
102
+ /*
103
+ * The content region. <main> is the answer when a theme emits one; <article> is
104
+ * the fallback; <body> is the last resort and is reported, because a page
105
+ * extracted from <body> has usually kept some chrome and wants a human.
106
+ */
107
+ function contentRegion(html) {
108
+ for (const [tag, label] of [['main', '<main>'], ['article', '<article>']]) {
109
+ const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*)<\\/${tag}>`, 'i').exec(html);
110
+ if (m && text(m[1]).length > 200) return { region: m[1], from: label };
111
+ }
112
+ const body = /<body\b[^>]*>([\s\S]*)<\/body>/i.exec(html);
113
+ return { region: body?.[1] ?? html, from: '<body> — check for leftover chrome' };
114
+ }
115
+
116
+ /*
117
+ * Portable image references, per build.md §2: content stores a path, never an
118
+ * infrastructure URL. WordPress's generated `-300x200` sizes and Elementor's
119
+ * thumbnail crops both point at derivatives — you want the original, and
120
+ * `npm run media` will regenerate the sizes.
121
+ */
122
+ function portableSrc(src) {
123
+ let out = src.trim();
124
+ try {
125
+ out = new URL(out, 'https://placeholder.invalid').pathname;
126
+ } catch {
127
+ /* leave a relative path alone */
128
+ }
129
+ return out
130
+ .replace(/\/uploads\/elementor\/thumbs\//, '/uploads/')
131
+ .replace(/-\d{2,4}x\d{2,4}(?=\.[a-z]{3,4}$)/i, '')
132
+ .replace(/-scaled(?=\.[a-z]{3,4}$)/i, '');
133
+ }
134
+
135
+ const turndown = new TurndownService({
136
+ headingStyle: 'atx',
137
+ bulletListMarker: '-',
138
+ codeBlockStyle: 'fenced',
139
+ emDelimiter: '*',
140
+ });
141
+
142
+ /* A builder wraps everything in divs and spans; they carry no meaning here. */
143
+ turndown.addRule('unwrap', {
144
+ filter: ['div', 'span', 'section', 'figure'],
145
+ replacement: (content) => content,
146
+ });
147
+
148
+ const files = readdirSync(SRC)
149
+ .filter((f) => f.endsWith('.html'))
150
+ .filter((f) => !only || f.includes(only))
151
+ .sort();
152
+
153
+ if (!files.length) {
154
+ console.error(`${RED}✗${RESET} no .html in ${SRC}${only ? ` matching --only=${only}` : ''}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ mkdirSync(OUT, { recursive: true });
159
+
160
+ console.log(`${BOLD}── Extract ${'─'.repeat(48)}${RESET}`);
161
+ console.log(` ${DIM}${files.length} page(s) from ${SRC}/ → ${OUT}/${RESET}\n`);
162
+
163
+ const rows = [];
164
+
165
+ for (const file of files) {
166
+ const slug = file.replace(/\.html$/, '');
167
+ const raw = readFileSync(`${SRC}/${file}`, 'utf8');
168
+ const flags = [];
169
+
170
+ const seoTitle = decode(text(attr(raw, /<title[^>]*>([\s\S]*?)<\/title>/i)));
171
+ const description = decode(
172
+ attr(raw, /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i),
173
+ );
174
+ const canonical = attr(raw, /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
175
+ const ogImage = attr(raw, /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
176
+
177
+ let { region, from } = contentRegion(raw);
178
+ if (from.startsWith('<body>')) flags.push('region');
179
+
180
+ for (const tag of FURNITURE) region = stripElement(region, tag);
181
+ region = region.replace(/<!--[\s\S]*?-->/g, '');
182
+
183
+ const h1 = decode(text(attr(region, /<h1[^>]*>([\s\S]*?)<\/h1>/i))) ||
184
+ decode(text(attr(raw, /<h1[^>]*>([\s\S]*?)<\/h1>/i)));
185
+ if (!h1) flags.push('no-h1');
186
+
187
+ /*
188
+ * Lazy-load placeholders: the real file is in data-src and `src` holds a
189
+ * transparent gif or a base64 blur. Taking `src` migrates the placeholder,
190
+ * which looks like a broken image on a page that built perfectly.
191
+ */
192
+ const images = [];
193
+ region = region.replace(/<img\b[^>]*>/gi, (tag) => {
194
+ const lazy = attr(tag, /\sdata-(?:src|lazy-src|original)=["']([^"']+)["']/i);
195
+ const plain = attr(tag, /\ssrc=["']([^"']+)["']/i);
196
+ const chosen = lazy || plain;
197
+ if (!chosen || /^data:/i.test(chosen)) return '';
198
+ const src = portableSrc(chosen);
199
+ const alt = decode(attr(tag, /\salt=["']([^"']*)["']/i));
200
+ /*
201
+ * Empty alt is the obvious failure. The commoner one is alt text the CMS
202
+ * generated FROM THE FILENAME — "pic 11", "service-maintenance-worker-
203
+ * repairing" — which passes every automated check, reads as described, and
204
+ * tells a screen-reader user nothing. Seen on every page of a real capture.
205
+ */
206
+ if (!alt) flags.push('img-alt');
207
+ else if (alt.replace(/[\s-]+/g, '-').toLowerCase() === src.split('/').pop().replace(/\.[a-z]+$/i, '').replace(/[\s-]+/g, '-').toLowerCase().slice(0, alt.length + 8).replace(/-+$/, '')) {
208
+ flags.push('alt-filename');
209
+ }
210
+ images.push({ src, alt });
211
+ return `<img src="${src}" alt="${alt}">`;
212
+ });
213
+
214
+ /* srcset points at generated sizes that will not exist on the new site. */
215
+ region = region.replace(/\ssrcset=["'][^"']*["']/gi, '').replace(/\ssizes=["'][^"']*["']/gi, '');
216
+
217
+ let body = turndown.turndown(region);
218
+ body = decode(body)
219
+ .replace(/\n{3,}/g, '\n\n')
220
+ .replace(/[ \t]+$/gm, '')
221
+ .trim();
222
+
223
+ const words = body.split(/\s+/).filter(Boolean).length;
224
+ if (words < 80) flags.push('thin');
225
+
226
+ /*
227
+ * ⚠ PAGE BUILDERS USE HEADING TAGS AS TYPE STYLES. On a real capture the
228
+ * lede paragraph was an <h5> and every section title an <h6>, chosen because
229
+ * they looked right in the builder's preview — so the extracted markdown
230
+ * carries a hierarchy that jumps h1 → h5 and never uses h2 at all.
231
+ *
232
+ * It survives every automated check (`verify` counts h1s, and there is
233
+ * exactly one), reads correctly to a sighted visitor, and is both an
234
+ * accessibility failure and the outline Google reads. build.md phase 1 says
235
+ * "normalise heading levels" — this is what it is asking you to look at.
236
+ */
237
+ const levels = [...body.matchAll(/^(#{1,6})\s/gm)].map((m) => m[1].length);
238
+ const jumps = levels.some((l, i) => i > 0 && l - levels[i - 1] > 1);
239
+ const deepOnly = levels.length > 2 && !levels.includes(2) && levels.some((l) => l >= 4);
240
+ if (jumps || deepOnly) flags.push('headings');
241
+
242
+ /*
243
+ * The script checks its own output for the failure it exists to prevent.
244
+ * traps.md has the entry; turndown does not create these, but a source page
245
+ * that genuinely lacked the space will carry it through, and either way it is
246
+ * a line somebody has to read.
247
+ */
248
+ const prose = body
249
+ /* A link TARGET is not prose. `goo.gl/uQxkSHWN…` matched the pattern on
250
+ every page of a real capture — the detector was reading URL slugs. */
251
+ .replace(/\]\([^)]*\)/g, ']')
252
+ .replace(/https?:\/\/\S+/g, '')
253
+ /* Nor is a code span, which is where identifiers legitimately live. */
254
+ .replace(/`[^`]*`/g, '');
255
+
256
+ const glued = [...prose.matchAll(/[a-z](?:https?:\/\/|[A-Z][a-z]{2,})/g)]
257
+ .map((m) => m[0])
258
+ .filter((s) => !/iPhone|YouTube|JavaScript|WordPress|PayPal|eBay|iPad|macOS/i.test(s));
259
+ if (glued.length) flags.push(`glued:${glued.length}`);
260
+
261
+ const yaml = [
262
+ '---',
263
+ `source: ${canonical || `/${slug === 'index' ? '' : slug + '/'}`}`,
264
+ `title: ${JSON.stringify(h1 || seoTitle || slug)}`,
265
+ seoTitle && seoTitle !== h1 ? `seoTitle: ${JSON.stringify(seoTitle)}` : null,
266
+ `description: ${JSON.stringify(description)}`,
267
+ ogImage ? `ogImage: ${JSON.stringify(portableSrc(ogImage))}` : null,
268
+ images.length ? 'images:' : null,
269
+ ...images.map((i) => ` - src: ${JSON.stringify(i.src)}\n alt: ${JSON.stringify(i.alt)}`),
270
+ '---',
271
+ '',
272
+ ].filter((l) => l !== null);
273
+
274
+ writeFileSync(`${OUT}/${slug}.md`, `${yaml.join('\n')}\n${body}\n`);
275
+ rows.push({ slug, words, images: images.length, from, flags });
276
+ }
277
+
278
+ /* ── Report ─────────────────────────────────────────────────────────────── */
279
+
280
+ const pad = Math.max(...rows.map((r) => r.slug.length));
281
+ for (const r of rows) {
282
+ const mark = r.flags.length ? `${YELLOW}!${RESET}` : `${GREEN}✓${RESET}`;
283
+ /* One flag per kind, with a count. Four images with filename alt text is one
284
+ thing to fix, not four things to read. */
285
+ const counted = [...r.flags.reduce((m, f) => m.set(f, (m.get(f) ?? 0) + 1), new Map())]
286
+ .map(([f, n]) => (n > 1 ? `${f}×${n}` : f))
287
+ .join(' ');
288
+ console.log(
289
+ ` ${mark} ${r.slug.padEnd(pad)} ${String(r.words).padStart(5)} words ` +
290
+ `${String(r.images).padStart(3)} img ${DIM}${counted}${RESET}`,
291
+ );
292
+ }
293
+
294
+ const legend = {
295
+ region: 'no <main> or <article> — extracted from <body>, check for leftover chrome',
296
+ 'no-h1': 'no <h1> found — the title fell back to <title> or the slug',
297
+ thin: 'under 80 words — the page may be mostly a builder layout, or the capture is partial',
298
+ 'img-alt': 'at least one image had no alt text — write it, do not copy the filename',
299
+ 'alt-filename': 'alt text derived from the filename ("pic 11") — reads as described, says nothing',
300
+ headings: 'heading levels jump or start deep — the builder used them as type styles, not structure',
301
+ glued: 'run-together words, e.g. "AreasWe". traps.md has the entry — read these',
302
+ };
303
+ const seen = new Set(rows.flatMap((r) => r.flags.map((f) => f.split(':')[0])));
304
+ if (seen.size) {
305
+ console.log(`\n ${DIM}${'─'.repeat(56)}${RESET}`);
306
+ for (const key of seen) if (legend[key]) console.log(` ${DIM}${key.padEnd(8)} ${legend[key]}${RESET}`);
307
+ }
308
+
309
+ console.log(
310
+ `\n${GREEN}✓${RESET} ${rows.length} page(s) → ${OUT}/ ` +
311
+ `${DIM}${rows.reduce((n, r) => n + r.words, 0).toLocaleString()} words, ` +
312
+ `${rows.reduce((n, r) => n + r.images, 0)} image(s)${RESET}`,
313
+ );
314
+ console.log(
315
+ ` ${DIM}Not placed in src/content/ deliberately — what the collections are is a phase-2\n` +
316
+ ` decision. Read these, then move what survives. build.md §2.${RESET}\n`,
317
+ );
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Submit URLs to IndexNow — Bing, Yandex, Seznam, Naver.
3
+ *
4
+ * node scripts/indexnow.mjs # every URL in the live sitemap
5
+ * node scripts/indexnow.mjs /about/ /contact/ # just these routes
6
+ *
7
+ * Run it AFTER a deploy has finished, never before. See the key check below.
8
+ *
9
+ * ── GOOGLE DOES NOT PARTICIPATE ────────────────────────────────────────────
10
+ * It is printed on every run, deliberately. IndexNow is Bing, Yandex, Seznam
11
+ * and Naver; Google has repeatedly declined to join. A green result here is not
12
+ * "submitted to search engines", and reading it that way is how a site goes
13
+ * weeks without anyone checking why Google has not picked something up.
14
+ *
15
+ * For Google, use Search Console — the URL Inspection tool for one page, or a
16
+ * sitemap resubmission for a batch.
17
+ *
18
+ * ── SETUP ──────────────────────────────────────────────────────────────────
19
+ * 1. Invent a key: 8–128 hex characters. `openssl rand -hex 16` is fine.
20
+ * 2. Put it in a file whose NAME is the key: public/<key>.txt, containing the
21
+ * key and nothing else.
22
+ * 3. Deploy, so the file is live.
23
+ * 4. export INDEXNOW_KEY=<key>
24
+ */
25
+
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+
28
+ const RESET = '';
29
+ const RED = '';
30
+ const GREEN = '';
31
+ const YELLOW = '';
32
+ const DIM = '';
33
+
34
+ const key = process.env.INDEXNOW_KEY;
35
+ if (!key) {
36
+ console.error(
37
+ `${RED}✗${RESET} INDEXNOW_KEY is not set.\n` +
38
+ ' Invent one with `openssl rand -hex 16`, save it as public/<key>.txt\n' +
39
+ ' containing the key, deploy, then export INDEXNOW_KEY=<key>.',
40
+ );
41
+ process.exit(1);
42
+ }
43
+ if (!/^[a-fA-F0-9]{8,128}$/.test(key)) {
44
+ console.error(`${RED}✗${RESET} INDEXNOW_KEY must be 8–128 hex characters.`);
45
+ process.exit(1);
46
+ }
47
+
48
+ /* The production host. Read from the same place the build reads it, so this
49
+ cannot be pointed at staging by accident — submitting a noindex staging host
50
+ is a waste at best. */
51
+ const SITE = (process.env.PUBLIC_SITE_URL ?? '').replace(/\/$/, '');
52
+ if (!SITE || !SITE.startsWith('https://')) {
53
+ console.error(
54
+ `${RED}✗${RESET} PUBLIC_SITE_URL must be the https production origin.\n` +
55
+ ' e.g. PUBLIC_SITE_URL=https://example.com node scripts/indexnow.mjs',
56
+ );
57
+ process.exit(1);
58
+ }
59
+ const host = new URL(SITE).hostname;
60
+
61
+ if (!existsSync(`public/${key}.txt`)) {
62
+ console.warn(
63
+ `${YELLOW}!${RESET} public/${key}.txt is not in this repo. It must be, or the\n` +
64
+ ' next deploy removes the key file and every later submission is rejected.',
65
+ );
66
+ }
67
+
68
+ /**
69
+ * ── VERIFY THE KEY FILE IS REACHABLE BEFORE POSTING ────────────────────────
70
+ *
71
+ * IndexNow validates ownership by fetching https://<host>/<key>.txt at the
72
+ * moment of submission. Submit before the deploy carrying that file has
73
+ * finished and the whole batch is rejected with a 403 — and because the API
74
+ * answers 200 for an accepted batch and says nothing else useful, a script that
75
+ * skipped this check would print success for a submission that never happened.
76
+ *
77
+ * A script that reports someone else's success is worse than no script.
78
+ */
79
+ const keyUrl = `${SITE}/${key}.txt`;
80
+ const probe = await fetch(keyUrl).catch(() => null);
81
+ if (!probe?.ok) {
82
+ console.error(
83
+ `${RED}✗${RESET} ${keyUrl} is not reachable (${probe ? probe.status : 'no response'}).\n` +
84
+ ' IndexNow fetches this to verify ownership. Deploy first, then submit.',
85
+ );
86
+ process.exit(1);
87
+ }
88
+ const served = (await probe.text()).trim();
89
+ if (served !== key) {
90
+ console.error(
91
+ `${RED}✗${RESET} ${keyUrl} does not contain the key.\n` +
92
+ ` Served: ${JSON.stringify(served.slice(0, 40))}\n Expected: ${key}`,
93
+ );
94
+ process.exit(1);
95
+ }
96
+
97
+ /* URLs: either the routes given as arguments, or the live sitemap. */
98
+ const args = process.argv.slice(2);
99
+ let urls;
100
+
101
+ if (args.length) {
102
+ urls = args.map((r) => new URL(r, SITE + '/').href);
103
+ } else {
104
+ const res = await fetch(`${SITE}/sitemap-index.xml`).catch(() => null);
105
+ const indexXml = res?.ok ? await res.text() : '';
106
+ const maps = [...indexXml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
107
+ const sources = maps.length ? maps : [`${SITE}/sitemap-0.xml`];
108
+
109
+ urls = [];
110
+ for (const map of sources) {
111
+ const r = await fetch(map).catch(() => null);
112
+ if (!r?.ok) continue;
113
+ const xml = await r.text();
114
+ urls.push(...[...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]));
115
+ }
116
+ urls = [...new Set(urls)];
117
+ }
118
+
119
+ if (!urls.length) {
120
+ console.error(
121
+ `${RED}✗${RESET} No URLs to submit. The sitemap is empty or unreachable —\n` +
122
+ ' note that staging builds emit no sitemap, deliberately.',
123
+ );
124
+ process.exit(1);
125
+ }
126
+
127
+ /* The API caps a batch at 10,000. Well above any site this kit builds, but a
128
+ silent truncation would read as a full submission. */
129
+ if (urls.length > 10000) {
130
+ console.error(`${RED}✗${RESET} ${urls.length} URLs exceeds the 10,000 batch limit.`);
131
+ process.exit(1);
132
+ }
133
+
134
+ const response = await fetch('https://api.indexnow.org/indexnow', {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json; charset=utf-8' },
137
+ body: JSON.stringify({ host, key, keyLocation: keyUrl, urlList: urls }),
138
+ });
139
+
140
+ /* 200 accepted, 202 accepted but key still validating. Anything else is a
141
+ refusal and the URLs were NOT submitted. */
142
+ if (response.status !== 200 && response.status !== 202) {
143
+ console.error(
144
+ `${RED}✗${RESET} IndexNow refused the batch: ${response.status} ${response.statusText}\n` +
145
+ ` ${(await response.text().catch(() => '')).slice(0, 300)}`,
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ console.log(`${GREEN}✓${RESET} ${urls.length} URL(s) submitted for ${host} (${response.status}).`);
151
+ console.log(
152
+ `${DIM} Bing, Yandex, Seznam and Naver. Google does NOT participate in IndexNow —\n` +
153
+ ` for Google use Search Console: URL Inspection, or resubmit the sitemap.${RESET}`,
154
+ );
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Regenerate src/data/lastmod.json — per-route content dates for the sitemap.
3
+ *
4
+ * npm run lastmod # then commit the JSON alongside your change
5
+ *
6
+ * ── WHY A COMMITTED FILE AND NOT git AT BUILD TIME ────────────────────────
7
+ * The first implementation read `git log` during the Astro build. It worked
8
+ * locally and emitted NOTHING in production, because Cloudflare Workers Builds
9
+ * shallow-clones: `git log` returns one grafted commit for every file, so the
10
+ * module's own guard correctly refused to stamp 23 identical dates.
11
+ *
12
+ * That guard was right and the design was wrong. A build should not depend on
13
+ * repository history it may not have been given. So the dates are computed
14
+ * here, by a human running a script with a full clone, and committed as data —
15
+ * which is how every other fact in this project is handled.
16
+ *
17
+ * ── UNCOMMITTED WORK COUNTS AS TODAY ──────────────────────────────────────
18
+ * If a page's source is modified but not yet committed, its last commit date
19
+ * is the PREVIOUS edit — so running this before committing would record a date
20
+ * that is already stale. Modified files are therefore dated today, so the
21
+ * normal flow works:
22
+ *
23
+ * edit a page → npm run lastmod → commit both together
24
+ *
25
+ * ── WHAT MOVES A DATE, AND WHAT DELIBERATELY DOES NOT ─────────────────────
26
+ * A route's own page file and content file move it. Layout, nav and stylesheet
27
+ * changes do NOT — see the long note in src/lib/lastmod.mjs. Google asks for
28
+ * the last SIGNIFICANT content change and says not to bump for boilerplate;
29
+ * treating a footer edit as a change to all 23 pages collapses every date to
30
+ * the same day and destroys the only signal the field carries.
31
+ */
32
+
33
+ import { execFileSync } from 'node:child_process';
34
+ import { existsSync, readdirSync, writeFileSync } from 'node:fs';
35
+
36
+ const OUT = 'src/data/lastmod.json';
37
+
38
+ const git = (args) =>
39
+ execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
40
+
41
+ if (git(['rev-parse', '--is-shallow-repository']) === 'true') {
42
+ console.error(
43
+ 'This is a shallow clone — per-file history is not available, so the dates\n' +
44
+ 'would all be identical. Run `git fetch --unshallow` first.',
45
+ );
46
+ process.exit(1);
47
+ }
48
+
49
+ /** Files with uncommitted changes. Their real "last modified" is now, not their last commit. */
50
+ const dirty = new Set(
51
+ git(['status', '--porcelain'])
52
+ .split('\n')
53
+ .filter(Boolean)
54
+ .map((l) => l.slice(3).trim()),
55
+ );
56
+
57
+ const today = new Date().toISOString().slice(0, 10);
58
+
59
+ function newest(files) {
60
+ const present = files.filter((f) => existsSync(f));
61
+ if (!present.length) return null;
62
+ if (present.some((f) => dirty.has(f))) return today;
63
+ try {
64
+ const out = git(['log', '-1', '--format=%cI', '--', ...present]);
65
+ return out ? out.slice(0, 10) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * ⚠ PROJECT CONFIG. Routes whose content comes from somewhere other than a
73
+ * same-named page file — a data file, a shared component, an API route.
74
+ *
75
+ * A route's date is the newest commit touching its own page file plus anything
76
+ * listed here. Omitting a real source UNDERSTATES a page's freshness, which is
77
+ * the safer direction to be wrong in: a crawler re-crawls later than ideal,
78
+ * rather than being told something false.
79
+ *
80
+ * ⚠ Deliberately NOT here: layouts, nav and stylesheets. Adding them is
81
+ * literally correct — a footer link does change every document — and it
82
+ * collapses every route to the same date, which is indistinguishable from
83
+ * stamping the build time and carries no prioritisation signal at all. Google
84
+ * asks for the last SIGNIFICANT content change and says explicitly not to bump
85
+ * for navigation or boilerplate.
86
+ *
87
+ * '/pricing/': ['src/data/pricing.json'],
88
+ * '/contact/': ['src/pages/api/contact.ts'],
89
+ */
90
+ const EXTRA_SOURCES = {};
91
+
92
+ const sources = new Map();
93
+
94
+ const walk = (dir, prefix = '') => {
95
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
96
+ const full = `${dir}/${entry.name}`;
97
+ if (entry.isDirectory()) {
98
+ if (entry.name !== 'api') walk(full, `${prefix}${entry.name}/`);
99
+ continue;
100
+ }
101
+ if (!entry.name.endsWith('.astro') || entry.name.startsWith('[')) continue;
102
+ const base = entry.name.replace(/\.astro$/, '');
103
+ sources.set(base === 'index' ? `/${prefix}` : `/${prefix}${base}/`, [full]);
104
+ }
105
+ };
106
+ walk('src/pages');
107
+
108
+ /*
109
+ * ⚠ PROJECT CONFIG — content collections rendered through a dynamic route.
110
+ *
111
+ * The markdown is what actually changed, so it leads; the template is a
112
+ * secondary source. Each directory is optional: a project without the
113
+ * collection simply skips it rather than crashing, because a kit script that
114
+ * assumes a directory exists only works on the project it was written for.
115
+ *
116
+ * { dir: 'src/content/legal', template: 'src/pages/[slug].astro', prefix: '' }
117
+ */
118
+ const COLLECTIONS = [
119
+ { dir: 'src/content/legal', template: 'src/pages/[slug].astro', prefix: '' },
120
+ ];
121
+
122
+ for (const { dir, template, prefix } of COLLECTIONS) {
123
+ if (!existsSync(dir)) continue;
124
+ for (const file of readdirSync(dir)) {
125
+ if (!file.endsWith('.md')) continue;
126
+ sources.set(`/${prefix}${file.replace(/\.md$/, '')}/`, [`${dir}/${file}`, template]);
127
+ }
128
+ }
129
+
130
+ /* Routes the sitemap never contains. Dating them is harmless but misleading. */
131
+ /* Routes the sitemap never contains — keep in step with the filter in
132
+ astro.config.mjs. Dating them is harmless but misleading. */
133
+ const EXCLUDED = new Set(['/404/', '/search/', '/thank-you/']);
134
+
135
+ const out = {};
136
+ for (const [route, files] of [...sources].sort()) {
137
+ if (EXCLUDED.has(route)) continue;
138
+ const date = newest([...files, ...(EXTRA_SOURCES[route] ?? [])]);
139
+ if (date) out[route] = date;
140
+ }
141
+
142
+ writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
143
+
144
+ const spread = Object.values(out).reduce((m, d) => m.set(d, (m.get(d) ?? 0) + 1), new Map());
145
+ console.log(`${Object.keys(out).length} routes written to ${OUT}`);
146
+ for (const [date, n] of [...spread].sort()) console.log(` ${date} ${n} page${n > 1 ? 's' : ''}`);
147
+ if (dirty.size) console.log(`\n${dirty.size} uncommitted file(s) dated today — commit the JSON with them.`);