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,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The tells of a templated site, checked mechanically.
|
|
4
|
+
*
|
|
5
|
+
* Two sections, and they answer different questions.
|
|
6
|
+
*
|
|
7
|
+
* UNDECIDED Has anyone made a design decision at all? The template ships
|
|
8
|
+
* with placeholder tokens, no typeface and a scaffold home page.
|
|
9
|
+
* All markers present = a fresh clone, fine. None present = a
|
|
10
|
+
* real project, fine. SOME present = someone set a colour and
|
|
11
|
+
* left the typeface, which is the state this exists to catch.
|
|
12
|
+
*
|
|
13
|
+
* TELLS The checkable half of references/design.md §3. Each one is a
|
|
14
|
+
* thing you can see on a page and measure in the source. Three
|
|
15
|
+
* or more and the page is not ready to show anyone.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately no browser and no dependency: it reads src/ and, if present,
|
|
18
|
+
* dist/. That keeps it runnable in CI and on a machine that has never
|
|
19
|
+
* installed Chromium. The cost is that the tells needing a rendered page —
|
|
20
|
+
* photography treatment, crop, whether the thing actually looks good — are
|
|
21
|
+
* listed at the end as the part you still have to do with your eyes.
|
|
22
|
+
*
|
|
23
|
+
* npm run tells everything, exits 1 on a failure
|
|
24
|
+
* npm run tells -- --undecided-only the gate build:production uses
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
28
|
+
import { join, extname } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const root = process.cwd();
|
|
31
|
+
const undecidedOnly = process.argv.includes('--undecided-only');
|
|
32
|
+
|
|
33
|
+
const RESET = '\x1b[0m';
|
|
34
|
+
const DIM = '\x1b[2m';
|
|
35
|
+
const RED = '\x1b[31m';
|
|
36
|
+
const YELLOW = '\x1b[33m';
|
|
37
|
+
const GREEN = '\x1b[32m';
|
|
38
|
+
|
|
39
|
+
/** Every file under `dir` whose extension is in `exts`. */
|
|
40
|
+
function walk(dir, exts, out = []) {
|
|
41
|
+
if (!existsSync(dir)) return out;
|
|
42
|
+
for (const entry of readdirSync(dir)) {
|
|
43
|
+
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
|
44
|
+
const path = join(dir, entry);
|
|
45
|
+
if (statSync(path).isDirectory()) walk(path, exts, out);
|
|
46
|
+
else if (exts.includes(extname(path))) out.push(path);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
|
52
|
+
const rel = (path) => path.replace(root + '/', '');
|
|
53
|
+
|
|
54
|
+
const styleFiles = walk(join(root, 'src/styles'), ['.css']);
|
|
55
|
+
const componentFiles = walk(join(root, 'src'), ['.astro']);
|
|
56
|
+
const distHtml = walk(join(root, 'dist'), ['.html']);
|
|
57
|
+
const distCss = walk(join(root, 'dist'), ['.css']);
|
|
58
|
+
|
|
59
|
+
const tokens = read(join(root, 'src/styles/tokens.css'));
|
|
60
|
+
const allCss = [...styleFiles, ...distCss].map(read).join('\n');
|
|
61
|
+
/** Component CSS lives inside <style> blocks in .astro files. */
|
|
62
|
+
const componentCss = componentFiles
|
|
63
|
+
.map(read)
|
|
64
|
+
.flatMap((source) => [...source.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]))
|
|
65
|
+
.join('\n');
|
|
66
|
+
const everyCss = allCss + '\n' + componentCss;
|
|
67
|
+
/**
|
|
68
|
+
* Raw component source, not just its <style> blocks. Inline `style=`
|
|
69
|
+
* attributes are exactly where a card grid gets written when someone is
|
|
70
|
+
* filling a page in quickly, so a check that only reads <style> misses the
|
|
71
|
+
* thing it exists to find.
|
|
72
|
+
*/
|
|
73
|
+
const componentSource = componentFiles.map(read).join('\n');
|
|
74
|
+
/** Pages and components only — a rhythm defined in global.css but never
|
|
75
|
+
* applied is not a rhythm the site uses. */
|
|
76
|
+
const appliedCss = componentSource + '\n' + distHtml.map(read).join('\n');
|
|
77
|
+
|
|
78
|
+
/* ── UNDECIDED ─────────────────────────────────────────────────────────── */
|
|
79
|
+
|
|
80
|
+
const markers = [
|
|
81
|
+
{
|
|
82
|
+
name: '--unset in tokens.css',
|
|
83
|
+
present: /--unset:/.test(tokens),
|
|
84
|
+
fix: 'set the brand ramp and the two faces, then delete the --unset line',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'no @font-face anywhere',
|
|
88
|
+
present: !/@font-face/.test(everyCss.replace(/\/\*[\s\S]*?\*\//g, '')),
|
|
89
|
+
fix: 'add the display and body faces to global.css and src/data/fonts.ts',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'scaffold home page',
|
|
93
|
+
present: /const unbuilt = true/.test(read(join(root, 'src/pages/index.astro'))),
|
|
94
|
+
fix: 'replace src/pages/index.astro with the real page',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const set = markers.filter((m) => m.present);
|
|
99
|
+
let failed = false;
|
|
100
|
+
|
|
101
|
+
console.log(`\n${DIM}── undecided ─────────────────────────────────────────${RESET}`);
|
|
102
|
+
if (set.length === markers.length) {
|
|
103
|
+
console.log(`${YELLOW}○${RESET} fresh template — no design decisions made yet`);
|
|
104
|
+
for (const m of markers) console.log(` ${DIM}${m.fix}${RESET}`);
|
|
105
|
+
} else if (set.length === 0) {
|
|
106
|
+
console.log(`${GREEN}✓${RESET} brand, type and home page are all real`);
|
|
107
|
+
} else {
|
|
108
|
+
failed = true;
|
|
109
|
+
console.log(`${RED}✗${RESET} half-decided — ${set.length} of ${markers.length} placeholders left:`);
|
|
110
|
+
for (const m of set) console.log(` ${RED}·${RESET} ${m.name} — ${m.fix}`);
|
|
111
|
+
console.log(
|
|
112
|
+
` ${DIM}A project with a brand colour and no typeface is the state this catches.${RESET}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (undecidedOnly) process.exit(failed ? 1 : 0);
|
|
117
|
+
|
|
118
|
+
/* ── TELLS ─────────────────────────────────────────────────────────────── */
|
|
119
|
+
|
|
120
|
+
const tells = [];
|
|
121
|
+
const tell = (name, hit, detail) => tells.push({ name, hit, detail });
|
|
122
|
+
|
|
123
|
+
// design.md §3 — "body text runs the full container width"
|
|
124
|
+
tell(
|
|
125
|
+
'body text runs the full container width',
|
|
126
|
+
!/var\(--measure(-narrow)?\)/.test(componentCss + allCss.replace(tokens, '')),
|
|
127
|
+
'--measure is never applied. Long-form text at container width reads as a document.',
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// "section padding is the same everywhere"
|
|
131
|
+
{
|
|
132
|
+
const rhythms = new Set();
|
|
133
|
+
if (/\bsection--tight\b|--section-y-tight\b/.test(appliedCss)) rhythms.add('tight');
|
|
134
|
+
if (/\bsection--loose\b|--section-y-loose\b/.test(appliedCss)) rhythms.add('loose');
|
|
135
|
+
// `section` as a standalone class token, not `section--tight`.
|
|
136
|
+
if (/\bsection\b(?!--)/.test(appliedCss.replace(/<\/?section\b/g, ''))) rhythms.add('base');
|
|
137
|
+
tell(
|
|
138
|
+
'every section shares one padding',
|
|
139
|
+
rhythms.size <= 1,
|
|
140
|
+
`${rhythms.size} of 3 section rhythms applied. Varying them is what gives a page a structure the eye can navigate; identical bands do not.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// "three equal cards, centred, more than twice on one page"
|
|
145
|
+
{
|
|
146
|
+
const grids = [
|
|
147
|
+
...(everyCss + componentSource).matchAll(/repeat\(\s*auto-(fill|fit)\s*,\s*minmax/g),
|
|
148
|
+
].length;
|
|
149
|
+
tell(
|
|
150
|
+
'the auto-fill card grid, more than twice',
|
|
151
|
+
grids > 2,
|
|
152
|
+
`${grids} auto-fill/auto-fit minmax grids. The full-width band → centred heading → three equal cards loop is the templated look.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// "the display and body faces are both sans-serif" — and the weaker case,
|
|
157
|
+
// both being literally the same stack.
|
|
158
|
+
{
|
|
159
|
+
const display = (tokens.match(/--font-display:\s*([^;]+);/) ?? [])[1] ?? '';
|
|
160
|
+
const body = (tokens.match(/--font-body:\s*([^;]+);/) ?? [])[1] ?? '';
|
|
161
|
+
tell(
|
|
162
|
+
'display and body are the same face',
|
|
163
|
+
display.trim() === body.trim(),
|
|
164
|
+
'Two families, display and body. Weights do not carry hierarchy on their own.',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// "the hero headline is over ~72px at desktop"
|
|
169
|
+
{
|
|
170
|
+
const top = (tokens.match(/--step-6:[^;]*?,\s*([\d.]+)rem\s*\)/) ?? [])[1];
|
|
171
|
+
const px = top ? Number(top) * 16 : 0;
|
|
172
|
+
tell(
|
|
173
|
+
'hero headline over ~72px at desktop',
|
|
174
|
+
px > 72,
|
|
175
|
+
`--step-6 tops out at ${Math.round(px)}px. Past ~72 it reads as a magazine cover rather than a business people trust with money.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// "headline tracking is the same as body tracking"
|
|
180
|
+
tell(
|
|
181
|
+
'headlines are not tracked in',
|
|
182
|
+
!/letter-spacing:\s*var\(--tracking-tight/.test(everyCss),
|
|
183
|
+
'Display type set at body tracking is one line of CSS away from being right.',
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// "any animation runs longer than ~400ms"
|
|
187
|
+
{
|
|
188
|
+
const slow = [...everyCss.matchAll(/(?:transition|animation)(?:-duration)?:[^;]*?(\d{3,4})ms/g)]
|
|
189
|
+
.map((m) => Number(m[1]))
|
|
190
|
+
.filter((ms) => ms > 400);
|
|
191
|
+
tell(
|
|
192
|
+
'motion longer than 400ms',
|
|
193
|
+
slow.length > 0,
|
|
194
|
+
`${slow.length} declaration(s) over 400ms (longest ${Math.max(0, ...slow)}ms). A visitor made to wait for decoration is the most common way a good site starts feeling cheap.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// "focus rings are the browser default, or removed"
|
|
199
|
+
{
|
|
200
|
+
const stripped = [...everyCss.matchAll(/outline:\s*(none|0)\b/g)].length;
|
|
201
|
+
const restored = /:focus-visible[^{]*\{[^}]*outline:/.test(everyCss);
|
|
202
|
+
tell(
|
|
203
|
+
'focus ring removed and not replaced',
|
|
204
|
+
stripped > 0 && !restored,
|
|
205
|
+
'outline:none with no :focus-visible replacement strands every keyboard user.',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Token discipline — a raw hex in a component is how a mid-project pivot
|
|
210
|
+
// becomes a rewrite instead of an afternoon.
|
|
211
|
+
{
|
|
212
|
+
const offenders = componentFiles
|
|
213
|
+
.map((path) => {
|
|
214
|
+
const css = [...read(path).matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)]
|
|
215
|
+
.map((m) => m[1])
|
|
216
|
+
.join('\n');
|
|
217
|
+
const hexes = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].length;
|
|
218
|
+
return hexes ? `${rel(path)} (${hexes})` : null;
|
|
219
|
+
})
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
tell(
|
|
222
|
+
'raw hex colours inside components',
|
|
223
|
+
offenders.length > 0,
|
|
224
|
+
`${offenders.join(', ')}. Tokens before components — no hard-coded hex or px in a component, ever.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// "the 404, the empty state or the form's invalid state was never designed"
|
|
229
|
+
tell(
|
|
230
|
+
'no invalid / busy form state',
|
|
231
|
+
!(/\[data-invalid\]/.test(everyCss) && /\[data-busy\]|:disabled/.test(everyCss)),
|
|
232
|
+
'Form states — hover, focus, invalid, disabled, submitting, succeeded — are where "considered" comes from.',
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const hits = tells.filter((t) => t.hit);
|
|
236
|
+
|
|
237
|
+
console.log(`\n${DIM}── tells (design.md §3) ──────────────────────────────${RESET}`);
|
|
238
|
+
for (const t of tells) {
|
|
239
|
+
if (t.hit) console.log(`${RED}✗${RESET} ${t.name}\n ${DIM}${t.detail}${RESET}`);
|
|
240
|
+
else console.log(`${GREEN}✓${RESET} ${DIM}${t.name}${RESET}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!distHtml.length) {
|
|
244
|
+
console.log(
|
|
245
|
+
`\n${DIM}dist/ not found — ran against src/ only. Build first to include the emitted CSS.${RESET}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(`\n${DIM}── still needs your eyes ────────────────────────────${RESET}`);
|
|
250
|
+
for (const line of [
|
|
251
|
+
'Photography: one consistent colour treatment, and not visibly free stock',
|
|
252
|
+
'Layout: something breaking the container; a 7/5 split rather than 6/6',
|
|
253
|
+
'Copy density: short and confident, not a business explaining itself',
|
|
254
|
+
'Then look at it on a phone, and beside the three reference sites',
|
|
255
|
+
]) {
|
|
256
|
+
console.log(`${DIM}·${RESET} ${line}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (hits.length >= 3) {
|
|
260
|
+
failed = true;
|
|
261
|
+
console.log(`\n${RED}${hits.length} tells — design.md §3 says three or more is not ready.${RESET}`);
|
|
262
|
+
} else if (hits.length) {
|
|
263
|
+
console.log(`\n${YELLOW}${hits.length} tell(s). Under the line, but each one is cheap to fix.${RESET}`);
|
|
264
|
+
} else {
|
|
265
|
+
console.log(`\n${GREEN}No mechanical tells.${RESET}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
process.exit(failed ? 1 : 0);
|