Skip to content
AstroCraft Docs
On this theme

Commands & Testing

The scripts

Command Does
pnpm install install dependencies
pnpm dev dev server at localhost:4321
pnpm build production build to dist/
pnpm preview preview the production build
pnpm check type-check .astro and .ts via astro check
pnpm lint ESLint, then prettier --check
pnpm format eslint --fix, then Prettier
pnpm test every *.test.ts self-check under src/
pnpm wiki:lint verify the wiki’s citations and links
pnpm deploy DEPLOY_ENV=production astro build && wrangler deploy

The full gate:

pnpm lint && pnpm check && pnpm build && pnpm test && pnpm wiki:lint

What each gate actually catches

pnpm build is the real check. Content-schema mistakes surface there, config mistakes surface there, and so do the two placeholder gates when DEPLOY_ENV is set. A build that passes is worth more than a lint that passes.

pnpm check runs astro check against strict TypeScript. This is what catches an illegal <Icon name>, a missing collection field, and a prop that does not exist.

pnpm lint is ESLint plus Prettier. Worth knowing about the ESLint config: it enables eslint-plugin-jsx-a11y for .astro files, so accessibility problems are lint errors rather than review comments. It also carries one deliberate exception — astro/no-exports-from-components is off for ui/** and svg/**, because the primitive contract requires exporting the tv() config from the component’s frontmatter.

That exception is why Sections/Global/_fastext.ts exists as a module: outside those two folders, an .astro file genuinely cannot export a value.

The self-check suite

pnpm test runs scripts/test.mjs, which is about fifty lines and has no framework, no config and no fixtures. It walks src/ recursively, finds every *.test.ts, and runs each through Node’s --experimental-strip-types.

12/12 check files passed.

Two design decisions in that runner are worth adopting:

Discovery, not registration. A check written next to the code it covers runs without being listed anywhere. One convention, one path.

Zero checks is a failure, not a pass.

if (tests.length === 0) {
  console.error("No checks found under src/ — expected at least one *.test.ts file.");
  process.exit(1);
}

The whole point of discovery is that it cannot quietly stop finding the checks it is supposed to run.

The twelve checks

File Covers
js/schema.test.ts the JSON-LD builders
js/cv.test.ts the CV text and PDF renderers — xref offsets, escaping, encoding
js/rss.test.ts the RSS builder and its XML escaping
js/contact.test.ts parseContact and rateLimited against hostile input
js/textUtils.test.ts slugify, dates, reading time, readable URLs
config/navData.test.ts the key-to-channel joins
styles/contrast.test.ts 25 colour pairs against WCAG AA
styles/theme-parity.test.ts the token architecture’s two invariants
components/svg/icons/registry.test.ts the icon merge and its collision rule
components/ui/_listbox.test.ts the filterable-listbox helpers
components/ui/count-up/countUp.test.ts the CountUp arithmetic
components/ui/password/strength.test.ts scorePassword

Their output on a clean run tells you what each one measured:

navData: 11 channels, 6 keys, 11 live href(s) — ok
contrast.test: ok (25 pairs ≥ 4.5:1, worst 4.56 — FB / X / LI / GH cap legends, on a lit cap face)
theme-parity.test: ok (40 in :root, 39 flip, 38 bridged)

The pattern the checks follow

Every one of them exists because its failure mode is silent. That is the selection criterion, and it is more useful than a coverage target.

A token declared only in .dark is undefined in light mode, so var() falls back to the property’s initial value and a 9%-opacity scanline becomes an opaque black sheet — nothing throws. A lost custom icon renders as an empty <svg>. A colour pair under 4.5:1 renders perfectly and cannot be read. A stale channel label drops its row from the site map, which looks exactly like the deliberate omissions.

Two of them go further and make the rule observable. The icon registry exports mergeIcons separately so a synthetic test can exercise the collision rule — the two real sets do not overlap, so a flipped spread order would otherwise be caught by nothing. rateLimited takes its store and its clock as parameters for the same reason. As the source puts it: the rule is only really checked if a test can reach it.

Writing your own

Put a *.test.ts beside the code, import node:assert/strict, and it runs:

import assert from "node:assert/strict";
import { readingTime } from "./textUtils.ts";

assert.equal(readingTime("word ".repeat(200)), 1);
assert.equal(readingTime(""), 1, "never zero minutes");
console.log("textUtils.test.ts passed");

Two constraints from running under plain Node rather than a bundler:

  • Relative imports with explicit .ts extensions. Node resolves neither path aliases nor extensionless imports.
  • import type, not import { type … }. The inline form survives type stripping as a runtime import and will break the check.

pnpm wiki:lint

The theme ships a maintained knowledge base in wiki/, and this script does for documentation what the checks do for code.

The wiki’s value rests on one promise: every non-obvious claim is anchored to a path:line citation, so drift is detectable. The linter’s load-bearing check is cite-anchor — the cited line must actually contain a symbol the surrounding prose names.

The bug that motivated it is instructive. When the SITE_URL gate added about twenty-one lines to the top of astro.config.mjs, six citations below the insertion point kept pointing at lines that still existed and still parsed — they just described different code. A bounds check cannot see that. Neither can a date check, and the pages had been re-dated by a later pass that never re-verified the anchors.

It also covers .claude/rules/*, which are not wiki pages but make the same kind of anchored claim. Those are imported into every AI session, so a wrong line there is briefing material — and one of them spent three commits naming a deleted file as “the outstanding violation in this repo”.

Like the test runner, it fails closed: zero citations, or zero pages, is a failure rather than a pass.

If you delete the wiki/ directory — which is a reasonable thing to do with a theme you are making your own — remove wiki:lint from your gate as well, or it will fail on an empty set.

The dev-server gotcha

Astro loads the content config once at startup. Edits to src/content.config.ts do not hot-reload, so a running dev server keeps the old schema and can silently drop or mis-validate entries. Restart the dev server after touching it.

The same applies to astro.config.mjs and, in practice, to src/config/navData.json.ts when you add a channel — the frontmatter helpers run at build, and a stale server can leave you debugging a channelFor throw that is already fixed.

NEXT STEPTroubleshooting