create-website-build-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,128 @@
1
+ ---
2
+ /**
3
+ * Accessibility statement.
4
+ *
5
+ * A **required published artefact** under the European Accessibility Act, the
6
+ * UK PSBAR and the EU Web Accessibility Directive. Not required in the US, and
7
+ * still worth publishing everywhere: it is dated evidence of a deliberate
8
+ * process, which is exactly what a demand letter is fishing for the absence of.
9
+ *
10
+ * ── FILL IN AND KEEP CURRENT. ──────────────────────────────────────────────
11
+ * - `reviewed` must be a real date. An undated statement reads as abandoned.
12
+ * - `knownGaps` must be honest. Naming a real gap is worth more than a clean
13
+ * claim — it is the difference between a documented backlog and an
14
+ * undiscovered failure. If the list is genuinely empty, say so explicitly.
15
+ * - Never write "fully accessible" or "fully compliant". Conformance is
16
+ * per-page, per-state, at a point in time, and one content edit can break it.
17
+ * - `enforcement` is required in EU/UK statements. Delete the block elsewhere.
18
+ */
19
+ import Base from '../layouts/Base.astro';
20
+ import { business } from '../data/business';
21
+
22
+ const TARGET = 'WCAG 2.2 Level AA';
23
+ const prepared = '2 August 2026';
24
+ const reviewed = '2 August 2026';
25
+ const responseDays = 5;
26
+
27
+ /** Be specific. "Some images" is not a gap, it is a shrug. */
28
+ const knownGaps: string[] = [
29
+ 'Third-party embeds (map, booking widget) are outside our control and may not meet the target.',
30
+ ];
31
+
32
+ /** Delete this block outside the EU and UK. */
33
+ /*
34
+ * `as` rather than a plain annotation: TypeScript narrows a `const` to its
35
+ * initialiser, so `const x: T | null = null` is exactly `null` forever and the
36
+ * `enforcement &&` block below becomes unreachable `never` — the type error
37
+ * arrives at the fill-in, not at the empty default.
38
+ */
39
+ type Enforcement = { body: string; url: string } | null;
40
+ const enforcement = null as Enforcement;
41
+ ---
42
+
43
+ <Base
44
+ title={`Accessibility - ${business.name}`}
45
+ description={`Our accessibility statement: the standard we build to, how we test, what we know is not yet right, and how to tell us about a problem.`}
46
+ canonical="/accessibility/"
47
+ >
48
+ <section class="section under-header">
49
+ <div class="container container--text prose">
50
+ <h1>Accessibility</h1>
51
+
52
+ <p class="statement__lede">
53
+ {business.name} wants everyone to be able to use this website, whatever technology they
54
+ browse with. This page says what we aim for, how we check it, and what to do if
55
+ something does not work for you.
56
+ </p>
57
+
58
+ <h2>The standard we build to</h2>
59
+ <p>
60
+ We build and test this site against <strong>{TARGET}</strong>, the international
61
+ standard for web accessibility. We aim to meet it throughout, but we do not claim the
62
+ site is perfect — conformance is measured page by page, and content changes over time.
63
+ </p>
64
+
65
+ <h2>How we test</h2>
66
+ <ul>
67
+ <li>Automated checks against every page, run as part of each release</li>
68
+ <li>
69
+ A keyboard pass by hand on one page of each type — every control reachable, focus
70
+ visible, nothing trapped
71
+ </li>
72
+ <li>Text scaled to 200% and 400%, and a 320&nbsp;pixel-wide screen</li>
73
+ <li>A screen reader pass using VoiceOver and NVDA</li>
74
+ <li>Colour contrast checked at the design-token level, so it holds across the site</li>
75
+ </ul>
76
+
77
+ <h2>What we know is not yet right</h2>
78
+ {
79
+ knownGaps.length > 0 ? (
80
+ <ul>{knownGaps.map((gap) => <li>{gap}</li>)}</ul>
81
+ ) : (
82
+ <p>
83
+ We are not currently aware of any part of this site that fails the standard above.
84
+ That is not the same as there being none — if you find something, please tell us.
85
+ </p>
86
+ )
87
+ }
88
+
89
+ <h2>Tell us about a problem</h2>
90
+ <p>
91
+ If you cannot use part of this site, or you need something in a different format, please
92
+ contact us and we will put it right.
93
+ </p>
94
+ <ul>
95
+ <li>Email: <a href={business.email.href}>{business.email.display}</a></li>
96
+ <li>Phone: <a href={business.phone.href}>{business.phone.display}</a></li>
97
+ </ul>
98
+ <p>
99
+ We aim to reply within <strong>{responseDays} working days</strong>. If the fix will take
100
+ longer than that, we will tell you when to expect it.
101
+ </p>
102
+
103
+ {
104
+ enforcement && (
105
+ <>
106
+ <h2>If you are not happy with our response</h2>
107
+ <p>
108
+ You can raise it with <a href={enforcement.url}>{enforcement.body}</a>.
109
+ </p>
110
+ </>
111
+ )
112
+ }
113
+
114
+ <h2>About this statement</h2>
115
+ <p>
116
+ Prepared on {prepared}. Last reviewed on {reviewed}.
117
+ </p>
118
+ </div>
119
+ </section>
120
+ </Base>
121
+
122
+ <style>
123
+ .statement__lede {
124
+ font-size: var(--step-1);
125
+ color: var(--text-muted);
126
+ max-inline-size: var(--measure);
127
+ }
128
+ </style>
@@ -0,0 +1,191 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { validateLead, leadKey, type LeadRecord } from '../../lib/lead';
3
+ import { notifyTeam } from '../../lib/brevo';
4
+ import { site } from '../../data/site';
5
+ import { kv, secret } from '../../lib/runtime';
6
+ import { business } from '../../data/business';
7
+
8
+ /**
9
+ * Where the no-JavaScript path lands. One place, because these are routes and
10
+ * a route that only exists in a redirect string is a route nobody notices has
11
+ * gone — the first version of this file pointed at /contact-us/ and /thank-you/
12
+ * from a different project, and both were 404s in a fresh build.
13
+ */
14
+ const FORM_PAGE = '/contact/';
15
+ const FORM_ANCHOR = '#quote';
16
+ const SUCCESS = `${FORM_PAGE}?sent=1${FORM_ANCHOR}`;
17
+
18
+ // This is the one route that needs a runtime. Everything else is static.
19
+ export const prerender = false;
20
+
21
+ const json = (body: unknown, status: number) =>
22
+ new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
25
+ });
26
+
27
+ /**
28
+ * A native form POST — no JavaScript — must not land on a page of raw JSON.
29
+ * The enhanced path sends `accept: application/json`; anything else is a
30
+ * browser submitting the form directly and gets a redirect it can follow.
31
+ */
32
+ const wantsHtml = (request: Request) => {
33
+ const accept = request.headers.get('accept') ?? '';
34
+ return accept.includes('text/html') && !accept.includes('application/json');
35
+ };
36
+
37
+ const seeOther = (location: string) =>
38
+ new Response(null, { status: 303, headers: { location, 'cache-control': 'no-store' } });
39
+
40
+ /**
41
+ * Refuse a POST that came from someone else's page.
42
+ *
43
+ * A browser always sends `Origin` on a cross-origin POST, so comparing it to
44
+ * our own host blocks the case this is for: another site putting a form in
45
+ * front of visitors that writes into this site's lead store.
46
+ *
47
+ * ⚠ WHAT IT DOES NOT STOP, so nobody reads more into it than it does: a script
48
+ * posting directly can simply omit the header, and `Origin` is absent on a
49
+ * same-origin form post from some privacy tooling — so absent has to be
50
+ * allowed, or the no-JavaScript path breaks for real people. Spam is the
51
+ * honeypot's job, not this.
52
+ *
53
+ * This check must run BEFORE validation. Ordering it after means a
54
+ * cross-origin post with a well-formed body is processed and stored, and the
55
+ * refusal only ever fires for submissions that were going to be rejected
56
+ * anyway — which looks identical in a status-code test.
57
+ */
58
+ const sameOrigin = (request: Request) => {
59
+ const origin = request.headers.get('origin');
60
+ if (!origin) return true;
61
+ try {
62
+ return new URL(origin).host === new URL(request.url).host;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ export const POST: APIRoute = async ({ request, clientAddress }) => {
69
+ if (!sameOrigin(request)) {
70
+ return json({ ok: false, error: 'Cross-origin submissions are not accepted.' }, 403);
71
+ }
72
+
73
+ const contentType = request.headers.get('content-type') ?? '';
74
+ let input: Record<string, string>;
75
+
76
+ try {
77
+ if (contentType.includes('application/json')) {
78
+ input = (await request.json()) as Record<string, string>;
79
+ } else {
80
+ const form = await request.formData();
81
+ input = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]));
82
+ }
83
+ } catch {
84
+ return json({ ok: false, error: 'Could not read the submission.' }, 400);
85
+ }
86
+
87
+ /*
88
+ * Honeypot. Accept silently — telling a bot it was caught just teaches it to
89
+ * try again without the field.
90
+ *
91
+ * ⚠ IT MUST NOT LAND ON `SUCCESS`. That URL is the conversion: the form page
92
+ * fires `generate_lead` on `?sent=1` for the no-JavaScript path. Sending
93
+ * caught spam there lets any bot that runs JavaScript inflate the only
94
+ * conversion the website owns — silently, in a shape that looks like the site
95
+ * performing unusually well, which nobody investigates.
96
+ *
97
+ * It goes to the plain form page instead: same 303, no error, nothing a bot
98
+ * can read as detection.
99
+ */
100
+ if (input.company) {
101
+ return wantsHtml(request) ? seeOther(FORM_PAGE) : json({ ok: true, id: 'accepted' }, 200);
102
+ }
103
+
104
+ const { ok, errors, values } = validateLead(input);
105
+ if (!ok) {
106
+ if (wantsHtml(request)) {
107
+ // Carry the failing field names back in the query string so the form can
108
+ // highlight them without JavaScript having to re-post anything.
109
+ return seeOther(`${FORM_PAGE}?invalid=${encodeURIComponent(Object.keys(errors).join(','))}${FORM_ANCHOR}`);
110
+ }
111
+ return json({ ok: false, errors }, 422);
112
+ }
113
+
114
+ const receivedAt = new Date().toISOString();
115
+ const id = crypto.randomUUID();
116
+ const lead: LeadRecord = {
117
+ id,
118
+ receivedAt,
119
+ env: site.leadTag,
120
+ ...values,
121
+ page: (input.page ?? '').slice(0, 300),
122
+ userAgent: (request.headers.get('user-agent') ?? '').slice(0, 300),
123
+ country: request.headers.get('cf-ipcountry') ?? '',
124
+ ip: clientAddress ?? '',
125
+ };
126
+
127
+ // ── Durable first, third party second ──────────────────────────────────
128
+ // The KV write happens BEFORE the Brevo call, so a provider outage costs a
129
+ // notification rather than a lead. If KV itself fails we still try to send,
130
+ // because an email in an inbox beats losing the enquiry entirely.
131
+ const store = kv(site.leadsBinding);
132
+
133
+ let stored = false;
134
+ if (store) {
135
+ try {
136
+ /*
137
+ * `expirationTtl` is the retention policy, enforced by the store. See
138
+ * site.leadRetentionDays — without it KV keeps a lead forever, which is
139
+ * not a defensible answer to "how long do you hold this data".
140
+ *
141
+ * Metadata carries NO personal data. It used to duplicate name and email
142
+ * here, which put them in `list()` — an operation that returns metadata
143
+ * without reading values, so the PII was available through the cheaper
144
+ * call. Nothing consumed it: the CSV export does a `get()` per key and
145
+ * reads the full record. Duplicated personal data with no reader is all
146
+ * cost and no benefit.
147
+ */
148
+ await store.put(leadKey(receivedAt, id), JSON.stringify(lead), {
149
+ expirationTtl: site.leadRetentionDays * 24 * 60 * 60,
150
+ metadata: { env: lead.env },
151
+ });
152
+ stored = true;
153
+ } catch (error) {
154
+ console.error('lead kv write failed', { id, error: String(error) });
155
+ }
156
+ } else {
157
+ console.error('lead kv binding missing', { binding: site.leadsBinding });
158
+ }
159
+
160
+ let emailed = false;
161
+ const apiKey = secret('BREVO_API_KEY');
162
+ if (apiKey) {
163
+ const result = await notifyTeam(apiKey, lead);
164
+ emailed = result.ok;
165
+ if (!result.ok) console.error('lead email failed', { id, error: result.error });
166
+ } else {
167
+ console.error('BREVO_API_KEY not set');
168
+ }
169
+
170
+ // Only a total loss is reported as failure to the visitor. If either channel
171
+ // captured it, the enquiry reached us and the visitor should not retry.
172
+ if (!stored && !emailed) {
173
+ if (wantsHtml(request)) return seeOther(`${FORM_PAGE}?failed=1${FORM_ANCHOR}`);
174
+ return json(
175
+ {
176
+ ok: false,
177
+ // From business.ts. A phone number written into an error string is the
178
+ // last place anyone looks, and the first place a previous client's
179
+ // number survives a copy-paste.
180
+ error: `We could not record your request. Please call us at ${business.phone.display}.`,
181
+ },
182
+ 502,
183
+ );
184
+ }
185
+
186
+ if (wantsHtml(request)) return seeOther(SUCCESS);
187
+ return json({ ok: true, id, stored, emailed }, 200);
188
+ };
189
+
190
+ /** A GET here is almost always a person pasting the URL. Send them to the form. */
191
+ export const GET: APIRoute = () => new Response(null, { status: 303, headers: { location: FORM_PAGE } });
@@ -0,0 +1,82 @@
1
+ import type { APIRoute } from 'astro';
2
+ import type { LeadRecord } from '../../lib/lead';
3
+ import { site } from '../../data/site';
4
+ import { kv, secret } from '../../lib/runtime';
5
+
6
+ export const prerender = false;
7
+
8
+ const COLUMNS: (keyof LeadRecord)[] = [
9
+ 'receivedAt',
10
+ 'name',
11
+ 'email',
12
+ 'phone',
13
+ 'service',
14
+ 'message',
15
+ 'page',
16
+ 'country',
17
+ 'env',
18
+ 'id',
19
+ ];
20
+
21
+ /** RFC 4180: quote everything, double internal quotes. Excel-safe. */
22
+ const cell = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`;
23
+
24
+ /**
25
+ * Every lead as a CSV, newest last (KV lists keys in lexicographic order and
26
+ * the key is `lead:<iso-timestamp>:<uuid>`).
27
+ *
28
+ * curl -H "Authorization: Bearer $LEADS_EXPORT_TOKEN" \
29
+ * https://example.com/api/leads.csv -o leads.csv
30
+ */
31
+ export const GET: APIRoute = async ({ request }) => {
32
+ const expected = secret('LEADS_EXPORT_TOKEN');
33
+
34
+ if (!expected) {
35
+ return new Response('Export is not configured.\n', { status: 503 });
36
+ }
37
+
38
+ const url = new URL(request.url);
39
+ const presented =
40
+ request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? url.searchParams.get('token') ?? '';
41
+
42
+ // Constant-time-ish: compare full length, never short-circuit on first byte.
43
+ const ok =
44
+ presented.length === expected.length &&
45
+ presented.split('').reduce((acc, char, i) => acc | (char.charCodeAt(0) ^ expected.charCodeAt(i)), 0) === 0;
46
+
47
+ if (!ok) {
48
+ return new Response('Unauthorized\n', {
49
+ status: 401,
50
+ headers: { 'www-authenticate': 'Bearer realm="leads"' },
51
+ });
52
+ }
53
+
54
+ const store = kv(site.leadsBinding);
55
+ if (!store) return new Response('Lead storage is not bound.\n', { status: 503 });
56
+
57
+ const rows: string[] = [COLUMNS.join(',')];
58
+ let cursor: string | undefined;
59
+
60
+ do {
61
+ const page = await store.list({ prefix: 'lead:', cursor, limit: 1000 });
62
+ const values = await Promise.all(
63
+ page.keys.map((key) => store.get(key.name, 'json') as Promise<LeadRecord | null>),
64
+ );
65
+ for (const lead of values) {
66
+ if (!lead) continue;
67
+ rows.push(COLUMNS.map((column) => cell(lead[column])).join(','));
68
+ }
69
+ cursor = page.list_complete ? undefined : page.cursor;
70
+ } while (cursor);
71
+
72
+ const stamp = new Date().toISOString().slice(0, 10);
73
+ // BOM so Excel opens UTF-8 accents correctly instead of mojibake.
74
+ return new Response('' + rows.join('\r\n') + '\r\n', {
75
+ status: 200,
76
+ headers: {
77
+ 'content-type': 'text/csv; charset=utf-8',
78
+ 'content-disposition': `attachment; filename="leads-${stamp}.csv"`,
79
+ 'cache-control': 'no-store',
80
+ },
81
+ });
82
+ };
@@ -0,0 +1,112 @@
1
+ ---
2
+ import Base from '../layouts/Base.astro';
3
+ import PageHero from '../components/PageHero.astro';
4
+ import ContactForm from '../components/ContactForm.astro';
5
+ import { business, addressOneLine } from '../data/business';
6
+
7
+ /**
8
+ * Rendered per request, not prerendered — the API redirects back here with
9
+ * ?invalid=… when a no-JavaScript form POST fails validation, and a static
10
+ * page has no query string to read.
11
+ *
12
+ * Structure only. The layout of this page is a project decision.
13
+ */
14
+ export const prerender = false;
15
+
16
+ const invalidFields = (Astro.url.searchParams.get('invalid') ?? '').split(',').filter(Boolean);
17
+ const submitFailed = Astro.url.searchParams.has('failed');
18
+ /** The no-JavaScript success landing. Keep in step with SUCCESS in
19
+ * src/pages/api/contact.ts — without this the native POST 303s to a page that
20
+ * looks exactly like the one the visitor just submitted. */
21
+ const submitted = Astro.url.searchParams.has('sent');
22
+ ---
23
+
24
+ <Base
25
+ title={`Contact - ${business.name}`}
26
+ description={`Get in touch with ${business.name}. Call ${business.phone.display} or send us a message.`}
27
+ canonical="/contact/"
28
+ breadcrumbs={[{ name: 'Home', href: '/' }, { name: 'Contact', href: '/contact/' }]}
29
+ >
30
+ <PageHero
31
+ title="Contact"
32
+ lede={`Send a message and we will come back to you. For anything urgent, call ${business.phone.display}.`}
33
+ breadcrumbs={[{ name: 'Home', href: '/' }, { name: 'Contact', href: '/contact/' }]}
34
+ />
35
+
36
+ <section class="section--tight">
37
+ <div class="container contact__grid">
38
+ <div>
39
+ <h2>Details</h2>
40
+ <ul role="list" class="contact__details">
41
+ <li><a href={business.phone.href}>{business.phone.display}</a></li>
42
+ <li><a href={business.email.href}>{business.email.display}</a></li>
43
+ <li>{addressOneLine}</li>
44
+ <li>{business.hours.display}</li>
45
+ </ul>
46
+ </div>
47
+
48
+ <div id="quote">
49
+ <h2>Send a message</h2>
50
+ {
51
+ submitted && (
52
+ <p role="status" class="contact__sent">
53
+ Thank you — your message is in. We will come back to you shortly.
54
+ </p>
55
+ )
56
+ }
57
+ {
58
+ invalidFields.length > 0 && (
59
+ <p role="alert" class="contact__alert">Please check the highlighted fields.</p>
60
+ )
61
+ }
62
+ {
63
+ submitFailed && (
64
+ <p role="alert" class="contact__alert">
65
+ Something went wrong — please call {business.phone.display} instead.
66
+ </p>
67
+ )
68
+ }
69
+ <div class="contact__form">
70
+ <ContactForm invalidFields={invalidFields} />
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </section>
75
+ </Base>
76
+
77
+ <style>
78
+ .contact__grid {
79
+ display: grid;
80
+ gap: var(--space-2xl);
81
+ }
82
+
83
+ @media (min-width: 55rem) {
84
+ .contact__grid {
85
+ grid-template-columns: 1fr 1.4fr;
86
+ }
87
+ }
88
+
89
+ .contact__details {
90
+ display: grid;
91
+ gap: var(--space-2xs);
92
+ margin-block-start: var(--space-m);
93
+ padding: 0;
94
+ color: var(--text-muted);
95
+ }
96
+
97
+ .contact__alert {
98
+ margin-block-start: var(--space-s);
99
+ color: var(--danger);
100
+ }
101
+
102
+ .contact__sent {
103
+ margin-block-start: var(--space-s);
104
+ padding: var(--space-s) var(--space-m);
105
+ border: 1px solid var(--border-strong);
106
+ border-radius: var(--radius-s);
107
+ }
108
+
109
+ .contact__form {
110
+ margin-block-start: var(--space-l);
111
+ }
112
+ </style>
@@ -0,0 +1,84 @@
1
+ ---
2
+ import Base from '../layouts/Base.astro';
3
+ import { business } from '../data/business';
4
+
5
+ /**
6
+ * SCAFFOLD — replace this file entirely.
7
+ *
8
+ * There is no starter home page here on purpose. A home page that arrives with
9
+ * the template is a page shape nobody chose, and it is what makes two sites
10
+ * built from the same starter look like the same site.
11
+ *
12
+ * What decides this page:
13
+ * - the ONE action that counts as a win → what the opening block must do
14
+ * - the archetype it implies → section order and proof model,
15
+ * references/archetypes.md
16
+ * - the Round 3 visual direction → what any of it looks like
17
+ *
18
+ * The archetype fixes the order; the direction fixes the appearance; they are
19
+ * independent, and neither is in this repository.
20
+ *
21
+ * Delete `unbuilt` below once this is a real page — `npm run tells` fails
22
+ * while a scaffold marker is still in a page that also has real content.
23
+ */
24
+ const unbuilt = true;
25
+
26
+ const checklist = [
27
+ 'Round 1: name the one action that counts as a win',
28
+ 'Pick the archetype it implies (references/archetypes.md) and write the section order down',
29
+ 'Clear --unset in src/styles/tokens.css: brand ramp, then the two faces',
30
+ 'Fill src/data/business.ts, then src/data/nav.ts with the real routes',
31
+ 'Build the opening block and one content section in two directions, deploy both, let the client choose',
32
+ 'Replace this file with the chosen direction',
33
+ ];
34
+ ---
35
+
36
+ <Base
37
+ title={`${business.name}`}
38
+ description={business.description}
39
+ canonical="/"
40
+ schemaType="home"
41
+ noindex={unbuilt}
42
+ >
43
+ <section class="section under-header">
44
+ <div class="container container--text">
45
+ <h1>This page has not been built yet.</h1>
46
+ <p class="scaffold__lede">
47
+ The template ships without a home page. Section order comes from the archetype, and
48
+ appearance from the design direction — both are project decisions, so neither is here.
49
+ </p>
50
+
51
+ <ol class="scaffold__list">
52
+ {checklist.map((step) => <li>{step}</li>)}
53
+ </ol>
54
+
55
+ <p class="scaffold__note">
56
+ This page is <code>noindex</code> while <code>unbuilt</code> is set, so a staging deploy
57
+ cannot leak it. See <code>docs/design.md</code> and <code>npm run tells</code>.
58
+ </p>
59
+ </div>
60
+ </section>
61
+ </Base>
62
+
63
+ <style>
64
+ .scaffold__lede {
65
+ margin-block-start: var(--space-m);
66
+ font-size: var(--step-1);
67
+ color: var(--text-muted);
68
+ max-inline-size: var(--measure);
69
+ }
70
+
71
+ .scaffold__list {
72
+ margin-block-start: var(--space-xl);
73
+ padding-inline-start: var(--space-m);
74
+ display: grid;
75
+ gap: var(--space-2xs);
76
+ max-inline-size: var(--measure);
77
+ }
78
+
79
+ .scaffold__note {
80
+ margin-block-start: var(--space-xl);
81
+ font-size: var(--step--1);
82
+ color: var(--text-muted);
83
+ }
84
+ </style>
@@ -0,0 +1,38 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { site } from '../data/site';
3
+
4
+ /**
5
+ * Generated, not a static file — so staging is disallow-all without anyone
6
+ * having to remember to swap a file at go-live.
7
+ */
8
+ export const GET: APIRoute = ({ site: siteUrl }) => {
9
+ const body = site.indexable
10
+ ? [
11
+ 'User-agent: *',
12
+ 'Allow: /',
13
+ 'Disallow: /api/',
14
+ '',
15
+ `Sitemap: ${new URL('/sitemap-index.xml', siteUrl).href}`,
16
+ '',
17
+ ].join('\n')
18
+ : [
19
+ '# Staging — not for indexing.',
20
+ '#',
21
+ '# ⚠ Disallow is a CRAWLING instruction, not an indexing one. Blocking the',
22
+ '# crawler means it never fetches the page and never reads the `noindex`',
23
+ '# every page here carries — so a staging URL that gets linked anywhere can',
24
+ '# still be indexed as a bare URL competing with production.',
25
+ '#',
26
+ '# Put staging behind Cloudflare Access. That is the actual answer, it is free',
27
+ '# at this scale, and it makes this file irrelevant. If staging must stay',
28
+ '# public, delete the Disallow below so the noindex is actually read.',
29
+ '# See docs/traps.md.',
30
+ 'User-agent: *',
31
+ 'Disallow: /',
32
+ '',
33
+ ].join('\n');
34
+
35
+ return new Response(body, {
36
+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'public, max-age=3600' },
37
+ });
38
+ };
@@ -0,0 +1,27 @@
1
+ import rss from '@astrojs/rss';
2
+ import type { APIRoute } from 'astro';
3
+ import { getPosts } from '../lib/posts';
4
+ import { business } from '../data/business';
5
+ import { site } from '../data/site';
6
+
7
+ export const GET: APIRoute = async (context) => {
8
+ // Staging must not publish a feed a reader could subscribe to.
9
+ if (!site.indexable) return new Response('Not found\n', { status: 404 });
10
+
11
+ const posts = await getPosts();
12
+
13
+ return rss({
14
+ title: `${business.name} — Insights`,
15
+ description: business.description,
16
+ site: context.site!,
17
+ trailingSlash: true,
18
+ items: posts.map((post) => ({
19
+ title: post.data.title,
20
+ description: post.data.description,
21
+ pubDate: post.data.publishedAt,
22
+ link: `/${post.id}/`,
23
+ categories: post.data.categories,
24
+ })),
25
+ customData: '<language>en-us</language>',
26
+ });
27
+ };