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,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "site-name",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.12.0"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "astro dev",
|
|
12
|
+
"check": "astro check",
|
|
13
|
+
"a11y": "pa11y-ci --config .pa11yci.json",
|
|
14
|
+
"a11y:evidence": "node scripts/a11y-evidence.mjs",
|
|
15
|
+
"reflow": "node scripts/check-reflow.mjs",
|
|
16
|
+
"console": "node scripts/check-console.mjs",
|
|
17
|
+
"shots": "node scripts/shots.mjs",
|
|
18
|
+
"tells": "node scripts/tells.mjs",
|
|
19
|
+
"media": "node scripts/optimize-media.mjs",
|
|
20
|
+
"cards": "node scripts/og-cards.mjs",
|
|
21
|
+
"lastmod": "node scripts/lastmod.mjs",
|
|
22
|
+
"indexnow": "node scripts/indexnow.mjs",
|
|
23
|
+
"verify": "node scripts/verify.mjs",
|
|
24
|
+
"check:sitemap": "node scripts/check-sitemap.mjs",
|
|
25
|
+
"recon": "node scripts/recon.mjs",
|
|
26
|
+
"extract": "node scripts/extract.mjs",
|
|
27
|
+
"dns": "node scripts/dns-snapshot.mjs",
|
|
28
|
+
"seo": "npx --yes github:nurkamol/seo-audit",
|
|
29
|
+
"redirects": "node scripts/redirects.mjs",
|
|
30
|
+
"handover": "node scripts/md-to-pdf.mjs docs/handover.md docs/handover.pdf",
|
|
31
|
+
"build": "astro build",
|
|
32
|
+
"build:staging": "PUBLIC_SITE_ENV=staging PUBLIC_SITE_URL=https://new.example.com astro build && PUBLIC_SITE_ENV=staging node scripts/staging-headers.mjs && PUBLIC_SITE_ENV=staging node scripts/check-env.mjs",
|
|
33
|
+
"build:production": "node scripts/tells.mjs --undecided-only && PUBLIC_SITE_ENV=production astro check && PUBLIC_SITE_ENV=production PUBLIC_SITE_URL=https://example.com astro build && PUBLIC_SITE_ENV=production node scripts/check-env.mjs && node scripts/check-sitemap.mjs",
|
|
34
|
+
"preview": "wrangler dev",
|
|
35
|
+
"deploy:staging": "npm run build:staging && wrangler deploy",
|
|
36
|
+
"deploy:production": "npm run build:production && wrangler deploy"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@astrojs/cloudflare": "^14.1.7",
|
|
40
|
+
"@astrojs/rss": "^4.0.19",
|
|
41
|
+
"@astrojs/sitemap": "^3.7.3",
|
|
42
|
+
"astro": "^7.1.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@astrojs/check": "^0.9.10",
|
|
46
|
+
"@cloudflare/workers-types": "^5.20260815.1",
|
|
47
|
+
"pa11y-ci": "^4.1.1",
|
|
48
|
+
"sharp": "^0.35.3",
|
|
49
|
+
"turndown": "^7.2.4",
|
|
50
|
+
"typescript": "^6.0.3",
|
|
51
|
+
"wrangler": "^4.118.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# ── Cache headers for assets the build does not fingerprint ──────────────────
|
|
2
|
+
#
|
|
3
|
+
# The Cloudflare adapter writes an immutable rule for /_astro/* — everything it
|
|
4
|
+
# hashes into a filename. Anything in public/ keeps the name it was given, so it
|
|
5
|
+
# gets the default instead: `public, max-age=0, must-revalidate`. Measured on
|
|
6
|
+
# staging, both webfonts came back with exactly that, which means every visit
|
|
7
|
+
# pays a revalidation round trip for a file that had not changed since the last
|
|
8
|
+
# deploy.
|
|
9
|
+
#
|
|
10
|
+
# ⚠ THESE FILENAMES ARE NOW A CONTRACT. `immutable` tells a browser never to
|
|
11
|
+
# ask again for a year, so a font or a badge REPLACED UNDER THE SAME NAME will
|
|
12
|
+
# not reach anyone who already has it. Change the filename when you change the
|
|
13
|
+
# file. That is the trade for not revalidating.
|
|
14
|
+
|
|
15
|
+
/fonts/*
|
|
16
|
+
Cache-Control: public, max-age=31536000, immutable
|
|
17
|
+
|
|
18
|
+
# Brand artwork: logos, the favicons' PNG fallbacks, the app-store badges.
|
|
19
|
+
/img/brand/*
|
|
20
|
+
Cache-Control: public, max-age=31536000, immutable
|
|
21
|
+
|
|
22
|
+
# Generated Open Graph cards. Regenerated by scripts/og-cards.mjs under the same
|
|
23
|
+
# names, so a week rather than a year — a stale share card is a live mistake.
|
|
24
|
+
/img/social/*
|
|
25
|
+
Cache-Control: public, max-age=604800
|
|
26
|
+
|
|
27
|
+
# The media pipeline emits these with width suffixes but no content hash.
|
|
28
|
+
/img/photos/*
|
|
29
|
+
Cache-Control: public, max-age=2592000
|
|
30
|
+
|
|
31
|
+
# ── Security headers ─────────────────────────────────────────────────────────
|
|
32
|
+
#
|
|
33
|
+
# HSTS and X-Content-Type-Options already arrive from the Cloudflare zone, not
|
|
34
|
+
# from here — check both are still present after any SSL/TLS settings change,
|
|
35
|
+
# because nothing in this repo would notice them disappearing.
|
|
36
|
+
#
|
|
37
|
+
# ⚠ WHAT THIS CSP DELIBERATELY DOES NOT DO: restrict scripts.
|
|
38
|
+
# `script-src` is absent on purpose. Google Tag Manager exists to inject
|
|
39
|
+
# arbitrary scripts chosen later by whoever runs the container, and this is a
|
|
40
|
+
# static site, so per-request nonces are not available. Any workable
|
|
41
|
+
# `script-src` would need `'unsafe-inline'` plus an allow-list of every origin
|
|
42
|
+
# GTM might load — which blocks almost no real XSS while silently breaking the
|
|
43
|
+
# next marketing tag the client adds. A CSP that gets switched off the first
|
|
44
|
+
# time it breaks something protects nobody.
|
|
45
|
+
#
|
|
46
|
+
# What IS here are the directives that cost nothing and cannot break a working
|
|
47
|
+
# page, verified against the live site before shipping:
|
|
48
|
+
# frame-ancestors clickjacking — nothing embeds this site
|
|
49
|
+
# base-uri stops an injected <base> rewriting every relative URL
|
|
50
|
+
# form-action every form on the site posts to its own origin
|
|
51
|
+
# object-src no plugins, no <object>, no Flash-era attack surface
|
|
52
|
+
#
|
|
53
|
+
# If a real script-src is ever wanted, ship it as Content-Security-Policy-
|
|
54
|
+
# Report-Only FIRST, and watch the console on every route that embeds a
|
|
55
|
+
# third-party surface — a booking widget, a chat bundle, a map, a review feed.
|
|
56
|
+
# Those are what a script-src breaks, and they break silently. Only then
|
|
57
|
+
# enforce.
|
|
58
|
+
/*
|
|
59
|
+
Referrer-Policy: strict-origin-when-cross-origin
|
|
60
|
+
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
|
|
61
|
+
Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Legacy URL map. Hit counts are from the Rank Math redirections export
|
|
2
|
+
# (e.g. dump/seo/rank-math-settings-*.json) — they are the difference between
|
|
3
|
+
# "cleanup" and "deleting traffic". Nothing here points at the homepage when a
|
|
4
|
+
# specific equivalent exists; search engines read that as a soft 404.
|
|
5
|
+
|
|
6
|
+
# ── Carried over from Rank Math ──────────────────────────────────────────────
|
|
7
|
+
# EXAMPLE, commented out because its target does not exist in a fresh template
|
|
8
|
+
# and `npm run verify` correctly reports a 301 into a 404. Uncomment and point
|
|
9
|
+
# it at a real route.
|
|
10
|
+
#
|
|
11
|
+
# Typo in an original slug, fixed later on the live site. Even at 0 recorded
|
|
12
|
+
# hits the wrong spelling is still in the wild and in old sitemaps.
|
|
13
|
+
#/exmaple-post-slug-with-a-typo /example-post-slug-with-a-typo/ 301
|
|
14
|
+
|
|
15
|
+
# ── WordPress plumbing that no longer exists ─────────────────────────────────
|
|
16
|
+
# These were live URLs on the old stack. Left to 404 they would show up in
|
|
17
|
+
# Search Console as broken links from anything that ever scraped the site.
|
|
18
|
+
/feed/ /rss.xml 301
|
|
19
|
+
/feed /rss.xml 301
|
|
20
|
+
/comments/feed/ /rss.xml 301
|
|
21
|
+
/sitemap_index.xml /sitemap-index.xml 301
|
|
22
|
+
/post-sitemap.xml /sitemap-index.xml 301
|
|
23
|
+
/page-sitemap.xml /sitemap-index.xml 301
|
|
24
|
+
/local-sitemap.xml /sitemap-index.xml 301
|
|
25
|
+
|
|
26
|
+
# Author archives were noindexed on the old site and have no equivalent here.
|
|
27
|
+
# The blog index is the closest real page, not the homepage.
|
|
28
|
+
/author/* /blog/ 301
|
|
29
|
+
|
|
30
|
+
# Attachment pages. Rank Math already redirected these to the homepage
|
|
31
|
+
# (`attachment_redirect_default`); the media library itself is gone. No rule
|
|
32
|
+
# here on purpose — Cloudflare `_redirects` only accepts 200/301/302/303/307/308,
|
|
33
|
+
# and these paths *should* 404 rather than 301 to a page that is not the image
|
|
34
|
+
# someone asked for. The catch-all worker returns a real 404 for them.
|
|
35
|
+
|
|
36
|
+
# ── Chrome ───────────────────────────────────────────────────────────────────
|
|
37
|
+
# /projects/ and /privacy-policy/ were registered in WordPress but returned 404
|
|
38
|
+
# on the live site — /privacy-policy/ now exists for real, /projects/ does not
|
|
39
|
+
# and correctly 404s.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Business Name",
|
|
3
|
+
"short_name": "Business",
|
|
4
|
+
"description": "What you do, in one sentence. Keep in sync with business.ts.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#ffffff",
|
|
8
|
+
"theme_color": "#000000",
|
|
9
|
+
"icons": [
|
|
10
|
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
11
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write the accessibility evidence pack.
|
|
3
|
+
*
|
|
4
|
+
* npm run a11y:evidence # against .pa11yci.json (wrangler dev)
|
|
5
|
+
* npm run a11y:evidence -- https://example.com # against a deployed host
|
|
6
|
+
*
|
|
7
|
+
* Writes docs/a11y-evidence/<date>.md and leaves it for you to commit.
|
|
8
|
+
*
|
|
9
|
+
* ── WHY THIS EXISTS ────────────────────────────────────────────────────────
|
|
10
|
+
* `build.md` phase 9 requires an evidence pack and `compliance.md` §7 defines
|
|
11
|
+
* what counts. Nothing produced it: `npm run a11y` printed to a terminal and
|
|
12
|
+
* the output died with the scrollback. So the artefact that has value if anyone
|
|
13
|
+
* ever asks was the one artefact never written down.
|
|
14
|
+
*
|
|
15
|
+
* `compliance.md` §7, verbatim: *"Hand over the evidence, not the assertion.
|
|
16
|
+
* Dated tool output, which pages were tested manually and how, known gaps with
|
|
17
|
+
* an owner."* Three of those four are things a script cannot know.
|
|
18
|
+
*
|
|
19
|
+
* ── IT MUST NOT LOOK COMPLETE ──────────────────────────────────────────────
|
|
20
|
+
* Automated tooling catches roughly a third of issues, and about 42% of WCAG
|
|
21
|
+
* criteria cannot be machine-checked at all. A pack that listed a clean axe run
|
|
22
|
+
* and stopped would be worse than no pack: it reads as a finished audit, and it
|
|
23
|
+
* is the floor.
|
|
24
|
+
*
|
|
25
|
+
* So the manual layers are written in as UNCHECKED every time, with the date
|
|
26
|
+
* they were last done left blank for a human. The file is deliberately not
|
|
27
|
+
* signable until someone fills those in.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
|
|
36
|
+
const RESET = '[0m';
|
|
37
|
+
const RED = '[31m';
|
|
38
|
+
const GREEN = '[32m';
|
|
39
|
+
const YELLOW = '[33m';
|
|
40
|
+
const DIM = '[2m';
|
|
41
|
+
const BOLD = '[1m';
|
|
42
|
+
|
|
43
|
+
const host = process.argv.slice(2).find((a) => a.startsWith('http'))?.replace(/\/$/, '');
|
|
44
|
+
const OUT_DIR = 'docs/a11y-evidence';
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* LOCAL date, not `toISOString()`.
|
|
48
|
+
*
|
|
49
|
+
* This is a record a person attests to having made, so it has to carry the date
|
|
50
|
+
* that person would write on it. `toISOString()` is UTC: on a machine at UTC+5
|
|
51
|
+
* it dates the pack YESTERDAY for the first five hours of every day, and on a
|
|
52
|
+
* machine behind UTC it dates it tomorrow for the last few. Either way the file
|
|
53
|
+
* is named a day off, the staleness comparison drifts with it, and the error is
|
|
54
|
+
* invisible because the date it prints is always plausible.
|
|
55
|
+
*/
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
58
|
+
|
|
59
|
+
const version = (pkg) => {
|
|
60
|
+
try {
|
|
61
|
+
return require(`${pkg}/package.json`).version;
|
|
62
|
+
} catch {
|
|
63
|
+
return 'unknown';
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/* ── 1. Automated sweep ──────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
console.log(`${BOLD}── pa11y-ci · WCAG2AA ${'─'.repeat(38)}${RESET}`);
|
|
70
|
+
|
|
71
|
+
const config = JSON.parse(readFileSync('.pa11yci.json', 'utf8'));
|
|
72
|
+
const standard = config.defaults?.standard ?? 'WCAG2AA';
|
|
73
|
+
const runners = config.defaults?.runners ?? ['htmlcs'];
|
|
74
|
+
|
|
75
|
+
const args = host
|
|
76
|
+
? ['pa11y-ci', '--sitemap', `${host}/sitemap-index.xml`, '--standard', standard, '--json']
|
|
77
|
+
: ['pa11y-ci', '--config', '.pa11yci.json', '--json'];
|
|
78
|
+
|
|
79
|
+
let report;
|
|
80
|
+
try {
|
|
81
|
+
/* pa11y-ci exits non-zero when it finds errors, and still prints the JSON.
|
|
82
|
+
A non-zero exit here is a RESULT, not a failure to run. */
|
|
83
|
+
const out = execFileSync('npx', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
84
|
+
report = JSON.parse(out);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const out = error.stdout?.toString() ?? '';
|
|
87
|
+
try {
|
|
88
|
+
report = JSON.parse(out);
|
|
89
|
+
} catch {
|
|
90
|
+
console.error(
|
|
91
|
+
`${RED}✗${RESET} pa11y-ci could not run.\n` +
|
|
92
|
+
` ${DIM}Start the site first: npm run build:staging && npx wrangler dev${RESET}\n` +
|
|
93
|
+
` ${(error.stderr?.toString() ?? '').slice(0, 400)}`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rows = Object.entries(report.results).map(([url, issues]) => ({
|
|
100
|
+
url,
|
|
101
|
+
errors: issues.filter((i) => i.type === 'error'),
|
|
102
|
+
warnings: issues.filter((i) => i.type === 'warning'),
|
|
103
|
+
notices: issues.filter((i) => i.type === 'notice'),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
const mark = r.errors.length ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
108
|
+
const path = new URL(r.url).pathname;
|
|
109
|
+
console.log(
|
|
110
|
+
` ${mark} ${path.padEnd(28)} ${String(r.errors.length).padStart(2)} error(s) ` +
|
|
111
|
+
`${DIM}${r.warnings.length} warning(s)${RESET}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── 2. Reflow, which pa11y does not cover ───────────────────────────────── */
|
|
116
|
+
|
|
117
|
+
console.log(`\n${BOLD}── reflow · 320px + 200% ${'─'.repeat(35)}${RESET}`);
|
|
118
|
+
|
|
119
|
+
let reflowOut = '';
|
|
120
|
+
let reflowOk = false;
|
|
121
|
+
try {
|
|
122
|
+
reflowOut = execFileSync('node', ['scripts/check-reflow.mjs', ...(host ? [host] : [])], {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
});
|
|
125
|
+
reflowOk = true;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
reflowOut = (error.stdout?.toString() ?? '') + (error.stderr?.toString() ?? '');
|
|
128
|
+
}
|
|
129
|
+
const reflowSummary = reflowOut.trim().split('\n').filter(Boolean).slice(-1)[0] ?? 'did not run';
|
|
130
|
+
console.log(` ${reflowOk ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`} ${reflowSummary.replace(/\[[0-9;]*m/g, '')}`);
|
|
131
|
+
|
|
132
|
+
/* ── 3. Is the published statement still telling the truth? ──────────────── */
|
|
133
|
+
|
|
134
|
+
const statement = existsSync('src/pages/accessibility.astro')
|
|
135
|
+
? readFileSync('src/pages/accessibility.astro', 'utf8')
|
|
136
|
+
: '';
|
|
137
|
+
const reviewed = /const reviewed = '([^']+)'/.exec(statement)?.[1] ?? null;
|
|
138
|
+
|
|
139
|
+
let staleWarning = null;
|
|
140
|
+
if (reviewed) {
|
|
141
|
+
const parsed = new Date(reviewed);
|
|
142
|
+
if (!Number.isNaN(parsed.valueOf())) {
|
|
143
|
+
const days = Math.round((Date.now() - parsed.valueOf()) / 86400000);
|
|
144
|
+
if (days > 0) {
|
|
145
|
+
staleWarning =
|
|
146
|
+
`/accessibility says it was last reviewed ${reviewed} — ${days} day(s) ago.\n` +
|
|
147
|
+
` This run is newer. Update \`reviewed\` in src/pages/accessibility.astro, or the\n` +
|
|
148
|
+
` published page is making a testing claim about a site that has moved on.`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── 4. The pack ─────────────────────────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
const totalErrors = rows.reduce((n, r) => n + r.errors.length, 0);
|
|
156
|
+
const target = host ?? 'http://localhost:8788 (wrangler dev)';
|
|
157
|
+
|
|
158
|
+
const issueLines = rows
|
|
159
|
+
.filter((r) => r.errors.length)
|
|
160
|
+
.map(
|
|
161
|
+
(r) =>
|
|
162
|
+
`### ${new URL(r.url).pathname}\n\n` +
|
|
163
|
+
r.errors
|
|
164
|
+
.map(
|
|
165
|
+
(e) =>
|
|
166
|
+
`- **${e.code ?? 'unknown'}** — ${e.message}\n` +
|
|
167
|
+
` - selector: \`${e.selector || '—'}\`\n` +
|
|
168
|
+
` - runner: ${e.runner ?? '—'}\n` +
|
|
169
|
+
` - owner: ⚠ · fixed by: ⚠`,
|
|
170
|
+
)
|
|
171
|
+
.join('\n'),
|
|
172
|
+
)
|
|
173
|
+
.join('\n\n');
|
|
174
|
+
|
|
175
|
+
const pack = `# Accessibility evidence — ${today}
|
|
176
|
+
|
|
177
|
+
Target: \`${target}\`
|
|
178
|
+
Standard: **${standard}** · runners: ${runners.join(', ')}
|
|
179
|
+
|
|
180
|
+
| Tool | Version |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| pa11y-ci | ${version('pa11y-ci')} |
|
|
183
|
+
| pa11y | ${version('pa11y')} |
|
|
184
|
+
| axe-core | ${version('axe-core')} |
|
|
185
|
+
|
|
186
|
+
> **This is the floor, not an audit.** Automated tooling catches roughly a third of
|
|
187
|
+
> issues by count, and about 42% of WCAG success criteria cannot be machine-checked at
|
|
188
|
+
> all. The section below marked *not done by this run* is where the rest lives.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 1. Automated — one URL per template family
|
|
193
|
+
|
|
194
|
+
| Page | Errors | Warnings | Notices |
|
|
195
|
+
| --- | --- | --- | --- |
|
|
196
|
+
${rows
|
|
197
|
+
.map(
|
|
198
|
+
(r) =>
|
|
199
|
+
`| \`${new URL(r.url).pathname}\` | ${r.errors.length} | ${r.warnings.length} | ${r.notices.length} |`,
|
|
200
|
+
)
|
|
201
|
+
.join('\n')}
|
|
202
|
+
|
|
203
|
+
**${totalErrors} error(s) across ${rows.length} page(s).**
|
|
204
|
+
|
|
205
|
+
${issueLines || '_No errors at this standard._'}
|
|
206
|
+
|
|
207
|
+
## 2. Reflow and resize — WCAG 1.4.10 and 1.4.4
|
|
208
|
+
|
|
209
|
+
\`\`\`
|
|
210
|
+
${reflowOut.replace(/\[[0-9;]*m/g, '').trim() || 'did not run'}
|
|
211
|
+
\`\`\`
|
|
212
|
+
|
|
213
|
+
## 3. NOT done by this run — a human has to, and has to date it
|
|
214
|
+
|
|
215
|
+
⚠ **Fill these in or the pack is incomplete.** Each is a layer no tool covers, from
|
|
216
|
+
\`compliance.md\` §7. One full pass on one page per template family, not every URL.
|
|
217
|
+
|
|
218
|
+
| Layer | How | Done on | By |
|
|
219
|
+
| --- | --- | --- | --- |
|
|
220
|
+
| Keyboard | Tab, Shift-Tab, Enter, Space, Escape — hands off the mouse | ⚠ | ⚠ |
|
|
221
|
+
| Screen reader | VoiceOver + Safari, or NVDA + Firefox | ⚠ | ⚠ |
|
|
222
|
+
| Forms | Submit empty, submit bad, submit with JavaScript off | ⚠ | ⚠ |
|
|
223
|
+
| Zoom | 200% and 400% | ⚠ | ⚠ |
|
|
224
|
+
| Looked at it | On a real phone, at 100% | ⚠ | ⚠ |
|
|
225
|
+
|
|
226
|
+
## 4. Known gaps
|
|
227
|
+
|
|
228
|
+
⚠ **List them with an owner against each.** A documented gap is worth more than a clean
|
|
229
|
+
claim — and never write "fully compliant", which is the claim that gets challenged.
|
|
230
|
+
|
|
231
|
+
| Gap | Impact | Owner | Plan |
|
|
232
|
+
| --- | --- | --- | --- |
|
|
233
|
+
| ⚠ | ⚠ | ⚠ | ⚠ |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
_Generated by \`npm run a11y:evidence\`. The published statement is at \`/accessibility\`;
|
|
238
|
+
keep its \`reviewed\` date in step with the newest pack in this directory._
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
242
|
+
const outPath = `${OUT_DIR}/${today}.md`;
|
|
243
|
+
writeFileSync(outPath, pack);
|
|
244
|
+
|
|
245
|
+
console.log(`\n${GREEN}✓${RESET} ${outPath}`);
|
|
246
|
+
console.log(
|
|
247
|
+
`${DIM} tool + version, date, per-family results, what was tested BY HAND (blank),\n` +
|
|
248
|
+
` and known gaps with an owner (blank). Fill in §3 and §4, then commit it.${RESET}`,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (staleWarning) console.log(`\n${YELLOW}⚠${RESET} ${staleWarning}`);
|
|
252
|
+
|
|
253
|
+
/*
|
|
254
|
+
* Exit 0 even with errors found. This writes a RECORD; it is not a gate — the
|
|
255
|
+
* gate is `npm run a11y`, which fails the build. A generator that refused to
|
|
256
|
+
* write the evidence whenever the evidence was bad would only ever document
|
|
257
|
+
* sites that were already fine.
|
|
258
|
+
*/
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console errors and failed requests, on the DEPLOYED site.
|
|
3
|
+
*
|
|
4
|
+
* npm run console # every route, localhost:8788
|
|
5
|
+
* npm run console -- https://new.example.com # a deployed host
|
|
6
|
+
* npm run console -- --only=/contact/ # one route, substring match
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS EXISTS ────────────────────────────────────────────────────────
|
|
9
|
+
* `build.md` §7 has carried this as a definition-of-done row from the start —
|
|
10
|
+
* *"zero console errors and zero failed requests on the deployed site"* — and
|
|
11
|
+
* the row says why nothing else can do it: **a blocked third-party script or a
|
|
12
|
+
* 404 asset is invisible to a status-code check.** The page returns 200. The
|
|
13
|
+
* HTML is correct. Something inside it failed after the response was complete.
|
|
14
|
+
*
|
|
15
|
+
* `npm run verify` deliberately uses only `fetch`, so it can never see this.
|
|
16
|
+
* That is the gap this closes, and it is why this is a separate script rather
|
|
17
|
+
* than another section there.
|
|
18
|
+
*
|
|
19
|
+
* ── WHAT COUNTS AS A FAILURE ───────────────────────────────────────────────
|
|
20
|
+
* Console *errors* and *failed requests* fail the run. Warnings are printed and
|
|
21
|
+
* do not, because third-party embeds produce warnings constantly and a check
|
|
22
|
+
* that goes red on someone else's deprecation notice gets switched off.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import puppeteer from 'puppeteer';
|
|
26
|
+
|
|
27
|
+
import { discoverRoutes } from './lib/routes.mjs';
|
|
28
|
+
|
|
29
|
+
const RESET = '[0m';
|
|
30
|
+
const RED = '[31m';
|
|
31
|
+
const GREEN = '[32m';
|
|
32
|
+
const YELLOW = '[33m';
|
|
33
|
+
const DIM = '[2m';
|
|
34
|
+
const BOLD = '[1m';
|
|
35
|
+
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const only = (args.find((a) => a.startsWith('--only=')) ?? '').replace('--only=', '');
|
|
38
|
+
const host = args.find((a) => !a.startsWith('--'));
|
|
39
|
+
const BASE = (host ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:8788').replace(/\/$/, '');
|
|
40
|
+
|
|
41
|
+
const { routes: discovered, source } = await discoverRoutes(BASE);
|
|
42
|
+
const ROUTES = discovered
|
|
43
|
+
.map((url) => new URL(url).pathname)
|
|
44
|
+
.filter((r) => !only || only.split(',').some((f) => r.includes(f.trim())));
|
|
45
|
+
|
|
46
|
+
if (!ROUTES.length) {
|
|
47
|
+
console.error(
|
|
48
|
+
`No routes for ${BASE}.\n Build first (\`npm run build:staging\`), or pass a host that serves a sitemap.`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(`${BOLD}── Console and network ${'─'.repeat(36)}${RESET}`);
|
|
54
|
+
console.log(` ${DIM}${ROUTES.length} route(s) from ${source}, against ${BASE}${RESET}\n`);
|
|
55
|
+
|
|
56
|
+
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
|
|
57
|
+
const findings = [];
|
|
58
|
+
let warnings = 0;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
for (const route of ROUTES) {
|
|
62
|
+
const page = await browser.newPage();
|
|
63
|
+
const errors = [];
|
|
64
|
+
const failed = [];
|
|
65
|
+
|
|
66
|
+
page.on('console', (msg) => {
|
|
67
|
+
if (msg.type() === 'error') errors.push(msg.text().slice(0, 200));
|
|
68
|
+
else if (msg.type() === 'warning') warnings++;
|
|
69
|
+
});
|
|
70
|
+
page.on('pageerror', (err) => errors.push(`uncaught: ${String(err).slice(0, 200)}`));
|
|
71
|
+
page.on('requestfailed', (req) => {
|
|
72
|
+
/* An aborted request is usually the navigation itself being replaced, not
|
|
73
|
+
a broken asset. Only report what actually could not be retrieved. */
|
|
74
|
+
const reason = req.failure()?.errorText ?? '';
|
|
75
|
+
if (/ERR_ABORTED/.test(reason)) return;
|
|
76
|
+
failed.push(`${reason} ${req.url()}`);
|
|
77
|
+
});
|
|
78
|
+
page.on('response', (res) => {
|
|
79
|
+
if (res.status() >= 400) failed.push(`${res.status()} ${res.url()}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await page.goto(BASE + route, { waitUntil: 'networkidle0', timeout: 45000 });
|
|
84
|
+
} catch (e) {
|
|
85
|
+
errors.push(`navigation: ${e.name}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (errors.length || failed.length) {
|
|
89
|
+
findings.push({ route, errors, failed });
|
|
90
|
+
console.log(` ${RED}✗${RESET} ${route}`);
|
|
91
|
+
for (const e of errors) console.log(` ${DIM}console ${e}${RESET}`);
|
|
92
|
+
for (const f of failed) {
|
|
93
|
+
/* Browsers request /favicon.ico on their own when no icon is declared.
|
|
94
|
+
No markup causes it, so say what it actually means rather than
|
|
95
|
+
leaving someone hunting for the reference. */
|
|
96
|
+
const hint = /\/favicon\.ico$/.test(f)
|
|
97
|
+
? ' ← the browser asks for this by itself; declare `icons` in src/data/site.ts'
|
|
98
|
+
: '';
|
|
99
|
+
console.log(` ${DIM}request ${f}${hint}${RESET}`);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
console.log(` ${GREEN}✓${RESET} ${route}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await page.close();
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
await browser.close();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (warnings) {
|
|
112
|
+
console.log(`\n ${YELLOW}!${RESET} ${warnings} console warning(s) — printed, not failed.`);
|
|
113
|
+
console.log(
|
|
114
|
+
` ${DIM}Third-party embeds warn constantly. A check that goes red on someone else's\n` +
|
|
115
|
+
` deprecation notice is a check that gets switched off.${RESET}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(
|
|
120
|
+
findings.length
|
|
121
|
+
? `\n${RED}✗ ${findings.length} route(s) with errors or failed requests${RESET}\n`
|
|
122
|
+
: `\n${GREEN}✓ no console errors and no failed requests across ${ROUTES.length} route(s)${RESET}\n`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
process.exit(findings.length ? 1 : 0);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse to ship a build whose environment does not match the domains it will
|
|
3
|
+
* be served on.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/check-env.mjs
|
|
6
|
+
*
|
|
7
|
+
* Runs at the end of `build:staging` and `build:production`, so it cannot be
|
|
8
|
+
* forgotten — including on Cloudflare Workers Builds, where the build command
|
|
9
|
+
* is typed once into a settings dialog and then never looked at again.
|
|
10
|
+
*
|
|
11
|
+
* ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
|
12
|
+
* `PUBLIC_SITE_ENV` decides indexability, canonical host, which KV namespace
|
|
13
|
+
* leads land in, and whether analytics is emitted at all. `wrangler.jsonc`
|
|
14
|
+
* decides which domains answer. Nothing connects the two.
|
|
15
|
+
*
|
|
16
|
+
* At go-live, two edits have to happen together: the routes gain
|
|
17
|
+
* example.com, and the build command becomes `build:production`. Do
|
|
18
|
+
* the first and forget the second and the live site ships with `noindex` on
|
|
19
|
+
* every page, canonicals pointing at the staging host, leads written to
|
|
20
|
+
* LEADS_STAGING, and no analytics.
|
|
21
|
+
*
|
|
22
|
+
* It would look perfect. It would be invisible to Google, and every enquiry
|
|
23
|
+
* would go to the wrong place. Nothing in a build log or a page would say so.
|
|
24
|
+
*
|
|
25
|
+
* The reverse — a production build served only on the staging host — is the
|
|
26
|
+
* other half: an indexable duplicate of the real site.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
|
|
31
|
+
const RESET = '[0m';
|
|
32
|
+
const RED = '[31m';
|
|
33
|
+
const GREEN = '[32m';
|
|
34
|
+
|
|
35
|
+
/** wrangler.jsonc is JSON with comments; strip them rather than add a parser. */
|
|
36
|
+
function readWrangler() {
|
|
37
|
+
const raw = readFileSync('wrangler.jsonc', 'utf8')
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
39
|
+
.replace(/(^|[^:"'])\/\/.*$/gm, '$1')
|
|
40
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const env = process.env.PUBLIC_SITE_ENV ?? 'development';
|
|
45
|
+
const wrangler = readWrangler();
|
|
46
|
+
const patterns = (wrangler.routes ?? []).map((r) => (typeof r === 'string' ? r : r.pattern));
|
|
47
|
+
|
|
48
|
+
/* The production host is the apex, or www — NOT the staging subdomain, which
|
|
49
|
+
also ends in the same string. Matching on `.includes()` alone would treat
|
|
50
|
+
new.example.com as production.
|
|
51
|
+
|
|
52
|
+
A custom domain is a bare hostname ("new.example.com"); a ROUTE
|
|
53
|
+
carries a path ("example.com/*"). Go-live uses routes, so the path
|
|
54
|
+
is stripped before matching — without this the guard would look at
|
|
55
|
+
"example.com/*", fail to match, and conclude a production build
|
|
56
|
+
was pointed at staging-only routes. It would have blocked the cutover. */
|
|
57
|
+
const hostOf = (p) => String(p).split('/')[0];
|
|
58
|
+
/*
|
|
59
|
+
* ⚠ SET THIS PER PROJECT. It must match the production apex, with or without
|
|
60
|
+
* `www.` — and must NOT match the staging subdomain, which ends in the same
|
|
61
|
+
* string. Keep it in step with `PRODUCTION_HOSTS` in src/data/site.ts; those
|
|
62
|
+
* two disagreeing is the failure this whole script exists to catch.
|
|
63
|
+
*/
|
|
64
|
+
const PRODUCTION_HOST = /^(www\.)?example\.com$/;
|
|
65
|
+
|
|
66
|
+
const isProdHost = (p) => PRODUCTION_HOST.test(hostOf(p));
|
|
67
|
+
const routesProduction = patterns.some(isProdHost);
|
|
68
|
+
const routesStagingOnly = patterns.length > 0 && !routesProduction;
|
|
69
|
+
|
|
70
|
+
const problems = [];
|
|
71
|
+
|
|
72
|
+
if (routesProduction && env !== 'production') {
|
|
73
|
+
problems.push(
|
|
74
|
+
`wrangler.jsonc routes the PRODUCTION domain (${patterns.filter(isProdHost).join(', ')}) ` +
|
|
75
|
+
`but this build is PUBLIC_SITE_ENV=${env}.\n` +
|
|
76
|
+
` That ships noindex on every page, canonicals pointing at the staging host,\n` +
|
|
77
|
+
` leads written to LEADS_STAGING, and no analytics — on the live site.\n` +
|
|
78
|
+
` Fix: build with \`npm run build:production\`.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (routesStagingOnly && env === 'production') {
|
|
83
|
+
problems.push(
|
|
84
|
+
`This is a PRODUCTION build, but wrangler.jsonc only routes ${patterns.join(', ')}.\n` +
|
|
85
|
+
` An indexable production build answering on the staging host is a duplicate\n` +
|
|
86
|
+
` of the real site. Fix: add the production routes, or build with\n` +
|
|
87
|
+
` \`npm run build:staging\`.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (problems.length) {
|
|
92
|
+
console.error(`\n${RED}✗ environment does not match the deploy target${RESET}\n`);
|
|
93
|
+
for (const p of problems) console.error(` ${p}\n`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(
|
|
98
|
+
`${GREEN}✓${RESET} PUBLIC_SITE_ENV=${env} matches routes: ${patterns.join(', ') || '(none)'}`,
|
|
99
|
+
);
|