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,129 @@
1
+ ---
2
+ /**
3
+ * Responsive WebP <img> driven by the build-time manifest.
4
+ *
5
+ * Takes a *portable reference* — `photos/hero-home`, not a URL — and resolves
6
+ * it to a src/srcset plus intrinsic width and height. Every image therefore
7
+ * carries dimensions, so nothing shifts while it loads, and moving where the
8
+ * images are served from is one change in the manifest generator rather than a
9
+ * content migration.
10
+ */
11
+ import manifest from '../data/image-manifest.json';
12
+
13
+ interface Props {
14
+ /** Manifest key, e.g. "photos/hero-home". */
15
+ name: string;
16
+ alt: string;
17
+ /** `sizes` attribute — how wide the image renders at each breakpoint. */
18
+ sizes?: string;
19
+ /** Above-the-fold images should be eager + high priority. */
20
+ priority?: boolean;
21
+ class?: string;
22
+ /** Crop to a ratio via CSS, e.g. "16/9". Keeps the intrinsic box honest. */
23
+ ratio?: string;
24
+ loading?: 'lazy' | 'eager';
25
+ style?: string;
26
+ }
27
+
28
+ const {
29
+ name,
30
+ alt,
31
+ sizes = '100vw',
32
+ priority = false,
33
+ class: className,
34
+ ratio,
35
+ loading,
36
+ style,
37
+ } = Astro.props;
38
+
39
+ const entry = (manifest as Record<string, { src: string; srcset: string; width: number; height: number; widths?: number[]; avifSrcset?: string | null }>)[name];
40
+
41
+ if (!entry) {
42
+ // Fail the build, not the page. A missing image is a content bug and should
43
+ // never reach a deploy as a silently broken <img>.
44
+ throw new Error(
45
+ `Img: no manifest entry for "${name}". Run \`npm run media\` after adding it to media/source/.`,
46
+ );
47
+ }
48
+
49
+ const eager = priority || loading === 'eager';
50
+ const objectStyle = ratio ? `aspect-ratio:${ratio};object-fit:cover;` : '';
51
+
52
+ /*
53
+ * ── THE `src` FALLBACK IS NOT THE LARGEST VARIANT ──────────────────────────
54
+ * The manifest's `src` points at the widest file generated. Real browsers never
55
+ * fetch it — `srcset` and `sizes` govern, so a phone takes the smallest that
56
+ * fits.
57
+ *
58
+ * But anything ignoring `srcset` takes `src` literally: crawlers, SEO auditors,
59
+ * feed readers and parts of Google's image pipeline. On one site that meant an
60
+ * audit tool correctly reporting a 380 KB homepage image that no visitor ever
61
+ * downloaded.
62
+ *
63
+ * So `src` is the widest variant at or under FALLBACK_WIDTH, and `srcset` stays
64
+ * complete. Costs real users nothing — they never use it.
65
+ *
66
+ * ⚠ Do NOT "fix" this by trimming the manifest's larger widths. Those are what
67
+ * a 2x or 3x display actually selects through `srcset`.
68
+ */
69
+ const FALLBACK_WIDTH = 1024;
70
+ const candidates = (entry.widths ?? []).filter((w) => w <= FALLBACK_WIDTH);
71
+ const fallbackWidth = candidates.length ? Math.max(...candidates) : null;
72
+ const fallbackSrc =
73
+ fallbackWidth && entry.srcset
74
+ ? (entry.srcset
75
+ .split(',')
76
+ .map((part) => part.trim())
77
+ .find((part) => part.endsWith(` ${fallbackWidth}w`))
78
+ ?.split(' ')[0] ?? entry.src)
79
+ : entry.src;
80
+
81
+ /*
82
+ * <picture> only wraps; the <img> is still the element that lays out, carries
83
+ * the class, and holds width/height. Order matters — the browser takes the
84
+ * FIRST <source> it can decode, so AVIF comes before the WebP fallback, and
85
+ * the <img> is what anything without AVIF support gets.
86
+ *
87
+ * The `src` on the <img> stays WebP deliberately: it is the fallback, and the
88
+ * FALLBACK_WIDTH rule above still governs which width it points at.
89
+ *
90
+ * Written here and not as an HTML comment — `<!-- -->` in an Astro template
91
+ * body is emitted into every rendered page.
92
+ */
93
+ ---
94
+
95
+ <picture>
96
+ {entry.avifSrcset && <source type="image/avif" srcset={entry.avifSrcset} sizes={sizes} />}
97
+ <img
98
+ src={fallbackSrc}
99
+ srcset={entry.srcset}
100
+ sizes={sizes}
101
+ width={entry.width}
102
+ height={entry.height}
103
+ alt={alt}
104
+ class={className}
105
+ loading={eager ? 'eager' : 'lazy'}
106
+ decoding={eager ? 'sync' : 'async'}
107
+ fetchpriority={priority ? 'high' : undefined}
108
+ style={[objectStyle, style].filter(Boolean).join('')}
109
+ />
110
+ </picture>
111
+
112
+ <style>
113
+ /*
114
+ * `display: contents` so <picture> adds no box.
115
+ *
116
+ * Without it the wrapper becomes an inline box between the layout parent and
117
+ * the image, and a grid or flex parent starts laying out the PICTURE instead
118
+ * of the img — `aspect-ratio`, `object-fit` and any `height: 100%` on the
119
+ * image quietly stop doing what they did. Nothing errors; the page just
120
+ * shifts, which is the worst way to find out.
121
+ *
122
+ * It does not rescue a direct-child selector: `.grid > img` no longer matches
123
+ * once there is a <picture> in between. Search for `> img` after turning AVIF
124
+ * on — see docs/traps.md.
125
+ */
126
+ picture {
127
+ display: contents;
128
+ }
129
+ </style>
@@ -0,0 +1,88 @@
1
+ ---
2
+ /**
3
+ * The opening block of an interior page: breadcrumbs, h1, optional lede.
4
+ *
5
+ * Structure only. There is no treatment here on purpose — no background, no
6
+ * image overlay, no gradient. What the opening of a page looks like is the
7
+ * single most identifying decision on the site; if it ships in the template,
8
+ * every site built from it opens the same way.
9
+ *
10
+ * Style it per project, or replace this component entirely. What is worth
11
+ * keeping is the breadcrumb markup: an ordered list inside a labelled <nav>
12
+ * with aria-current on the last item, which is what assistive tech and
13
+ * BreadcrumbList structured data both expect.
14
+ */
15
+
16
+ interface Props {
17
+ title: string;
18
+ lede?: string;
19
+ breadcrumbs?: { name: string; href: string }[];
20
+ /** Extra classes for the section — the project's own treatment. */
21
+ class?: string;
22
+ }
23
+
24
+ const { title, lede, breadcrumbs, class: className = '' } = Astro.props;
25
+
26
+ /* Narrowed once here rather than inside the template: a `&&` guard in an Astro
27
+ expression does not carry the narrowing into the callback, so `.map((crumb, i)`
28
+ picks up implicit `any` and stops being checked at all. */
29
+ const crumbs = breadcrumbs ?? [];
30
+ ---
31
+
32
+ <section class:list={['hero', 'under-header', 'section--tight', className]}>
33
+ <div class="container">
34
+ {
35
+ crumbs.length > 0 && (
36
+ <nav class="hero__crumbs" aria-label="Breadcrumb">
37
+ <ol role="list">
38
+ {crumbs.map((crumb, i) => (
39
+ <li>
40
+ {i < crumbs.length - 1 ? (
41
+ <>
42
+ <a href={crumb.href}>{crumb.name}</a>
43
+ <span aria-hidden="true">/</span>
44
+ </>
45
+ ) : (
46
+ <span aria-current="page">{crumb.name}</span>
47
+ )}
48
+ </li>
49
+ ))}
50
+ </ol>
51
+ </nav>
52
+ )
53
+ }
54
+
55
+ <h1>{title}</h1>
56
+ {lede && <p class="hero__lede">{lede}</p>}
57
+ <slot />
58
+ </div>
59
+ </section>
60
+
61
+ <style>
62
+ .hero__crumbs ol {
63
+ display: flex;
64
+ flex-wrap: wrap;
65
+ align-items: center;
66
+ gap: var(--space-3xs);
67
+ margin-block-end: var(--space-m);
68
+ padding: 0;
69
+ list-style: none;
70
+ font-size: var(--step--1);
71
+ color: var(--text-muted);
72
+ }
73
+
74
+ .hero__crumbs li {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: var(--space-3xs);
78
+ }
79
+
80
+ .hero__lede {
81
+ margin-block-start: var(--space-m);
82
+ font-size: var(--step-1);
83
+ color: var(--text-muted);
84
+ /* Long-form text at full container width is the most common reason a page
85
+ reads as a document rather than a designed thing. */
86
+ max-inline-size: var(--measure);
87
+ }
88
+ </style>
@@ -0,0 +1,119 @@
1
+ ---
2
+ /**
3
+ * Every <head> tag that search engines and social scrapers read.
4
+ *
5
+ * Titles and descriptions were diffed against the Rank Math export; see
6
+ * docs/seo-parity.md for the table and the reason behind each difference.
7
+ */
8
+ import { site, isProduction } from '../data/site';
9
+ import { business } from '../data/business';
10
+ import manifest from '../data/image-manifest.json';
11
+
12
+ type ManifestEntry = { src: string; width: number; height: number };
13
+ const images = manifest as Record<string, ManifestEntry>;
14
+
15
+ /**
16
+ * Resolve a page image to the JPEG twin the media pipeline emits for social.
17
+ *
18
+ * The site serves WebP everywhere, but Facebook and LinkedIn still fail to
19
+ * render a WebP og:image, so a shared post would unfurl with no picture at all.
20
+ * Anything without a twin falls back to the branded default card.
21
+ */
22
+ /*
23
+ * Returns null when the project has no card yet, and the tags are then OMITTED.
24
+ *
25
+ * This used to emit `/img/social/og-default.jpg` regardless, reasoning that a
26
+ * well-formed tag was better than a missing one. It is not: the file does not
27
+ * exist until `npm run media` or `npm run cards` has run, so every page
28
+ * advertised a card that 404s. Scrapers cache that failure, the markup looks
29
+ * correct, and `npm run verify` was the first thing ever to notice.
30
+ *
31
+ * A missing og:image degrades to a title-and-description unfurl. A broken one
32
+ * is a blank rectangle plus a cached negative. Same reasoning as
33
+ * `business.logoPath` in the structured data.
34
+ */
35
+ function socialCard(src: string | undefined): ManifestEntry | null {
36
+ const fallback = images['social/og-default'] ?? null;
37
+ if (!src) return fallback;
38
+ if (src.endsWith('.jpg') || src.endsWith('.jpeg')) {
39
+ const direct = Object.values(images).find((entry) => entry.src === src);
40
+ return direct ?? fallback;
41
+ }
42
+ const name = /\/img\/[^/]+\/(.+?)(?:-\d+)?\.webp$/.exec(src)?.[1];
43
+ return (name && images[`social/${name}`]) || fallback;
44
+ }
45
+
46
+ interface Props {
47
+ title: string;
48
+ description: string;
49
+ /** Path-only canonical, e.g. "/services/". Resolved against the build host. */
50
+ canonical?: string;
51
+ image?: string;
52
+ imageAlt?: string;
53
+ type?: 'website' | 'article';
54
+ publishedAt?: Date;
55
+ updatedAt?: Date;
56
+ noindex?: boolean;
57
+ }
58
+
59
+ const {
60
+ title,
61
+ description,
62
+ canonical,
63
+ image,
64
+ imageAlt = business.name,
65
+ type = 'website',
66
+ publishedAt,
67
+ updatedAt,
68
+ noindex = false,
69
+ } = Astro.props;
70
+
71
+ const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site).href;
72
+ const card = socialCard(image);
73
+ const imageUrl = card ? new URL(card.src, Astro.site).href : null;
74
+
75
+ // Staging is never indexable, whatever the page asks for. This is derived from
76
+ // the build variable, so there is no switch anyone can forget at go-live.
77
+ const blockIndexing = noindex || !site.indexable;
78
+ const robots = blockIndexing
79
+ ? 'noindex, nofollow'
80
+ : 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1';
81
+ ---
82
+
83
+ <title>{title}</title>
84
+ <meta name="description" content={description} />
85
+ <meta name="robots" content={robots} />
86
+ <link rel="canonical" href={canonicalUrl} />
87
+
88
+ <meta property="og:type" content={type} />
89
+ <meta property="og:locale" content="en_US" />
90
+ <meta property="og:site_name" content={business.name} />
91
+ <meta property="og:title" content={title} />
92
+ <meta property="og:description" content={description} />
93
+ <meta property="og:url" content={canonicalUrl} />
94
+ {
95
+ card && imageUrl && (
96
+ <>
97
+ <meta property="og:image" content={imageUrl} />
98
+ <meta property="og:image:width" content={String(card.width)} />
99
+ <meta property="og:image:height" content={String(card.height)} />
100
+ <meta property="og:image:alt" content={imageAlt} />
101
+ </>
102
+ )
103
+ }
104
+
105
+ {publishedAt && <meta property="article:published_time" content={publishedAt.toISOString()} />}
106
+ {updatedAt && <meta property="article:modified_time" content={updatedAt.toISOString()} />}
107
+
108
+ <meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
109
+ <meta name="twitter:title" content={title} />
110
+ <meta name="twitter:description" content={description} />
111
+ {imageUrl && <meta name="twitter:image" content={imageUrl} />}
112
+
113
+ {
114
+ isProduction && (
115
+ <>
116
+ <link rel="alternate" type="application/rss+xml" title={`${business.name} — Insights`} href="/rss.xml" />
117
+ </>
118
+ )
119
+ }
@@ -0,0 +1,173 @@
1
+ ---
2
+ /**
3
+ * JSON-LD, built from src/data/business.ts — the same file the visible header,
4
+ * footer and contact page read from. The schema physically cannot claim a
5
+ * phone number or an address the page does not show.
6
+ */
7
+ import { business, addressOneLine } from '../data/business';
8
+ import { services } from '../data/services';
9
+
10
+ interface Props {
11
+ type?: 'home' | 'page' | 'article' | 'blog';
12
+ breadcrumbs?: { name: string; href: string }[];
13
+ article?: {
14
+ headline: string;
15
+ description: string;
16
+ image?: string;
17
+ publishedAt: Date;
18
+ updatedAt?: Date;
19
+ url: string;
20
+ };
21
+ faq?: { question: string; answer: string }[];
22
+ }
23
+
24
+ const { type = 'page', breadcrumbs, article, faq } = Astro.props;
25
+
26
+ const siteUrl = Astro.site!.origin;
27
+ const abs = (p: string) => new URL(p, Astro.site).href;
28
+
29
+ const organizationId = `${siteUrl}/#organization`;
30
+ const websiteId = `${siteUrl}/#website`;
31
+
32
+ const organization = {
33
+ /* From business.ts — never hardcoded here. A subtype left in the shared
34
+ layer becomes every future site's industry. See the note on schemaTypes. */
35
+ '@type': business.schemaTypes,
36
+ '@id': organizationId,
37
+ name: business.name,
38
+ legalName: business.legalName,
39
+ url: siteUrl,
40
+ description: business.description,
41
+ foundingDate: String(business.foundedYear),
42
+ telephone: business.phone.e164,
43
+ email: business.email.display,
44
+ priceRange: business.priceRange,
45
+ /* Omitted rather than pointed at a file that may not exist — a schema logo
46
+ returning 404 is a Search Console warning nothing in a build would show. */
47
+ ...(business.logoPath ? { logo: { '@type': 'ImageObject', url: abs(business.logoPath) } } : {}),
48
+ image: abs('/img/social/og-default.jpg'),
49
+ address: {
50
+ '@type': 'PostalAddress',
51
+ streetAddress: business.address.street,
52
+ addressLocality: business.address.locality,
53
+ addressRegion: business.address.region,
54
+ postalCode: business.address.postalCode,
55
+ addressCountry: business.address.country,
56
+ },
57
+ geo: {
58
+ '@type': 'GeoCoordinates',
59
+ latitude: business.geo.latitude,
60
+ longitude: business.geo.longitude,
61
+ },
62
+ hasMap: business.address.mapUrl,
63
+ openingHoursSpecification: [
64
+ {
65
+ '@type': 'OpeningHoursSpecification',
66
+ dayOfWeek: business.hours.days.map((d) => `https://schema.org/${d}`),
67
+ opens: business.hours.opens,
68
+ closes: business.hours.closes,
69
+ },
70
+ ],
71
+ areaServed: business.serviceAreas.map((area) => ({
72
+ '@type': 'City',
73
+ name: area,
74
+ containedInPlace: { '@type': 'State', name: business.address.regionName },
75
+ })),
76
+ hasOfferCatalog: {
77
+ '@type': 'OfferCatalog',
78
+ name: `${business.name} services`,
79
+ itemListElement: services.map((s) => ({
80
+ '@type': 'Offer',
81
+ itemOffered: { '@type': 'Service', name: s.title, description: s.short },
82
+ })),
83
+ },
84
+ ...(business.socials.length ? { sameAs: business.socials.map((s) => s.url) } : {}),
85
+ };
86
+
87
+ const website = {
88
+ '@type': 'WebSite',
89
+ '@id': websiteId,
90
+ url: siteUrl,
91
+ name: business.name,
92
+ description: business.description,
93
+ publisher: { '@id': organizationId },
94
+ inLanguage: business.locale,
95
+ };
96
+
97
+ /*
98
+ * The page node. `type` used to be destructured and never read — Base.astro
99
+ * passed `schemaType` on every page and it did nothing at all, which is the
100
+ * quietest kind of dead prop: the call site looks correct and the output has
101
+ * simply never contained it.
102
+ */
103
+ const PAGE_TYPE = {
104
+ home: 'WebPage',
105
+ page: 'WebPage',
106
+ article: 'WebPage',
107
+ blog: 'CollectionPage',
108
+ } as const;
109
+
110
+ const page = {
111
+ '@type': PAGE_TYPE[type],
112
+ '@id': `${abs(Astro.url.pathname)}#webpage`,
113
+ url: abs(Astro.url.pathname),
114
+ isPartOf: { '@id': websiteId },
115
+ about: { '@id': organizationId },
116
+ inLanguage: business.locale,
117
+ };
118
+
119
+ const graph: Record<string, unknown>[] = [organization, website, page];
120
+
121
+ const crumbs = breadcrumbs ?? [];
122
+ const questions = faq ?? [];
123
+
124
+ if (crumbs.length) {
125
+ graph.push({
126
+ '@type': 'BreadcrumbList',
127
+ itemListElement: crumbs.map((crumb, i) => ({
128
+ '@type': 'ListItem',
129
+ position: i + 1,
130
+ name: crumb.name,
131
+ item: abs(crumb.href),
132
+ })),
133
+ });
134
+ }
135
+
136
+ if (article) {
137
+ graph.push({
138
+ '@type': 'BlogPosting',
139
+ headline: article.headline,
140
+ description: article.description,
141
+ ...(article.image ? { image: abs(article.image) } : {}),
142
+ datePublished: article.publishedAt.toISOString(),
143
+ dateModified: (article.updatedAt ?? article.publishedAt).toISOString(),
144
+ author: { '@id': organizationId },
145
+ publisher: { '@id': organizationId },
146
+ mainEntityOfPage: { '@type': 'WebPage', '@id': abs(article.url) },
147
+ isPartOf: { '@id': websiteId },
148
+ });
149
+ }
150
+
151
+ if (questions.length) {
152
+ graph.push({
153
+ '@type': 'FAQPage',
154
+ mainEntity: questions.map((item) => ({
155
+ '@type': 'Question',
156
+ name: item.question,
157
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
158
+ })),
159
+ });
160
+ }
161
+
162
+ const jsonLd = { '@context': 'https://schema.org', '@graph': graph };
163
+ ---
164
+
165
+ <script
166
+ type="application/ld+json"
167
+ is:inline
168
+ set:html={JSON.stringify(jsonLd)}
169
+ />
170
+ <meta name="geo.placename" content={`${business.address.locality}, ${business.address.regionName}`} />
171
+ <meta name="geo.position" content={`${business.geo.latitude};${business.geo.longitude}`} />
172
+ <meta name="geo.region" content={`${business.address.country}-${business.address.region}`} />
173
+ <meta name="business:contact_data:street_address" content={addressOneLine} />
@@ -0,0 +1,5 @@
1
+ Posts go here as markdown. `docs/content.md` has the frontmatter and the rules.
2
+
3
+ This file exists so the directory ships. Without it Astro's glob loader warns
4
+ "The base directory src/content/blog/ does not exist" on every single build,
5
+ and `npm run media` threw ENOENT looking for posts to twin social cards for.
File without changes
@@ -0,0 +1,81 @@
1
+ import { defineCollection, z } from 'astro:content';
2
+ import { glob } from 'astro/loaders';
3
+
4
+ /**
5
+ * Blog posts. The schema is strict on purpose: a bad edit fails the build
6
+ * rather than shipping a page with a missing title or a broken date.
7
+ */
8
+ const blog = defineCollection({
9
+ loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
10
+ schema: z.object({
11
+ title: z.string().min(1).max(120),
12
+ /** Overrides <title>; falls back to `title` when absent. */
13
+ seoTitle: z.string().max(120).optional(),
14
+ description: z.string().min(50).max(200),
15
+ publishedAt: z.coerce.date(),
16
+ updatedAt: z.coerce.date().optional(),
17
+ /** Portable reference into public/img — never an absolute CDN URL. */
18
+ image: z.string().optional(),
19
+ imageAlt: z.string().optional(),
20
+ categories: z.array(z.string()).min(1),
21
+ focusKeyword: z.string().optional(),
22
+ draft: z.boolean().default(false),
23
+ }),
24
+ });
25
+
26
+ /**
27
+ * Legal and policy pages — privacy, terms, house rules, cookie notice.
28
+ *
29
+ * One markdown file each, rendered through one route, so three near-identical
30
+ * page files never get written and an editor can change an effective date
31
+ * without a developer. Empty in a fresh template: it emits no routes until
32
+ * somebody adds a file, which is why shipping it costs the template no pages.
33
+ *
34
+ * ── DATES ARE STRINGS HERE, DELIBERATELY ───────────────────────────────────
35
+ * `z.coerce.date()` turns `2026-08-21` into 2026-08-21T00:00:00**Z**, and
36
+ * formatting that instant anywhere west of Greenwich renders **20 August** —
37
+ * measured: US Pacific and US Eastern both shift it, London and Tokyo do not.
38
+ * So a legal page's effective date is correct on a European laptop and a day
39
+ * early off a US CI runner, with nothing anywhere to report it.
40
+ *
41
+ * An effective date is a CALENDAR DATE, not an instant. Keeping it a string
42
+ * removes the class of bug rather than handling it. `formatLegalDate` in
43
+ * lib/legal.ts renders it, pinned to UTC.
44
+ */
45
+ const isoDate = z
46
+ .preprocess(
47
+ /*
48
+ * ⚠ ASTRO'S FRONTMATTER PARSER RETURNS A **Date** FOR AN UNQUOTED
49
+ * `2026-08-21`, not a string — which is where this whole class of bug
50
+ * starts, and it is invisible until something formats it. Editors write
51
+ * dates unquoted because that is the natural thing to write, so accept
52
+ * both and normalise to the UTC calendar date immediately. After this
53
+ * point the value is a plain string and cannot drift again.
54
+ */
55
+ (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value),
56
+ z
57
+ .string()
58
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'use YYYY-MM-DD')
59
+ /* The regex accepts 2026-13-45. Confirm it is a date that exists. */
60
+ .refine((v) => new Date(`${v}T00:00:00Z`).toISOString().startsWith(v), 'not a real date'),
61
+ );
62
+
63
+ const legal = defineCollection({
64
+ loader: glob({ pattern: '**/*.md', base: './src/content/legal' }),
65
+ schema: z.object({
66
+ title: z.string().min(1).max(120),
67
+ description: z.string().min(50).max(200),
68
+ /** The date the terms took effect. Shown on the page. */
69
+ effective: isoDate,
70
+ /** Only when it has genuinely changed since. Omit otherwise. */
71
+ updated: isoDate.optional(),
72
+ /** Footer link text, when the title is too long for it. */
73
+ navLabel: z.string().max(40).optional(),
74
+ /** Footer ordering, low first. Ties fall back to the title. */
75
+ order: z.number().default(50),
76
+ /** A page that must exist and must not be indexed — rare, but real. */
77
+ noindex: z.boolean().default(false),
78
+ }),
79
+ });
80
+
81
+ export const collections = { blog, legal };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Service-area landing pages: one template, one entry each.
3
+ *
4
+ * ── ONLY add a place you can describe distinctly. ──────────────────────────
5
+ * Pages that differ by nothing but the town name are doorway pages and can be
6
+ * penalised. Each needs something genuinely local: which authority issues the
7
+ * permit, what the housing stock is, what actually drives demand there.
8
+ *
9
+ * A place you cannot write about distinctly still belongs in
10
+ * business.serviceAreas — it just does not get a page.
11
+ *
12
+ * Leave this array empty if the business is not service-area based.
13
+ */
14
+
15
+ export type Area = {
16
+ slug: string;
17
+ city: string;
18
+ category?: string; // matches a blog category, so the page can pull its own articles
19
+ county: string;
20
+ permitAuthority: string; // or whatever the local gatekeeper is in your field
21
+ title: string;
22
+ description: string;
23
+ intro: string;
24
+ localFactors: { heading: string; body: string }[]; // three, no filler
25
+ commonWork: string[];
26
+ image: string; // manifest key
27
+ };
28
+
29
+ export const areas: Area[] = [];
30
+
31
+ export const areaBySlug = (slug: string) => areas.find((area) => area.slug === slug);