create-website-build-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- package/template/wrangler.jsonc +63 -0
|
@@ -0,0 +1,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);
|