@webgrip/astro-site-toolkit (0.6.0)

Published 2026-09-18 06:07:25 +00:00 by webgrip-ci in webgrip/frontend-toolkit

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=0 binds 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] (default ops/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 as text/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) walks roots (plus extraFiles), keeps files by extensions (default: astro, md, mdx, yml, yaml, html), skips paths matching ignore, and reports file:line [rule] preview with 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 passes extract: 'raw'. A line containing claims-allow is exempt. A rule with scope applies only to paths that match it; active: false parks a rule.
  • rulesWithoutProof(rules) is the mutation guard: every rule carries a canonical violation, and a rule whose pattern stops matching it is named, so a regex edit cannot silently disarm a rule.
  • retiredVocabularyViolations({ modelPath, exempt }) reads the retired list from a docs/domain/model.yaml (the domain-language skill's format) and sweeps every tracked file for those words, the model and exempt paths excepted. It returns { retired, violations }.
  • missingOgImages({ files, publicDir }) lists ogImage="…" references that point at no file under public/.
  • sourceFiles(options) and copyOf(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 a MailDocument (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. The theme is the site's: siteUrl, siteName, a hosted logo, a seven-colour palette, two font stacks, and footer(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 at privacyUrl(locale), subject and preheader within inbox limits and not empty, every image with an alt, no half-empty fact. Pass localePath(locale) on a bilingual site and a link that leaves the mail's locale is a finding too.
  • mailBuildCli(options) is pnpm mail: no arguments lists the targets, <id> [locale] writes the files, --all writes everything, --check renders and checks every target and is the CI gate. Returns the exit code.
  • brevoDraftCli(options) is pnpm mail:draft: renders, checks, and writes the mail into Brevo as a draft campaign named by campaignName(document), updating an existing draft of that name rather than creating a second. --dry-run prints the payload and never calls Brevo; without BREVO_API_KEY in the environment it stops. There is deliberately no send.
  • mailFilename, mailName, escapeHtml and slugify are 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
Details
npm
2026-09-18 06:07:25 +00:00
2
MIT
latest
21 KiB
Assets (1)
Versions (7) View all
0.6.0 2026-09-18
0.5.0 2026-09-05
0.4.0 2026-09-05
0.3.0 2026-09-05
0.2.1 2026-09-04