Repository navigation
Releases: nurkamol/website-build-kit
Release list
0.1.22 — the lockfile is what ships, and it shipped a high
⚠ Scaffold from 0.1.21 and npm audit reports a high, in minute one
Measured on a site scaffolded from the published package, before a line of code was written:
sharp <0.35.5
Severity: high
sharp : Vulnerability in librsvg dependency CVE-2026-96889
The scaffolder packs template/package-lock.json, so the lockfile is what ships and every new
site inherited that pin. Fixed on master eleven days ago; fixed for anybody only now.
Severity, honestly: librsvg bites when processing untrusted SVG, and the media pipeline
processes images a developer put in media/source/. This is not a live exposure on a typical
site. It is still a high on the first npm audit of a new project.
A scaffold from 0.1.22 audits clean.
On an existing site: npm audit --omit=dev, then npm audit fix — sharp 0.35.5 and the
wrangler chain are inside the ranges the template already declares.
A malformed submission returned 500
api/contact.ts asserted (await request.json()) as Record<string, string>, which is an assertion
rather than a check. validateLead does (input.name ?? '').trim(), so a non-string value threw
TypeError: .trim is not a function — unhandled, which is a 500 for a body the API could simply
have refused. Measured against wrangler dev:
before 500
after 400 {"ok":false,"error":"Expected a string for \"name\"."}
⚠ The obvious fix is worse than the bug. String(value) turns {"name":{"a":1}} into
"[object Object]" — fifteen characters, so it passes the name rule, and the lead is stored in KV
and emailed to the site owner with a nonsense name. A silent bad record for a loud error is the
wrong trade, so this refuses and names the field.
It also runs after the honeypot: a caught submission still leaves by the quiet door, and the
trap never answers two different ways. {"phone": null} still works, because ?? '' already meant
absent.
npm run verify gained a fourth form submission that fails on anything 2xx as well as any 5xx,
so a future "fix" that coerces cannot pass either.
Reported as #9 by an automated scanner — the report was accurate and honest about being unverified.
The audit runs on Mondays now
npm audit --omit=dev has gone red three times in six weeks — a critical Astro RCE through AVIF,
ten undici advisories, and these two — every time under a commit that had nothing to do with
it. .github/workflows/audit.yml asks on a schedule, against the lockfile, with no install.
A failure there is the world moving. A failure on push is the repository moving. Keeping them apart
is the point.
0.1.21 — the alt nobody read, the number nobody dialled, and ten undici advisories
Six commits since 0.1.20, and none of them had reached a new scaffold — the scaffolder packs
template/ at pack time, so a gate is only real once a version ships. Five of these are new gates.
⚠ Your accessibility gate may have been measuring one palette twice
Carried over from 0.1.20 and worth repeating, because it reaches sites built before it: check-a11y
forced the colour scheme with --force-prefers-color-scheme, which is not a Chrome switch. Run
npm run check:drift in a delivered site — D9 reports it, and the state it names is present and
inert: the runner, the green gate and the evidence pack are all there, and the dark palette was
never measured.
New gates
npm run check:alt— alt text read as text. axe proves the attribute exists and<Img>
makes it required; nothing read the value, soalt="DSC_0421.jpg",alt="image",
alt="Photo of a kitchen"and nine photographs all readingalt="Our work"passed every gate
while telling a screen-reader user nothing. Most alt on a finished site is typed by the client
into a CMS field, which is why it is weak. ⚠alt=""is never a findingnpm run check:contact— the number a visitor taps against the number they read.
business.phoneheld four fields for one fact;hrefandsmsare now derived, and the pair
that cannot be derived is compared on every build. A UK trunk zero is not a mismatchverifyasks the link check backwards — a route that returns 200, sits in the sitemap, is
indexed, and cannot be reached by clicking, which is what a rewritten nav leaves behindcheck:secretsasks wrangler for JSON instead of trusting its default, and refuses when the
answer is unreadable rather than reporting every secret as missing — the right alarm for the
wrong reasona11y:evidencemeasures one origin. With no host argument its two halves could describe two
different sites in one dated compliance document
Template
src/styles/index.css is now the only stylesheet the layout loads: the kit's three go into
@layer kit and project.css stays unlayered, so a project rule beats a kit rule regardless of
specificity. .on-dark .btn (0,2,0) used to beat a project's own .btn (0,1,0) whatever the load
order, and the answer was !important.
Security
Ten undici advisories reaching in through wrangler → miniflare — DoS, response splitting, a TLS
certificate-validation bypass in BalancedPool, cross-user cookie disclosure. Cleared inside the
declared range: wrangler 4.135.0 → 4.144.0. Production dependencies report zero.
On an existing site: npm audit --omit=dev — the same chain is almost certainly in range there,
and npm audit fix is enough.
Upgrading an existing site
Nothing here arrives by itself; the template is copied, not linked.
node path/to/website-build-kit/template/scripts/check-drift.mjs0.1.20 — a critical Astro advisory, and an a11y gate measuring one palette twice
Why this release exists
Fifteen commits had touched template/ since 0.1.19 and none of them reached a new scaffold —
the scaffolder packs template/ at pack time, so nothing is live until a version ships. Two of
them fixed checks that were reporting clean on real defects.
⚠ Security — a critical advisory, already red before this work
npm audit --omit=dev was failing on GHSA-26w7-cxv4-gfx2:
remote code execution through Astro's AVIF image optimization — in a kit whose entire media
argument is AVIF. Every fix sat inside a declared semver range, so nothing anybody had pinned moved:
| was | now | |
|---|---|---|
| astro | 7.1.6 | 7.3.3 |
| sharp | 0.35.3 | 0.35.4 |
| wrangler | 4.118.0 | 4.135.0 |
Production dependencies now report zero. The dev-tree extract-zip advisory is unchanged and
still unpatched upstream — docs/dependencies.md carries why, and how to answer a client who asks.
If you have a site built from an earlier version: run npm audit --omit=dev in it. The same
three packages are almost certainly in range there too, and npm audit fix is enough.
⚠ Your accessibility gate has been measuring one palette twice
check-a11y forced the colour scheme with --force-prefers-color-scheme. That is not a Chrome
switch, and Chrome ignores flags it does not know without a word — so both passes measured
whatever scheme the machine was set to (light twice on a CI runner) while printing "clean in light
and dark", and a11y:evidence wrote that sentence into a dated compliance pack.
It is now --blink-settings=preferredColorScheme=<ordinal>, and assertForced() reads
prefers-color-scheme back out of a real page before either script measures anything — an ordinal
can go quiet exactly the way a flag name did, so the assertion is the fix, not the switch.
npm run check:drift reports this on a delivered site (D9). The state it names is present and
inert — the runner, the green gate and the evidence pack are all there, and the dark palette has
never been measured. Copy scripts/lib/schemes.mjs and scripts/check-a11y.mjs across.
Also fixed: npm run a11y:evidence -- https://host had never worked. pa11y-ci has no
--standard option, so it exited with unknown option '--standard', reported as "pa11y-ci could
not run. Start the site first."
The gates that were never tested
test:gates excused six scripts as "needs a browser". A browser points at localhost, and
fixture-site.mjs is localhost — all six are covered now, and the ledger is down to the two that
reach a third party. Two of the six needed no server at all: md-to-pdf renders a local file.
203 cases across 24 gates, 104 of them proving a gate still refuses. Writing the first of them
is what found the dead flag.
puppeteer is declared now
Four scripts import it outright and it was arriving under pa11y-ci — a dependency of every site
by accident of how pa11y happened to be installed.
⚠ The package is not the browser. puppeteer fetches Chrome in a postinstall, and npm 11
withholds install scripts pending approval, so a clean npm ci can leave the module present and
Chrome absent. If a browser-driven command says Chrome is missing:
npx puppeteer browsers install chromeUpgrading an existing site
Nothing here reaches a delivered site by itself — the template is copied, not linked.
npm audit --omit=dev # the advisory above
node path/to/website-build-kit/template/scripts/check-drift.mjs # what else this site is behind on0.1.19 — a picker that opens on an empty folder
The check
options.path narrows which directory the media browser shows an editor. Point it at a folder that has since moved and they get an empty picker — no error, no warning, the build does not care. ⚠ It is visible only to the person trying to choose an image, which is the one person who cannot fix it.
npm run check:cms refuses it, with cases both ways.
Found by mapping the two remaining runbooks against the checks the kit already runs — the gap fell out of doing that properly rather than from a build failing.
And a rule no check can enforce
⚠ Never store a derived value in the CMS. A tel: link beside the phone number it is built from means two fields hold one fact — which is exactly how a tap-to-call button ends up dialling last year's number. Somebody changes the number they can see and has no reason to suspect a second copy. Derive them in code; show the client only what genuinely differs.
Auditing a site built before any of this
Several shipped sites carry none of these checks. They do not need to. Every check resolves paths from the working directory, so a clone of this repository audits a project in place:
cd path/to/the-site
node path/to/website-build-kit/template/scripts/check-drift.mjs
node path/to/website-build-kit/template/scripts/check-cms.mjsVerified on a shipped site carrying none of the four scripts: every check ran, and git status reported zero changed files. That matters twice — no tooling committed to a client's repository in order to read it, and the checks are the current ones rather than a copy that has itself gone stale.
Repository, not package
The two remaining runbooks in site-runbooks now call the checks that implement their detection phases instead of restating them as bash. Mapped row by row, field-mapping A1–A3 and media-playbook sections 0–5 and 7 were all things check:cms, check:contrast and the media pipeline already do.
What stays there is the half no script carries: what a finding means, what to fix first on a live site, and the judgement calls — ⚠ is the sidebar organised or merely a list has no check and cannot have one.
151 cases across 22 gates, 87 proving a refusal.
0.1.18 — the redirect map nothing had ever checked
npm run check:redirects
redirects.mjs proposes a map from the old site's inventory. Nothing had ever checked the one a human then edited — and the editing is where the mistakes are, because a redirect file is the single migration artefact written by hand, in bulk, under time pressure, about URLs nobody can see any more. 298 lines of generator, zero lines of validation.
⚠ Every failure it catches is invisible at deploy. The file parses, the site builds, the pages are fine. What breaks is a URL that used to rank, weeks later, in somebody else's analytics.
| Duplicate source | Cloudflare takes the first match and ignores the rest, silently — so the later rule, usually the one added deliberately to fix something, never fires, and the fix appears not to work for reasons nothing explains |
| Self-redirect | The page is simply gone, and only in production, because nobody clicks the old URL in development |
| Loop | Detected across trailing-slash forms — the one nobody spots by eye |
| Unsupported status | _redirects accepts only 200/301/302/303/307/308 |
| Chain | A warning: a redundant round trip on every visit, and Cloudflare resolves one hop per request |
Validated against the four shipped sites that have a map — 18, 21, 48 and 34 rules, no false positives. The fifth correctly reports having no map at all.
Two things that would have made it wrong, both caught by running it rather than reading it:
- ⚠ Split on runs of whitespace, not a single space. Columns in a hand-edited file are aligned with spaces, and a naive split reports every aligned rule as malformed — on a real migration, all of them.
- ⚠ Walk the whole path before deciding what it is. The first version announced a loop as a chain and then as a loop — two messages, the wrong one first.
Also
check:drift now runs in build:production. It existed and only ran when somebody remembered to run it, which is the exact failure it was built for. It still exits 0 whatever it finds.
Reusable components in the CMS recipe — ⚠ every copy of a repeated schema is another place to forget a key, and a forgotten key is deleted on the client's first save. One definition cannot disagree with itself.
Per-page SEO fields, with two held back: ⚠ noindex and a canonical override can delist a site silently — pages render, build passes, traffic goes to zero over weeks. Never beside ordinary copy where somebody can toggle one while editing a paragraph.
149 cases across 22 gates, 86 proving a refusal.
0.1.17 — navigation becomes a CMS field, because the build checks it
A rule change, backed by evidence rather than preference.
The rule, and the gap it created
stacks.md said "keep nav, redirects and structured data OUT of the CMS", and the reason was sound: a typo'd path gives a menu item that renders perfectly and 404s only for a visitor.
⚠ The result was navigation missing from all five audited sites. Every client had to ask for a menu change — the second most common request after changing text. That is not a rule being respected; it is a gap the rule creates.
The fix is verification, not prohibition
npm run check:cms now resolves every internal path in CMS-managed data against the routes in src/pages — before the build, while somebody is still looking at the config. A bad value still fails rather than publishing, so the property the rule protected is kept and the gap it caused is not.
Not dist/ and not the sitemap: the check runs before the build, so it reads the page tree directly. Failing while the config is open beats failing after a deploy.
The part that decided whether this was shippable
⚠ A dynamic route is a pattern, not a route. [slug].astro serves every legal page, so treating routes as literal strings reports most of a real site as broken — the failure that gets a check switched off within a day.
Tested against five live sites carrying 14–29 static routes and up to twelve dynamic patterns each:
zero false positives on all five
and it still fires on a genuinely unreachable path
External links, mailto: and tel: are left alone. verify checks those against the deployed site, where they can actually be resolved.
What did not change
Redirects stay out, now for a sharper reason than before: a client toggling one off is silent traffic loss with no visible symptom anywhere, and there is no equivalent way to make that unrepresentable.
140 cases across 21 gates, 81 proving a refusal.
0.1.16 — the checks, the CMS, and something for sites already shipped
Ten commits since 0.1.15, with one through-line: every gate this kit has ever added protects the next project. The template is copied, not linked — so a delivered site received none of it. This release does something about that.
The failure that started it
Five live sites' CMS configs were audited. All five failed.
⚠ A CMS rewrites the whole file from its schema, so any key the schema does not declare is gone on the client's first save — silently, looking like an ordinary content commit. One site deleted all four analytics IDs and its opening hours the moment the owner opened Site Settings. Reverting gets you a commit that already did not have them. 27 keys were at risk.
Every one of those configs was valid YAML with paths that all resolved. The config was not wrong, it was incomplete, and nothing in a build could see the difference.
New checks
check:cms |
Every key declared, nested included. Validates stored image values against the declared field type; warns about content no CMS entry points at, fields shaped like technical configuration, hardcoded images, and a client guide that has drifted from the config |
check:contrast |
Text on a photograph — which axe and pa11y report as a flat ~1.01:1, because neither composites a transparent element over an image. ⚠ The danger is a weakened scrim, never the photograph: two of three real regions could not fail at any photo |
check:drift |
What a delivered site is behind on. Ten rows, reported and never fixed. Measured 4 of 10 on one live site and 8 of 10 on another |
check:binary |
A tracked source file git calls binary — invisible to git diff, to grep, and to the provenance sweep, which is written with grep -I |
/media-audit |
Detect → report → ask → fix. ⚠ Never a menu before there are findings |
Media
HEIC accepted (libvips already read it; our own regex was discarding iPhone photos). Nothing dropped in silence. One bad file no longer aborts the run and desyncs the manifest. Oversized sources flagged explicitly as a repository cost, so nobody fixes a page-speed problem that does not exist.
And toImageKey turns a CMS image field from a quiz into a real picker — a picker returns /img/photos/hero-1200.webp, never photos/hero, so every image field used to be a free-text box asking a non-technical editor to type a manifest key from memory.
⚠ That tolerance is also the dangerous half: a reader that accepts two formats cannot tell you which one you stored. check:cms catches the case where the site renders perfectly and every picker in the CMS is an empty square.
Uploads were pointed the wrong way round
optimize-media.mjs reads media/source/ and writes public/img/. Two of the five audited sites uploaded into the output, where a file has no variants, no width/height and no manifest entry — so <Img> throws and the client's own edit turns the build red.
And a site can finally say where it came from
"websiteBuildKit": { "version": "0.1.16", "scaffolded": "2026-09-01" }Nothing the kit fixes afterwards reaches a site already built. Without a stamp, "is this site current?" is archaeology.
136 cases across 21 gates, 80 proving a refusal — from 91/15/57.
0.1.15 — the tolerant reader, and the check it made necessary
Round 2 of a media backport from a live site — and it corrects something 0.1.14 shipped.
A reader that accepts two formats cannot tell you which one you stored
<Img> accepting both a manifest key and a picker path is what makes a CMS image field usable instead of a quiz. It is also the dangerous half.
type: image is built around the path: the picker returns one, the thumbnail loads one, the repo link resolves one. Convert a field to type: image without migrating its stored values and the site is unaffected, because the reader is tolerant. What you get instead:
- eighteen grey squares in the editor, and a repo link 404ing
- a green build, clean
astro check, clean pa11y, and byte-identical rendered HTML
Every automated check said the site was fine. The CMS is not a page, so nothing that renders the site can see it. The client finds it; you do not.
What's new
npm run check:cms now validates the stored value against the declared field type — every type: image value must be a path under that media source's output. It is the only check that looks at the editing surface rather than the rendered one, and it found key-form values sitting in image fields on a live site that nothing else could see.
⚠ It was too noisy at first: 78 problems for three fields, because a thirty-item collection reported the same bad field thirty times. A gate that floods is a gate that gets switched off, and the fix is one edit for the whole field either way. Now one problem per field with samples — 78 → 12.
Every image on a page is a field, or a stated exception. A config sentence saying "photographs are chosen in code" covered the five fields that existed and quietly excused the eight that did not — a header band, four class tiles and a gift-card picture, all string literals in .astro. The client opened the page, saw a photograph, and had no way to change it.
Alt text defaults from the shared data. A required alt prop means every page passes one — on one site the same sentence hardcoded twenty-one times, where a correction meant twenty-one edits and any missed one was an inconsistency only a screen-reader user would meet.
Two mistakes worth reading
⚠ The check shipped a ReferenceError for exactly one run — const mediaByName declared below its first use, a temporal dead zone error that node --check passes. Caught by running it, not by reading it.
⚠ audit:docs passed locally and failed on both runners for the same commit. It resolves paths against the working tree, and this machine had an untracked, empty generated directory that made a broken reference resolve. An empty directory is invisible to git status, so the tree looked clean. If the audit disagrees with CI, clone into a temp directory and run it there.
Gates
120 cases across 18 gates, 76 proving a refusal. Documented failures: 37.
0.1.14 — verify is tested, and prepack packs what git tracks
Kit-internal. No change to any script a scaffolded site runs — the package is a file-for-file match with 0.1.13.
verify had no test, for a reason that was wrong
verify.mjs is 1,069 lines deciding go-live across eleven sections, with three exit(1) paths and not one case proving any of them still fired. It sat in the UNCOVERED ledger behind "needs a deployed site or a live zone" — copied from the first script that reason was written for, and never re-examined.
| Actual blocker | |
|---|---|
shots, check-console, check-reflow, check-a11y, a11y-evidence, md-to-pdf |
a browser, which can point at localhost |
dns-snapshot |
genuinely a live zone |
indexnow |
submits to real search engines |
verify |
neither — it takes a URL |
⚠ A wrong reason in a ledger is worse than a missing entry, because it reads as a decision somebody made. Nobody revisits a line that looks considered.
scripts/fixture-site.mjs serves a deliberately faulty site on localhost. The clean fixture passes all 32 checks and exits 0, and every fault is paired against it — a suite that only ever sees a broken site cannot tell "this check works" from "this check always fires".
105 cases across 16 gates, 67 proving a refusal (from 91/15/57), mutation-tested four ways.
Three of my expectations were wrong and the harness said so rather than being loosened until it agreed: a second h1 is a warning; a preserved path landing on the homepage warns while the exit code comes from the redirect rule; and a canonical pointing elsewhere correctly passes, because verify relaxes canonicals against a localhost origin. That check cannot be covered from here, and the block says so.
⚠ And cutting this release caught a packing bug
The file count moved 97 → 98. The extra file was template/docs/handover.pdf — generated by npm run handover, ignored by template/.gitignore, and copied into the package anyway, because that ignore file is renamed to gitignore for publishing and npm then stops honouring it.
prepack was filtering with a hand-maintained denylist that had never heard of it. This one was the placeholder document, so nothing leaked. The same path ships a real client's rendered handover from any machine that has run the command.
prepack now copies what git ls-files reports. If the repo does not track it, it is not part of the template — a published package should never depend on which commands happen to have been run on the packer's laptop.
0.1.13 — Actions get version updates, npm does not
Documentation and repo hygiene. No behaviour change to any script.
Version updates is two decisions, not one
| Ecosystem | Surface | |
|---|---|---|
github-actions |
5 actions, all first-party actions/*, all pinned to a major tag |
on |
npm |
593 packages, and the lockfile is a shipped artefact | off |
Five actions on major tags means a PR appears only on a major bump — roughly five a year, each tested by kit.yml on ubuntu and windows before anyone looks at it. 593 npm packages, in a lockfile copied into every site built from the kit, is a change to what users receive: that needs a judgement and the full gate suite, not a queue of automated PRs.
The failure it prevents: GitHub has forcibly retired action runtimes before — the Node 12 and Node 16 deprecations. CI is this repo's entire quality signal, and finding out it has gone stale from a red build during a release is the wrong moment.
⚠ npm security updates stay on. They live in repository settings, not in dependabot.yml, and this file does not disable them. What is declined is routine version churn, never the vulnerability signal.
The audit finding every site inherits, answered once
New: docs/dependencies.md, shipped into every scaffolded site.
Run npm audit on a fresh site and it reports a high. It is GHSA-jmr9-qjv8-65gv — extract-zip, CVSS 8.1 — reached through pa11y-ci → pa11y → puppeteer → @puppeteer/browsers. Production dependencies report zero, nothing under src/ imports it, and a production install does not pull it.
It is proven unfixable rather than asserted:
npm view extract-zip versions → … 2.0.1 the newest release IS the vulnerable one
last published → 2023-03-04 unmaintained for ~3.5 years
npm audit fix --dry-run → +0 ~0 -0
puppeteer → @puppeteer/browsers pinned at an exact 2.13.2, not a range
That last check matters: overrides is the usual escape hatch for a transitive pin, and it is useless here only because there is no version to point it at. "No patch available" and "we checked every version ever published" are different claims.
⚠ npm audit and Dependabot disagree on the count, and both are right. npm prints one row per affected package in the chain (six); GitHub prints one per advisory (one). Same single problem — a reader assuming six separate holes escalates something that is not there.
The note carries the wording to give a client, every clause checkable by two commands. It does not say the site has "no vulnerabilities" — a bigger claim than the evidence supports — and it says plainly that low risk is not the same as fine. It ends by instructing whoever reads it to delete the file once a patch ships.
91 cases across 15 gates, 57 proving a refusal.