@webgrip/astro-site-toolkit (0.6.0)
Installation
@webgrip:registry=npm install @webgrip/astro-site-toolkit@0.6.0"@webgrip/astro-site-toolkit": "0.6.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.
Rules axe ships disabled
axe-core 4.13 ships 16 of its 105 rules with enabled: false, so a default analyze() does not
run them. The scan turns on the eight that map to WCAG 2.2 A/AA, the standard the estate's sites
commit to: aria-roledescription, audio-caption, css-orientation-lock,
label-content-name-mismatch, p-as-heading, table-fake-caption, target-size and
td-has-header. target-size and label-content-name-mismatch are the two that catch defects
nothing else here does — a control too small to hit, and a control whose visible text is missing
from the name voice control matches against.
The other eight stay off on purpose. color-contrast-enhanced, identical-links-same-purpose
and meta-refresh-no-exceptions are AAA, which W3C advises against adopting as a blanket policy.
duplicate-id and duplicate-id-active test a criterion WCAG 2.2 removed. focus-order-semantics,
hidden-content and landmark-complementary-is-top-level carry no WCAG tag and report style
preferences, and hidden-content is noisy enough that Deque flags it as such.
Pass enableRules to override the set; [] restores axe's own defaults.
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.
Newsletter mail pipeline
// scripts/build-mail.ts — the per-site glue; content, copy and brand stay in the site:
import { mailBuildCli } from '@webgrip/astro-site-toolkit/mail';
process.exit(
await mailBuildCli({
targets: await collectTargets(),
locales: LOCALES,
isLocale,
outDir: 'build/mail',
render: (document) => renderMail(document, MAIL_THEME),
check: (document, html) => checkMail(document, html, { siteUrl, privacyUrl }),
}),
);
renderMail(document, theme)turns aMailDocument(subject, preheader, kicker, headline, lead, facts, one call to action, the reason the reader gets it) into a single-column, table-based HTML mail with inline styles. Thethemeis the site's:siteUrl,siteName, a hostedlogo, a seven-colourpalette, two font stacks, andfooter(locale)with the reply, unsubscribe and privacy copy. The unsubscribe link is Brevo's{{ unsubscribe }}tag, and a⟦…⟧placeholder that survived into the copy throws.checkMail(document, html, options)returns{ document, message }[]: exactly one unsubscribe tag, every href absolute https, a privacy link atprivacyUrl(locale), subject and preheader within inbox limits and not empty, every image with an alt, no half-empty fact. PasslocalePath(locale)on a bilingual site and a link that leaves the mail's locale is a finding too.mailBuildCli(options)ispnpm mail: no arguments lists the targets,<id> [locale]writes the files,--allwrites everything,--checkrenders and checks every target and is the CI gate. Returns the exit code.brevoDraftCli(options)ispnpm mail:draft: renders, checks, and writes the mail into Brevo as a draft campaign named bycampaignName(document), updating an existing draft of that name rather than creating a second.--dry-runprints the payload and never calls Brevo; withoutBREVO_API_KEYin the environment it stops. There is deliberately no send.mailFilename,mailName,escapeHtmlandslugifyare exported for the site's sources.
What stays in the site: the MailDocument sources (which content becomes a mail, and with
which facts), the copy per locale, and the theme. Extracted from twente.dev, where
ADR 0017
holds the draft-not-send decision.
Dependencies
Dependencies
| ID | Version |
|---|---|
| yaml | 2.9.0 |
Peer dependencies
| ID | Version |
|---|---|
| @axe-core/playwright | ^4.10.0 |
| playwright-core | ^1.50.0 |