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.
- package/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- 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(/ /g, ' ')
|
|
93
|
+
.replace(/&/g, '&')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/"/g, '"')
|
|
97
|
+
.replace(/�?39;|'|’/g, "'")
|
|
98
|
+
.replace(/“|”/g, '"')
|
|
99
|
+
.replace(/—/g, '—')
|
|
100
|
+
.replace(/–/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 = '[0m';
|
|
29
|
+
const RED = '[31m';
|
|
30
|
+
const GREEN = '[32m';
|
|
31
|
+
const YELLOW = '[33m';
|
|
32
|
+
const DIM = '[2m';
|
|
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.`);
|