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,150 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { defineConfig, sessionDrivers } from 'astro/config';
|
|
3
|
+
import cloudflare from '@astrojs/cloudflare';
|
|
4
|
+
import sitemap from '@astrojs/sitemap';
|
|
5
|
+
import { satteri } from '@astrojs/markdown-satteri';
|
|
6
|
+
import { hastMedia } from './src/lib/hast-media.mjs';
|
|
7
|
+
import { lastmodByRoute } from './src/lib/lastmod.mjs';
|
|
8
|
+
import { noindexLegalRoutes } from './src/lib/legal-routes.mjs';
|
|
9
|
+
|
|
10
|
+
// The build environment is the single source of truth for what this deploy
|
|
11
|
+
// *is*. Nothing downstream re-derives it, and nothing is toggled by hand.
|
|
12
|
+
const SITE_ENV = process.env.PUBLIC_SITE_ENV ?? 'development';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Refuse to BUILD for a deploy without saying which deploy it is.
|
|
16
|
+
*
|
|
17
|
+
* `development` resolves the site URL to http://localhost:4321, so a build with
|
|
18
|
+
* no environment emits canonical tags pointing at localhost. It builds clean,
|
|
19
|
+
* deploys clean, and is wrong.
|
|
20
|
+
*
|
|
21
|
+
* ── WHY THIS KEYS ON THE COMMAND AND NOT ON `CI` ───────────────────────────
|
|
22
|
+
* It used to be `if (process.env.CI && …)`, which left the hole wide open on
|
|
23
|
+
* the machine where it is most likely to be walked into. `npm run build` is the
|
|
24
|
+
* most reflexive command in the npm ecosystem, and here it produced a
|
|
25
|
+
* development build in silence — verified: canonical `http://localhost:4321/`,
|
|
26
|
+
* exit 0, no warning. `npx astro build` did the same. The kit's own README
|
|
27
|
+
* claimed this was already guarded.
|
|
28
|
+
*
|
|
29
|
+
* Keying on the argv command closes both, and leaves `astro dev` alone, where
|
|
30
|
+
* `development` is the correct answer rather than a missing one.
|
|
31
|
+
*/
|
|
32
|
+
const isBuilding = process.argv.some((a) => a === 'build');
|
|
33
|
+
|
|
34
|
+
if (isBuilding && SITE_ENV === 'development') {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'PUBLIC_SITE_ENV is not set.\n\n' +
|
|
37
|
+
'A build must declare its environment, or it emits localhost canonical\n' +
|
|
38
|
+
'URLs — cleanly, with no error, and wrong. Use one of:\n\n' +
|
|
39
|
+
' npm run build:staging → new.example.com, noindex\n' +
|
|
40
|
+
' npm run build:production → example.com, indexable\n\n' +
|
|
41
|
+
'On Cloudflare Workers Builds, that is the build command in the dashboard.\n' +
|
|
42
|
+
'For a one-off local build, set it inline:\n\n' +
|
|
43
|
+
' PUBLIC_SITE_ENV=staging npx astro build\n',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const SITE_URL =
|
|
47
|
+
process.env.PUBLIC_SITE_URL ??
|
|
48
|
+
(SITE_ENV === 'production'
|
|
49
|
+
? 'https://example.com'
|
|
50
|
+
: SITE_ENV === 'staging'
|
|
51
|
+
? 'https://new.example.com'
|
|
52
|
+
: 'http://localhost:4321');
|
|
53
|
+
|
|
54
|
+
const isProduction = SITE_ENV === 'production';
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Per-page `lastmod` for the sitemap, from src/data/lastmod.json.
|
|
58
|
+
*
|
|
59
|
+
* Generated by `npm run lastmod` and COMMITTED — never read from git at build
|
|
60
|
+
* time. CI shallow-clones, so `git log` there returns one grafted commit for
|
|
61
|
+
* every file and every page would claim the same date. An empty map means no
|
|
62
|
+
* `lastmod` is emitted at all, which is the correct outcome: a sitemap saying
|
|
63
|
+
* "I don't know" is honest, one claiming every page changed today is not, and
|
|
64
|
+
* Google discounts a file whose dates are not consistently accurate.
|
|
65
|
+
*/
|
|
66
|
+
const LASTMOD = lastmodByRoute();
|
|
67
|
+
|
|
68
|
+
/** Legal pages that must exist and must not be indexed. See legal-routes.mjs. */
|
|
69
|
+
const NOINDEX_LEGAL = noindexLegalRoutes();
|
|
70
|
+
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
site: SITE_URL,
|
|
73
|
+
output: 'static',
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
* This site has no sessions. Left unset, the Cloudflare adapter helpfully
|
|
77
|
+
* configures a KV session driver and writes a `SESSION` kv_namespace binding
|
|
78
|
+
* with NO id into the generated wrangler.json — which wrangler then *creates*
|
|
79
|
+
* on deploy. That works exactly once: the namespace outlives the worker, so
|
|
80
|
+
* tearing the deployment down and recreating it from git fails on a name the
|
|
81
|
+
* previous incarnation left behind. The null driver keeps the binding out.
|
|
82
|
+
*/
|
|
83
|
+
// @ts-ignore - `null` exists at runtime (verified: `'null' in sessionDrivers`)
|
|
84
|
+
// but is missing from Astro's exported driver types. Not @ts-expect-error:
|
|
85
|
+
// when upstream adds it, expect-error would start failing every build.
|
|
86
|
+
session: { driver: sessionDrivers.null() },
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* `platformProxy` is NOT set here. It was, for two projects, and the
|
|
90
|
+
* Cloudflare adapter stopped accepting it — `Options` now derives from
|
|
91
|
+
* auxiliaryWorkers | configPath | inspectorPort | persistState |
|
|
92
|
+
* remoteBindings. An unknown key in an options object is dropped in silence,
|
|
93
|
+
* so it read as configuration for a build that had never once applied it.
|
|
94
|
+
*
|
|
95
|
+
* Nothing was lost: it existed to give `astro dev` real bindings, and the
|
|
96
|
+
* answer to that is `wrangler dev`, which is what the runbook already says.
|
|
97
|
+
*/
|
|
98
|
+
adapter: cloudflare({
|
|
99
|
+
imageService: 'passthrough',
|
|
100
|
+
}),
|
|
101
|
+
trailingSlash: 'always',
|
|
102
|
+
build: { format: 'directory' },
|
|
103
|
+
prefetch: { prefetchAll: true, defaultStrategy: 'hover' },
|
|
104
|
+
integrations: [
|
|
105
|
+
// Staging must never advertise itself. No sitemap at all off production.
|
|
106
|
+
...(isProduction
|
|
107
|
+
? [
|
|
108
|
+
sitemap({
|
|
109
|
+
/*
|
|
110
|
+
* ⚠ EVERY PAGE THAT CARRIES `noindex` MUST BE EXCLUDED HERE.
|
|
111
|
+
*
|
|
112
|
+
* A URL in the sitemap is a request to index it; the same URL
|
|
113
|
+
* serving `noindex` is a refusal. Search Console reports the pair
|
|
114
|
+
* as "Submitted URL marked 'noindex'" — an ERROR, not a warning,
|
|
115
|
+
* counted against the whole submission.
|
|
116
|
+
*
|
|
117
|
+
* Seen on a live site: a /search/ page was in the sitemap and
|
|
118
|
+
* noindex from the day it was built. Nothing compares the two
|
|
119
|
+
* lists, so it stayed invisible until the sitemap was submitted.
|
|
120
|
+
*
|
|
121
|
+
* Keep this list and the pages' own `noindex` in step. The 404 is
|
|
122
|
+
* never emitted here. Add a page to BOTH or to NEITHER.
|
|
123
|
+
*/
|
|
124
|
+
filter: (page) =>
|
|
125
|
+
!page.includes('/thank-you') &&
|
|
126
|
+
!page.includes('/search') &&
|
|
127
|
+
/* Legal pages carry their own `noindex`, and unlike a rendered
|
|
128
|
+
page it is readable here — so this half derives instead of
|
|
129
|
+
being a second list to keep in step. */
|
|
130
|
+
!NOINDEX_LEGAL.some((route) => new URL(page).pathname === route),
|
|
131
|
+
serialize(item) {
|
|
132
|
+
if (item.url === `${SITE_URL}/`) item.priority = 1.0;
|
|
133
|
+
const lastmod = LASTMOD.get(new URL(item.url).pathname);
|
|
134
|
+
if (lastmod) item.lastmod = lastmod;
|
|
135
|
+
return item;
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
]
|
|
139
|
+
: []),
|
|
140
|
+
],
|
|
141
|
+
markdown: {
|
|
142
|
+
// Markdown images ship as root-relative paths the dev server can serve
|
|
143
|
+
// directly; this plugin adds srcset and intrinsic dimensions at build time.
|
|
144
|
+
processor: satteri({ hastPlugins: [hastMedia] }),
|
|
145
|
+
shikiConfig: { theme: 'github-dark' },
|
|
146
|
+
},
|
|
147
|
+
vite: {
|
|
148
|
+
build: { cssMinify: 'lightningcss' },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Analytics
|
|
2
|
+
|
|
3
|
+
What is already measured, what is genuinely missing, and the rules whose failure
|
|
4
|
+
looks like success.
|
|
5
|
+
|
|
6
|
+
All IDs live in `src/data/site.ts`. They are **the client's own or empty** — an unset ID must
|
|
7
|
+
never fall back to another project's container. Nothing is emitted off production, so staging
|
|
8
|
+
HTML contains zero references rather than a disabled snippet.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. What is already captured — do not rebuild it
|
|
13
|
+
|
|
14
|
+
GA4's **enhanced measurement** is on by default and covers more than people expect. Building
|
|
15
|
+
a custom event for any of these produces two numbers that disagree:
|
|
16
|
+
|
|
17
|
+
| Already captured | Event |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Outbound link clicks | `click` (with `outbound: true`) |
|
|
20
|
+
| Site search | `view_search_results` |
|
|
21
|
+
| Scroll depth (90%) | `scroll` |
|
|
22
|
+
| File downloads (pdf, doc, xlsx, zip…) | `file_download` |
|
|
23
|
+
| Video engagement (embedded YouTube) | `video_start`, `video_progress`, `video_complete` |
|
|
24
|
+
| Page views, including history-API routes | `page_view` |
|
|
25
|
+
|
|
26
|
+
Check Admin → Data streams → the stream → Enhanced measurement before writing any tag.
|
|
27
|
+
|
|
28
|
+
## 2. What is genuinely missing
|
|
29
|
+
|
|
30
|
+
**`tel:` and `mailto:` clicks are not captured.** They are not outbound links in GA4's sense
|
|
31
|
+
and no enhanced-measurement category covers them. For a business whose phone number sits in
|
|
32
|
+
every header and footer, that is a primary conversion going unmeasured.
|
|
33
|
+
|
|
34
|
+
If you add it, the announcer belongs next to the one in `ContactForm.astro` — one function,
|
|
35
|
+
both pipes — and the same no-double-count rule applies.
|
|
36
|
+
|
|
37
|
+
## 3. The rules whose failure looks like success
|
|
38
|
+
|
|
39
|
+
**Never add a GA4 configuration tag inside Tag Manager alongside a direct `gtag.js`.**
|
|
40
|
+
Both fire `page_view`. Sessions halve, bounce rate collapses, and the numbers look *better*,
|
|
41
|
+
which is why it survives review. `Base.astro` loads `gtag.js` directly and GTM as a
|
|
42
|
+
container — do not add a GA4 Configuration tag inside the container.
|
|
43
|
+
|
|
44
|
+
**Never send the same conversion down both pipes.** `announceLead()` in `ContactForm.astro`
|
|
45
|
+
pushes to `dataLayer` *and* calls `gtag`. If the container also has a trigger creating a GA4
|
|
46
|
+
event from `lead_submitted`, every lead counts twice.
|
|
47
|
+
|
|
48
|
+
**Never trigger a conversion on "URL contains `sent=1`".** It is the obvious implementation
|
|
49
|
+
and it is wrong: the enhanced path intercepts the submit and never changes the URL, so the
|
|
50
|
+
trigger only catches visitors without JavaScript. It fires correctly when you test by hand and
|
|
51
|
+
then under-reports for the life of the site. See the long comment in `ContactForm.astro`.
|
|
52
|
+
|
|
53
|
+
**Verify the container is the client's own.** Fetch it and read it — do not reason about it:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -s https://example.com/ | grep -o 'GTM-[A-Z0-9]*' | sort -u
|
|
57
|
+
curl -s "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX" | grep -oE 'G-[A-Z0-9]{8,}' | sort -u
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A site shipped once with a container inherited from the *previous* site's Google account:
|
|
61
|
+
invisible, un-editable and un-revokable by the client, executing on every page. It was empty.
|
|
62
|
+
Reading it is what found it.
|
|
63
|
+
|
|
64
|
+
**Confirm one pageview per visit in Realtime.** Nothing in a build catches a double-count.
|
|
65
|
+
|
|
66
|
+
## 4. Cloudflare Web Analytics
|
|
67
|
+
|
|
68
|
+
Set `CF_BEACON_TOKEN` in `src/data/site.ts`. Independent of the Google tags — set either,
|
|
69
|
+
both or neither. Worth having on every site:
|
|
70
|
+
|
|
71
|
+
- **Real-user Core Web Vitals from the first visitor.** CrUX needs months of traffic before it
|
|
72
|
+
reports on a new domain, so field data is otherwise unavailable for exactly the period after
|
|
73
|
+
launch when it matters.
|
|
74
|
+
- **A control group for ad blockers.** Cookieless and first-party, so the gap between it and
|
|
75
|
+
GA4 measures what the Google tags are losing instead of leaving you to guess.
|
|
76
|
+
|
|
77
|
+
Cookieless also means it raises no consent-banner obligation of its own — see `compliance.md`
|
|
78
|
+
before assuming the same of anything else.
|
|
79
|
+
|
|
80
|
+
## 5. Before go-live
|
|
81
|
+
|
|
82
|
+
- [ ] Both IDs are the client's own, from their own property
|
|
83
|
+
- [ ] No GA4 Configuration tag inside the GTM container
|
|
84
|
+
- [ ] No conversion trigger on `sent=1`
|
|
85
|
+
- [ ] One submission produces exactly one `generate_lead` in Realtime
|
|
86
|
+
- [ ] Staging HTML contains zero analytics references (`curl -s https://new.example.com/ | grep -c gtag` → 0)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Editing content
|
|
2
|
+
|
|
3
|
+
Everything here is a file in the repo. A bad edit **fails the build** rather than the page —
|
|
4
|
+
that is deliberate, and it means a broken page can never reach visitors.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Where things live
|
|
9
|
+
|
|
10
|
+
| Change | File |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| Phone, address, hours, service areas, credentials | `src/data/business.ts` |
|
|
13
|
+
| What you sell | `src/data/services.ts` |
|
|
14
|
+
| Service-area pages | `src/data/areas.ts` |
|
|
15
|
+
| Blog categories | `src/data/categories.ts` |
|
|
16
|
+
| A blog post | `src/content/blog/<slug>.md` |
|
|
17
|
+
| Privacy, terms, house rules | `src/content/legal/<slug>.md` |
|
|
18
|
+
| Images | `media/source/…`, then `npm run media` |
|
|
19
|
+
| Colours, fonts, spacing | `src/styles/tokens.css` |
|
|
20
|
+
|
|
21
|
+
**`business.ts` is the single source.** The header, footer, every call to action, the
|
|
22
|
+
notification emails and the structured data all read from it. Change the phone number there and
|
|
23
|
+
it changes everywhere — including what Google reads. Never type a phone number into a page.
|
|
24
|
+
|
|
25
|
+
**Legal pages write themselves into the footer.** A file at
|
|
26
|
+
`src/content/legal/privacy.md` is served at `/privacy/` and linked in the footer automatically —
|
|
27
|
+
add the file and the link appears; delete it and the link goes. There is no list to keep in
|
|
28
|
+
step, which is the point: a footer link to a page nobody wrote yet is a 404 on *every* page of
|
|
29
|
+
the site and nothing reports it.
|
|
30
|
+
|
|
31
|
+
Each one needs `title`, `description` and `effective` (the date the terms took effect, as
|
|
32
|
+
`YYYY-MM-DD`). Add `updated` only when it has genuinely changed, `navLabel` when the title is
|
|
33
|
+
too long for a footer, and `order` to move it in the row. Changing an effective date is editing
|
|
34
|
+
one line of frontmatter — no developer.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Writing a post
|
|
39
|
+
|
|
40
|
+
Create `src/content/blog/my-post.md`. The filename is the URL.
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
---
|
|
44
|
+
title: 'What a pre-purchase survey actually covers'
|
|
45
|
+
description: 'One sentence that would make sense as a search result. ~155 characters.'
|
|
46
|
+
pubDate: 2026-08-02
|
|
47
|
+
category: 'Tips'
|
|
48
|
+
image: 'blog/example-post' # a manifest key, never a URL or a path
|
|
49
|
+
imageAlt: 'A surveyor recording a reading'
|
|
50
|
+
draft: false
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
Body in markdown. Start with the answer, then explain it.
|
|
54
|
+
|
|
55
|
+
## Real headings, in order
|
|
56
|
+
|
|
57
|
+
Never skip a level to get a size — heading level is structure, size is a token. `h4` because
|
|
58
|
+
it "looked right" breaks the document outline for anyone using a screen reader.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**The frontmatter is typed and validated at build time** (`src/content.config.ts`). A missing
|
|
62
|
+
`title`, a malformed date or a category that does not exist in `categories.ts` fails the build
|
|
63
|
+
with the file and field named. That is the system working.
|
|
64
|
+
|
|
65
|
+
- `draft: true` keeps it out of production entirely
|
|
66
|
+
- `category` must match a `name` in `categories.ts` exactly
|
|
67
|
+
- `image` is a manifest key — see below
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Images
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cp ~/Desktop/new-photo.jpg media/source/blog/example-post.jpg
|
|
75
|
+
npm run media
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then reference the **key**, not a path: `image: 'blog/example-post'`.
|
|
79
|
+
|
|
80
|
+
The pipeline emits a modern format at several widths, plus a JPEG social twin for anything used
|
|
81
|
+
as an `og:image`, plus a dimensions manifest so every `<img>` carries `width` and `height` and
|
|
82
|
+
nothing shifts as the page loads.
|
|
83
|
+
|
|
84
|
+
- **Never reference an external URL.** Hotlinked stock images rot — two Pexels URLs referenced
|
|
85
|
+
by articles had already 404'd at source on the last migration
|
|
86
|
+
- **Brand assets copy byte-for-byte.** Do not run logos through the photo pipeline
|
|
87
|
+
- **Cap source images at ~2400px.** True camera originals belong outside version control
|
|
88
|
+
- **Write real alt text**, or `alt=""` if the image is decorative. An empty alt is correct far
|
|
89
|
+
more often than people expect; a filename never is
|
|
90
|
+
- **Check for duplicate covers.** Ten of seventeen posts sharing one photo reads as a broken
|
|
91
|
+
page, and nobody notices while writing
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Adding a service or a service area
|
|
96
|
+
|
|
97
|
+
Both are template + data: add an entry, the page generates.
|
|
98
|
+
|
|
99
|
+
**A service area is different.** Only add a place you can describe **distinctly** — which
|
|
100
|
+
authority issues the permit, what the housing stock is, what actually drives demand there.
|
|
101
|
+
Pages differing by nothing but the town name are doorway pages and can be penalised. A place
|
|
102
|
+
you cannot write about distinctly still belongs in `business.serviceAreas`; it just does not
|
|
103
|
+
get a page.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Accessibility, when writing
|
|
108
|
+
|
|
109
|
+
The build cannot check these. They are the ones that matter and the ones that slip.
|
|
110
|
+
|
|
111
|
+
- **Headings in order.** Structure, not size
|
|
112
|
+
- **Link text that makes sense alone.** "Read more" is meaningless in a screen reader's link
|
|
113
|
+
list; "read the compliance guide" is not
|
|
114
|
+
- **Alt text that says what the image conveys**, not what it depicts. If it conveys nothing,
|
|
115
|
+
`alt=""`
|
|
116
|
+
- **Do not describe by position or colour.** "The button on the right", "the green box"
|
|
117
|
+
- **Expand an abbreviation on first use.** Assume no prior knowledge
|
|
118
|
+
- **Update `/accessibility`** — the statement is dated, and an undated statement reads as
|
|
119
|
+
abandoned. If you know of a gap, name it there. A documented gap is worth more than a clean
|
|
120
|
+
claim
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Publishing
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git add . && git commit -m "Add post: what a pre-purchase survey covers"
|
|
128
|
+
git push
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Deployment is a push to the tracked branch. To preview first:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm run build:staging && npx wrangler dev # localhost:8788
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If the build fails, **read the error** — it names the file and the field. It has caught a real
|
|
138
|
+
mistake, not invented one.
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Handover — Business Name
|
|
2
|
+
|
|
3
|
+
⚠ **THIS IS A TEMPLATE. Fill it in and delete this block before sending.**
|
|
4
|
+
>
|
|
5
|
+
> This is the only document in the repo written for the **client**, not for whoever maintains
|
|
6
|
+
> the code. `runbook.md`, `content.md` and `traps.md` are for a developer; this one is for the
|
|
7
|
+
> person who owns the business and will be asked, in two years, who hosts their website.
|
|
8
|
+
>
|
|
9
|
+
> Render it with `npm run handover`, which writes `docs/handover.pdf` beside it. Send the PDF;
|
|
10
|
+
> keep the markdown in the repo so the next change updates the same document instead of
|
|
11
|
+
> starting a new one.
|
|
12
|
+
>
|
|
13
|
+
> Every ⚠ below marks something you must supply. A blank left in is worse than the section
|
|
14
|
+
> being absent, because it reads as a completed answer.
|
|
15
|
+
>
|
|
16
|
+
> Cross-check against `BUILD-STATE.md` before deleting that file — its **Integrations** and
|
|
17
|
+
> **Preserve** lines are the source for §3 and the "deliberately not built" list in §4.
|
|
18
|
+
|
|
19
|
+
**Prepared:** ⚠ date · **By:** ⚠ name · **Site:** ⚠ https://example.com
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. What you own, and where
|
|
24
|
+
|
|
25
|
+
You own all of it. Nothing here is held in anyone else's name, and you can move any of it
|
|
26
|
+
without our involvement.
|
|
27
|
+
|
|
28
|
+
| Thing | Where it lives | Who has admin |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Domain name | ⚠ registrar | ⚠ |
|
|
31
|
+
| Website hosting | ⚠ Cloudflare account | ⚠ |
|
|
32
|
+
| The site's code | ⚠ repository URL | ⚠ |
|
|
33
|
+
| Email sending | ⚠ provider | ⚠ |
|
|
34
|
+
| Analytics | ⚠ Google account | ⚠ |
|
|
35
|
+
|
|
36
|
+
⚠ **Attach `recon/dns.md`** if this was a migration. It is the record of what the domain
|
|
37
|
+
published before the move — the thing to compare against if mail or a verification ever stops
|
|
38
|
+
working, and the thing a future supplier will ask for first.
|
|
39
|
+
|
|
40
|
+
⚠ **Name a real person against each row, not a company.** "The agency" is not an answer when
|
|
41
|
+
the agency has moved on. If any row is owned by someone unreachable, say so here in plain
|
|
42
|
+
words — it is the single thing most likely to block an urgent change.
|
|
43
|
+
|
|
44
|
+
**Your domain is the one that matters.** As long as you control the domain registration, you
|
|
45
|
+
can move the site anywhere. Keep the registrar login somewhere you will still have it in five
|
|
46
|
+
years, and turn on auto-renew.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 2. What it costs to run
|
|
51
|
+
|
|
52
|
+
⚠ Fill in real figures. A client who does not know the running cost assumes it is zero and is
|
|
53
|
+
alarmed by the first invoice.
|
|
54
|
+
|
|
55
|
+
| Service | What it does | Cost |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| Domain renewal | Your address on the internet | ⚠ /year |
|
|
58
|
+
| Hosting | Serves every page | ⚠ /month |
|
|
59
|
+
| Email sending | Enquiry notifications | ⚠ /month |
|
|
60
|
+
| ⚠ | ⚠ | ⚠ |
|
|
61
|
+
|
|
62
|
+
**What happens if a payment fails**, in order of how bad it is:
|
|
63
|
+
|
|
64
|
+
1. **Domain lapses** — the site goes dark and email may stop. Recoverable for a short grace
|
|
65
|
+
period, then someone else can buy your address. This is the one to protect.
|
|
66
|
+
2. **Hosting lapses** — the site goes dark, but nothing is lost. Restored by paying.
|
|
67
|
+
3. **Email sending lapses** — the site keeps working and enquiries are still *saved*, but
|
|
68
|
+
nobody gets notified. See §5: this is why they are stored before the email is sent.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 3. What is connected
|
|
73
|
+
|
|
74
|
+
⚠ One row per integration, from `recon/integrations.md` and the `Integrations` lines in
|
|
75
|
+
`BUILD-STATE.md`. Include the ones you did not build, and say so.
|
|
76
|
+
|
|
77
|
+
| Connected | What it does | Whose account |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| ⚠ | ⚠ | ⚠ |
|
|
80
|
+
|
|
81
|
+
⚠ **Flag anything inherited.** A tag or booking widget set up by a previous supplier, in an
|
|
82
|
+
account nobody at the business can log into, keeps running and cannot be changed or switched
|
|
83
|
+
off. It is better to say this now than to discover it during an urgent request.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 4. What we deliberately did not build
|
|
88
|
+
|
|
89
|
+
⚠ List it. This section matters as much as everything above.
|
|
90
|
+
|
|
91
|
+
The difference between a decision and an oversight is whether it was written down. If a
|
|
92
|
+
feature was discussed and dropped — for cost, for speed, because it duplicated something you
|
|
93
|
+
already have — it belongs here with the reason.
|
|
94
|
+
|
|
95
|
+
| Not built | Why | What to do if you want it |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| ⚠ | ⚠ | ⚠ |
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 5. Your data, and how long it is kept
|
|
102
|
+
|
|
103
|
+
**Enquiries from the website are saved before the notification email is sent.** If the email
|
|
104
|
+
provider has an outage you lose a notification, never the enquiry itself.
|
|
105
|
+
|
|
106
|
+
- **Where:** stored with the hosting, in your own account
|
|
107
|
+
- **How long:** ⚠ days, then automatically deleted — see `leadRetentionDays` in
|
|
108
|
+
`src/data/site.ts`
|
|
109
|
+
- **How to get it out:** ⚠ the export URL and where the token is kept
|
|
110
|
+
|
|
111
|
+
⚠ **Keep this number and your privacy notice in step.** If the privacy notice says one
|
|
112
|
+
retention period and the site enforces another, the published one is the promise you are
|
|
113
|
+
judged against.
|
|
114
|
+
|
|
115
|
+
If someone asks you to delete their data, they are entitled to that. ⚠ Name who does it and
|
|
116
|
+
how.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 6. Making changes
|
|
121
|
+
|
|
122
|
+
| Change | Who can do it |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| ⚠ Text on an existing page | ⚠ |
|
|
125
|
+
| ⚠ Adding a blog post | ⚠ |
|
|
126
|
+
| ⚠ Prices, opening hours, phone number | ⚠ |
|
|
127
|
+
| A new page or section | A developer |
|
|
128
|
+
| Anything about how it looks | A developer |
|
|
129
|
+
|
|
130
|
+
**Opening hours, address and phone number live in one place** and update the whole site at
|
|
131
|
+
once — the header, the footer, every button, the notification emails, and what Google reads.
|
|
132
|
+
Ask for them to be changed in that one file rather than page by page, or they will drift.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 7. Accessibility
|
|
137
|
+
|
|
138
|
+
⚠ Do not describe the site as "fully accessible" or "fully compliant". Nobody can claim that
|
|
139
|
+
honestly, and the claim is what gets challenged.
|
|
140
|
+
|
|
141
|
+
- **Published statement:** ⚠ https://example.com/accessibility
|
|
142
|
+
- **Standard targeted:** WCAG 2.2 AA
|
|
143
|
+
- **Last tested:** ⚠ date · **Tested how:** ⚠ automated tool + which pages by hand
|
|
144
|
+
- **Evidence:** ⚠ attach the newest pack from `docs/a11y-evidence/`
|
|
145
|
+
- **Known gaps:** ⚠ list them, with who owns each
|
|
146
|
+
|
|
147
|
+
**This needs re-testing whenever the site's layout changes**, not on a calendar. The published
|
|
148
|
+
statement carries a date and a claim; a redesign that leaves the date alone turns it into a
|
|
149
|
+
false statement about a site that no longer exists.
|
|
150
|
+
|
|
151
|
+
⚠ Run `npm run a11y:evidence` after any layout change — it runs the automated sweep and the
|
|
152
|
+
reflow pass, writes a dated pack, and tells you when the statement's date has fallen behind.
|
|
153
|
+
Update the statement when you do.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 8. If something looks wrong
|
|
158
|
+
|
|
159
|
+
In order — the first two resolve most of it:
|
|
160
|
+
|
|
161
|
+
1. **Hard-refresh the page.** Browsers hold on to old copies. `Cmd+Shift+R` or `Ctrl+F5`.
|
|
162
|
+
2. **Try it on a phone and on another network.** If it works there, it is your connection or
|
|
163
|
+
your browser, not the site.
|
|
164
|
+
3. **Check the enquiry form** by sending yourself one. It should arrive within a minute.
|
|
165
|
+
4. ⚠ **Contact:** name, email, and what response time to expect.
|
|
166
|
+
|
|
167
|
+
⚠ Tell them what is *not* an emergency. A page that looks slightly different in one browser is
|
|
168
|
+
not the same as the site being down, and knowing the difference saves a weekend call.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 9. Current state
|
|
173
|
+
|
|
174
|
+
⚠ What is actually live, and what is outstanding, on the day you send this. Be specific and
|
|
175
|
+
dated. This section is why the document is trusted in six months.
|
|
176
|
+
|
|
177
|
+
**Live:** ⚠
|
|
178
|
+
|
|
179
|
+
**Outstanding:** ⚠ with an owner and a date against each
|
|
180
|
+
|
|
181
|
+
**Watch in the first month:** ⚠ e.g. enquiries arriving, search rankings settling after the
|
|
182
|
+
address change, the 404 log for URLs the migration missed
|
|
Binary file
|