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,96 @@
1
+ /**
2
+ * Brevo transactional email.
3
+ *
4
+ * Deliberately thin: one fetch, no SDK. The API is three fields and a bearer
5
+ * header — a dependency here would be a liability with no upside.
6
+ */
7
+
8
+ import type { LeadRecord } from './lead';
9
+ import { business } from '../data/business';
10
+ import { site } from '../data/site';
11
+
12
+ const ENDPOINT = 'https://api.brevo.com/v3/smtp/email';
13
+
14
+ type SendResult = { ok: true; messageId?: string } | { ok: false; error: string };
15
+
16
+ async function send(
17
+ apiKey: string,
18
+ payload: Record<string, unknown>,
19
+ timeoutMs = 8000,
20
+ ): Promise<SendResult> {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
23
+ try {
24
+ const response = await fetch(ENDPOINT, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'api-key': apiKey,
28
+ 'content-type': 'application/json',
29
+ accept: 'application/json',
30
+ },
31
+ body: JSON.stringify(payload),
32
+ signal: controller.signal,
33
+ });
34
+
35
+ if (!response.ok) {
36
+ const body = await response.text().catch(() => '');
37
+ return { ok: false, error: `Brevo ${response.status}: ${body.slice(0, 300)}` };
38
+ }
39
+
40
+ const data = (await response.json().catch(() => ({}))) as { messageId?: string };
41
+ return { ok: true, messageId: data.messageId };
42
+ } catch (error) {
43
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ const escapeHtml = (value: string) =>
50
+ value.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
51
+
52
+ const row = (label: string, value: string) =>
53
+ value
54
+ ? `<tr><td style="padding:8px 16px 8px 0;color:#5b656e;font-size:14px;white-space:nowrap;vertical-align:top">${label}</td><td style="padding:8px 0;color:#14171a;font-size:14px"><strong>${escapeHtml(value)}</strong></td></tr>`
55
+ : '';
56
+
57
+ /** Internal new-lead notification. */
58
+ export function notifyTeam(apiKey: string, lead: LeadRecord) {
59
+ const subjectTag = lead.env === 'test' ? '[TEST] ' : '';
60
+ return send(apiKey, {
61
+ sender: { name: business.email.senderName, email: business.email.sender },
62
+ to: [{ email: site.leadNotifyTo, name: business.name }],
63
+ replyTo: { email: lead.email, name: lead.name },
64
+ subject: `${subjectTag}New quote request — ${lead.name}${lead.service ? ` (${lead.service})` : ''}`,
65
+ htmlContent: `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f6f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
66
+ <div style="max-width:560px;margin:0 auto;background:#fff;border-radius:16px;padding:28px;border:1px solid #e3e8eb">
67
+ ${lead.env === 'test' ? '<p style="margin:0 0 16px;padding:8px 12px;background:#fdf6e3;border-radius:8px;color:#7a5c00;font-size:13px">Submitted from staging — this is a test lead.</p>' : ''}
68
+ <h1 style="margin:0 0 4px;font-size:20px;color:#14171a">New quote request</h1>
69
+ <p style="margin:0 0 20px;color:#5b656e;font-size:14px">${new Date(lead.receivedAt).toLocaleString(business.locale, { timeZone: business.address.timeZone, dateStyle: 'full', timeStyle: 'short' })} ${business.address.timeZoneLabel}</p>
70
+ <table style="width:100%;border-collapse:collapse">
71
+ ${row('Name', lead.name)}
72
+ ${row('Email', lead.email)}
73
+ ${row('Phone', lead.phone)}
74
+ ${row('Service', lead.service)}
75
+ ${row('Page', lead.page)}
76
+ </table>
77
+ ${lead.message ? `<div style="margin-top:20px;padding-top:20px;border-top:1px solid #e3e8eb"><p style="margin:0 0 6px;color:#5b656e;font-size:13px">Message</p><p style="margin:0;color:#14171a;font-size:15px;line-height:1.6;white-space:pre-wrap">${escapeHtml(lead.message)}</p></div>` : ''}
78
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #e3e8eb;color:#78838c;font-size:12px">Lead ${lead.id} · reply directly to reach ${escapeHtml(lead.name)}</p>
79
+ </div></body></html>`,
80
+ textContent: [
81
+ lead.env === 'test' ? '[TEST LEAD — submitted from staging]' : '',
82
+ 'New quote request',
83
+ `Name: ${lead.name}`,
84
+ `Email: ${lead.email}`,
85
+ `Phone: ${lead.phone}`,
86
+ `Service: ${lead.service}`,
87
+ `Page: ${lead.page}`,
88
+ '',
89
+ lead.message,
90
+ '',
91
+ `Lead ${lead.id} · ${lead.receivedAt}`,
92
+ ]
93
+ .filter(Boolean)
94
+ .join('\n'),
95
+ });
96
+ }
@@ -0,0 +1,55 @@
1
+ import { defineHastPlugin } from 'satteri';
2
+ // Generated by `npm run media`. Empty on a fresh project — that is fine.
3
+ import manifest from '../data/image-manifest.json' with { type: 'json' };
4
+
5
+ /** `/img/blog/name-1200.webp` → `blog/name`, the manifest key. */
6
+ function manifestKey(src) {
7
+ const match = /^\/img\/(.+)-\d+\.webp$/.exec(typeof src === 'string' ? src : '');
8
+ return match ? match[1] : null;
9
+ }
10
+
11
+ /**
12
+ * Upgrades markdown output at build time.
13
+ *
14
+ * - <img> gains srcset/sizes and intrinsic width/height from the media
15
+ * manifest, so article bodies never shift as images load. The markdown
16
+ * itself keeps a plain root-relative path — portable, and the dev server
17
+ * serves it directly.
18
+ * - <table> is wrapped in a scroll container, so a wide table scrolls itself
19
+ * rather than making the whole page scroll sideways on a phone.
20
+ *
21
+ * Written against Sätteri's native hast API rather than rehype: the legacy
22
+ * rehype path needs @astrojs/markdown-remark and swaps the whole pipeline back
23
+ * to unified, which is a dependency and a slowdown for no gain here.
24
+ */
25
+ export const hastMedia = defineHastPlugin({
26
+ name: 'media-manifest',
27
+ element: [
28
+ {
29
+ filter: ['img'],
30
+ visit(node, ctx) {
31
+ const entry = manifest[manifestKey(node.properties?.src)];
32
+ if (!entry) return;
33
+ ctx.setProperty(node, 'srcset', entry.srcset);
34
+ ctx.setProperty(node, 'sizes', '(min-width: 46rem) 44rem, 92vw');
35
+ ctx.setProperty(node, 'width', entry.width);
36
+ ctx.setProperty(node, 'height', entry.height);
37
+ if (!node.properties?.loading) ctx.setProperty(node, 'loading', 'lazy');
38
+ if (!node.properties?.decoding) ctx.setProperty(node, 'decoding', 'async');
39
+ },
40
+ },
41
+ {
42
+ filter: ['table'],
43
+ visit(node, ctx) {
44
+ const parent = ctx.parent(node);
45
+ if (parent?.type === 'element' && parent.properties?.className?.includes?.('table-scroll')) return;
46
+ ctx.wrapNode(node, {
47
+ type: 'element',
48
+ tagName: 'div',
49
+ properties: { className: ['table-scroll'] },
50
+ children: [],
51
+ });
52
+ },
53
+ },
54
+ ],
55
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Per-route `lastmod` for the sitemap.
3
+ *
4
+ * Reads src/data/lastmod.json, which is generated by `npm run lastmod` and
5
+ * committed. This module does no git work — it exists so astro.config.mjs can
6
+ * stay readable.
7
+ *
8
+ * ── WHY THE DATES ARE COMMITTED DATA AND NOT READ FROM git HERE ────────────
9
+ * The first version shelled out to `git log` during the build. It produced
10
+ * correct dates locally and NOTHING in production: Cloudflare Workers Builds
11
+ * shallow-clones, so per-file history does not exist there, and the module's
12
+ * own shallow guard correctly refused to stamp all 23 pages with one date.
13
+ *
14
+ * The guard was right; depending on history the build may not have been given
15
+ * was the mistake. Confirmed after deploying — the live sitemap had zero
16
+ * `lastmod` elements while the local build had 23.
17
+ *
18
+ * ── WHY NOT JUST STAMP THE BUILD TIME ─────────────────────────────────────
19
+ * Because it claims every page changed on every deploy, including deploys that
20
+ * only touched a script. Google uses `lastmod` where a site's values are
21
+ * consistently accurate and discounts them where they are not, so a
22
+ * plausible-looking wrong date does not buy a faster re-crawl — it spends the
23
+ * credibility that would have earned one.
24
+ *
25
+ * ── WHY LAYOUT AND NAV CHANGES DO NOT MOVE ANY DATE ───────────────────────
26
+ * An earlier cut treated src/layouts/Base.astro and src/data/nav.ts as sources
27
+ * for every route, reasoning that a footer link really does change all 23
28
+ * documents. True, and still wrong: it collapsed every date to the same day,
29
+ * which from outside is indistinguishable from the build-timestamp failure
30
+ * above and carries no prioritisation signal at all. Google's guidance asks
31
+ * for the last date a page's CONTENT changed significantly and says explicitly
32
+ * not to bump it for navigation or boilerplate.
33
+ *
34
+ * If the JSON is missing or unreadable, no `lastmod` is emitted anywhere.
35
+ * "I don't know" is a valid thing for a sitemap to say; guessing is not.
36
+ */
37
+
38
+ import { readFileSync } from 'node:fs';
39
+
40
+ export function lastmodByRoute() {
41
+ try {
42
+ return new Map(Object.entries(JSON.parse(readFileSync('src/data/lastmod.json', 'utf8'))));
43
+ } catch {
44
+ console.warn('[sitemap] src/data/lastmod.json missing — omitting lastmod. Run `npm run lastmod`.');
45
+ return new Map();
46
+ }
47
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Lead validation and shape. Shared by the API route and (for the field names)
3
+ * the form component, so the two cannot drift.
4
+ */
5
+
6
+ export type LeadField = 'name' | 'email' | 'phone' | 'service' | 'message';
7
+
8
+ export type LeadInput = Record<LeadField, string> & {
9
+ /** Honeypot. Real users never fill this. */
10
+ company?: string;
11
+ page?: string;
12
+ };
13
+
14
+ export type LeadRecord = {
15
+ id: string;
16
+ receivedAt: string;
17
+ env: 'live' | 'test';
18
+ name: string;
19
+ email: string;
20
+ phone: string;
21
+ service: string;
22
+ message: string;
23
+ page: string;
24
+ userAgent: string;
25
+ country: string;
26
+ ip: string;
27
+ };
28
+
29
+ /**
30
+ * The form's service dropdown. Keep in step with `src/data/services.ts` — these
31
+ * strings land in the lead record and the notification email, so a value that
32
+ * does not match a real service makes the enquiry harder to route, not easier.
33
+ * Always keep a final catch-all option.
34
+ */
35
+ export const SERVICE_OPTIONS = [
36
+ 'Service one',
37
+ 'Service two',
38
+ 'Something else',
39
+ ] as const;
40
+
41
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
42
+
43
+ /** Digits only, 10 or 11 (with a leading 1). Formatting is the user's business. */
44
+ const digits = (value: string) => value.replace(/\D/g, '');
45
+
46
+ export function validateLead(input: Partial<LeadInput>): {
47
+ ok: boolean;
48
+ errors: Partial<Record<LeadField, string>>;
49
+ values: Record<LeadField, string>;
50
+ } {
51
+ const values = {
52
+ name: (input.name ?? '').trim(),
53
+ email: (input.email ?? '').trim(),
54
+ phone: (input.phone ?? '').trim(),
55
+ service: (input.service ?? '').trim(),
56
+ message: (input.message ?? '').trim(),
57
+ };
58
+
59
+ const errors: Partial<Record<LeadField, string>> = {};
60
+
61
+ if (values.name.length < 2) errors.name = 'Please tell us your name.';
62
+ else if (values.name.length > 100) errors.name = 'That name is too long.';
63
+
64
+ if (!values.email) errors.email = 'We need an email to send your quote to.';
65
+ else if (!EMAIL.test(values.email) || values.email.length > 200)
66
+ errors.email = 'That email address does not look right.';
67
+
68
+ /*
69
+ * Country-agnostic on purpose. E.164 caps a number at 15 digits including the
70
+ * country code, and 7 is about the shortest real national number, so anything
71
+ * in that band is plausible somewhere. A 10-digit rule is a US rule, and it
72
+ * silently rejects every UK, Irish and Australian visitor on a kit whose own
73
+ * compliance reference covers the EAA and AODA.
74
+ *
75
+ * ⚠ Tighten this per project if you serve one country. Do not tighten it in
76
+ * the template.
77
+ */
78
+ const phoneDigits = digits(values.phone);
79
+ if (!values.phone) errors.phone = 'A phone number lets us confirm your appointment.';
80
+ else if (phoneDigits.length < 7 || phoneDigits.length > 15)
81
+ errors.phone = 'That phone number does not look right.';
82
+
83
+ if (values.message.length > 4000) errors.message = 'Please keep your message under 4000 characters.';
84
+
85
+ return { ok: Object.keys(errors).length === 0, errors, values };
86
+ }
87
+
88
+ /**
89
+ * Key format sorts chronologically in a KV list, so the CSV export comes out
90
+ * newest-last without a sort step and pagination is stable.
91
+ */
92
+ export const leadKey = (receivedAt: string, id: string) => `lead:${receivedAt}:${id}`;
@@ -0,0 +1,31 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+
3
+ const DIR = 'src/content/legal';
4
+
5
+ /**
6
+ * Legal routes whose frontmatter says `noindex: true`.
7
+ *
8
+ * Read at CONFIG time, so `@astrojs/sitemap` can leave them out.
9
+ *
10
+ * `check-sitemap.mjs` explains why the sitemap's exclusion list is hand-kept in
11
+ * general: inclusion is decided in astro.config.mjs, before any page renders,
12
+ * so at that moment nothing knows which routes will emit the tag. Legal pages
13
+ * are the exception — their `noindex` is frontmatter sitting on disk, readable
14
+ * without rendering anything, so this one list can derive rather than drift.
15
+ *
16
+ * Deliberately a flat regex rather than a YAML parser: the config should not
17
+ * pull in a second content pipeline to answer one boolean, and if the shape
18
+ * ever stops matching, `npm run check:sitemap` fails the production build —
19
+ * which is exactly the cross-check that exists for this.
20
+ */
21
+ export function noindexLegalRoutes() {
22
+ if (!existsSync(DIR)) return [];
23
+
24
+ return readdirSync(DIR)
25
+ .filter((file) => file.endsWith('.md'))
26
+ .filter((file) => {
27
+ const frontmatter = readFileSync(`${DIR}/${file}`, 'utf8').split(/^---\s*$/m)[1] ?? '';
28
+ return /^\s*noindex:\s*true\s*$/m.test(frontmatter);
29
+ })
30
+ .map((file) => `/${file.replace(/\.md$/, '')}/`);
31
+ }
@@ -0,0 +1,75 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+
3
+ import { business } from '../data/business';
4
+ import type { NavItem } from '../data/nav';
5
+
6
+ export type LegalPage = CollectionEntry<'legal'>;
7
+
8
+ /*
9
+ * ⚠ Does the collection have anything in it? Resolved by Vite at build time,
10
+ * so this costs nothing at runtime.
11
+ *
12
+ * `getCollection` on an EMPTY collection logs "The collection \"legal\" does not
13
+ * exist or is empty" — and the footer runs on every page, so a fresh template
14
+ * printed that line once per route on every build. Nothing was wrong; an empty
15
+ * legal collection is the normal state of a site nobody has written a privacy
16
+ * policy for yet. A starter that shouts at you about the correct state is a
17
+ * starter people stop reading the output of.
18
+ */
19
+ const HAS_PAGES = Object.keys(import.meta.glob('../content/legal/**/*.md')).length > 0;
20
+
21
+ /**
22
+ * Legal and policy pages, in footer order.
23
+ *
24
+ * Empty in a fresh template, and that is the point: nothing is emitted and
25
+ * nothing is linked until a page is actually written.
26
+ */
27
+ export async function getLegalPages(): Promise<LegalPage[]> {
28
+ if (!HAS_PAGES) return [];
29
+ const pages = await getCollection('legal');
30
+ return pages.sort(
31
+ (a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The same pages as footer links.
37
+ *
38
+ * `nav.ts` explains why privacy and terms were never listed there by hand: a
39
+ * footer link to a page nobody has written yet is a 404 on EVERY page of the
40
+ * site, and nothing reports it. Deriving the links from the pages themselves
41
+ * removes that — the link cannot exist without the page.
42
+ *
43
+ * A `noindex` page is still linked. It has to be reachable to be enforceable;
44
+ * not being indexed is a separate decision from not being findable.
45
+ */
46
+ export async function getLegalLinks(): Promise<NavItem[]> {
47
+ const pages = await getLegalPages();
48
+ return pages.map((page) => ({
49
+ label: page.data.navLabel ?? page.data.title,
50
+ href: `/${page.id}/`,
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * ⚠ PINNED TO UTC, AND THAT IS THE WHOLE FUNCTION.
56
+ *
57
+ * An effective date is a calendar date, not an instant. `2026-08-21` parsed as
58
+ * a Date is midnight *Z*, and rendering that in the build machine's zone gives
59
+ * 20 August anywhere west of Greenwich — measured: US Pacific and US Eastern
60
+ * shift it, London and Tokyo do not. On a legal page that is the date the terms
61
+ * took effect being wrong by a day, on a page written precisely so a date can
62
+ * be relied on, with a clean build and nothing to report it.
63
+ *
64
+ * The schema keeps these as strings for the same reason. This builds the
65
+ * instant explicitly from the parts and formats it in the zone it was built in.
66
+ */
67
+ export function formatLegalDate(iso: string): string {
68
+ const [year, month, day] = iso.split('-').map(Number);
69
+ return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString(business.locale, {
70
+ month: 'long',
71
+ day: 'numeric',
72
+ year: 'numeric',
73
+ timeZone: 'UTC',
74
+ });
75
+ }
@@ -0,0 +1,64 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import { business } from '../data/business';
3
+
4
+ export type Post = CollectionEntry<'blog'>;
5
+
6
+ /** Published posts, newest first. Drafts never reach a build. */
7
+ export async function getPosts(): Promise<Post[]> {
8
+ const posts = await getCollection('blog', ({ data }) => !data.draft);
9
+ return posts.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime());
10
+ }
11
+
12
+ export async function getPostsByCategory(categoryName: string): Promise<Post[]> {
13
+ const posts = await getPosts();
14
+ return posts.filter((post) => post.data.categories.includes(categoryName));
15
+ }
16
+
17
+ /**
18
+ * Related posts: prefer ones sharing the most categories, then the most recent.
19
+ * "General" is on nearly everything, so it is worth little as a signal.
20
+ */
21
+ export function relatedPosts(post: Post, all: Post[], limit = 3): Post[] {
22
+ const weight = (name: string) => (name === 'General' ? 1 : 4);
23
+ return all
24
+ .filter((candidate) => candidate.id !== post.id)
25
+ .map((candidate) => ({
26
+ candidate,
27
+ score: candidate.data.categories
28
+ .filter((c) => post.data.categories.includes(c))
29
+ .reduce((sum, c) => sum + weight(c), 0),
30
+ }))
31
+ .sort(
32
+ (a, b) =>
33
+ b.score - a.score ||
34
+ b.candidate.data.publishedAt.getTime() - a.candidate.data.publishedAt.getTime(),
35
+ )
36
+ .slice(0, limit)
37
+ .map((entry) => entry.candidate);
38
+ }
39
+
40
+ /** ~230 wpm, rounded up. Matches what Rank Math reported on the old site. */
41
+ export function readingMinutes(body: string): number {
42
+ const words = body.trim().split(/\s+/).length;
43
+ return Math.max(1, Math.round(words / 230));
44
+ }
45
+
46
+ /**
47
+ * ⚠ `timeZone: 'UTC'` is load-bearing, not a default being spelled out.
48
+ *
49
+ * `publishedAt: 2026-08-21` in frontmatter becomes 2026-08-21T00:00:00Z, and
50
+ * formatting that instant in the BUILD MACHINE's zone renders 20 August
51
+ * anywhere west of Greenwich. Measured: US Pacific and US Eastern both shift
52
+ * it; London and Tokyo do not. So the same commit published a different date
53
+ * depending on who ran the build, and nothing anywhere reported it.
54
+ *
55
+ * The locale still comes from the business — that decides the ORDER and the
56
+ * wording. Only the zone is pinned.
57
+ */
58
+ export const formatDate = (date: Date) =>
59
+ date.toLocaleDateString(business.locale, {
60
+ month: 'long',
61
+ day: 'numeric',
62
+ year: 'numeric',
63
+ timeZone: 'UTC',
64
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Access to Cloudflare bindings.
3
+ *
4
+ * `Astro.locals.runtime.env` was removed in Astro v6 — reading it throws at
5
+ * request time while the build, the types and the deploy all stay green. The
6
+ * only signal is an actual request. Everything that touches a binding goes
7
+ * through here so there is one place to change if it moves again.
8
+ *
9
+ * `env` from `cloudflare:workers` is a lazy proxy: importing it at module scope
10
+ * is safe, but touching a property outside a request context is not. Only read
11
+ * it inside a handler.
12
+ */
13
+ import { env } from 'cloudflare:workers';
14
+
15
+ type Bindings = {
16
+ LEADS?: KVNamespace;
17
+ LEADS_STAGING?: KVNamespace;
18
+ BREVO_API_KEY?: string;
19
+ LEADS_EXPORT_TOKEN?: string;
20
+ };
21
+
22
+ export function bindings(): Bindings {
23
+ return env as unknown as Bindings;
24
+ }
25
+
26
+ export function kv(name: 'LEADS' | 'LEADS_STAGING'): KVNamespace | undefined {
27
+ return bindings()[name];
28
+ }
29
+
30
+ export function secret(name: 'BREVO_API_KEY' | 'LEADS_EXPORT_TOKEN'): string | undefined {
31
+ const value = bindings()[name];
32
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
33
+ }
@@ -0,0 +1,51 @@
1
+ ---
2
+ import Base from '../layouts/Base.astro';
3
+ import { business } from '../data/business';
4
+ import { nav } from '../data/nav';
5
+
6
+ /**
7
+ * A real 404 — the worker returns status 404 for this route, not 200.
8
+ *
9
+ * The onward links come from src/data/nav.ts so they can never point at a page
10
+ * that has since been removed. Offer SPECIFIC routes, not "go home": someone
11
+ * who landed on a dead link wanted something particular, and a link back to
12
+ * the homepage asks them to start the search again.
13
+ */
14
+ ---
15
+
16
+ <Base
17
+ title={`Page not found - ${business.name}`}
18
+ description="The page you were looking for could not be found."
19
+ noindex
20
+ >
21
+ <section class="section under-header">
22
+ <div class="container container--text">
23
+ <h1>This page doesn’t exist.</h1>
24
+ <p class="notfound__lede">
25
+ The link may be out of date, or the page may have moved. Here is where most people are
26
+ heading — or call and we will point you the right way.
27
+ </p>
28
+
29
+ <ul role="list" class="notfound__links">
30
+ {nav.map((link) => <li><a href={link.href}>{link.label}</a></li>)}
31
+ </ul>
32
+
33
+ <p><a class="btn" href={business.phone.href}>Call {business.phone.display}</a></p>
34
+ </div>
35
+ </section>
36
+ </Base>
37
+
38
+ <style>
39
+ .notfound__lede {
40
+ margin-block-start: var(--space-m);
41
+ font-size: var(--step-1);
42
+ color: var(--text-muted);
43
+ }
44
+
45
+ .notfound__links {
46
+ display: grid;
47
+ gap: var(--space-2xs);
48
+ margin-block: var(--space-xl);
49
+ padding: 0;
50
+ }
51
+ </style>
@@ -0,0 +1,111 @@
1
+ ---
2
+ /**
3
+ * Legal and policy pages: /privacy/, /terms/, /house-rules/.
4
+ *
5
+ * One route for all of them. Three near-identical .astro files is the thing
6
+ * this replaces, and the second cost is worse than the duplication: an editor
7
+ * who cannot change an effective date without a developer.
8
+ *
9
+ * ── WHY THIS SITS AT THE ROOT ──────────────────────────────────────────────
10
+ * Because /privacy/ is the URL, and on a migration it is a URL that already
11
+ * exists and must be preserved. /legal/privacy/ would be a redirect to write
12
+ * and a link everyone else has wrong.
13
+ *
14
+ * ── WHICH MAKES IT A CATCH-ALL, SO IT GUARDS ITSELF ────────────────────────
15
+ * Astro gives a static route precedence over a dynamic one, so adding
16
+ * src/content/legal/contact.md does NOT break /contact/ — it silently does
17
+ * nothing, which is worse. You would have a page in the collection, a link to
18
+ * it in the footer, and the contact form served at that URL. The check below
19
+ * turns that into a build failure naming both files.
20
+ *
21
+ * ── THERE IS NO DESIGN HERE, ON PURPOSE ────────────────────────────────────
22
+ * A heading, a date line and the prose. Legal pages are the one page type with
23
+ * genuinely nothing to art-direct, and `prose.css` already carries the
24
+ * long-form treatment this project chose. If this page needs a look, the look
25
+ * belongs in project.css.
26
+ */
27
+ import { render } from 'astro:content';
28
+
29
+ import PageHero from '../components/PageHero.astro';
30
+ import Base from '../layouts/Base.astro';
31
+ import { business } from '../data/business';
32
+ import { getLegalPages, formatLegalDate, type LegalPage } from '../lib/legal';
33
+
34
+ export async function getStaticPaths() {
35
+ /*
36
+ * Every route file that already exists, as its first path segment.
37
+ *
38
+ * import.meta.glob is resolved at build time, so this is the real directory
39
+ * rather than a list somebody maintains — the same reason the scripts
40
+ * discover routes instead of carrying an array.
41
+ *
42
+ * Declared INSIDE getStaticPaths deliberately: Astro hoists this function
43
+ * into its own scope for the prerender pass, so a module-level const here is
44
+ * `ReferenceError: RESERVED is not defined` at build time, not a lint error.
45
+ */
46
+ const reserved = new Set(
47
+ Object.keys(import.meta.glob('./**/*.{astro,md,ts,js}'))
48
+ .map((file) => file.replace(/^\.\//, '').split('/')[0])
49
+ .map((segment) => segment.replace(/\.(astro|md|ts|js)$/, ''))
50
+ .filter((segment) => !segment.startsWith('[')),
51
+ );
52
+
53
+ const pages = await getLegalPages();
54
+
55
+ for (const page of pages) {
56
+ if (reserved.has(page.id)) {
57
+ throw new Error(
58
+ `src/content/legal/${page.id}.md would be served at /${page.id}/, but src/pages/${page.id} ` +
59
+ `already claims that route.\n` +
60
+ `Astro gives the existing page precedence, so the legal page would be built, linked ` +
61
+ `from the footer, and never reachable.\n` +
62
+ `Rename the markdown file, or delete the page that shadows it.`,
63
+ );
64
+ }
65
+ }
66
+
67
+ return pages.map((page) => ({ params: { slug: page.id }, props: { page } }));
68
+ }
69
+
70
+ interface Props {
71
+ page: LegalPage;
72
+ }
73
+
74
+ const { page } = Astro.props;
75
+ const { Content } = await render(page);
76
+ ---
77
+
78
+ <Base
79
+ title={`${page.data.title} - ${business.name}`}
80
+ description={page.data.description}
81
+ canonical={`/${page.id}/`}
82
+ noindex={page.data.noindex}
83
+ breadcrumbs={[{ name: 'Home', href: '/' }, { name: page.data.title, href: `/${page.id}/` }]}
84
+ >
85
+ <PageHero
86
+ title={page.data.title}
87
+ breadcrumbs={[{ name: 'Home', href: '/' }, { name: page.data.title, href: `/${page.id}/` }]}
88
+ />
89
+
90
+ <section class="section">
91
+ <div class="container container--text prose">
92
+ {/* A <time> with a machine-readable datetime: the visible string is
93
+ localised, the attribute stays ISO for anything that parses it. */}
94
+ <p>
95
+ <strong>Effective <time datetime={page.data.effective}>{formatLegalDate(page.data.effective)}</time></strong>
96
+ {
97
+ page.data.updated && (
98
+ <>
99
+ {' · '}
100
+ <span>
101
+ Last updated <time datetime={page.data.updated}>{formatLegalDate(page.data.updated)}</time>
102
+ </span>
103
+ </>
104
+ )
105
+ }
106
+ </p>
107
+
108
+ <Content />
109
+ </div>
110
+ </section>
111
+ </Base>