@webgrip/astro-site-toolkit (0.4.0)
Installation
@webgrip:registry=npm install @webgrip/astro-site-toolkit@0.4.0"@webgrip/astro-site-toolkit": "0.4.0"About this package
@webgrip/astro-site-toolkit
Build-time guards and rigs shared by Webgrip Astro static sites. Extracted after the two sites' copies of the CSP validator diverged 125 vs 171 lines within a month — copy-reuse demonstrably fails for these; this package is reference-reuse.
Bins
webgrip-validate-csp [dist]— asserts every governed inline script AND stylesheet is authorised by its page's own meta CSP, per directive (a style-src hash cannot authorise a script). Wire into the build:"build": "astro build && webgrip-validate-csp".webgrip-serve-dist [dist]— serves dist/ with Cloudflare's auto-trailing-slash semantics and gzip, for Lighthouse rigs that must measure the site, not the server.PORT=0binds ephemerally; the bound port is printed.
Axe engine
// scripts/axe-scan.ts — the thin per-site file that stays in the consumer:
import { runAxeScan } from '@webgrip/astro-site-toolkit/axe-engine';
await runAxeScan({
// Pages that matter for a11y but carry no Lighthouse budget — with each
// site's own argued rationale. This is exactly the part that must NOT be
// shared.
a11yOnlyPages: ['/404.html'],
});
Ships TypeScript declarations (lib/axe-engine.d.mts), so a strict consumer needs no shims.
Peer deps (consumer installs): @axe-core/playwright, playwright-core. The page list is
read from lighthouserc.json so the two gates cannot drift apart.
Mail-auth drift
webgrip-validate-mail-auth [intent](defaultops/mail-auth.intent.yml) — reads a per-site intent file that declares, per domain, what should be published, and fails when the world disagrees: MX, SPF, DKIM key length, DMARC policy and report addresses, CAA, DNSSEC, and the MTA-STS policy (DNS records, served astext/plain, byte-identical to the repo copy). Exit 1 on drift, 2 without an intent file. Run it nightly from a scheduled workflow; wire it as"validate:mail-auth": "webgrip-validate-mail-auth".
Two things a generic checker gets wrong are decisions in the intent, not in the engine: a domain
whose spf is deliberately-absent fails when an SPF record appears, and a record that is
published while the intent still says published: false fails, so the declared state cannot
quietly fall behind the world. Steps the intent declares as not yet done are printed, not failed.
# ops/mail-auth.intent.yml
resolver: 8.8.8.8
mtaSts:
policyInRepo: public/.well-known/mta-sts.txt
policyUrl: https://mta-sts.example.nl/.well-known/mta-sts.txt
dnsRecordsPublished: true
domains:
example.nl:
mx: [smtp.google.com]
spf: v=spf1 include:_spf.google.com ~all
dkim:
- selector: google
keyCharacters: 408
dmarcPolicy: p=none
dmarcReportsTo: [dmarc@example.nl]
caa:
published: true
issuers: [letsencrypt.org]
dnssec:
dsPublished: true
mtaSts is optional; a site without a policy omits it. The engine is importable as
@webgrip/astro-site-toolkit/mail-auth (auditMailAuth(intent, lookups)), with lookups
injectable so a consumer can test its intent file against a fixture world without touching DNS.
Copy-claims guard
// src/lib/claims.test.ts — the per-site file; the rules are the site's, the engine is shared:
import { bannedCopyViolations, rulesWithoutProof } from '@webgrip/astro-site-toolkit/claims';
import type { ClaimRule } from '@webgrip/astro-site-toolkit/claims';
const FORBIDDEN: ClaimRule[] = [
{
name: 'em dash in copy',
pattern: /—/,
rationale: 'house style: rewrite the sentence',
canonical: 'een zin — met kastlijntje',
},
];
test('source copy carries no banned variants', () => {
assert.deepEqual(bannedCopyViolations(FORBIDDEN, { roots: ['src/pages'] }), []);
});
test('every rule still matches its own canonical violation', () => {
assert.deepEqual(rulesWithoutProof(FORBIDDEN), []);
});
bannedCopyViolations(rules, options)walksroots(plusextraFiles), keeps files byextensions(default: astro, md, mdx, yml, yaml, html), skips paths matchingignore, and reportsfile:line [rule] previewwith the rationale. By default it scans copy only: Astro frontmatter,<script>/<style>, HTML comments and{expressions}are blanked (line numbers preserved), as are fenced code in Markdown, comments in TypeScript and YAML. A site that keeps copy in frontmatter passesextract: 'raw'. A line containingclaims-allowis exempt. A rule withscopeapplies only to paths that match it;active: falseparks a rule.rulesWithoutProof(rules)is the mutation guard: every rule carries acanonicalviolation, and a rule whose pattern stops matching it is named, so a regex edit cannot silently disarm a rule.retiredVocabularyViolations({ modelPath, exempt })reads theretiredlist from adocs/domain/model.yaml(the domain-language skill's format) and sweeps every tracked file for those words, the model andexemptpaths excepted. It returns{ retired, violations }.missingOgImages({ files, publicDir })listsogImage="…"references that point at no file underpublic/.sourceFiles(options)andcopyOf(path, raw)are exported for custom checks.
What stays in the site is exactly what differs per site: the rules, their rationale, the roots and the runtime facts (twente.dev derives its literal-release-fact pattern from the current release entry). Extracted from twente.dev and webgrip.nl, whose two copies had already grown apart in what they blanked and what they reported.
Dependencies
Dependencies
| ID | Version |
|---|---|
| yaml | 2.9.0 |
Peer dependencies
| ID | Version |
|---|---|
| @axe-core/playwright | ^4.10.0 |
| playwright-core | ^1.50.0 |