+ >;
+}
+
+const MAIN_ONLY = "github.ref == 'refs/heads/main'";
+
+function triggerProblems(workflow: Workflow): readonly string[] {
+ const triggers = Object.keys(workflow.on ?? {}).sort();
+ const problems: string[] = [];
+ if (triggers.join() !== 'push,workflow_dispatch') {
+ problems.push(`The triggers are ${triggers.join(', ')}. Use push and workflow_dispatch only.`);
+ }
+ const push = workflow.on?.['push'] as { readonly branches?: readonly string[] } | null;
+ if (push?.branches?.join() !== 'main') {
+ problems.push('The push trigger must name the branch main and no other.');
+ }
+ return problems;
+}
+
+function checkoutProblems(workflow: Workflow): readonly string[] {
+ return Object.entries(workflow.jobs ?? {}).flatMap(([name, job]) =>
+ (job?.steps ?? [])
+ .filter((step) => step.uses?.startsWith('actions/checkout@') === true)
+ .filter((step) => step.with?.['persist-credentials'] !== false)
+ .map(() => `The checkout in ${name} must set persist-credentials to false.`),
+ );
+}
+
+/**
+ * Checks the rules that keep the publishing workflow safe. Only a push to main and a manual run
+ * may start it, the deploy job runs only on main, and no checkout leaves a token in the clone.
+ *
+ * @param text - The YAML text of the workflow.
+ * @returns One line for each rule that the workflow breaks.
+ */
+export function pagesProblems(text: string): readonly string[] {
+ const workflow = parse(text) as Workflow;
+ const guard =
+ workflow.jobs?.['deploy']?.if === MAIN_ONLY
+ ? []
+ : [`The deploy job needs the guard if: ${MAIN_ONLY}.`];
+ return [...triggerProblems(workflow), ...guard, ...checkoutProblems(workflow)];
+}
diff --git a/test/unit/php-reference.test.ts b/test/unit/php-reference.test.ts
new file mode 100644
index 0000000..90f957f
--- /dev/null
+++ b/test/unit/php-reference.test.ts
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { execFile } from 'node:child_process';
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { promisify } from 'node:util';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const run = promisify(execFile);
+const SCRIPT = 'scripts/php-reference.php';
+let folder: string;
+
+function page(name: string): string {
+ return readFileSync(join(folder, `${name}.md`), 'utf8');
+}
+
+beforeAll(async () => {
+ folder = mkdtempSync(join(tmpdir(), 'php-reference-'));
+ await run('php', [SCRIPT, 'php', folder]);
+});
+
+afterAll(() => {
+ rmSync(folder, { recursive: true, force: true });
+});
+
+describe('the PHP reference', () => {
+ it('has one page for each class and enum of the API dump, and an index', () => {
+ const dump = readFileSync('php/docs/api-dump.md', 'utf8');
+ const names = Array.from(dump.matchAll(/^(?:final class|enum) (\w+)/gm), ([, name]) => name);
+ const pages = readdirSync(folder).map((file) => file.replace(/\.md$/, ''));
+ expect(pages.map((slug) => slug.replace(/^client-/, '')).sort()).toEqual(
+ ['index', ...names.map((name) => name?.toLowerCase())].sort(),
+ );
+ });
+
+ it('shows each method with its signature, its doc comment and its parameters', () => {
+ const postcode = page('postcode');
+ expect(postcode).toContain('### parse()\n\n```php\n');
+ expect(postcode).toContain(
+ 'public static function parse(string $input, bool $allowPartial = false): ParseResult',
+ );
+ expect(postcode).toContain("echo Postcode::normalize(' ek-01 a03.fk-01 '); // EK01A03FK01");
+ expect(postcode).toMatch(/\| `\$allowPartial` \| Also accept a code that stops after/);
+ });
+
+ it('leaves out the test seam of the client, which has an internal type', () => {
+ const client = page('client-postcodeclient');
+ expect(client).toContain('?Psr\\SimpleCache\\CacheInterface $cache = null,\n)');
+ expect(client).not.toContain('Timer');
+ });
+
+ it('lists the cases of an enum with their values', () => {
+ expect(page('client-errorcode')).toContain("- `RateLimited = 'rate_limited'`");
+ });
+
+ it('fails with a message when the package folder is missing', async () => {
+ await expect(run('php', [SCRIPT, join(folder, 'absent'), folder])).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining('has no src folder') as unknown,
+ });
+ });
+
+ it('fails with a message when the package holds no public class', async () => {
+ const empty = mkdtempSync(join(tmpdir(), 'php-empty-'));
+ mkdirSync(join(empty, 'src'));
+ try {
+ await expect(run('php', [SCRIPT, empty, folder])).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining('holds no public class') as unknown,
+ });
+ } finally {
+ rmSync(empty, { recursive: true, force: true });
+ }
+ });
+
+ it('refuses a call without two folders', async () => {
+ await expect(run('php', [SCRIPT])).rejects.toMatchObject({ code: 2 });
+ });
+});
diff --git a/test/unit/release-state.test.ts b/test/unit/release-state.test.ts
new file mode 100644
index 0000000..c567f6f
--- /dev/null
+++ b/test/unit/release-state.test.ts
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { installLine, pageExamples } from '../support/examples.ts';
+import { trackedPages } from '../../scripts/check-prose.ts';
+
+// The sentence that a guide holds while its package is not on a registry. One commit removes it
+// from each guide and sets the state in src/release-state.json to "published".
+const NOTICE = 'The first alpha is not published yet.';
+
+const { state } = JSON.parse(readFileSync('src/release-state.json', 'utf8')) as { state: string };
+
+const INSTALL_PAGES = [
+ ...new Set(
+ pageExamples()
+ .filter(
+ ({ language, source }) =>
+ language === 'sh' && source.split('\n').some((line) => installLine(line) !== undefined),
+ )
+ .map(({ page }) => page),
+ ),
+];
+
+describe('the release state', () => {
+ it('is one of the two known values', () => {
+ expect(['published', 'not published']).toContain(state);
+ });
+
+ it('has guides with an install command, so the checks below cannot pass on no page', () => {
+ expect(INSTALL_PAGES.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it.each(INSTALL_PAGES)('matches the install notice of %s', (page) => {
+ const text = readFileSync(page, 'utf8');
+ expect(
+ text.includes(NOTICE),
+ state === 'not published'
+ ? 'the guide gives an install command and must say that the package is not published'
+ : 'the guide still says that the package is not published',
+ ).toBe(state === 'not published');
+ });
+
+ it('keeps the notice off every page that gives no install command', () => {
+ const others = trackedPages().filter((page) => !INSTALL_PAGES.includes(page));
+ expect(others.filter((page) => readFileSync(page, 'utf8').includes(NOTICE))).toEqual([]);
+ });
+});
diff --git a/test/unit/spec-pages.test.ts b/test/unit/spec-pages.test.ts
new file mode 100644
index 0000000..bf5518c
--- /dev/null
+++ b/test/unit/spec-pages.test.ts
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { readFileSync } from 'node:fs';
+import { SPEC_VERSION as CLIENT_SPEC_VERSION } from '@gatepost/client';
+import { SPEC_VERSION as CORE_SPEC_VERSION } from '@gatepost/core';
+import { describe, expect, it } from 'vitest';
+import { specPages, wrapTables } from '../../scripts/spec-pages.ts';
+
+const SPEC_VERSION = readFileSync('spec/VERSION', 'utf8').trim();
+const PAGES = specPages('spec', 'abc123');
+
+describe('specPages', () => {
+ it('builds the grammar, the client contract and the glossary', () => {
+ expect(PAGES.map(({ slug }) => slug)).toEqual(['grammar', 'client', 'glossary']);
+ });
+
+ it('gives each page a title and drops the first heading of its file', () => {
+ const grammar = PAGES[0]?.text ?? '';
+ expect(grammar).toMatch(/^---\ntitle: Postcode grammar\n/);
+ expect(grammar).not.toContain('\n# Postcode grammar\n');
+ expect(grammar).toContain('\n## Segments\n');
+ });
+
+ it('links each page to its file at the commit of the submodule, and names the version', () => {
+ expect(PAGES[1]?.text).toContain(
+ `[\`client.md\`](https://github.com/gatepost-dev/spec/blob/abc123/client.md) from spec ${SPEC_VERSION}.`,
+ );
+ });
+});
+
+describe('wrapTables', () => {
+ it('puts each table in a labelled box with a focus stop, named after the heading above', () => {
+ const text = '## Errors\n\nIntro.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter.\n';
+ expect(wrapTables(text)).toBe(
+ [
+ '## Errors',
+ '',
+ 'Intro.',
+ '',
+ '',
+ '',
+ '| a | b |',
+ '| - | - |',
+ '| 1 | 2 |',
+ '',
+ '
',
+ '',
+ 'After.',
+ '',
+ ].join('\n'),
+ );
+ });
+
+ it('labels a table that has no heading above it, and keeps text without a table', () => {
+ expect(wrapTables('| a |\n| - |\n')).toContain('aria-label="Table"');
+ expect(wrapTables('Plain text.\n')).toBe('Plain text.\n');
+ });
+
+ it('wraps every table of the spec pages that the site builds', () => {
+ for (const { text } of PAGES) {
+ expect(text.match(/^\|/gm)?.length ?? 0).toBe(
+ text.split('\n').filter((line) => line.startsWith('|')).length,
+ );
+ expect(text.split(' line.startsWith('|') && !all[i - 1]?.startsWith('|')).length +
+ 1,
+ );
+ }
+ });
+});
+
+describe('the spec that the site shows', () => {
+ it('is the spec that each SDK of the site implements', () => {
+ const php = readFileSync('php/src/Postcode.php', 'utf8');
+ const phpVersion = /SPEC_VERSION = '([^']+)'/.exec(php)?.[1];
+ expect([CORE_SPEC_VERSION, CLIENT_SPEC_VERSION, phpVersion]).toEqual([
+ SPEC_VERSION,
+ SPEC_VERSION,
+ SPEC_VERSION,
+ ]);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..66aad84
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "astro/tsconfigs/strictest",
+ "compilerOptions": {
+ "types": ["node"],
+ "allowImportingTsExtensions": true,
+ "erasableSyntaxOnly": true,
+ "verbatimModuleSyntax": true,
+ "noPropertyAccessFromIndexSignature": true
+ },
+ "include": [".astro/types.d.ts", "src", "scripts", "test"],
+ "exclude": ["dist", "src/content/docs/reference"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..4a21136
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026 The Gatepost authors
+// SPDX-License-Identifier: Apache-2.0
+import { defineConfig } from 'vitest/config';
+
+// The unit tests run before the build. The site tests read the built pages in dist/.
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'unit',
+ include: ['test/unit/**/*.test.ts'],
+ setupFiles: ['test/support/block-network.ts'],
+ },
+ },
+ { test: { name: 'site', include: ['test/site/**/*.test.ts'] } },
+ ],
+ // The unit tests measure the logic of the playground. The browser tests cover its DOM module,
+ // view.ts.
+ coverage: {
+ include: ['src/playground/describe.ts'],
+ thresholds: { branches: 80 },
+ },
+ },
+});