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,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>