polystella-contributor
You are editing the PolyStella package source. This skill is recipes
for the common contributor tasks.
If you are integrating PolyStella into a downstream Astro project,
STOP and load polystella-consumer instead.
Read first:
Then come back here for step-by-step task recipes.
Package ownership follows the direct in-process flow:
source/record -> adapter -> core -> provider -> core -> adapter -> output
Core owns low-level translation contracts and orchestration, adapters own
portable formats, providers own transports, and Astro owns host policy.
Reusable packages use standard Web APIs and must work without
nodejs_compat; consumers may enable it. Do not add compatibility shims
for low-level imports that moved out of the Astro package.
Recipes
Add a file-format adapter
When to use: Supporting a new file extension (.xml, .html, .po, custom format).
Contract: FileAdapter in packages/adapters/src/adapter.ts; Astro policies wrap it in packages/astro/src/parsing/adapter.ts. See #adapter-contract.
Steps:
Implement the portable adapter at packages/adapters/src/adapters/<name>.ts:
import type { Segment } from "@cloudflare/polystella-core";
import type { FileAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js";
export const myFormatAdapter: FileAdapter<MyParsedShape> = {
extensions: [".myext"],
parse(source, sourcePath) {
// Pure. No I/O. Throw on syntactic errors — the per-pair
// try/catch in runTranslationPass will surface them without
// aborting the build.
},
extractSegments(parsed, source, opts): Segment[] {
// Emit { id, text } per translatable unit.
// IDs must be unique within a single file.
// Empty text → no segment (translating "" is meaningless).
},
applyTranslations(parsed, source, translations, opts): string {
// Splice translations back into source bytes.
// INVARIANT 3: produce the EXACT bytes that will be PUT to R2.
// Weave any AI-translation marker from opts.topLevelAdditions
// into the output here, not after.
},
groupSegments(parsed, segments): Segment[][] { ... }, // optional, INVARIANT 2
};
Add Astro's cache-selection, noTranslate, URL, document-context,
marker, and parser policies in a small wrapper under
packages/astro/src/parsing/adapters/, then register that wrapper in
packages/astro/src/parsing/registry.ts:
import { myFormatAdapter } from "./adapters/myformat.js";
// ...
registerAdapter(myFormatAdapter);
First-registered wins. If your adapter claims an extension another adapter already owns, your registration is silently ignored. The order at the bottom of registry.ts is the de-facto priority.
Add portable tests under packages/adapters/tests/ and retain Astro-policy parity tests under packages/astro/tests/parsing/.
Required portable coverage: parsing/reconstruction, segment IDs,
translation application, and group flattening by reference. Astro wrapper
tests cover selected hash values, noTranslate, markers, context, and
idempotent URL rewriting.
No changes to packages/astro/src/translation/run.ts or packages/astro/src/storage/cache.ts. The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong.
Verify:
pnpm test
pnpm typecheck
Update the package README and any per-format docs.
Add a CLI subcommand
When to use: Adding a new top-level verb (polystella <verb>).
Pattern: Each subcommand owns its argv parsing and a run<Name>(args, deps) handler. Shared catalog commands live in packages/cli; host dispatchers stay thin.
Steps:
Create packages/cli/src/<name>.ts for a shared catalog command. Keep an Astro-only command under packages/astro/src/cli/:
export interface MySubcommandArgs {
// Parsed flags.
help: boolean;
someFlag?: string;
}
export const MY_SUBCOMMAND_USAGE = `polystella my-subcommand
<description>
Usage:
polystella my-subcommand [flags]
Flags:
--some-flag <value> ...
--help Print this message.
Exit codes:
0 ok
1 config error
2 <subcommand-specific failure>
`;
export function parseMySubcommandArgs(argv: ReadonlyArray<string>): MySubcommandArgs {
// Throw on unknown flag or missing value — accept-then-reject
// would silently swallow typos.
}
export interface MySubcommandDeps {
cwd: string;
log: (msg: string) => void;
err: (msg: string) => void;
// Add fakeable I/O / clock / etc. for tests.
}
export async function runMySubcommand(args: MySubcommandArgs, deps: MySubcommandDeps): Promise<number> {
// Return process exit code.
}
Register a shared catalog command in packages/cli/src/run-command.ts and both host CLIs. For an Astro-only command, wire packages/astro/src/cli.ts:
- Add to the
Subcommand union type.
- Add the literal to
parseSubcommand's if (first === "translate" || ...) check.
- Add a case to
main()'s switch statement.
- Update
TOP_LEVEL_USAGE to mention the new verb.
Add tests:
packages/cli/tests/<name>.test.ts for a shared parser + handler, or packages/astro/tests/cli/<name>.test.ts for an Astro-only command.
- Extend
packages/astro/tests/cli.test.ts if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches my-subcommand to the right handler" case).
If consumers typically wrap the subcommand in a pnpm script (e.g. pnpm i18n:sync), document the pattern in the docs site's CLI section. Don't add the wrapper to this package — consumer projects own their own scripts.
Verify:
pnpm test
pnpm typecheck
pnpm build
node packages/astro/dist/cli.js my-subcommand --help # Astro host
node packages/emdash/dist/cli.js my-subcommand --help # shared catalog command
Add a translation provider
When to use: Adding a third translator (e.g. OpenAI, Bedrock).
Contract: Translator in packages/core/src/translator.ts. Provider transports live in packages/providers; packages/astro/src/translation/provider.ts only maps Astro config. See #translator-contract.
Steps:
Add a config variant to the provider zod schema in packages/astro/src/config/options.ts:
const newProviderSchema = z.object({
kind: z.literal("new-provider"),
apiKey: z.string(),
model: modelSpecSchema, // string | per-locale map
maxTokens: z.number().int().positive().default(8192),
endpoint: z.string().url().optional(),
});
// Add to the discriminated union:
const providerSchema = z.discriminatedUnion("kind", [workersAISchema, anthropicSchema, newProviderSchema]);
Implement a concrete-model factory in packages/providers/src/<name>.ts:
export function createNewProviderTranslator(options: {
apiKey: string;
modelId: string;
maxTokens: number;
fetchImpl?: typeof fetch;
}): Translator {
return {
modelId: options.modelId,
async translate(systemPrompt, userPrompt, signal) {
const res = await (options.fetchImpl ?? fetch)(endpoint, {
method: "POST",
headers: { ... },
body: JSON.stringify({ ... }),
...(signal !== undefined ? { signal } : {}),
});
if (!res.ok) throw await createProviderHttpError("New provider", res, signal);
return normalizeResponse(await res.json());
},
};
}
Export the factory from packages/providers/src/index.ts, then map the validated config in Astro's createTranslator:
if (provider.kind === "new-provider") {
return createNewProviderTranslator({
apiKey: provider.apiKey,
modelId: resolveModelId(provider.model, locale),
maxTokens: provider.maxTokens,
});
}
Permanent vs retriable — reuse the providers package's HTTP classifier. The permanent set is {400, 401, 403, 404, 422}; 5xx, 408, 425, and 429 are retriable. Ask first before adding statuses.
Add transport tests under packages/providers/tests/ and retain Astro facade parity coverage in packages/astro/tests/translation/provider.test.ts:
- Happy path (mock fetch returns expected shape).
- Each permanent status →
PermanentProviderError.
- 5xx → plain
Error (retriable).
- Network error → plain
Error.
- Unexpected response shape → clear error message with raw response preview.
signal propagation to fetch.
Document the new provider in the package README and docs provider section.
Change the cache contract
When to use: Modifying any input to the cache hash formula.
Severity: Cache-wide invalidation. Every cached translation across every consumer becomes a miss on the next build.
Steps:
Read #cache-key. The current formula is:
hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash)
Stop. Coordinate with the owner before merging. This is Invariant 1 in AGENTS.md. The change needs to be in a major version bump and called out in CHANGELOG.
If you're confident this is the right change:
- Edit
packages/astro/src/storage/hash.ts (the computeSourceHash function).
- Update the formula description in
ARCHITECTURE.md#cache-key.
- Update
AGENTS.md Invariant #1.
- Update the hash test pin in
packages/astro/tests/storage/hash.test.ts — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it.
- Add a CHANGELOG entry under a "Breaking changes" heading.
- Bump the major version (or 0.x minor pre-1.0).
Verify:
pnpm test
pnpm typecheck
The pinned-hash test will catch drift if you missed the test update.
Debug a translation regression
When to use: A translation that used to work is wrong, missing, or failing.
Diagnostic flow:
Reproduce on the fixture. If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under packages/astro/tests/fixtures/ if it's worth a regression test.
Inspect what the cache layer planned:
polystella translate --dry-run --file 'path/to/source.md'
# or in a consumer repo:
pnpm translate --dry-run --file 'path/to/source.md'
Output includes the planned R2 key. If the key is wrong, the bug is in computeSourceHash or buildR2Key.
Inspect the staged output:
cat <root>/.astro/i18n-staging/<locale>/<source-path>
Compare to expected. Is the AI-translation marker (aiTranslated: true) present? Are URLs rewritten? Is the body translated at all?
Inspect the build report:
cat dist/i18n-r2-report.json | jq '.entries[] | select(.sourcePath == "<path>")'
Outcome will be hit, miss, override, error, or localSkipped. Read the corresponding code path in packages/astro/src/storage/cache.ts or packages/astro/src/source/overrides.ts.
Crank up verbosity:
LOG_LEVEL=debug polystella translate --file 'path/to/source.md'
Emits per-batch detail (segment count, batch count, oversize warnings, retry attempts).
Bypass the cache: delete the relevant R2 object, or delete the local index entry:
rm <root>/.astro/i18n-staging/.polystella-cache.json
Bypass R2 entirely by passing r2Override: null to runTranslationPass (test-only). Useful for isolating the translator from the cache layer.
Common regression causes:
- Adapter
parse not idempotent — calling it twice produces different output. (Asserted by some tests; if you added a new adapter, add this test.)
- Cache key formula input added/removed without updating consumers.
- Workers AI
maxTokens was lowered — multi-segment translation truncated to invalid JSON.
- Glossary YAML syntax error — silently ignored on load, term not applied.
noTranslate: true accidentally set in source frontmatter.
- Override file path mismatch — locale or mirrored-path slug differs from source.
- URL rewriter doubling prefixes — confirm both rewrite layers are idempotent on already-rewritten input.
Modify a runtime API
When to use: Editing Astro.locals.t, lhref, getLocalizedEntry, getLocalizedCollection, the React hooks, or the middleware that binds them.
Files:
packages/astro/src/runtime/middleware.ts — request middleware; pre-binds locale to all four locals.
packages/astro/src/runtime/middleware-core.ts — middleware body (test-friendly extract).
packages/astro/src/runtime/get-localized-entry.ts, get-localized-collection.ts — fetcher implementations.
packages/astro/src/runtime/localized-href.ts — URL prefixer.
packages/astro/src/runtime/custom-loader-runtime.ts — the bridge (symbol-keyed globalThis state shared with sibling collections across Vite module reloads).
packages/astro/src/runtime/locals.ts — TypeScript ambient declarations for Astro.locals. Was locals.d.ts until the dist-emit rework; renamed so tsc emits both an empty .js and the .d.ts declarations, and runtime/index.ts pulls it in via a side-effect import (the previous triple-slash <reference path> directive gets stripped by tsc at emit time).
packages/astro/src/react/index.ts — useTranslations, useLocalizedHref hooks.
Key contracts:
- Bridge timing (Invariant 5) — the bridge must be set in
astro:config:setup before sibling collections register. Edits that defer bridge setup will silently break sibling content loading.
- Per-locale closures —
t, lhref, getLocalizedEntry, getLocalizedCollection are pre-bound to the request's locale by the middleware. Don't expose unbound versions in .astro files — they're imported separately from @cloudflare/polystella-astro/runtime for non-template contexts.
Steps:
- Edit the relevant runtime file.
- Update
packages/astro/src/runtime/locals.ts if you're changing the shape of Astro.locals.
- Update the
polystella-consumer skill's "Runtime APIs" section.
- Add tests under
packages/astro/tests/runtime/:
- Behaviour test for the new/changed function.
- Middleware-binding test if the locals shape changes (
packages/astro/tests/runtime/middleware.test.ts).
- Don't forget the React side —
useTranslations / useLocalizedHref and their consumer-side wiring (getDictionary).
Edit UI-string handling
When to use: Changing drift detection rules, sync writer behaviour, AI-fill orchestration, or the {{token}} validator.
Files:
packages/cli/src/drift.ts — checkI18nDrift, loadAndCheckDrift.
packages/cli/src/sync.ts — key reconciliation; layout-aware JSON writer (formatLocaleFile).
packages/core/src/catalog/translate.ts — AI-fill orchestrator; {{token}} validator + retry wrapper.
packages/astro/src/i18n/ui-translate.ts — compatibility re-export for Astro's CLI.
packages/astro/src/i18n/loader.ts, i18n/index.ts — content-layer loader, dictionary fetcher.
packages/astro/src/catalog/* — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports.
packages/cli/src/check-ui.ts, sync-ui.ts, translate-ui.ts — shared CLI handlers.
Key contracts:
- Three drift failure modes — missing keys, extra keys, empty-placeholder values (a non-default locale has
"" where the source has a non-empty string). The build's astro:config:setup drift check and the check-ui CLI use the SAME predicate. If you add a fourth failure mode, update both.
- Layout-aware sync writer — parses the source file's text (not just its JSON) to recover key order and blank-line section breaks. The output mirrors that layout for every locale. Don't drop this — every sync would churn diffs.
{{token}} validator runs OUTSIDE translateBatch — the orchestrator's retry wrapper sets maxRetries: 0 on translateBatch. Don't add a second retry layer.
- Queued locales catch errors internally —
translate-ui pre-scans locale JSONs, skips complete catalogs before provider setup, then runs queued locales in parallel via runWithConcurrency with a hard cap of 3. Each locale is split into small sequential request batches. Workers MUST catch every error and record it on the per-locale outcome — never re-throw. Re-throwing kills the whole run.
- Catalog-only middleware scope —
polystella/catalog/middleware binds Astro.locals.t and Astro.locals.lhref only. Do not add localized collection APIs to that surface.
See #ui-strings.
Strict tsconfig patterns
All four stricter TypeScript flags are on (noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitReturns, noFallthroughCasesInSwitch). Patterns that come up repeatedly:
noUncheckedIndexedAccess
Indexed access returns T | undefined. Patterns:
// ❌ Old:
const first = arr[0];
first.foo; // type error: first might be undefined
// ✅ Guard:
const first = arr[0];
if (first === undefined) continue;
first.foo;
// ✅ Destructure with default (when default is safe):
const [first = defaultValue] = arr;
exactOptionalPropertyTypes
foo?: string is NOT the same as foo: string | undefined. Callers passing undefined explicitly need the latter:
// ❌ Old:
interface Opts {
signal?: AbortSignal;
}
function foo(opts: { signal?: AbortSignal }) {
inner({ signal: opts.signal }); // type error: opts.signal might be `undefined` literal
}
// ✅ When the callee accepts explicit `undefined`:
interface Opts {
signal?: AbortSignal | undefined;
}
noImplicitReturns
Every code path returns. Add explicit return to early-exit branches:
function foo(): number {
if (cond) {
sideEffect();
return 0;
} // explicit return
return 1;
}
Replacing ! and any
! and any are banned outside test code. Replace with:
// ❌
const value = map.get(key)!;
const data = JSON.parse(x) as any;
// ✅
const value = map.get(key);
if (value === undefined) throw new Error(`unexpected: ${key} not in map`);
const data = JSON.parse(x) as unknown;
if (typeof data !== "object" || data === null) throw new Error(`unexpected: ${x}`);
// narrow via structural type guards from here.
Testing conventions
- Astro tests live under
packages/astro/tests/<src-dir>/<basename>.test.ts. Top-level exceptions: packages/astro/tests/cli.test.ts (top-level dispatch + translate-subcommand parsing), packages/astro/tests/cli/ (per-subcommand handlers), packages/astro/tests/smoke.test.ts (end-to-end integration smoke).
- Astro Vitest config is
packages/astro/vitest.config.ts. singleThread: true — faster than multi-worker at this scale.
- Fakeable boundaries: each subsystem accepts a
deps-shaped object so tests can inject stubs. The CLI's runCheckUi(args, deps) shape is the canonical example.
- For tests that need a clean adapter registry: call
resetRegistry() before re-registering.
- For tests that exercise R2: follow the inline in-memory client in
packages/astro/tests/storage/cache.test.ts.
- For tests that exercise the translator: pass
translatorOverrides to runTranslationPass with a fake Translator.
- For smoke tests: drive
polystella(options) with stubbed Astro context against a real temp project. packages/astro/tests/smoke.test.ts is the template.
- For the doc-claims test (
packages/astro/tests/docs.test.ts): pins file paths and command names referenced in AGENTS.md / ARCHITECTURE.md. If you move a file or rename a subcommand, update both the docs AND this test.
Verify before pushing:
pnpm test
pnpm typecheck
1---2name: polystella-contributor3description: Edit the PolyStella package source. Use when adding a file-format adapter, adding a CLI subcommand, adding a translation provider, modifying the cache contract, debugging a translation regression, or otherwise working on the package itself (not consuming it).4---56# polystella-contributor78You are editing the PolyStella package source. This skill is recipes9for the common contributor tasks.1011If you are integrating PolyStella into a downstream Astro project,12STOP and load `polystella-consumer` instead.1314Read first:1516- [`AGENTS.md`](../../AGENTS.md) — orientation, invariants, boundaries.17- [`ARCHITECTURE.md`](../../ARCHITECTURE.md) — subsystem reference.1819Then come back here for step-by-step task recipes.2021Package ownership follows the direct in-process flow:2223```text24source/record -> adapter -> core -> provider -> core -> adapter -> output25```2627Core owns low-level translation contracts and orchestration, adapters own28portable formats, providers own transports, and Astro owns host policy.29Reusable packages use standard Web APIs and must work without30`nodejs_compat`; consumers may enable it. Do not add compatibility shims31for low-level imports that moved out of the Astro package.3233---3435## Recipes3637- [Add a file-format adapter](#add-adapter)38- [Add a CLI subcommand](#add-cli-subcommand)39- [Add a translation provider](#add-provider)40- [Change the cache contract](#change-cache-contract)41- [Debug a translation regression](#debug-translation)42- [Modify a runtime API](#modify-runtime-api)43- [Edit UI-string handling](#edit-ui-strings)44- [Strict tsconfig patterns](#strict-tsconfig)45- [Testing conventions](#testing)4647---4849## Add a file-format adapter5051<a id="add-adapter"></a>5253**When to use:** Supporting a new file extension (`.xml`, `.html`, `.po`, custom format).5455**Contract:** `FileAdapter` in `packages/adapters/src/adapter.ts`; Astro policies wrap it in `packages/astro/src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract).5657**Steps:**58591. Implement the portable adapter at `packages/adapters/src/adapters/<name>.ts`:6061 ```ts62 import type { Segment } from "@cloudflare/polystella-core";63 import type { FileAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js";6465 export const myFormatAdapter: FileAdapter<MyParsedShape> = {66 extensions: [".myext"],6768 parse(source, sourcePath) {69 // Pure. No I/O. Throw on syntactic errors — the per-pair70 // try/catch in runTranslationPass will surface them without71 // aborting the build.72 },7374 extractSegments(parsed, source, opts): Segment[] {75 // Emit { id, text } per translatable unit.76 // IDs must be unique within a single file.77 // Empty text → no segment (translating "" is meaningless).78 },7980 applyTranslations(parsed, source, translations, opts): string {81 // Splice translations back into source bytes.82 // INVARIANT 3: produce the EXACT bytes that will be PUT to R2.83 // Weave any AI-translation marker from opts.topLevelAdditions84 // into the output here, not after.85 },8687 groupSegments(parsed, segments): Segment[][] { ... }, // optional, INVARIANT 288 };89 ```90912. Add Astro's cache-selection, `noTranslate`, URL, document-context,92 marker, and parser policies in a small wrapper under93 `packages/astro/src/parsing/adapters/`, then register that wrapper in94 `packages/astro/src/parsing/registry.ts`:9596 ```ts97 import { myFormatAdapter } from "./adapters/myformat.js";98 // ...99 registerAdapter(myFormatAdapter);100 ```101102 **First-registered wins.** If your adapter claims an extension another adapter already owns, your registration is silently ignored. The order at the bottom of `registry.ts` is the de-facto priority.1031043. Add portable tests under `packages/adapters/tests/` and retain Astro-policy parity tests under `packages/astro/tests/parsing/`.105106 Required portable coverage: parsing/reconstruction, segment IDs,107 translation application, and group flattening by reference. Astro wrapper108 tests cover selected hash values, `noTranslate`, markers, context, and109 idempotent URL rewriting.1101114. **No changes to `packages/astro/src/translation/run.ts` or `packages/astro/src/storage/cache.ts`.** The orchestrator dispatches by extension via the registry; the cache layer is format-agnostic. If you find yourself editing either, you're doing something wrong.1121135. Verify:114115 ```sh116 pnpm test117 pnpm typecheck118 ```1191206. Update the package README and any per-format docs.121122---123124## Add a CLI subcommand125126<a id="add-cli-subcommand"></a>127128**When to use:** Adding a new top-level verb (`polystella <verb>`).129130**Pattern:** Each subcommand owns its argv parsing and a `run<Name>(args, deps)` handler. Shared catalog commands live in `packages/cli`; host dispatchers stay thin.131132**Steps:**1331341. Create `packages/cli/src/<name>.ts` for a shared catalog command. Keep an Astro-only command under `packages/astro/src/cli/`:135136 ```ts137 export interface MySubcommandArgs {138 // Parsed flags.139 help: boolean;140 someFlag?: string;141 }142143 export const MY_SUBCOMMAND_USAGE = `polystella my-subcommand144 145 <description>146 147 Usage:148 polystella my-subcommand [flags]149 150 Flags:151 --some-flag <value> ...152 --help Print this message.153 154 Exit codes:155 0 ok156 1 config error157 2 <subcommand-specific failure>158 `;159160 export function parseMySubcommandArgs(argv: ReadonlyArray<string>): MySubcommandArgs {161 // Throw on unknown flag or missing value — accept-then-reject162 // would silently swallow typos.163 }164165 export interface MySubcommandDeps {166 cwd: string;167 log: (msg: string) => void;168 err: (msg: string) => void;169 // Add fakeable I/O / clock / etc. for tests.170 }171172 export async function runMySubcommand(args: MySubcommandArgs, deps: MySubcommandDeps): Promise<number> {173 // Return process exit code.174 }175 ```1761772. Register a shared catalog command in `packages/cli/src/run-command.ts` and both host CLIs. For an Astro-only command, wire `packages/astro/src/cli.ts`:178 - Add to the `Subcommand` union type.179 - Add the literal to `parseSubcommand`'s `if (first === "translate" || ...)` check.180 - Add a case to `main()`'s switch statement.181 - Update `TOP_LEVEL_USAGE` to mention the new verb.1821833. Add tests:184 - `packages/cli/tests/<name>.test.ts` for a shared parser + handler, or `packages/astro/tests/cli/<name>.test.ts` for an Astro-only command.185 - Extend `packages/astro/tests/cli.test.ts` if the top-level dispatch needs new coverage (it usually does — add at least one "dispatches `my-subcommand` to the right handler" case).1861874. If consumers typically wrap the subcommand in a `pnpm` script (e.g. `pnpm i18n:sync`), document the pattern in the docs site's CLI section. Don't add the wrapper to this package — consumer projects own their own scripts.1881895. Verify:190191 ```sh192 pnpm test193 pnpm typecheck194 pnpm build195 node packages/astro/dist/cli.js my-subcommand --help # Astro host196 node packages/emdash/dist/cli.js my-subcommand --help # shared catalog command197 ```198199---200201## Add a translation provider202203<a id="add-provider"></a>204205**When to use:** Adding a third translator (e.g. OpenAI, Bedrock).206207**Contract:** `Translator` in `packages/core/src/translator.ts`. Provider transports live in `packages/providers`; `packages/astro/src/translation/provider.ts` only maps Astro config. See [#translator-contract](../../ARCHITECTURE.md#translator-contract).208209**Steps:**2102111. Add a config variant to the provider zod schema in `packages/astro/src/config/options.ts`:212213 ```ts214 const newProviderSchema = z.object({215 kind: z.literal("new-provider"),216 apiKey: z.string(),217 model: modelSpecSchema, // string | per-locale map218 maxTokens: z.number().int().positive().default(8192),219 endpoint: z.string().url().optional(),220 });221222 // Add to the discriminated union:223 const providerSchema = z.discriminatedUnion("kind", [workersAISchema, anthropicSchema, newProviderSchema]);224 ```2252262. Implement a concrete-model factory in `packages/providers/src/<name>.ts`:227228 ```ts229 export function createNewProviderTranslator(options: {230 apiKey: string;231 modelId: string;232 maxTokens: number;233 fetchImpl?: typeof fetch;234 }): Translator {235 return {236 modelId: options.modelId,237 async translate(systemPrompt, userPrompt, signal) {238 const res = await (options.fetchImpl ?? fetch)(endpoint, {239 method: "POST",240 headers: { ... },241 body: JSON.stringify({ ... }),242 ...(signal !== undefined ? { signal } : {}),243 });244245 if (!res.ok) throw await createProviderHttpError("New provider", res, signal);246 return normalizeResponse(await res.json());247 },248 };249 }250 ```2512523. Export the factory from `packages/providers/src/index.ts`, then map the validated config in Astro's `createTranslator`:253254 ```ts255 if (provider.kind === "new-provider") {256 return createNewProviderTranslator({257 apiKey: provider.apiKey,258 modelId: resolveModelId(provider.model, locale),259 maxTokens: provider.maxTokens,260 });261 }262 ```2632644. **Permanent vs retriable** — reuse the providers package's HTTP classifier. The permanent set is `{400, 401, 403, 404, 422}`; 5xx, 408, 425, and 429 are retriable. **Ask first** before adding statuses.2652665. Add transport tests under `packages/providers/tests/` and retain Astro facade parity coverage in `packages/astro/tests/translation/provider.test.ts`:267 - Happy path (mock fetch returns expected shape).268 - Each permanent status → `PermanentProviderError`.269 - 5xx → plain `Error` (retriable).270 - Network error → plain `Error`.271 - Unexpected response shape → clear error message with raw response preview.272 - `signal` propagation to `fetch`.2732746. Document the new provider in the package README and docs provider section.275276---277278## Change the cache contract279280<a id="change-cache-contract"></a>281282**When to use:** Modifying any input to the cache hash formula.283284**Severity:** Cache-wide invalidation. Every cached translation across every consumer becomes a miss on the next build.285286**Steps:**2872881. Read [#cache-key](../../ARCHITECTURE.md#cache-key). The current formula is:289290 ```291 hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash)292 ```2932942. **Stop.** Coordinate with the owner before merging. This is **Invariant 1** in `AGENTS.md`. The change needs to be in a major version bump and called out in CHANGELOG.2952963. If you're confident this is the right change:297 - Edit `packages/astro/src/storage/hash.ts` (the `computeSourceHash` function).298 - Update the formula description in `ARCHITECTURE.md#cache-key`.299 - Update `AGENTS.md` Invariant #1.300 - Update the hash test pin in `packages/astro/tests/storage/hash.test.ts` — it pins a literal hash to catch accidental formula drift. Compute the new literal and replace it.301 - Add a CHANGELOG entry under a "Breaking changes" heading.302 - Bump the major version (or 0.x minor pre-1.0).3033044. Verify:305306 ```sh307 pnpm test308 pnpm typecheck309 ```310311 The pinned-hash test will catch drift if you missed the test update.312313---314315## Debug a translation regression316317<a id="debug-translation"></a>318319**When to use:** A translation that used to work is wrong, missing, or failing.320321**Diagnostic flow:**3223231. **Reproduce on the fixture.** If the regression is reported against a consumer's content, reduce to the smallest source file that reproduces. Add it under `packages/astro/tests/fixtures/` if it's worth a regression test.3243252. **Inspect what the cache layer planned:**326327 ```sh328 polystella translate --dry-run --file 'path/to/source.md'329 # or in a consumer repo:330 pnpm translate --dry-run --file 'path/to/source.md'331 ```332333 Output includes the planned R2 key. If the key is wrong, the bug is in `computeSourceHash` or `buildR2Key`.3343353. **Inspect the staged output:**336337 ```sh338 cat <root>/.astro/i18n-staging/<locale>/<source-path>339 ```340341 Compare to expected. Is the AI-translation marker (`aiTranslated: true`) present? Are URLs rewritten? Is the body translated at all?3423434. **Inspect the build report:**344345 ```sh346 cat dist/i18n-r2-report.json | jq '.entries[] | select(.sourcePath == "<path>")'347 ```348349 Outcome will be `hit`, `miss`, `override`, `error`, or `localSkipped`. Read the corresponding code path in `packages/astro/src/storage/cache.ts` or `packages/astro/src/source/overrides.ts`.3503515. **Crank up verbosity:**352353 ```sh354 LOG_LEVEL=debug polystella translate --file 'path/to/source.md'355 ```356357 Emits per-batch detail (segment count, batch count, oversize warnings, retry attempts).3583596. **Bypass the cache:** delete the relevant R2 object, or delete the local index entry:360361 ```sh362 rm <root>/.astro/i18n-staging/.polystella-cache.json363 ```3643657. **Bypass R2 entirely** by passing `r2Override: null` to `runTranslationPass` (test-only). Useful for isolating the translator from the cache layer.3663678. **Common regression causes:**368 - Adapter `parse` not idempotent — calling it twice produces different output. (Asserted by some tests; if you added a new adapter, add this test.)369 - Cache key formula input added/removed without updating consumers.370 - Workers AI `maxTokens` was lowered — multi-segment translation truncated to invalid JSON.371 - Glossary YAML syntax error — silently ignored on load, term not applied.372 - `noTranslate: true` accidentally set in source frontmatter.373 - Override file path mismatch — locale or mirrored-path slug differs from source.374 - URL rewriter doubling prefixes — confirm both rewrite layers are idempotent on already-rewritten input.375376---377378## Modify a runtime API379380<a id="modify-runtime-api"></a>381382**When to use:** Editing `Astro.locals.t`, `lhref`, `getLocalizedEntry`, `getLocalizedCollection`, the React hooks, or the middleware that binds them.383384**Files:**385386- `packages/astro/src/runtime/middleware.ts` — request middleware; pre-binds locale to all four locals.387- `packages/astro/src/runtime/middleware-core.ts` — middleware body (test-friendly extract).388- `packages/astro/src/runtime/get-localized-entry.ts`, `get-localized-collection.ts` — fetcher implementations.389- `packages/astro/src/runtime/localized-href.ts` — URL prefixer.390- `packages/astro/src/runtime/custom-loader-runtime.ts` — the **bridge** (symbol-keyed `globalThis` state shared with sibling collections across Vite module reloads).391- `packages/astro/src/runtime/locals.ts` — TypeScript ambient declarations for `Astro.locals`. Was `locals.d.ts` until the dist-emit rework; renamed so tsc emits both an empty `.js` and the `.d.ts` declarations, and `runtime/index.ts` pulls it in via a side-effect import (the previous triple-slash `<reference path>` directive gets stripped by tsc at emit time).392- `packages/astro/src/react/index.ts` — `useTranslations`, `useLocalizedHref` hooks.393394**Key contracts:**395396- **Bridge timing (Invariant 5)** — the bridge must be set in `astro:config:setup` before sibling collections register. Edits that defer bridge setup will silently break sibling content loading.397- **Per-locale closures** — `t`, `lhref`, `getLocalizedEntry`, `getLocalizedCollection` are pre-bound to the request's locale by the middleware. Don't expose unbound versions in `.astro` files — they're imported separately from `@cloudflare/polystella-astro/runtime` for non-template contexts.398399**Steps:**4004011. Edit the relevant runtime file.4022. Update `packages/astro/src/runtime/locals.ts` if you're changing the shape of `Astro.locals`.4033. Update the `polystella-consumer` skill's "Runtime APIs" section.4044. Add tests under `packages/astro/tests/runtime/`:405 - Behaviour test for the new/changed function.406 - Middleware-binding test if the locals shape changes (`packages/astro/tests/runtime/middleware.test.ts`).4075. Don't forget the React side — `useTranslations` / `useLocalizedHref` and their consumer-side wiring (`getDictionary`).408409---410411## Edit UI-string handling412413<a id="edit-ui-strings"></a>414415**When to use:** Changing drift detection rules, sync writer behaviour, AI-fill orchestration, or the `{{token}}` validator.416417**Files:**418419- `packages/cli/src/drift.ts` — `checkI18nDrift`, `loadAndCheckDrift`.420- `packages/cli/src/sync.ts` — key reconciliation; **layout-aware** JSON writer (`formatLocaleFile`).421- `packages/core/src/catalog/translate.ts` — AI-fill orchestrator; `{{token}}` validator + retry wrapper.422- `packages/astro/src/i18n/ui-translate.ts` — compatibility re-export for Astro's CLI.423- `packages/astro/src/i18n/loader.ts`, `i18n/index.ts` — content-layer loader, dictionary fetcher.424- `packages/astro/src/catalog/*` — catalog-only public exports, middleware, and Astro integration. Must stay free of content translation, R2, route shims, and localized collection imports.425- `packages/cli/src/check-ui.ts`, `sync-ui.ts`, `translate-ui.ts` — shared CLI handlers.426427**Key contracts:**428429- **Three drift failure modes** — missing keys, extra keys, **empty-placeholder values** (a non-default locale has `""` where the source has a non-empty string). The build's `astro:config:setup` drift check and the `check-ui` CLI use the SAME predicate. If you add a fourth failure mode, update both.430- **Layout-aware sync writer** — parses the source file's text (not just its JSON) to recover key order and blank-line section breaks. The output mirrors that layout for every locale. Don't drop this — every sync would churn diffs.431- **`{{token}}` validator runs OUTSIDE `translateBatch`** — the orchestrator's retry wrapper sets `maxRetries: 0` on `translateBatch`. Don't add a second retry layer.432- **Queued locales catch errors internally** — `translate-ui` pre-scans locale JSONs, skips complete catalogs before provider setup, then runs queued locales in parallel via `runWithConcurrency` with a hard cap of 3. Each locale is split into small sequential request batches. Workers MUST catch every error and record it on the per-locale outcome — never re-throw. Re-throwing kills the whole run.433- **Catalog-only middleware scope** — `polystella/catalog/middleware` binds `Astro.locals.t` and `Astro.locals.lhref` only. Do not add localized collection APIs to that surface.434435See [#ui-strings](../../ARCHITECTURE.md#ui-strings).436437---438439## Strict tsconfig patterns440441<a id="strict-tsconfig"></a>442443All four stricter TypeScript flags are on (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitReturns`, `noFallthroughCasesInSwitch`). Patterns that come up repeatedly:444445### `noUncheckedIndexedAccess`446447Indexed access returns `T | undefined`. Patterns:448449```ts450// ❌ Old:451const first = arr[0];452first.foo; // type error: first might be undefined453454// ✅ Guard:455const first = arr[0];456if (first === undefined) continue;457first.foo;458459// ✅ Destructure with default (when default is safe):460const [first = defaultValue] = arr;461```462463### `exactOptionalPropertyTypes`464465`foo?: string` is NOT the same as `foo: string | undefined`. Callers passing `undefined` explicitly need the latter:466467```ts468// ❌ Old:469interface Opts {470 signal?: AbortSignal;471}472function foo(opts: { signal?: AbortSignal }) {473 inner({ signal: opts.signal }); // type error: opts.signal might be `undefined` literal474}475476// ✅ When the callee accepts explicit `undefined`:477interface Opts {478 signal?: AbortSignal | undefined;479}480```481482### `noImplicitReturns`483484Every code path returns. Add explicit `return` to early-exit branches:485486```ts487function foo(): number {488 if (cond) {489 sideEffect();490 return 0;491 } // explicit return492 return 1;493}494```495496### Replacing `!` and `any`497498`!` and `any` are banned outside test code. Replace with:499500```ts501// ❌502const value = map.get(key)!;503const data = JSON.parse(x) as any;504505// ✅506const value = map.get(key);507if (value === undefined) throw new Error(`unexpected: ${key} not in map`);508509const data = JSON.parse(x) as unknown;510if (typeof data !== "object" || data === null) throw new Error(`unexpected: ${x}`);511// narrow via structural type guards from here.512```513514---515516## Testing conventions517518<a id="testing"></a>519520- Astro tests live under `packages/astro/tests/<src-dir>/<basename>.test.ts`. Top-level exceptions: `packages/astro/tests/cli.test.ts` (top-level dispatch + translate-subcommand parsing), `packages/astro/tests/cli/` (per-subcommand handlers), `packages/astro/tests/smoke.test.ts` (end-to-end integration smoke).521- Astro Vitest config is `packages/astro/vitest.config.ts`. `singleThread: true` — faster than multi-worker at this scale.522- Fakeable boundaries: each subsystem accepts a `deps`-shaped object so tests can inject stubs. The CLI's `runCheckUi(args, deps)` shape is the canonical example.523- For tests that need a clean adapter registry: call `resetRegistry()` before re-registering.524- For tests that exercise R2: follow the inline in-memory client in `packages/astro/tests/storage/cache.test.ts`.525- For tests that exercise the translator: pass `translatorOverrides` to `runTranslationPass` with a fake `Translator`.526- For smoke tests: drive `polystella(options)` with stubbed Astro context against a real temp project. `packages/astro/tests/smoke.test.ts` is the template.527- For the doc-claims test (`packages/astro/tests/docs.test.ts`): pins file paths and command names referenced in `AGENTS.md` / `ARCHITECTURE.md`. If you move a file or rename a subcommand, update both the docs AND this test.528529Verify before pushing:530531```sh532pnpm test533pnpm typecheck534```