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
.tsextensions. Node resolves neither path aliases nor extensionless imports. import type, notimport { 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.