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,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 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
|
+
};
|