Skip to content

About

Fast post-build auditor for Astro sites: SEO, links, and lightweight WCAG checks

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

astro-post-audit

Fast, offline post-build auditor for Astro sites — SEO signals, internal link consistency, and lightweight WCAG heuristics against your dist/ output, with 32 check modules covering structured data, performance, privacy, and more. Static analysis only: no browser, and no network calls unless opt-in external-link checking is enabled — runs in <1s on typical sites. Optional checks cover per-route CSS payloads, Astro/Tailwind source patterns, HTML5 conformance and C2PA Content Credentials.

Website and documentation: casoon.github.io/astro-post-audit

Release history and upgrade notes are in the Changelog.

Installation

npm i -D @casoon/astro-post-audit

The package downloads the audit binary for your platform from the GitHub releases and checks it against the published SHA-256 first. Official binaries come only from there, built by the release workflow. To verify an archive by hand:

shasum -a 256 -c <archive>.sha256
gh attestation verify <archive> --repo casoon/astro-post-audit

Setup

// astro.config.mjs
import { defineConfig } from 'astro/config';
import postAudit from '@casoon/astro-post-audit';

export default defineConfig({
  site: 'https://example.com',
  integrations: [postAudit()],
});

That's it. The audit runs automatically after every astro build.

Note: If you use @astrojs/sitemap, make sure postAudit() comes after sitemap() in the integrations array. Both plugins use the astro:build:done hook and run in array order — the sitemap file needs to exist before the audit can check it.

Skipping the audit

Set the SKIP_AUDIT environment variable to skip the audit for a single build. Useful for quick dev builds when external link checking would slow things down:

SKIP_AUDIT=1 astro build

Or add a dedicated script to your package.json:

{
  "scripts": {
    "build": "astro build",
    "build:fast": "SKIP_AUDIT=1 astro build"
  }
}

Then run npm run build:fast (or pnpm build:fast) when you want to skip the audit.

You can also disable the audit permanently via config: postAudit({ disable: true }).

Presets

Use a preset to start with a predefined configuration. Individual rules override preset defaults.

postAudit({ preset: 'standard' })

// Preset + custom overrides
postAudit({
  preset: 'seo',
  rules: {
    headings: { no_skip: true },   // add a rule on top of the preset
  },
})
Preset What it enables
standard Comprehensive quality checks without aggressive extras. Canonical self-reference, heading gaps, meta description, Open Graph (title/description/image + absolute image URL + twitter card validation), a11y (skip link, img alt, button/label names, landmark structure, duplicate IDs, ARIA roles), image dimensions/lazy loading, fragment validation, sitemap, security (target-blank), hreflang, assets, JSON-LD, content quality (duplicate titles/descriptions/H1). Warnings stay warnings.
strict Everything in standard plus orphan detection, extended robots.txt (disallow-all, crawl-delay), inline-script warnings, Twitter Card, OG type/url requirements, structured data property completeness, i18n audit, crawl budget, render blocking, privacy/security, structured data graph. Sets strict: true (warnings become errors).
production Alias for strict.
seo SEO signals only — canonical (self-reference, clusters), html basics (lang, title, meta description, viewport, length limits), Open Graph (all four tags), JSON-LD syntax, sitemap.
accessibility WCAG heuristics — lang attr, title, viewport, heading hierarchy (H1 required, single, no gaps), full a11y ruleset (img alt, link/button names, form labels, generic link text, aria-hidden, skip link, landmark structure, duplicate IDs, ARIA roles), fragment validation.
performance Static performance signals — broken asset references, missing image dimensions (CLS), lazy loading, srcset hints, hashed filenames, render blocking scripts.
relaxed Core SEO and link checks only. No heading gaps, no Open Graph, no structured data, no content quality. Broken links are warnings, not errors. Good starting point for existing sites with known issues.
editorial Enables the content_style module with its built-in ruleset (em-dash density, contrast-formula repetition, chatbot leftovers). Doesn't touch any other check — combine with standard/seo via rules overrides. See Content style.

Speed mode

mode is orthogonal to preset: it's a speed switch, not a rule-set choice. On large sites, rules.html_validation (full HTML5 conformance validation, opt-in) can dominate build time — it runs a RELAX NG content-model check per page and scales with page count. mode: 'fast' disables it unconditionally, even if rules.html_validation.enabled: true is set elsewhere (e.g. inherited from a preset or a shared config), which makes it a safe one-line override for local/dev builds without touching the rest of the config. mode: 'full' (the default) leaves your config untouched.

// astro.config.mjs
postAudit({
  preset: 'seo',
  mode: process.env.POST_AUDIT_FAST === '1' ? 'fast' : 'full',
  rules: {
    html_validation: { enabled: true }, // still off when mode: 'fast'
  },
})
POST_AUDIT_FAST=1 pnpm build   # fast: skips html_validation
pnpm build                     # full: runs everything as configured

Example configurations

A working reference implementation can be found in casoon/astro-v7-template.

Simple site

Minimal config for a small personal or marketing site — the relaxed preset covers core SEO with lenient settings, then we add a meta description requirement on top.

postAudit({
  preset: 'relaxed',
  failOn: 'errors',
  rules: {
    filters: { exclude: ['404.html'] },
    html_basics: { meta_description_required: true },
    sitemap: { require: true },
  },
})

Blog with Content Collections

The seo preset handles canonical, html basics, Open Graph, JSON-LD and sitemap. Source-file hints show MDX paths next to dist/ findings.

postAudit({
  preset: 'seo',
  failOn: 'errors',
  hints: { sourceFiles: true },
  rules: {
    filters: { exclude: ['404.html', 'blog/index.html'] },
    headings: { no_skip: true },
    content_quality: {
      detect_duplicate_titles: true,
      detect_duplicate_descriptions: true,
    },
  },
})

Multilingual site (hreflang)

The standard preset already includes hreflang (valid codes, self-reference, x-default, reciprocal). Code validation and the self-reference come from the shared web-checks crate: x-default does not count as a page's reference to itself — every language version must name itself under its own code. Only exclusions and failOn need to be set.

postAudit({
  preset: 'standard',
  failOn: 'errors',
  rules: {
    filters: { exclude: ['404.html'] },
  },
})

Accessibility-focused

The accessibility preset covers the full WCAG heuristic ruleset. Add seo rules on top if needed.

postAudit({
  preset: 'accessibility',
  failOn: 'errors',
  rules: {
    filters: { exclude: ['404.html'] },
    html_basics: { meta_description_required: true },
    sitemap: { require: true },
  },
})

Strict production gate

production (= strict) enables everything. Only additional opt-in checks and output config need to be specified.

postAudit({
  preset: 'production',
  failOn: 'errors',
  maxWarnings: 0,
  hints: { sourceFiles: true },
  reports: {
    json: 'audit-report.json',
    sarif: 'audit.sarif',
  },
  rules: {
    filters: { exclude: ['404.html'] },
    external_links: { enabled: true, fail_on_broken: true },
  },
})

AI-optimised content

Enable AI visibility checks for content that should surface well in LLM-generated answers. Useful for documentation, blogs, and product pages targeting AI-assisted search.

postAudit({
  preset: 'seo',
  failOn: 'errors',
  aiVisibility: true,
  rules: {
    filters: { exclude: ['404.html'] },
    headings: { no_skip: true },
    structured_data: { check_json_ld: true },
  },
})

aiVisibility: true enables the opt-in AI visibility module, which checks LLM-readability (word count, lang attribute), citability (og:title, og:description, canonical, author schema), chunk quality (semantic sectioning, heading structure on long pages), and AI bot policy in robots.txt.

UX heuristics for marketing sites

Enable UX heuristics to catch missing calls-to-action, generic link text, and missing trust signals:

postAudit({
  preset: 'standard',
  failOn: 'errors',
  uxHeuristics: {
    maxLinksPerPage: 60,
    minCtaPerPage: 2,
  },
  rules: {
    filters: { exclude: ['404.html'] },
  },
})

Pass uxHeuristics: true to use the defaults (maxLinksPerPage: 80, minCtaPerPage: 1), or pass an object to override them. Values in rules.ux_heuristics are kept; uxHeuristics: false switches the check off.

Environment-based gate (dev / staging / prod)

Use plain JavaScript in astro.config.mjs — no profile system needed. The goLive option automatically uses Astro's site as the expected production origin so you don't have to repeat the domain.

const isProd = process.env.DEPLOY_CONTEXT === 'production'

postAudit({
  preset: isProd ? 'production' : 'relaxed',
  failOn: isProd ? 'errors' : 'never',
  maxWarnings: isProd ? 0 : undefined,
  goLive: {
    enabled: isProd,
    forbiddenDomains: ['staging.example.com', 'localhost'],
  },
})

When goLive.enabled is true, the integration verifies that the expected production origin matches:

  1. goLive.expectedSite — only needed when the go-live target differs from Astro's site config
  2. Astro config.site — used automatically otherwise

If neither is set and goLive.enabled is true, the audit fails with a config error.

Environment selection always belongs in astro.config.mjs via normal JavaScript — the plugin does not have a built-in profile system. If you find yourself duplicating a lot of config across environments, a profile system may be worth revisiting in a future release.

Production rollout

The new dist-only audits (i18n_audit, crawl_budget, render_blocking, privacy_security, structured_data_graph) are intentionally heuristic. They are useful in production, but best rolled out in two steps.

Step 1: Recommended baseline (warn-first)

postAudit({
  failOn: 'never',
  reports: { json: 'audit-report.json' },
  rules: {
    i18n_audit: { enabled: true },
    crawl_budget: { enabled: true },
    render_blocking: { enabled: true },
    privacy_security: { enabled: true },
    structured_data_graph: { enabled: true },
    severity: {
      'privacy-security/third-party-domains': 'info',
      'crawl-budget/noindex-with-internal-demand': 'info',
    },
  },
})

Step 2: Strict gate (after tuning)

postAudit({
  failOn: 'errors',
  maxErrors: 50,
  rules: {
    i18n_audit: { enabled: true },
    crawl_budget: { enabled: true },
    render_blocking: { enabled: true },
    privacy_security: { enabled: true },
    structured_data_graph: { enabled: true },
    severity: {
      'privacy-security/missing-sri-script': 'error',
      'privacy-security/missing-sri-stylesheet': 'error',
      'structured-data-graph/type-conflict': 'error',
      'crawl-budget/redirect-target-missing': 'error',
    },
  },
})

Configuration

All options are optional. Your editor provides autocomplete with descriptions and concrete defaults where a field has one.

postAudit({
  preset: 'standard',        // Apply a predefined config (see Presets)
  failOn: 'errors',          // Fail the build on errors (or 'warnings' / 'never')
  maxErrors: 20,             // Stop after 20 errors
  reports: {                 // Write report files (multiple formats at once)
    json: 'audit-report.json',
    markdown: 'audit-summary.md',
    sarif: 'audit.sarif',
  },
  benchmark: true,           // Print per-check timing breakdown
  pageOverview: true,        // Show page properties overview instead of checks
  rules: {
    filters: { exclude: ['404.html', 'drafts/**'] },
    canonical: { self_reference: true },
    a11y: { require_skip_link: true },
    assets: { check_broken_assets: true, check_image_dimensions: true },
    structured_data: { check_json_ld: true },
    security: { check_target_blank: true },
    content_quality: { detect_duplicate_titles: true },
    opengraph: { require_og_title: true, require_og_image: true },
    external_links: { enabled: true, timeout_ms: 5000 },
    headings: { no_skip: true },
    severity: {
      'html/title-too-long': 'off',
      'images/alt-missing': 'error',
    },
  },
})

Top-level options reference

Option Type Default Description
preset 'standard' | 'strict' | 'production' | 'seo' | 'accessibility' | 'performance' | 'relaxed' | 'editorial' — Apply a predefined config before your rules overrides. See Presets.
mode 'fast' | 'full' 'full' Orthogonal to preset. 'fast' forcibly disables checks known to be expensive on large sites (currently rules.html_validation), regardless of how they're configured. See Speed mode.
strict boolean false Treat warnings as errors (exit code 1).
throwOnError boolean false Throw an error (fail the build) when the audit finds issues. Ignored when failOn is set.
failOn 'errors' | 'warnings' | 'never' — Fail on errors only, on warnings and errors, or never. Takes precedence over throwOnError; 'warnings' always implies strict mode.
maxErrors number — Stop after this many errors; the report is then marked truncated. Errors known from the baseline do not count, and writeBaseline ignores the limit.
maxWarnings number — Fail the build if the warning count exceeds this number. Activates build gating unless failOn: 'never' is set.
site string auto Base URL — auto-detected from Astro's site config. Astro's base is applied too: links, canonicals and sitemap URLs carry it, files in dist/ do not.
reports ReportsConfig — Write report files. See Report files.
output string — Write a JSON report to this path. Legacy alias for reports.json.
outputMarkdown string — Write a Markdown report to this path. Legacy alias for reports.markdown.
baseline string — Path to a baseline file. Only new findings since the baseline are reported.
writeBaseline boolean false Write current findings as the new baseline and exit 0. Run once to adopt the plugin on a site with existing issues.
hints.sourceFiles boolean false Show likely source file paths (e.g. src/content/blog/post.mdx) next to dist/ findings. Heuristic — may not always match.
groups GroupsConfig — Enable rule groups: seo, a11y, links, performance, privacy, viz_alternatives. true enables the group, "warn" enables but downgrades all findings to warnings. See Display-mode convention for viz_alternatives.
goLive GoLiveConfig — Production readiness gate. See Go-live gate.
pageOverview boolean false Print a page properties table (title, description, canonical, OG, H1, lang, JSON-LD) instead of running checks.
benchmark boolean false Print per-check timing breakdown.
progress boolean | 'verbose' auto Live progress bar on stderr while checks run. Auto-on in an interactive terminal, silent in CI. Set true/false to force; 'verbose' lists every check with its finding count and time.
color 'auto' | 'always' | 'never' 'auto' Colour for the text report and progress output. auto styles only an interactive terminal and honours NO_COLOR; always forces colour and Unicode symbols for piped logs shown in a colour-capable terminal.
debug boolean false Verbose diagnostics on stderr (resolved config, discovery stats, per-check counts/timings). Never touches the stdout report; replaces the progress bar. See Diagnostics.
aiVisibility boolean false Enable AI visibility checks (LLM-readability, citability, chunk quality). See AI visibility.
uxHeuristics boolean | { maxLinksPerPage?: number, minCtaPerPage?: number } false Enable UX heuristic checks (CTAs, generic link text, trust signals). See UX heuristics.
contentStyle boolean false Enable content style checks (recurring "reads like AI" writing patterns). See Content style.
disable boolean false Disable the integration entirely.
rules RulesConfig — Inline check configuration — see full reference below.

Baseline workflow

Use baseline + writeBaseline to adopt the plugin on a site that already has findings:

// Step 1: write the current state as a baseline (run once)
postAudit({ writeBaseline: true, baseline: '.audit-baseline.json' })

// Step 2: from now on, only new findings are reported
postAudit({ baseline: '.audit-baseline.json' })

Commit .audit-baseline.json to version control. Delete entries from it to re-enable specific checks.

Groups shorthand

postAudit({
  groups: {
    seo: true,           // enable all SEO rules
    a11y: 'warn',        // enable a11y rules but never block the build
    performance: true,
  },
})

Go-live gate

The goLive option adds a set of production-readiness checks that run only when enabled: true. These checks catch staging/dev leftovers before a build goes to production.

Field Type Default Description
goLive.enabled boolean false Enable go-live checks.
goLive.expectedSite string auto Expected production origin. Defaults to Astro's site config. Only set this when the go-live target differs from Astro site.
goLive.forbiddenDomains string[] [] Domains that must not appear in canonical URLs, sitemaps, OG tags, or absolute links.
goLive.imageHosts string[] [] Further hosts that may serve og:image, e.g. an image CDN on another domain. The production host and its subdomains are always allowed.

What it checks (rule IDs prefixed with golive/):

Rule ID Description
golive/noindex Page has a noindex robots directive
golive/canonical-origin Canonical URL uses the wrong origin
golive/og-origin og:url uses the wrong origin, or og:image comes from a host that is neither the production host, one of its subdomains nor listed in goLive.imageHosts
golive/sitemap-origin Sitemap entry uses the wrong origin
golive/forbidden-domain Absolute link, script, canonical, or sitemap entry contains a forbidden domain
golive/robots-blocked robots.txt globally blocks crawlers with Disallow: /
golive/config-missing-site goLive.enabled is true but no expected site could be resolved

All go-live findings are errors (exit code 1). They cannot be downgraded via severity overrides — they are explicit production gates.

Full rules reference

The integration options reference lists every rule setting with its default. The package also exports a JSON Schema at @casoon/astro-post-audit/schema.

What it checks

  • Note on signal type — Most checks are deterministic (broken links, missing tags). The five dist-only audits are heuristic by design and should be tuned via severity for your project.
  • SEO — Canonical tags (including cluster detection), robots meta, URL normalization (trailing slash, index.html)
  • Links — Broken internal links, query parameters, fragment validation, orphan pages, URL depth, links pointing at redirect pages
  • External Links — HEAD requests to verify external URLs return 2xx, with domain filtering and concurrency control
  • Sitemap — Cross-reference with canonical URLs, stale entries, missing pages
  • robots.txt — Existence check, sitemap link, disallow-all detection, crawl-delay threshold, AI bot policy (citation bots such as PerplexityBot/OAI-SearchBot blocked, training bots such as GPTBot/CCBot allowed), noindex/Disallow contradiction, sitemap entries blocked by robots
  • Redirects — Static meta-refresh redirect chains, loops, and internal links that point at redirect pages
  • HTML — <html lang>, <title>, viewport, meta description, heading hierarchy, native HTML5 conformance validation (opt-in)
  • Accessibility — img alt + alt-text quality heuristics, link/button names, form labels (including wrapping labels), generic link text, skip link, aria-hidden on focusable elements, landmark structure (<main>, <nav>, <header>, <footer>), duplicate IDs, WAI-ARIA role validation
  • Open Graph — og:title, og:description, og:image (absolute URL + existence/dimensions/size), og:type, og:url, twitter:card (valid values), twitter:image, title consistency
  • Structured Data — JSON-LD syntax, semantics, duplicate type detection, property completeness (author, datePublished, image, publisher, breadcrumb positions …)
  • Images — Missing width/height attributes (CLS), missing loading="lazy", missing srcset, modern format hints
  • Hreflang — Multilingual link validation, x-default, self-reference, reciprocal links, target existence
  • Security — target="_blank" without noopener, mixed content, inline scripts
  • Assets — Broken references, image dimensions, file size limits, cache-busting hashes
  • Performance — Client-side JS and CSS payloads per route and font-loading hints (opt-in)
  • Content Quality — Duplicate titles, descriptions, H1s, near-identical pages
  • Content Sync (opt-in) — src/content/ collection items with no corresponding generated page
  • Source Analysis (opt-in) — Static Astro/Tailwind source inventory: utility-family usage, exact duplicate class signatures, mutually exclusive utilities, and oversized components. Never executes JavaScript or guesses dynamic classes.
  • I18n Audit — Consistency between localized routes, html[lang], hreflang, and canonical
  • Crawl Budget — Query/variant URL dilution, duplicate canonical clusters, and indexability mismatches
  • Render Blocking — Sync <head> scripts and missing preconnect hints for critical third-party resources
  • Privacy/Security (Static) — Third-party domain inventory, missing SRI, CSP-readiness, consent signals, GDPR/DSGVO transfers (opt-in: Google Fonts, YouTube, Maps, public CDNs, external images)
  • Structured Data Graph — Cross-page JSON-LD entity conflicts (@id, type/name/url) and missing internal entity URLs
  • AI Visibility (opt-in) — LLM readability (word count, lang), citability (OG metadata, canonical, author schema), semantic structure, AI bot policy, and llms.txt / llms-full.txt link integrity
  • UX Heuristics (opt-in) — Missing CTAs, generic link text ("click here", "mehr"), missing trust signals (Impressum, Datenschutz, contact), link density, interactive element density
  • Content Style (opt-in) — Configurable heuristics for recurring "reads like AI" writing patterns: em-dash overuse, contrast-formula repetition ("nicht/kein X, sondern Y"), chatbot leftovers, uniform sentence rhythm
  • View Transitions (opt-in) — Duplicate transition:name values and Client Router external-link reload hints
  • C2PA Provenance (opt-in) — Local validation of embedded C2PA Content Credentials in JPEG, PNG, and WebP assets

HTML5 conformance validation

Enable rules.html_validation.enabled for offline content-model, parser, ARIA, attribute, and table validation. Astro's generated client-island runtime style (astro-island,astro-slot,astro-static-slot{display:contents}) is ignored automatically; other <style> elements in body content remain findings. Under the current HTML standard, <style> is metadata content and belongs in <head> (or a <noscript> in <head>), even when it is the first child of a body-level container. In Astro and MDX, use Astro's normal component-scoped <style> handling instead of emitting a raw body-level <style> tag.

Validation is the most expensive check on large sites, so results are cached per page in node_modules/.cache/astro-post-audit/html-validation.json, keyed by a hash of the page's HTML and the plugin version. An incremental build only validates the pages whose HTML changed; a layout change or a plugin update validates everything again. The cache stores the validator's raw output, so changing max_per_page or severity takes effect without a rerun. Without a persisted cache (e.g. a fresh CI checkout) every page is validated, as before: a cold run costs roughly 0.3–0.5 s per page, so about 1.5 minutes for 1,000 pages. In CI, persist node_modules/.cache/astro-post-audit/ between runs (for example with actions/cache) to skip the unchanged pages. Set rules.html_validation.cache: false to switch it off; debug: true prints how many pages were validated and how many came from the cache.

html/assertion.roles.unnecessary-list reports an explicit role="list" on <ul>/<ol> as redundant according to ARIA-in-HTML. If the role is intentional to retain VoiceOver/Safari list semantics when CSS removes list markers, keep the workaround and tune only that finding:

rules: {
  html_validation: { enabled: true },
  severity: {
    "html/assertion.roles.unnecessary-list": "off",
  },
}

AI visibility

Enable via aiVisibility: true (top-level option) or rules.ai_visibility.enabled: true.

By default this also looks for dist/llms.txt or dist/llms-full.txt and validates their internal Markdown links. Set rules.ai_visibility.require_llms_txt: false to keep the other AI visibility checks without this file check.

Findings that only rest on a heuristic carry the outcome review (see Rule ids come from the shared core): they do not change how severity gates the build. The module no longer repeats what other modules already report: a missing lang, og:title, og:description or canonical shows up as document/lang-missing, opengraph/title-missing, opengraph/description-missing and canonical/missing. Turn on rules.opengraph.require_og_title and require_og_description if you want the Open Graph ones.

Rule ID Level Description
ai-visibility/missing-llms-txt Info Neither llms.txt nor llms-full.txt exists in dist/
ai-visibility/llms-txt-broken-link Warning An internal Markdown link in an LLM context file has no published target
ai-visibility/low-word-count Info, review Fewer than 300 words of visible text (script and style excluded); a rule of thumb, short pages can be intentional
ai-visibility/missing-author-schema Info, review No author, publisher or creator in JSON-LD; depends on the page type
ai-visibility/no-semantic-sections Info, review No <article>, <section> or <nav> element
ai-visibility/no-subheadings Warning, review More than 600 words of visible text and no H2/H3
ai-visibility/noindex-page Info Page has a noindex robots meta tag

UX heuristics

Enable via uxHeuristics: true (top-level option) or rules.ux_heuristics.enabled: true. Pass an object to configure thresholds: uxHeuristics: { maxLinksPerPage: 60, minCtaPerPage: 2 }.

These checks infer quality from keywords and counts, so their findings carry the outcome review. Generic link text is reported by links/generic-name, not by this module.

Rule ID Level Description
ux/no-cta Warning, review Fewer than minCtaPerPage links or buttons with a call-to-action keyword
ux/no-trust-signals Warning, review No link to an Impressum, privacy, contact or about page and no <address> (keyword match)
ux/high-link-density Info, review Link count exceeds max_links_per_page (default 80)
ux/high-interactive-density Info, review More than 20 buttons, inputs, selects and textareas on a page

Source analysis (Astro + Tailwind)

Enable via rules.source_analysis.enabled: true. Runs entirely offline against your Astro/Tailwind source files (needs the project root, which the integration passes automatically) and only evaluates quoted static class and class:list entries — it never executes JavaScript or guesses dynamic classes. .git, .astro, dist, node_modules, and target are always excluded; exclude adds project-specific globs. All findings are advisory info.

rules: {
  source_analysis: {
    enabled: true,
    exclude: ["src/components/generated/**"],
    min_duplicate_occurrences: 3,
    max_component_lines: 300,
  },
}
Rule ID Description
source-analysis/tailwind-inventory Utility-family inventory across quoted static class lists
source-analysis/duplicate-signature An exact class signature repeats at least min_duplicate_occurrences times (default 3)
source-analysis/utility-conflict Duplicate or mutually exclusive utilities within the same variant scope (e.g. p-2 p-4, block hidden)
source-analysis/component-complexity An Astro component exceeds max_component_lines (default 300), max_component_props (default 12), or max_component_slots (default 6)

CSS architecture

Enable via rules.css_architecture.enabled: true. The check measures every route's directly referenced local stylesheets and inline <style> blocks. Repeated references to the same file on one page count once. External stylesheets and CSS loaded later by JavaScript are excluded because their size cannot be established reliably from dist/ alone.

rules: {
  css_architecture: {
    enabled: true,
    max_route_kb: 50,
    detect_route_outliers: true,
    outlier_factor: 2,
    min_outlier_kb: 20,
  },
}
Rule ID Level Description
css-architecture/route-payload Warning Direct local and inline CSS exceeds max_route_kb
css-architecture/route-outlier Info A route above min_outlier_kb exceeds the site median by outlier_factor

Content style

Configurable heuristics for recurring "reads like AI" writing patterns. This is a stylistic signal, not a correctness check — every finding comes back as outcome: "review". Info-level findings do not affect --strict; the built-in chatbot-leftover rule is a warning and therefore does. Regex-based findings include a short excerpt around the first match so they can be reviewed in context.

Enable via contentStyle: true (top-level option), rules.content_style.enabled: true, or preset: 'editorial'. With the default content_selector (article, main, .prose), the audit prefers a semantic <article>, otherwise <main>, then .prose. It omits header, nav, aside and footer content as well as repeated linked card groups (three or more equal sibling cards with a link and H2/H3), so article teasers and a small related-content hub do not distort one continuous writing sample. A custom content_selector uses its outermost matches directly.

For archive or taxonomy pages whose card structure cannot be recognised reliably, use exclude with dist-relative globs. It only disables content-style checks for those pages; link, accessibility, SEO and all other checks still run. For example: exclude: ["tags/**", "serien/**"].

Tune built-in density rules without copying their regexes:

rules: {
  content_style: {
    enabled: true,
    thresholds: {
      em_dash_density: 16,
      contrast_formula_density: 6,
      contrast_formula_density_en: 6,
    },
  },
}

The built-in ruleset is kept in sync with the anti-ai-copy skill's checklist:

Rule ID Level Description
content-style/em-dash-density Info More than 12 em-dashes (—) per 1000 words
content-style/contrast-formula-density Info German: more than 4 "nicht/kein X, sondern Y" contrast constructions per 1000 words
content-style/chatbot-leftover Warning German: unedited chatbot phrases ("Ich hoffe, das hilft", "Gerne!", "Lass uns eintauchen")
content-style/contrast-formula-density-en Info English: more than 4 "not X, but Y" contrast constructions per 1000 words
content-style/chatbot-leftover-en Warning English: unedited chatbot phrases ("I hope this helps", "Happy to help!", "Let's dive in")
content-style/language-mismatch Info <html lang> conflicts with a strong German/English function-word signal

New patterns discovered during manual review become a config entry, not a code change — the engine supports three generic rule types:

  • density_per_1000_words — flags when regex matches per 1000 words exceed threshold
  • presence — flags when the regex matches at all, anywhere in the content
  • sentence_length_uniformity — flags when sentence word-lengths are unusually uniform (coefficient of variation below threshold); not in the default ruleset since it needs per-project tuning to avoid false positives on reference-style content

Rule types are validated at runtime as well as by TypeScript. This catches misspellings in plain astro.config.mjs files and stops the build instead of silently disabling the affected rule.

The German and English built-in rule packs are selected from the primary <html lang> value (de-AT selects German, en-US English). Language-neutral rules always run; pages without lang run both language-specific packs so a missing language signal does not hide findings. Custom rules can use languages: ["de"] or languages: ["en"] for the same behavior.

The built-in language-consistency heuristic compares common German and English function words with <html lang>. It only reports a clear mismatch (at least 12 signal words and a 2:1 advantage), always as review; it does not attempt to classify other languages or mixed-language pages. Tune or disable it with language_detection.

contentStyle: true, // or, for fine-grained control:
rules: {
  content_style: {
    enabled: true,
    disabled_rules: ["em-dash-density"], // Disable a built-in rule by its short ID
    exclude: ["tags/**", "serien/**"], // Skip only content-style checks on archive hubs
    language_detection: {
      enabled: true,              // Compare German/English signal words with html[lang]
      min_signal_words: 12,
      mismatch_ratio: 2,
    },
    // Replaces the built-in defaults entirely when set (even to []):
    rules: [
      { id: "unleash", type: "presence", pattern: "(?i)entfesseln|unleash", languages: ["de", "en"], level: "warning" },
    ],
    // Or keep the defaults and just add one pattern:
    extra_rules: [
      { id: "tapestry-metaphor", type: "presence", pattern: "(?i)geflecht aus|mosaik aus", level: "info" },
    ],
  },
},

Each rule supports message/help overrides with {count}/{word_count}/{density}/{threshold}/{cv}/{sentences} placeholders, languages for language-scoping, and level: "off" to disable a rule without removing it from the list. Use disabled_rules to switch off a built-in rule without copying the default ruleset.

C2PA provenance

Enable via rules.c2pa.enabled: true. Validates embedded C2PA Content Credentials in local JPEG, PNG, and WebP assets found in dist/ — entirely offline, no remote manifest fetching.

By default, a missing manifest is silent: most images simply don't carry Content Credentials, and that's not a signal of anything. Set require_for to a list of dist-relative globs to require credentials on specific assets (e.g. AI-generated illustrations you want to keep provenance metadata on):

rules: {
  c2pa: {
    enabled: true,
    require_for: ["blog/**/*.jpg", "og/*.png"],
    require_trusted: true,
  },
}
Rule ID Level Description
c2pa/invalid Warning An embedded manifest exists but failed validation
c2pa/missing-required Warning No readable manifest found on an asset matched by require_for
c2pa/untrusted Warning Manifest is cryptographically valid but its signing certificate does not chain to a trusted root (only reported when require_trusted: true)

Missing credentials never imply that an image is AI-generated or otherwise non-compliant — they only mean no Content Credentials were embedded or none could be read.

By default, a Valid manifest passes even if it wasn't signed by a certificate chaining to a trusted root — a self-signed certificate produces Valid, never Trusted. That's fine for local testing but not sufficient as a compliance signal, since no real-world C2PA verifier (browser viewer, Adobe Content Credentials) would display it as trustworthy. Set require_trusted: true to flag Valid-but-not-Trusted manifests too. Keep it off if you intentionally sign with an in-house or test CA that isn't in the C2PA default trust list — you'd otherwise start failing your own legitimate manifests.

GDPR / DSGVO

Enable via rules.privacy_security.gdpr: true. Detects third-party transfers that send visitor IPs abroad without consent — particularly relevant in the DACH region. All findings are warnings.

Rule ID Description
privacy-security/google-fonts-external Loads fonts from fonts.googleapis.com / fonts.gstatic.com — self-host with @fontsource
privacy-security/youtube-direct-embed YouTube iframe uses youtube.com instead of youtube-nocookie.com / a consent wrapper
privacy-security/google-maps-embed Direct Google Maps iframe — use a static preview or gate behind consent
privacy-security/cdn-resources Scripts/styles from public CDNs (unpkg, jsDelivr, cdnjs, …) — bundle locally instead
privacy-security/external-images <img> from a third-party domain — host the image locally in src/assets/

Diagnostics

Two stderr-only diagnostics help you see what the audit is doing — neither touches the report on stdout, so JSON/SARIF output stays clean.

Progress bar

A live single-line progress bar over the check phases. Auto-enabled when stderr is an interactive terminal and silent in CI / when piped. Force it with progress: true / false.

  Auditing 770 pages…
  [███████████░░░░░░░░░░░] 17/32  crawl_budget

Debug mode

debug: true prints verbose diagnostics on stderr and replaces the progress bar:

postAudit({ debug: true })
[debug] effective config:
Config { preset: None, strict: false, ... }            ← resolved config after preset merge
[debug] discovery: 770 HTML file(s) found, 2 excluded by filters, 768 parsed into pages (180 ms)
[debug] sitemap.xml: 768 URL(s)
[debug]  1/32 seo                        12 finding(s)  40 ms
[debug]  2/32 links                       3 finding(s)  95 ms
...
[debug] 32/32 source_analysis             0 finding(s)   2 ms

Use it to confirm which config actually applies, what discovery found/filtered, and which check produces (or misses) findings and how long it takes.

Output

Text output is rendered by Runemark with semantic severity markers, grouped findings, metrics, and remedies. It uses colors in an interactive terminal and deterministic ASCII output when piped or in CI. The remedies reference Astro idioms (BaseHead, astro:assets, Content Collections, Astro.site) so fixes are actionable in context:

[FAIL] astro-post-audit

  Errors: 1   Warnings: 1   Info: 0   Files: 12

* blog/post/index.html (2)
  - Missing canonical tag [canonical/missing] at `head`
    Remedy: Set `site` in astro.config.mjs and render <link rel="canonical" ... /> in BaseHead.
  - <img> missing alt attribute [images/alt-missing] at `img[src='/photo.jpg']`
    Remedy: Add an `alt` prop to <Image>/<Picture> or the <img> tag.

When the result set is large (20 or more findings), the report prepends a top-rule summary so you get an at-a-glance view before the per-file detail:

  Top issue rules:
       8x  html/meta-description-missing
       3x  canonical/missing
       2x  images/alt-missing
       1x  links/broken

Rule ids come from the shared core

The accessibility and document rules are not implemented here. They come from a11y-rules, the shared core in barrierlab that also backs auditmysite and LiveAudit, so a finding has the same id on every surface: images/alt-missing, not a11y/img-alt.

Older ids still work in severity overrides and in baseline files — they are translated on read, with one warning per id. Writing always uses the new ones.

A finding carries two axes, never one merged number: outcome says how certain the statement is (fail, review, pass, untested), severity how heavy the problem is (low … critical). The JSON report also lists rule_runs for rules this tool could not serve at all — static HTML has no computed styles, so the contrast rules appear there with capability_missing rather than silently counting as passed.

Findings with outcome review are heuristic and need a human to confirm them; their severity still decides whether they fail the build, so use severity overrides if one doesn't apply to your site. The manual/* checklist — criteria no machine can decide, such as whether alt texts fit their context — is off by default. Enable it with rules.a11y.manual_checklist: true; its items carry outcome untested and count by severity like any finding, so tune them with severity.

Findings that only concern WCAG AAA criteria (such as links/new-window-unannounced, 3.2.5) are reported once per element pattern across the site, not once per page: a target="_blank" link in the footer yields one finding whose message says how often and on how many pages it occurs. Findings for level A and AA stay per page.

Display-mode convention

Sites that follow barrierlab's display-mode convention (figure[data-viz] with text, still and live layers; html[data-display] with a toggle) can have it checked with groups: { viz_alternatives: true } (or rules.a11y.viz_alternatives: true). Pages that don't use the convention produce no findings.

Rule ID Severity Checks
viz/text-missing High A figure[data-viz] has no non-empty text layer
viz/caption-missing Low A figure[data-viz] has no <figcaption>
viz/static-missing Medium A live visualisation has no still image
viz/table-missing Low (review) A chart has no data table
display/text-hidden High The text layer is hidden from assistive technology
display/toggle-missing Medium The page uses display modes but offers no toggle
display/init-missing Low (review) No sign that data-display is set before first paint

Report files

Use reports to write one or more report files alongside the terminal output. All four formats can be active at the same time:

postAudit({
  reports: {
    json:     'audit-report.json',   // Machine-readable, one finding per entry
    markdown: 'audit-summary.md',    // Human-readable table, useful as CI artifact or PR comment
    sarif:    'audit.sarif',         // SARIF 2.1.0 — consumed by GitHub Code Scanning
    html:     'audit-report.html',   // Standalone, human-readable HTML report
  },
})

The legacy output option (JSON only) remains supported for backwards compatibility.

GitHub Code Scanning (SARIF)

Upload the SARIF file with the github/codeql-action/upload-sarif action to get inline PR annotations:

- name: Build
  run: npm run build

- name: Upload SARIF
  if: always()
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: audit.sarif

Set benchmark: true to see a per-check timing breakdown — useful for identifying slow checks on large sites.

Maintainer release process

The tag workflow builds the binaries, attaches them to the GitHub release and publishes the npm package through npm trusted publishing. The last job waits in the npm environment until a maintainer approves it in the GitHub UI; no npm token exists anywhere, and the package gets a provenance statement. The crates.io package is published by a maintainer before the tag is pushed:

cargo publish --manifest-path crates/astro-post-audit/Cargo.toml

The Cargo package, npm package, lockfiles and vX.Y.Z tag must use the same version. The release workflow rejects mismatches before building artifacts.

License

MIT

About

Fast post-build auditor for Astro sites: SEO, links, and lightweight WCAG checks

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages