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
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # create-website-build-kit
2
+
3
+ Scaffold a production marketing site: **Astro, static, on Cloudflare Workers** — with the
4
+ gates, the migration playbook and the accessibility work already wired.
5
+
6
+ ```bash
7
+ npm create website-build-kit@latest my-site
8
+ ```
9
+
10
+ Node 22.12+. It builds green immediately, with no content, no images and no secrets.
11
+
12
+ ## What you get
13
+
14
+ A skeleton, not a theme. It ships **no palette, no typeface and no home page** — deliberately,
15
+ so that two sites built from it cannot look alike. What it does ship is the part that takes
16
+ longest to get right and is easiest to get subtly wrong:
17
+
18
+ - **A form endpoint** with server-side validation, a honeypot, and the lead written to
19
+ durable storage **before** the email provider is called — so a provider outage costs a
20
+ notification, never a lead. It works with JavaScript off.
21
+ - **Environment derived from one build variable** — indexability, analytics, canonical host,
22
+ which store leads land in, who gets notified. Nothing toggled by hand at go-live, and a bare
23
+ build with no environment set is refused rather than quietly publishing `localhost`
24
+ canonicals.
25
+ - **A media pipeline** — AVIF and WebP per width, a dimensions manifest so nothing shifts,
26
+ favicons rendered from the vector.
27
+ - **Verification against the deployed site**, as a gate that exits non-zero: routes, a real
28
+ 404, every redirect rule *and whether its target resolves*, security headers, the meta
29
+ sweep, page weight, and the form submissions the API is supposed to refuse.
30
+ - **WCAG 2.2 AA as a build constraint** — a published accessibility statement, `pa11y-ci`
31
+ wired up, a reflow check at 320px and 200% text, and a dated evidence pack.
32
+ - **A migration path**: inventory the old site, extract its content to markdown, propose a
33
+ redirect map, and fail the build if a URL the old site served no longer resolves.
34
+
35
+ ## Migrating off WordPress?
36
+
37
+ ```bash
38
+ npm run recon -- https://old-site.com # URLs, preserved paths, integrations, DNS
39
+ npm run extract # captured HTML → clean markdown
40
+ npm run redirects # proposes a map; never writes it for you
41
+ ```
42
+
43
+ Run recon **before** you design routes. For any page builder — Elementor, Divi, WPBakery,
44
+ Bricks — the extractable copy is the rendered HTML, never the database.
45
+
46
+ ## The method
47
+
48
+ The template is half of it. The other half is a Claude Code skill and the written method
49
+ behind it, including a file of failures that were **green in a build**: clean build, clean
50
+ types, clean deploy, wrong result.
51
+
52
+ **https://github.com/nurkamol/website-build-kit**
53
+
54
+ MIT.
package/index.mjs ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm create website-build-kit@latest my-site
4
+ *
5
+ * Scaffolds the Astro + Cloudflare Workers template, then gets out of the way.
6
+ *
7
+ * ── WHAT IT REFUSES TO DO ──────────────────────────────────────────────────
8
+ * It will not write into a directory that already has files, and it will not
9
+ * proceed on a Node it knows Astro rejects. Both are failures that otherwise
10
+ * surface much later as something that reads like a different problem: Astro on
11
+ * Node 20 dies with a version notice buried in a build log, and scaffolding
12
+ * over an existing project is unrecoverable without git.
13
+ *
14
+ * No dependencies, deliberately. `npm create` downloads this before it can do
15
+ * anything, so every dependency here is latency on the first command a new user
16
+ * ever runs.
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { createInterface } from 'node:readline/promises';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const RESET = '\x1b[0m';
26
+ const RED = '\x1b[31m';
27
+ const GREEN = '\x1b[32m';
28
+ const YELLOW = '\x1b[33m';
29
+ const DIM = '\x1b[2m';
30
+ const BOLD = '\x1b[1m';
31
+
32
+ const argv = process.argv.slice(2);
33
+ const flag = (name) => argv.includes(`--${name}`);
34
+ const target = argv.find((a) => !a.startsWith('-'));
35
+
36
+ if (flag('help') || flag('h')) {
37
+ console.log(`
38
+ ${BOLD}create-website-build-kit${RESET}
39
+
40
+ npm create website-build-kit@latest ${DIM}<directory>${RESET}
41
+
42
+ ${DIM}--no-install${RESET} skip npm install
43
+ ${DIM}--no-git${RESET} skip git init
44
+ ${DIM}--force${RESET} write into a non-empty directory
45
+ `);
46
+ process.exit(0);
47
+ }
48
+
49
+ /*
50
+ * Astro's floor is 22.12. On anything older the build dies with a version
51
+ * notice inside a wall of build output, which is a confusing way to learn it —
52
+ * so it is checked before a single file is written.
53
+ */
54
+ const MIN = [22, 12];
55
+ const current = process.versions.node.split('.').map(Number);
56
+ if (current[0] < MIN[0] || (current[0] === MIN[0] && current[1] < MIN[1])) {
57
+ console.error(
58
+ `\n${RED}✗${RESET} Node ${process.versions.node} is too old — Astro needs ${MIN.join('.')}+.\n` +
59
+ ` ${DIM}nvm install 24 && nvm use 24${RESET}\n`,
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ console.log(`\n${BOLD}Website Build Kit${RESET}${DIM} — Astro on Cloudflare Workers${RESET}\n`);
65
+
66
+ let dir = target;
67
+ if (!dir) {
68
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
69
+ dir = (await rl.question(` Directory ${DIM}(my-site)${RESET}: `)).trim() || 'my-site';
70
+ rl.close();
71
+ }
72
+
73
+ const dest = resolve(process.cwd(), dir);
74
+ const name = dest.split('/').pop();
75
+
76
+ if (existsSync(dest) && readdirSync(dest).length && !flag('force')) {
77
+ console.error(
78
+ `\n${RED}✗${RESET} ${dir} is not empty.\n` +
79
+ ` ${DIM}Scaffolding over an existing project cannot be undone without git.\n` +
80
+ ` Pick another directory, or pass --force if you are sure.${RESET}\n`,
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ const src = join(dirname(fileURLToPath(import.meta.url)), 'template');
86
+ if (!existsSync(src)) {
87
+ console.error(`\n${RED}✗${RESET} the template is missing from this package — please report it.\n`);
88
+ process.exit(1);
89
+ }
90
+
91
+ mkdirSync(dest, { recursive: true });
92
+ cpSync(src, dest, { recursive: true });
93
+
94
+ /*
95
+ * npm strips .gitignore from published packages, so it ships as `gitignore`.
96
+ * Restoring it is not cosmetic: it is what keeps .dev.vars — which holds
97
+ * BREVO_API_KEY and the leads export token — out of the repository.
98
+ */
99
+ const shipped = join(dest, 'gitignore');
100
+ if (existsSync(shipped)) renameSync(shipped, join(dest, '.gitignore'));
101
+
102
+ if (!existsSync(join(dest, '.gitignore'))) {
103
+ console.error(`\n${RED}✗${RESET} no .gitignore was written. Stopping — .dev.vars would be committable.\n`);
104
+ process.exit(1);
105
+ }
106
+
107
+ /* The site's own name, so package.json is not "site-name" forever. */
108
+ const pkgPath = join(dest, 'package.json');
109
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
110
+ pkg.name = name.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'site';
111
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
112
+
113
+ console.log(` ${GREEN}✓${RESET} template → ${dir}/`);
114
+
115
+ const run = (cmd, args, label) => {
116
+ const r = spawnSync(cmd, args, { cwd: dest, stdio: 'ignore', shell: process.platform === 'win32' });
117
+ console.log(r.status === 0 ? ` ${GREEN}✓${RESET} ${label}` : ` ${YELLOW}!${RESET} ${label} — skipped`);
118
+ return r.status === 0;
119
+ };
120
+
121
+ if (!flag('no-git')) {
122
+ if (run('git', ['init', '-q'], 'git initialised')) {
123
+ run('git', ['add', '-A'], 'files staged');
124
+ }
125
+ }
126
+
127
+ let installed = false;
128
+ if (!flag('no-install')) {
129
+ console.log(` ${DIM}installing…${RESET}`);
130
+ installed = run('npm', ['install', '--silent'], 'dependencies installed');
131
+ }
132
+
133
+ console.log(`
134
+ ${BOLD}Next${RESET}
135
+
136
+ ${DIM}cd${RESET} ${dir}${installed ? '' : `\n ${DIM}npm install${RESET}`}
137
+
138
+ ${BOLD}1.${RESET} Fill in ${BOLD}src/data/business.ts${RESET} — everything reads from it, and the
139
+ defaults are deliberately neutral rather than correct.
140
+ ${BOLD}2.${RESET} ${DIM}npm run tells${RESET} — says what is still undecided. ${DIM}build:production${RESET}
141
+ refuses until the palette, the two typefaces and the home page are yours.
142
+ ${BOLD}3.${RESET} Migrating? ${DIM}npm run recon -- https://old-site.com${RESET} first, before you
143
+ design routes. Then ${DIM}npm run extract${RESET}.
144
+
145
+ ${DIM}docs/runbook.md §1 is the fill-in order. It builds green right now:${RESET}
146
+ ${DIM}npm run build:staging${RESET}
147
+
148
+ ${DIM}The method, and the traps: https://github.com/nurkamol/website-build-kit${RESET}
149
+ `);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "create-website-build-kit",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
5
+ "keywords": [
6
+ "astro",
7
+ "cloudflare-workers",
8
+ "marketing-site",
9
+ "starter",
10
+ "template",
11
+ "wordpress-migration",
12
+ "scaffold",
13
+ "create",
14
+ "accessibility",
15
+ "wcag",
16
+ "seo"
17
+ ],
18
+ "homepage": "https://github.com/nurkamol/website-build-kit#readme",
19
+ "bugs": "https://github.com/nurkamol/website-build-kit/issues",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/nurkamol/website-build-kit.git",
23
+ "directory": "create"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Nurkamol Vakhidov",
27
+ "type": "module",
28
+ "bin": {
29
+ "create-website-build-kit": "index.mjs"
30
+ },
31
+ "files": [
32
+ "index.mjs",
33
+ "template"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22.12.0"
37
+ },
38
+ "scripts": {
39
+ "prepack": "node prepack.mjs",
40
+ "postpack": "node -e \"require('fs').rmSync('template',{recursive:true,force:true})\""
41
+ }
42
+ }
@@ -0,0 +1,3 @@
1
+ # Copy to .dev.vars for local `wrangler dev`. Never commit the real values.
2
+ BREVO_API_KEY="xkeysib-..."
3
+ LEADS_EXPORT_TOKEN="a long random string"
@@ -0,0 +1,58 @@
1
+ # Runs the gates that do not need a deployed site.
2
+ #
3
+ # This does NOT deploy. Cloudflare Workers Builds does that, running
4
+ # `npm run build:production`, which carries its own gates — tells, astro check,
5
+ # check-env and check-sitemap. This workflow exists so those same failures show
6
+ # up on a pull request instead of at deploy time, and so `build:staging` is
7
+ # proven from a clean checkout on every push.
8
+ #
9
+ # The gates that need a live URL — npm run verify, a11y, reflow — are not here.
10
+ # They run against a deployed site, which this workflow does not have. See
11
+ # docs/runbook.md §2.
12
+
13
+ name: Gates
14
+
15
+ on:
16
+ push:
17
+ branches: [main, master]
18
+ pull_request:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ gates:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ # node-version-file, not a literal. A version pinned in two places drifts,
30
+ # and the failure is a build that works locally and not in CI.
31
+ - uses: actions/setup-node@v4
32
+ with:
33
+ node-version-file: .node-version
34
+ cache: npm
35
+
36
+ # `npm ci` not `npm install` — it installs exactly the lockfile and fails
37
+ # if package.json and the lock disagree, which is the point of running it
38
+ # here rather than trusting a local install.
39
+ - run: npm ci
40
+
41
+ # The kit's promise: green from a clean checkout, with no content, no
42
+ # images and no secrets. CI=true so Astro reports as a CI environment.
43
+ - name: Build (staging)
44
+ run: CI=true npm run build:staging
45
+
46
+ - name: Types
47
+ run: PUBLIC_SITE_ENV=staging npm run check
48
+
49
+ # Reports what is undecided and counts the design tells. Not a hard gate
50
+ # on a fresh template by design — `--undecided-only` blocks the
51
+ # HALF-decided state, which is the one that looks finished and is not.
52
+ - name: Design tells
53
+ run: npm run tells
54
+
55
+ # A no-op on staging, which emits no sitemap. Here so the check runs on
56
+ # every push rather than only inside build:production.
57
+ - name: Sitemap and noindex agree
58
+ run: npm run check:sitemap
@@ -0,0 +1 @@
1
+ 24.2.0
@@ -0,0 +1,24 @@
1
+ {
2
+ "defaults": {
3
+ "standard": "WCAG2AA",
4
+ "runners": ["axe", "htmlcs"],
5
+ "timeout": 30000,
6
+ "concurrency": 4,
7
+ "hideElements": "iframe[src*='google.com/maps'], iframe[src*='youtube.com']",
8
+ "chromeLaunchConfig": { "args": ["--no-sandbox"] }
9
+ },
10
+ "//": [
11
+ "One URL per template family, not every page — a homepage-only pass misses",
12
+ "everything the blog does differently. Point these at `wrangler dev`",
13
+ "(localhost:8788) while building, or at the deployed staging host.",
14
+ "Staging emits no sitemap on purpose, which is why these are explicit.",
15
+ "Against production you can instead run:",
16
+ " npx pa11y-ci --sitemap https://example.com/sitemap-index.xml --standard WCAG2AA"
17
+ ],
18
+ "urls": [
19
+ "http://localhost:8788/",
20
+ "http://localhost:8788/contact/",
21
+ "http://localhost:8788/accessibility/",
22
+ "http://localhost:8788/404"
23
+ ]
24
+ }
@@ -0,0 +1,47 @@
1
+ # Build state
2
+
3
+ ⚠ **Written at every gate, not continuously.** A file edited every few minutes is noise and
4
+ nobody reads it. Update it when a phase gate passes — `build.md` §3.
5
+
6
+ **Delete this file at handover**, folding what survived into `docs/handover.md`. It is a
7
+ working file, not a deliverable: its **Integrations** and **Preserve** lines are the source for
8
+ `handover.md` §3 and the "deliberately not built" list in `handover.md` §4.
9
+
10
+ ```
11
+ ## Phase 0 · Recon [in progress]
12
+
13
+ Gates passed —
14
+ Locked —
15
+ Archetype ⚠ from the win, not the industry — archetypes.md
16
+ Open ! ⚠ blocking items go here, with a date
17
+ ? ⚠ open questions
18
+ Next ⚠ one line
19
+ ```
20
+
21
+ ## Integrations
22
+
23
+ ⚠ One line each, from `recon/integrations.md`. **Every line ends in a verified state or an
24
+ explicit dated drop. No third state** — "integrations" as one task is the item that silently
25
+ ships at 80%, and a missing conversion tag is invisible for a month.
26
+
27
+ ```
28
+ - [ ] ⚠ GA4 · G-XXXXXXXXXX · not ported · ⚠ confirm the client owns this account
29
+ - [ ] ⚠ vendor · detected in markup · not ported · ⚠ owner unknown
30
+ ```
31
+
32
+ ## Preserve
33
+
34
+ ⚠ Paths other systems point at, from `recon/preserved.md`. Losing one is silent — see
35
+ `stacks.md` §1d.
36
+
37
+ ```
38
+ - [ ] ⚠ /sitemap_index.xml → emitted at the old path, or 301 to the new one
39
+ - [ ] ⚠ verification file or DNS TXT carried over
40
+ - [ ] ⚠ /feed/ → the new feed path, never dropped
41
+ ```
42
+
43
+ ## Decisions locked
44
+
45
+ ⚠ `Locked` means settled — do not reopen without saying so. This is the row that stops a pivot
46
+ quietly becoming a rewrite. Stack, providers, archetype, and the design direction chosen from a
47
+ real comp on staging (`design.md` §1).
@@ -0,0 +1,153 @@
1
+ # Working on this site
2
+
3
+ Astro (static) on Cloudflare Workers. Built from
4
+ [website-build-kit](https://github.com/nurkamol/website-build-kit).
5
+
6
+ ## Commands
7
+
8
+ ```bash
9
+ npm run dev # localhost:4321 — no bindings, /api/* will not work
10
+ npm run media # after adding or replacing any image
11
+ npm run build:staging && npx wrangler dev # localhost:8788, real KV + secrets
12
+ npm run a11y # accessibility check, one URL per family
13
+ npm run tells # what is undecided, and the design tells
14
+ npm run recon -- https://old-site.com # inventory the old site BEFORE designing routes
15
+ npm run dns -- old-site.com # capture the zone. MX loss kills client email
16
+ npm run seo -- https://old-site.com # optional: SEO baseline to diff after cutover
17
+ npm run verify -- https://new.example.com # the deployed site, not the build. exits non-zero
18
+ npm run console # console errors + failed requests, in a real browser
19
+ npm run shots -- --before https://old-site.com # visual record, WHILE the old site is still up
20
+ npm run extract # captured HTML → recon/extracted/*.md
21
+ npm run deploy:staging # or deploy:production
22
+ ```
23
+
24
+ Node **22.12+**. `.node-version` pins the version this was built against.
25
+
26
+ **Use `wrangler dev` for anything touching `/api/*`, redirects, or 404 status codes.** The
27
+ Astro dev server models none of them, and they are exactly where things break.
28
+
29
+ ## Rules for this codebase
30
+
31
+ **`src/data/business.ts` is the only place business facts live.** Name, phone, address,
32
+ hours, service areas, credentials — plus `locale`, `timeZone` and `schemaTypes`, whose
33
+ defaults are deliberately neutral (`en`, `UTC`, `LocalBusiness`) rather than correct. Neutral
34
+ is a prompt to set them; a plausible wrong value is not. The header, footer, every call-to-action, the notification
35
+ emails and the JSON-LD all read from it. Never hard-code a phone number in a component.
36
+
37
+ **`src/styles/tokens.css` is the only place a raw colour, size, radius or duration is
38
+ written.** Components reference tokens and nothing else. If you are typing a hex or a px in a
39
+ component, add a token instead.
40
+
41
+ **This template has no design, and adding one to it is not the job.** It ships a grey
42
+ placeholder ramp, the system stack for both faces and a scaffold home page — deliberately, so
43
+ that two sites built from it cannot look alike. Decide the palette, the two typefaces and the
44
+ page shapes *for this project*, in this project. Do not add a card style, a hero treatment or
45
+ a component library to the shared layer; `global.css` carries the interactive **states**, not
46
+ a look. Run `npm run tells` before showing anyone a page.
47
+
48
+ **Legal pages are markdown, never `.astro` files.** `src/content/legal/<slug>.md` is served at
49
+ `/<slug>/` by one route and linked in the footer from the collection itself, so the link cannot
50
+ exist without the page. Do not write `privacy.astro`. The root-level `[slug].astro` refuses to
51
+ build if a legal slug shadows a real page — Astro would give the page precedence and the legal
52
+ one would be built, linked and unreachable.
53
+
54
+ **`src/data/nav.ts` is the only place routes and the primary call to action live.** The
55
+ header, the mobile menu, the footer columns and the 404's onward links all read it, so the
56
+ 404 can never offer a page that no longer exists.
57
+
58
+ **Every non-production build shows a badge**, driven by `site.indexable` — it cannot be left
59
+ on in production and cannot be turned on by hand. It reads the live DOM rather than printing
60
+ the build variable, so it alarms when the page disagrees with the environment (`NOT NOINDEX`,
61
+ `ANALYTICS LIVE`). `?nobadge=1` hides it for a session; never make that persistent.
62
+
63
+ **`src/data/site.ts` is the only environment switch.** `PUBLIC_SITE_ENV` derives
64
+ indexability, analytics, canonical host, which KV namespace leads land in, and who gets
65
+ notified. Do not add a second flag — extend this one.
66
+
67
+ **Images are AVIF + WebP.** `optimize-media.mjs` emits both per width and `<Img />` renders a
68
+ `<picture>`; AVIF is 26% smaller at better quality (measured — see `stacks.md` §3). WebP is the
69
+ fallback and is never dropped. Turning it off is `FORMATS = ['webp']` and nothing else. Note
70
+ that a `.parent > img` selector stops matching once there is a `<picture>` — see `docs/traps.md`.
71
+
72
+ **Images go through the manifest.** Put the original in `media/source/`, run `npm run media`,
73
+ reference it by key (`photos/hero-home`) via `<Img />`. Never an external URL, never a raw
74
+ path. This is what keeps `width`/`height` on every image so nothing shifts.
75
+
76
+ **Forms write to storage before calling anyone.** In `src/pages/api/contact.ts` the KV write
77
+ happens *before* the email provider. A provider outage should cost a notification, not a
78
+ lead. Keep that ordering. The cross-origin refusal runs *before* validation, for the same
79
+ reason: ordered after, it only ever fires on submissions that were being rejected anyway.
80
+
81
+ **Stored leads expire.** `site.leadRetentionDays` becomes a KV `expirationTtl`, because KV
82
+ keeps a value forever otherwise and "indefinitely" is not a retention period any regulator
83
+ accepts. Keep the number and the privacy notice in step, and keep personal data out of KV
84
+ metadata — `list()` returns metadata without reading values.
85
+
86
+ **Everything works without JavaScript.** The page renders, the form submits, stores and
87
+ notifies. Anything that hides an element must be the same thing that reveals it — CSS that JS
88
+ is expected to undo shows a blank page whenever a bundle fails.
89
+
90
+ **Analytics IDs are the client's own, or empty.** `src/data/site.ts` emits no tag unless both
91
+ are set, so an unset ID cannot fall back to another project's container. Never copy one in.
92
+ The rules whose failure looks like success — double-counted pageviews, a conversion sent down
93
+ two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
94
+ Read it before adding any tag.
95
+
96
+ **Evidence, not assertions.** `npm run a11y:evidence` writes a dated pack to
97
+ `docs/a11y-evidence/` — commit it. It fills in the machine half and leaves the keyboard,
98
+ screen-reader and forms passes **blank**, because no tool does those; a pack with them still
99
+ blank is an incomplete pack, not a passing one. It also warns when `/accessibility` claims a
100
+ review date older than the run.
101
+
102
+ **`/accessibility` is a published artefact, not filler.** Required under the EAA and PSBAR,
103
+ footer-linked from every page. Keep its dates current and its known-gaps list honest — a
104
+ documented gap is worth more than a clean claim. Never write "fully compliant".
105
+
106
+ **Astro scoped styles do not reach a class passed *into* a component.** If you write
107
+ `<Icon class="thing" />` and then `.thing { … }` in the same file, it will not match. Use
108
+ `:global()`. This has caused real bugs; see `docs/traps.md`.
109
+
110
+ ## Before debugging anything strange
111
+
112
+ Read **`docs/traps.md`**. Every entry failed silently — clean build, clean deploy, wrong
113
+ result. The recurring ones:
114
+
115
+ - Scoped styles not reaching a class passed into a component
116
+ - Handlers bound behind a `transition:persist` guard holding stale, replaced elements
117
+ - `justify-content: center` making overflowing content unreachable — use `margin: auto`
118
+ - `100vh` being taller than the visible area on mobile — use `100dvh`
119
+ - DNS negative caching: if `dig` and `getaddrinfo` disagree, it is your cache
120
+
121
+ ## Verifying
122
+
123
+ Against the **deployed** site, not the build. A green build proves the bundler ran.
124
+
125
+ The checklist is in `docs/runbook.md`: every route 200, unknown paths a real 404, legacy URLs
126
+ 301 to their specific equivalent, preserved paths still resolving, staging noindex with zero
127
+ analytics references, form valid/empty/honeypot/cross-origin, and a submission producing both a
128
+ stored record and a delivered email. It also carries the **go-live order** and the first-week
129
+ watch list.
130
+
131
+ ## Handover
132
+
133
+ `docs/handover.md` is the only doc written for the **client**, not a developer. Fill in every
134
+ ⚠ and render with `npm run handover`. Its "what we deliberately did not build" section is not
135
+ optional — the difference between a decision and an oversight is whether it was written down.
136
+
137
+ ## Content
138
+
139
+ Editing guide in `docs/content.md`. In short: posts are markdown in `src/content/blog/` with
140
+ typed frontmatter — a bad edit fails the build rather than the page.
141
+
142
+ ## Do not
143
+
144
+ - Hard-code a colour, size, phone number or business fact in a component — including inside a
145
+ JavaScript error string, which is where one hides longest
146
+ - Ship with `--unset` still in `tokens.css`, or the scaffold home page still in place
147
+ - Add a card, hero or section style to `global.css` — it belongs to this project, not the kit
148
+ - Add a second environment flag
149
+ - Reference an image by URL instead of manifest key
150
+ - Call a third-party API before writing the submission down
151
+ - Describe unverified work as done — check it against the deployed site
152
+ - Copy an analytics ID from another project
153
+ - Claim the site is "fully accessible" or "fully compliant"