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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place business facts live.
|
|
3
|
+
*
|
|
4
|
+
* Both the visible UI and the JSON-LD structured data read from here, so the
|
|
5
|
+
* page and the schema can never drift apart. Change a phone number here and it
|
|
6
|
+
* updates the header, footer, every call-to-action, the notification emails and
|
|
7
|
+
* what Google reads.
|
|
8
|
+
*
|
|
9
|
+
* ── FILL THIS IN FIRST. Everything else depends on it. ─────────────────────
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const business = {
|
|
13
|
+
name: 'Business Name',
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* BCP 47 language tag. Drives `<html lang>`, `inLanguage` in structured data,
|
|
17
|
+
* and how dates are formatted in the lead notification.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ SET THIS. The template shipped `en-US` hardcoded in five files while its
|
|
20
|
+
* own compliance reference covered the EAA, UK GDPR and AODA — so a British
|
|
21
|
+
* or Canadian build announced itself as American to every screen reader and
|
|
22
|
+
* every crawler, and printed dates in the wrong order.
|
|
23
|
+
*/
|
|
24
|
+
locale: 'en',
|
|
25
|
+
legalName: 'Business Name Ltd',
|
|
26
|
+
tagline: 'What you do, in six words',
|
|
27
|
+
description:
|
|
28
|
+
'One or two sentences that would make sense read aloud on the phone. This becomes the ' +
|
|
29
|
+
'default meta description and the Organization description in structured data.',
|
|
30
|
+
foundedYear: 2010,
|
|
31
|
+
|
|
32
|
+
phone: {
|
|
33
|
+
display: '(000) 000-0000', // what a visitor reads, in local convention
|
|
34
|
+
e164: '+00000000000', // E.164 WITH the country code — schema.org and tel: need it
|
|
35
|
+
href: 'tel:+00000000000',
|
|
36
|
+
sms: 'sms:+00000000000',
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
email: {
|
|
40
|
+
display: 'hello@example.com', // shown on the site
|
|
41
|
+
href: 'mailto:hello@example.com',
|
|
42
|
+
sender: 'hello@example.com', // must be a VERIFIED sender at your email provider
|
|
43
|
+
senderName: 'Business Name',
|
|
44
|
+
notify: 'owner@example.com', // where live enquiries land
|
|
45
|
+
notifyTest: 'dev@example.com', // where staging enquiries land — never the client
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
address: {
|
|
49
|
+
street: '1 Example Street',
|
|
50
|
+
locality: 'Town',
|
|
51
|
+
region: 'ST',
|
|
52
|
+
regionName: 'State',
|
|
53
|
+
/**
|
|
54
|
+
* IANA timezone, used to stamp lead notifications in the business's own
|
|
55
|
+
* local time. A hardcoded zone in the email template is a previous
|
|
56
|
+
* client's clock: every enquiry then carries a plausible timestamp that is
|
|
57
|
+
* silently hours out, and nobody checks a date they can read.
|
|
58
|
+
*/
|
|
59
|
+
timeZone: 'UTC',
|
|
60
|
+
/** Short label printed after the time. Keep it in step with `timeZone`. */
|
|
61
|
+
timeZoneLabel: 'UTC',
|
|
62
|
+
postalCode: '00000',
|
|
63
|
+
country: 'US',
|
|
64
|
+
mapUrl: 'https://maps.google.com/?q=...',
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** Used by LocalBusiness JSON-LD. Get them from Google Maps. */
|
|
68
|
+
geo: { latitude: 0, longitude: 0 },
|
|
69
|
+
|
|
70
|
+
hours: {
|
|
71
|
+
days: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
|
72
|
+
opens: '09:00',
|
|
73
|
+
closes: '17:00',
|
|
74
|
+
display: 'Mon – Fri · 9am – 5pm',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
priceRange: '$$',
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* schema.org types for the organisation node, most general first.
|
|
81
|
+
*
|
|
82
|
+
* ⚠ SET THE SUBTYPE FOR THIS BUSINESS, or leave the two generic ones. The
|
|
83
|
+
* template shipped `HVACBusiness` hardcoded for two projects, so every site
|
|
84
|
+
* built from it silently declared itself a heating and ventilation company
|
|
85
|
+
* to Google — valid markup, clean build, wrong business.
|
|
86
|
+
*
|
|
87
|
+
* A wrong subtype is worse than no subtype: it is a specific claim, and rich
|
|
88
|
+
* results and local classification act on it. Pick from the LocalBusiness
|
|
89
|
+
* subtypes at https://schema.org/LocalBusiness — e.g. `Dentist`, `Plumber`,
|
|
90
|
+
* `HealthClub`, `LegalService`. If nothing fits exactly, leave it out.
|
|
91
|
+
*/
|
|
92
|
+
schemaTypes: ['Organization', 'LocalBusiness'] as string[],
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Logo path for structured data, relative to the site root, or null.
|
|
96
|
+
*
|
|
97
|
+
* Null by default because the template ships no brand assets, and a schema
|
|
98
|
+
* `logo` pointing at a 404 is a warning in Search Console that nothing in a
|
|
99
|
+
* build would surface. Set it after `npm run media` puts the real file in
|
|
100
|
+
* public/img/brand/.
|
|
101
|
+
*/
|
|
102
|
+
logoPath: null as string | null,
|
|
103
|
+
|
|
104
|
+
/** Drives areaServed in structured data, and the footer list. */
|
|
105
|
+
serviceAreas: ['Town', 'Neighbouring Town'],
|
|
106
|
+
|
|
107
|
+
serviceAreaSummary: 'Serving Town and the surrounding area.',
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Accreditations and credential badges. `image` is a manifest key, not a URL.
|
|
111
|
+
* Check the artwork before designing the strip around it — badges are often
|
|
112
|
+
* near-white PNGs drawn for a dark background.
|
|
113
|
+
*/
|
|
114
|
+
credentials: [] as { name: string; image: string }[],
|
|
115
|
+
|
|
116
|
+
socials: [] as { name: string; url: string }[],
|
|
117
|
+
|
|
118
|
+
credit: { name: '', url: '' },
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
export const addressOneLine = `${business.address.street}, ${business.address.locality}, ${business.address.region} ${business.address.postalCode}`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog categories.
|
|
3
|
+
*
|
|
4
|
+
* On a migration, **slugs must match the source CMS term slugs exactly** so
|
|
5
|
+
* every /category/<slug>/ URL that exists today keeps working. Take them from
|
|
6
|
+
* the export rather than re-slugging the display names \u2014 a name containing "&"
|
|
7
|
+
* or an accent does not slug identically everywhere, and a changed category URL
|
|
8
|
+
* is lost traffic with no error anywhere to tell you.
|
|
9
|
+
*
|
|
10
|
+
* Archive pages generate only where posts exist, so an unused entry costs
|
|
11
|
+
* nothing \u2014 but delete it anyway.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const categories = [
|
|
15
|
+
{ name: 'General', slug: 'general', description: 'Everything that does not fit a narrower category.' },
|
|
16
|
+
{ name: 'Tips', slug: 'tips', description: 'Practical advice, field-tested.' },
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type Category = (typeof categories)[number];
|
|
20
|
+
|
|
21
|
+
/* Keyed by `string`, not by the literal union `categories` infers from `as
|
|
22
|
+
const`. Left inferred, the Map only accepts the two slugs that exist today,
|
|
23
|
+
so `categoryBySlug(someSlugFromTheURL)` is a type error rather than the
|
|
24
|
+
lookup-that-may-miss it is meant to be. */
|
|
25
|
+
const bySlug = new Map<string, Category>(categories.map((c) => [c.slug, c]));
|
|
26
|
+
const byName = new Map<string, Category>(categories.map((c) => [c.name, c]));
|
|
27
|
+
|
|
28
|
+
export const categoryBySlug = (slug: string) => bySlug.get(slug);
|
|
29
|
+
export const categoryByName = (name: string) => byName.get(name);
|
|
30
|
+
|
|
31
|
+
/** Turn a category display name into its URL. Unknown names slug themselves. */
|
|
32
|
+
export function categoryHref(name: string): string {
|
|
33
|
+
const slug =
|
|
34
|
+
byName.get(name)?.slug ??
|
|
35
|
+
name.toLowerCase().replace(/&/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
36
|
+
return `/category/${slug}/`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-hosted fonts to preload.
|
|
3
|
+
*
|
|
4
|
+
* Empty on purpose. The template ships no typeface — the display/body pairing
|
|
5
|
+
* is the single largest premium lever there is, and a typeface that arrives
|
|
6
|
+
* with the starter is one nobody chose.
|
|
7
|
+
*
|
|
8
|
+
* To add one:
|
|
9
|
+
* 1. put the variable woff2 in `public/fonts/` (subset — see docs/design.md)
|
|
10
|
+
* 2. add the `@font-face` block to `src/styles/global.css`
|
|
11
|
+
* 3. name the family in `--font-display` / `--font-body` in `tokens.css`
|
|
12
|
+
* 4. add the file here, so Base.astro emits the preload
|
|
13
|
+
*
|
|
14
|
+
* Only preload faces used above the fold — a preload for a face first needed
|
|
15
|
+
* at the footer competes with the LCP image for bandwidth and costs more than
|
|
16
|
+
* it saves. Two entries is normally the whole list.
|
|
17
|
+
*
|
|
18
|
+
* Same-origin means the preload is the whole story: no DNS lookup, no TLS
|
|
19
|
+
* handshake, no third-party round trip on the critical path.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const preloadFonts: string[] = [
|
|
23
|
+
// '/fonts/display.woff2',
|
|
24
|
+
// '/fonts/body.woff2',
|
|
25
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation and the primary call to action.
|
|
3
|
+
*
|
|
4
|
+
* One place, read by the header, the mobile menu, the footer columns and the
|
|
5
|
+
* 404's onward links — so a route added here appears everywhere it should and
|
|
6
|
+
* the 404 can never offer a page that no longer exists.
|
|
7
|
+
*
|
|
8
|
+
* The list below is the minimum that resolves in a fresh template. Replace it
|
|
9
|
+
* with the real information architecture; the route inventory from recon is
|
|
10
|
+
* what decides it, not this file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface NavItem {
|
|
14
|
+
label: string;
|
|
15
|
+
href: string;
|
|
16
|
+
/** Show in the footer's link column. Defaults to true. */
|
|
17
|
+
footer?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const nav: NavItem[] = [
|
|
21
|
+
{ label: 'Home', href: '/' },
|
|
22
|
+
{ label: 'Contact', href: '/contact/' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The one action that counts as a win, as a link.
|
|
27
|
+
*
|
|
28
|
+
* Discovery names exactly one — call, form, booking, demo. This is it, and it
|
|
29
|
+
* is what the header CTA, the mobile menu and any conversion band point at.
|
|
30
|
+
* Two competing calls to action is the same as none.
|
|
31
|
+
*/
|
|
32
|
+
export const primaryAction = {
|
|
33
|
+
label: 'Get in touch',
|
|
34
|
+
href: '/contact/',
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Legal and required links, in the footer on every page.
|
|
39
|
+
*
|
|
40
|
+
* `/accessibility` is required under the EAA and the UK PSBAR — do not remove
|
|
41
|
+
* it. It is hand-written, so it lives here.
|
|
42
|
+
*
|
|
43
|
+
* Privacy, terms and the cookie notice do NOT go here. They are markdown in
|
|
44
|
+
* `src/content/legal/`, and the footer reads that collection directly — a
|
|
45
|
+
* footer link to a page that does not exist yet is a 404 on every page of the
|
|
46
|
+
* site and nothing will report it, so the link is derived from the page rather
|
|
47
|
+
* than typed alongside it. Add the file; the link appears.
|
|
48
|
+
*/
|
|
49
|
+
export const legalNav: NavItem[] = [{ label: 'Accessibility', href: '/accessibility/' }];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service catalogue. One entry per service; the pages are generated from these.
|
|
3
|
+
*
|
|
4
|
+
* Content, not presentation. There is no `icon` or `number` field on purpose —
|
|
5
|
+
* a data file that carries card decoration has decided what the page looks
|
|
6
|
+
* like. Add whatever fields the chosen design genuinely needs, once it exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Service = {
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
short: string; // one sentence a customer would recognise as their problem
|
|
13
|
+
body: string; // the fuller explanation, for the service's own page
|
|
14
|
+
featured: boolean; // surfaced in the footer and any short list
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const services: Service[] = [
|
|
18
|
+
{
|
|
19
|
+
slug: 'first-service',
|
|
20
|
+
title: 'First Service',
|
|
21
|
+
short: 'One sentence a customer would recognise as their problem.',
|
|
22
|
+
body: 'The fuller explanation. Written for someone deciding whether to call, not for a search engine.',
|
|
23
|
+
featured: true,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Why this business rather than another. Specific, not generic —
|
|
29
|
+
* "same-day report" beats "great service".
|
|
30
|
+
*
|
|
31
|
+
* Where these appear, and whether they appear at all, is the archetype's
|
|
32
|
+
* decision: see references/archetypes.md for the proof model each one needs.
|
|
33
|
+
*/
|
|
34
|
+
export const differentiators: { title: string; body: string }[] = [
|
|
35
|
+
{ title: 'Reason one', body: 'Specific, not generic.' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Anything handled on the customer's behalf — paperwork, permits, filings. */
|
|
39
|
+
export const documentationHandled: string[] = [];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment behaviour, derived once from the build variable.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here is a hand-flipped switch. `PUBLIC_SITE_ENV` is set by the build
|
|
5
|
+
* script (`build:staging` / `build:production`); everything else follows from
|
|
6
|
+
* it, so there is nothing a human has to remember to change at go-live.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { business } from './business';
|
|
10
|
+
|
|
11
|
+
export type SiteEnv = 'development' | 'staging' | 'production';
|
|
12
|
+
|
|
13
|
+
export const SITE_ENV = (import.meta.env.PUBLIC_SITE_ENV ?? 'development') as SiteEnv;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Exact hostname allowlist. A suffix match would treat
|
|
17
|
+
* `new.example.com` as production, which is precisely the bug.
|
|
18
|
+
*/
|
|
19
|
+
export const PRODUCTION_HOSTS = ['example.com', 'www.example.com'] as const;
|
|
20
|
+
|
|
21
|
+
export const isProduction = SITE_ENV === 'production';
|
|
22
|
+
export const isStaging = SITE_ENV === 'staging';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The client's own analytics IDs. Both must be set or nothing is emitted.
|
|
26
|
+
*
|
|
27
|
+
* ── FILL IN OR LEAVE EMPTY. Never inherit an ID from another project. ───────
|
|
28
|
+
* A copied container sends a real business's traffic to someone else's
|
|
29
|
+
* property, builds green, deploys clean, and reports nothing wrong.
|
|
30
|
+
*/
|
|
31
|
+
const ANALYTICS = {
|
|
32
|
+
gtmId: '', // GTM-XXXXXXX
|
|
33
|
+
ga4Id: '', // G-XXXXXXXXXX
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cloudflare Web Analytics. Independent of the Google tags above — set either,
|
|
38
|
+
* both or neither.
|
|
39
|
+
*
|
|
40
|
+
* Worth having on every site for two reasons that have nothing to do with
|
|
41
|
+
* duplicating GA4:
|
|
42
|
+
*
|
|
43
|
+
* 1. **Real-user Core Web Vitals from the first visitor.** CrUX needs months
|
|
44
|
+
* of traffic before it reports on a new domain, so field data is otherwise
|
|
45
|
+
* unavailable for exactly the period after launch when you need it.
|
|
46
|
+
* 2. **A control group for ad blockers.** It is cookieless and first-party, so
|
|
47
|
+
* the gap between this and GA4 measures what the Google tags are losing
|
|
48
|
+
* rather than leaving you to guess.
|
|
49
|
+
*
|
|
50
|
+
* Dashboard → Web Analytics → the site → the token in the beacon snippet.
|
|
51
|
+
*/
|
|
52
|
+
const CF_BEACON_TOKEN = ''; // 32-char hex
|
|
53
|
+
|
|
54
|
+
/** True only on a real production host. Used by the worker as a runtime guard. */
|
|
55
|
+
export function isProductionHost(hostname: string): boolean {
|
|
56
|
+
return (PRODUCTION_HOSTS as readonly string[]).includes(hostname.toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const site = {
|
|
60
|
+
env: SITE_ENV,
|
|
61
|
+
url: import.meta.env.SITE ?? 'http://localhost:4321',
|
|
62
|
+
|
|
63
|
+
/** Search engines only ever see production. */
|
|
64
|
+
indexable: isProduction,
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Analytics only ever fire on production. Staging HTML contains zero references.
|
|
68
|
+
*
|
|
69
|
+
* Leave these empty and no tag is emitted at all — an unset ID must never fall
|
|
70
|
+
* back to *someone else's* container. Fill both in from the client's own
|
|
71
|
+
* property (Admin → Data streams for GA4; the workspace header for GTM), or
|
|
72
|
+
* delete the block if they are not using Google analytics.
|
|
73
|
+
*/
|
|
74
|
+
analytics: isProduction && ANALYTICS.ga4Id && ANALYTICS.gtmId ? ANALYTICS : null,
|
|
75
|
+
|
|
76
|
+
/** Cloudflare Web Analytics. Production only, and only if a token is set. */
|
|
77
|
+
cfBeaconToken: isProduction && CF_BEACON_TOKEN ? CF_BEACON_TOKEN : null,
|
|
78
|
+
|
|
79
|
+
/** Leads submitted off production are tagged and written to a separate namespace. */
|
|
80
|
+
leadsBinding: isProduction ? ('LEADS' as const) : ('LEADS_STAGING' as const),
|
|
81
|
+
leadTag: isProduction ? ('live' as const) : ('test' as const),
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How long a stored lead survives, in days. Written as a KV `expirationTtl`,
|
|
85
|
+
* so expiry is enforced by the store rather than by anyone remembering.
|
|
86
|
+
*
|
|
87
|
+
* ── THIS IS A COMPLIANCE DECISION, NOT A STORAGE ONE ──────────────────────
|
|
88
|
+
* A lead is personal data. The UK GDPR and the GDPR both require a defined
|
|
89
|
+
* retention period and neither accepts "indefinitely" — and KV keeps a value
|
|
90
|
+
* forever unless it is told otherwise, so the default without this line is
|
|
91
|
+
* exactly the thing that cannot be justified.
|
|
92
|
+
*
|
|
93
|
+
* 180 days is the kit's default because this store is a SAFETY NET, not the
|
|
94
|
+
* system of record: the notification email is the delivery, and KV exists so
|
|
95
|
+
* that a provider outage costs a notification rather than the lead. Six
|
|
96
|
+
* months is long enough to recover from an outage nobody noticed and to run
|
|
97
|
+
* an export, and short enough to state plainly in a privacy notice.
|
|
98
|
+
*
|
|
99
|
+
* ⚠ Raise it only with a reason you would give a regulator, and make sure the
|
|
100
|
+
* privacy notice says the same number. Staging is deliberately shorter — test
|
|
101
|
+
* submissions are not records of anything.
|
|
102
|
+
*/
|
|
103
|
+
leadRetentionDays: isProduction ? 180 : 30,
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Who gets the new-lead notification. Off production it goes to the
|
|
107
|
+
* developer, not the client — a verification run should never land in the
|
|
108
|
+
* inbox someone answers real enquiries from.
|
|
109
|
+
*/
|
|
110
|
+
leadNotifyTo: isProduction ? business.email.notify : business.email.notifyTest,
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Icons that actually exist in public/. Nothing is advertised until it does.
|
|
115
|
+
*
|
|
116
|
+
* ⚠ SET THESE once the brand assets land — `npm run media` puts them in
|
|
117
|
+
* public/img/brand/.
|
|
118
|
+
*
|
|
119
|
+
* The template used to declare all four unconditionally, so a fresh build made
|
|
120
|
+
* three failed requests on EVERY page: /favicon.ico, /img/brand/favicon.svg and
|
|
121
|
+
* the manifest's /icon-192.png. `npm run console` was the first thing to notice,
|
|
122
|
+
* which is the point of it.
|
|
123
|
+
*
|
|
124
|
+
* Declared rather than detected with `fs`, because Base.astro also renders
|
|
125
|
+
* server-side for the contact page and there is no filesystem in the Workers
|
|
126
|
+
* runtime. Same reason the analytics IDs and `business.logoPath` are declared.
|
|
127
|
+
*/
|
|
128
|
+
export const icons = {
|
|
129
|
+
ico: null as string | null, // '/favicon.ico'
|
|
130
|
+
svg: null as string | null, // '/img/brand/favicon.svg'
|
|
131
|
+
appleTouch: null as string | null, // '/apple-touch-icon.png'
|
|
132
|
+
/** The manifest lists its own icons — ship it only once those exist. */
|
|
133
|
+
manifest: false,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const defaultSocialImage = '/img/social/og-default.jpg';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare's runtime types — `KVNamespace`, `cloudflare:workers`, and the rest
|
|
5
|
+
* of what src/lib/runtime.ts touches.
|
|
6
|
+
*
|
|
7
|
+
* Without this, `astro check` cannot see the Workers globals, so the one file in
|
|
8
|
+
* the project that talks to a real binding is the one file TypeScript checks
|
|
9
|
+
* least. It reports `Cannot find name 'KVNamespace'` and then infers `any`
|
|
10
|
+
* through everything downstream, including the lead export.
|
|
11
|
+
*
|
|
12
|
+
* A triple-slash reference rather than `compilerOptions.types`, because setting
|
|
13
|
+
* that key switches OFF automatic @types inclusion for everything else.
|
|
14
|
+
*
|
|
15
|
+
* For types that know your actual binding NAMES rather than the generic shapes,
|
|
16
|
+
* run `npx wrangler types` and reference the file it generates instead. That
|
|
17
|
+
* has to be re-run whenever wrangler.jsonc changes, which is the trade.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface ImportMetaEnv {
|
|
21
|
+
/** Set by the build script. Never read directly — go through src/data/site.ts. */
|
|
22
|
+
readonly PUBLIC_SITE_ENV?: 'development' | 'staging' | 'production';
|
|
23
|
+
readonly PUBLIC_SITE_URL?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ImportMeta {
|
|
27
|
+
readonly env: ImportMetaEnv;
|
|
28
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ClientRouter } from 'astro:transitions';
|
|
3
|
+
import Seo from '../components/Seo.astro';
|
|
4
|
+
import StructuredData from '../components/StructuredData.astro';
|
|
5
|
+
import Header from '../components/Header.astro';
|
|
6
|
+
import Footer from '../components/Footer.astro';
|
|
7
|
+
import EnvBadge from '../components/EnvBadge.astro';
|
|
8
|
+
import { site, icons } from '../data/site';
|
|
9
|
+
import { business } from '../data/business';
|
|
10
|
+
import { preloadFonts } from '../data/fonts';
|
|
11
|
+
import '../styles/global.css';
|
|
12
|
+
/* This project's own look. See the note at the top of the file — a card or a
|
|
13
|
+
hero style belongs here, never in global.css. */
|
|
14
|
+
import '../styles/project.css';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
canonical?: string;
|
|
20
|
+
image?: string;
|
|
21
|
+
imageAlt?: string;
|
|
22
|
+
type?: 'website' | 'article';
|
|
23
|
+
publishedAt?: Date;
|
|
24
|
+
updatedAt?: Date;
|
|
25
|
+
noindex?: boolean;
|
|
26
|
+
schemaType?: 'home' | 'page' | 'article' | 'blog';
|
|
27
|
+
breadcrumbs?: { name: string; href: string }[];
|
|
28
|
+
article?: Parameters<typeof StructuredData>[0]['article'];
|
|
29
|
+
faq?: { question: string; answer: string }[];
|
|
30
|
+
bodyClass?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
canonical,
|
|
37
|
+
image,
|
|
38
|
+
imageAlt,
|
|
39
|
+
type,
|
|
40
|
+
publishedAt,
|
|
41
|
+
updatedAt,
|
|
42
|
+
noindex,
|
|
43
|
+
schemaType,
|
|
44
|
+
breadcrumbs,
|
|
45
|
+
article,
|
|
46
|
+
faq,
|
|
47
|
+
bodyClass,
|
|
48
|
+
} = Astro.props;
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<!doctype html>
|
|
52
|
+
<html lang={business.locale}>
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8" />
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
56
|
+
<!-- Set once the brand ramp is real, and keep it in step with
|
|
57
|
+
public/site.webmanifest — the two disagreeing is what makes an
|
|
58
|
+
installed icon's splash screen the wrong colour. -->
|
|
59
|
+
<meta name="theme-color" content="#ffffff" />
|
|
60
|
+
<meta name="format-detection" content="telephone=no" />
|
|
61
|
+
|
|
62
|
+
<Seo
|
|
63
|
+
title={title}
|
|
64
|
+
description={description}
|
|
65
|
+
canonical={canonical}
|
|
66
|
+
image={image}
|
|
67
|
+
imageAlt={imageAlt}
|
|
68
|
+
type={type}
|
|
69
|
+
publishedAt={publishedAt}
|
|
70
|
+
updatedAt={updatedAt}
|
|
71
|
+
noindex={noindex}
|
|
72
|
+
/>
|
|
73
|
+
<StructuredData type={schemaType} breadcrumbs={breadcrumbs} article={article} faq={faq} />
|
|
74
|
+
|
|
75
|
+
<!--
|
|
76
|
+
Order matters, and so does the artwork. Browsers prefer an SVG icon over
|
|
77
|
+
the .ico when both are declared — the original /logo/favicon.svg was a
|
|
78
|
+
different mark entirely (a blue stopwatch), so the tab showed the wrong
|
|
79
|
+
logo while the .ico was perfectly correct. If this SVG is ever replaced,
|
|
80
|
+
render it and look at it before shipping.
|
|
81
|
+
-->
|
|
82
|
+
{icons.ico && <link rel="icon" href={icons.ico} sizes="32x32" />}
|
|
83
|
+
{icons.svg && <link rel="icon" href={icons.svg} type="image/svg+xml" />}
|
|
84
|
+
{icons.appleTouch && <link rel="apple-touch-icon" href={icons.appleTouch} />}
|
|
85
|
+
{icons.manifest && <link rel="manifest" href="/site.webmanifest" />}
|
|
86
|
+
|
|
87
|
+
<!-- Fonts are same-origin, so a preload here is the whole story: no DNS,
|
|
88
|
+
no TLS handshake, no third-party round trip on the critical path.
|
|
89
|
+
Empty until src/data/fonts.ts names one. -->
|
|
90
|
+
{
|
|
91
|
+
preloadFonts.map((href) => (
|
|
92
|
+
<link rel="preload" href={href} as="font" type="font/woff2" crossorigin />
|
|
93
|
+
))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
<ClientRouter />
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
/* Analytics is null off production, so staging HTML contains zero
|
|
100
|
+
references — not a disabled snippet, no snippet at all. */
|
|
101
|
+
site.analytics && (
|
|
102
|
+
<>
|
|
103
|
+
<script
|
|
104
|
+
is:inline
|
|
105
|
+
async
|
|
106
|
+
src={`https://www.googletagmanager.com/gtag/js?id=${site.analytics.ga4Id}`}
|
|
107
|
+
/>
|
|
108
|
+
<script is:inline define:vars={{ ga4Id: site.analytics.ga4Id, gtmId: site.analytics.gtmId }}>
|
|
109
|
+
window.dataLayer = window.dataLayer || [];
|
|
110
|
+
function gtag() {
|
|
111
|
+
dataLayer.push(arguments);
|
|
112
|
+
}
|
|
113
|
+
gtag('js', new Date());
|
|
114
|
+
gtag('config', ga4Id);
|
|
115
|
+
|
|
116
|
+
/* `l` is declared. Google's published snippet takes it as a fourth
|
|
117
|
+
parameter; dropping the parameter without adding a `var` leaks an
|
|
118
|
+
implicit global called `l` onto every page — harmless until
|
|
119
|
+
something else wants that name, and impossible to find when it
|
|
120
|
+
is not. Inline scripts are non-strict, so nothing warns. */
|
|
121
|
+
(function (w, d, s, i) {
|
|
122
|
+
var l = 'dataLayer';
|
|
123
|
+
w[l] = w[l] || [];
|
|
124
|
+
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
|
|
125
|
+
var f = d.getElementsByTagName(s)[0],
|
|
126
|
+
j = d.createElement(s);
|
|
127
|
+
j.async = true;
|
|
128
|
+
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
|
|
129
|
+
f.parentNode.insertBefore(j, f);
|
|
130
|
+
})(window, document, 'script', gtmId);
|
|
131
|
+
</script>
|
|
132
|
+
</>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
/* Cloudflare Web Analytics — a separate pipe from the Google tags above,
|
|
138
|
+
and deliberately not gated on them. Cookieless, so it needs no consent
|
|
139
|
+
banner, and it reports real-user Core Web Vitals from the first visitor
|
|
140
|
+
rather than waiting months for CrUX to cover a new domain. */
|
|
141
|
+
site.cfBeaconToken && (
|
|
142
|
+
<script
|
|
143
|
+
is:inline
|
|
144
|
+
defer
|
|
145
|
+
src="https://static.cloudflareinsights.com/beacon.min.js"
|
|
146
|
+
data-cf-beacon={JSON.stringify({ token: site.cfBeaconToken })}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
</head>
|
|
151
|
+
|
|
152
|
+
<body class={bodyClass}>
|
|
153
|
+
{
|
|
154
|
+
site.analytics && (
|
|
155
|
+
<noscript>
|
|
156
|
+
<iframe
|
|
157
|
+
src={`https://www.googletagmanager.com/ns.html?id=${site.analytics.gtmId}`}
|
|
158
|
+
height="0"
|
|
159
|
+
width="0"
|
|
160
|
+
style="display:none;visibility:hidden"
|
|
161
|
+
title="Google Tag Manager"
|
|
162
|
+
/>
|
|
163
|
+
</noscript>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
<EnvBadge />
|
|
168
|
+
|
|
169
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
170
|
+
<Header />
|
|
171
|
+
<main id="main">
|
|
172
|
+
<slot />
|
|
173
|
+
</main>
|
|
174
|
+
<Footer />
|
|
175
|
+
|
|
176
|
+
<script>
|
|
177
|
+
/**
|
|
178
|
+
* Scroll reveal.
|
|
179
|
+
*
|
|
180
|
+
* The library adds the hidden state itself (data-reveal-ready on <html>)
|
|
181
|
+
* and only after it has confirmed it can observe. If this script never
|
|
182
|
+
* runs, every [data-reveal] element is simply visible — the page is
|
|
183
|
+
* never blank because a bundle failed.
|
|
184
|
+
*
|
|
185
|
+
* The observer is rooted at the viewport, which works because <body>
|
|
186
|
+
* uses `overflow-x: clip` rather than `hidden`; `hidden` would make body
|
|
187
|
+
* a scroll container and this would never fire.
|
|
188
|
+
*/
|
|
189
|
+
function setupReveal() {
|
|
190
|
+
const targets = document.querySelectorAll<HTMLElement>('[data-reveal]');
|
|
191
|
+
if (!targets.length) return;
|
|
192
|
+
|
|
193
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
194
|
+
if (reduced || !('IntersectionObserver' in window)) {
|
|
195
|
+
document.documentElement.removeAttribute('data-reveal-ready');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
document.documentElement.setAttribute('data-reveal-ready', '');
|
|
200
|
+
|
|
201
|
+
const observer = new IntersectionObserver(
|
|
202
|
+
(entries) => {
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (!entry.isIntersecting) continue;
|
|
205
|
+
entry.target.classList.add('is-revealed');
|
|
206
|
+
observer.unobserve(entry.target);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{ rootMargin: '0px 0px -12% 0px', threshold: 0.05 },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
for (const target of targets) observer.observe(target);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
document.addEventListener('astro:page-load', setupReveal);
|
|
216
|
+
|
|
217
|
+
// The router leaves <html> attributes in place across navigations.
|
|
218
|
+
document.addEventListener('astro:before-swap', () =>
|
|
219
|
+
document.documentElement.removeAttribute('data-reveal-ready'),
|
|
220
|
+
);
|
|
221
|
+
</script>
|
|
222
|
+
</body>
|
|
223
|
+
</html>
|