| name | polystella-contributor |
| description | 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). |
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.
Recipes
Add a file-format adapter
When to use: Supporting a new file extension (.xml, .html, .po, custom format).
Contract: FileTypeAdapter in src/parsing/adapter.ts. See #adapter-contract.
Steps:
-
Implement the adapter at src/parsing/adapters/<name>.ts:
import type { FileTypeAdapter, AdapterExtractOptions, AdapterApplyOptions } from "../adapter.js";
import type { Segment } from "../extract.js";
export const myFormatAdapter: FileTypeAdapter<MyParsedShape> = {
extensions: [".myext"],
parse(source, sourcePath) {
},
extractSegments(parsed, source, opts): Segment[] {
},
applyTranslations(parsed, source, translations, opts): string {
},
selectedValuesForHash(parsed, source, opts): Record<string, unknown> {
},
peekNoTranslate(parsed): boolean {
},
rewriteUrls(bytes, opts): string { ... },
groupSegments(parsed, segments): Segment[][] { ... },
documentContext(parsed, opts): string | undefined { ... },
};
-
Register in 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 tests under tests/parsing/adapters/<name>.test.ts. Mirror the structure of an existing adapter test (tests/parsing/adapters/toml.test.ts is a good template — it's structured-data-flavoured like most new adapters will be).
Required test coverage:
parse round-trip (parse → reserialize via applyTranslations with no translations → byte-identical)
extractSegments produces expected IDs
applyTranslations splices correctly
selectedValuesForHash snapshots ONLY translatable fields
peekNoTranslate honours your format's opt-out convention
- If you implement
rewriteUrls: idempotent on already-rewritten input
- If you implement
groupSegments: flat(result) === segments (reference-equal)
-
No changes to src/translation/run.ts or 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 exec tsc --noEmit
-
Update the contributor README's status table 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. The dispatcher in src/cli.ts is a thin router.
Steps:
-
Create src/cli/<name>.ts:
export interface MySubcommandArgs {
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 {
}
export interface MySubcommandDeps {
cwd: string;
log: (msg: string) => void;
err: (msg: string) => void;
}
export async function runMySubcommand(args: MySubcommandArgs, deps: MySubcommandDeps): Promise<number> {
}
-
Wire dispatch in 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:
tests/cli/<name>.test.ts for the argv parser + handler (with stubbed deps).
- Extend
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 exec tsc --noEmit
pnpm build
node dist/cli.js my-subcommand --help
Add a translation provider
When to use: Adding a third translator (e.g. OpenAI, Bedrock).
Contract: Translator in src/translation/provider.ts. See #translator-contract.
Steps:
-
Add a config variant to the provider zod schema in src/config/options.ts:
const newProviderSchema = z.object({
kind: z.literal("new-provider"),
apiKey: z.string(),
model: modelSpecSchema,
maxTokens: z.number().int().positive().default(8192),
endpoint: z.string().url().optional(),
});
const providerSchema = z.discriminatedUnion("kind", [workersAISchema, anthropicSchema, newProviderSchema]);
-
Implement the translator factory in src/translation/provider.ts:
function createNewProviderTranslator(
provider: NewProviderConfig,
locale: string,
fetchImpl: typeof fetch,
): Translator {
const modelId = resolveModelId(provider.model, locale);
return {
modelId,
async translate(systemPrompt, userPrompt, signal) {
const res = await fetchImpl(endpoint, {
method: "POST",
headers: { ... },
body: JSON.stringify({ ... }),
...(signal !== undefined ? { signal } : {}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const message = `[polystella] new-provider request failed: ${res.status} ${res.statusText}${text ? `\n${text}` : ""}`;
if (PERMANENT_HTTP_STATUSES.has(res.status)) {
throw new PermanentProviderError(message);
}
throw new Error(message);
}
const data = await res.json();
return text;
},
};
}
-
Wire into createTranslator:
if (provider.kind === "new-provider") {
return createNewProviderTranslator(provider, locale, fetchImpl);
}
-
Permanent vs retriable — PERMANENT_HTTP_STATUSES is {400, 401, 403, 404, 422}. Don't widen this without thinking about what flaky responses might wrongly skip retry. 5xx, 408, 425, 429 are retriable. Ask first before adding statuses (per AGENTS.md Boundaries).
-
Add tests at tests/translation/provider.test.ts covering:
- 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's config 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)
-
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
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
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 exec tsc --noEmit
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 tests/fixtures/ if it's worth a regression test.
-
Inspect what the cache layer planned:
polystella translate --dry-run --file 'path/to/source.md'
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 src/storage/cache.ts or 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:
src/runtime/middleware.ts — request middleware; pre-binds locale to all four locals.
src/runtime/middleware-core.ts — middleware body (test-friendly extract).
src/runtime/get-localized-entry.ts, get-localized-collection.ts — fetcher implementations.
src/runtime/localized-href.ts — URL prefixer.
src/runtime/custom-loader-runtime.ts — the bridge (module-scoped singleton shared with sibling collections).
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).
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 polystella/runtime for non-template contexts.
Steps:
- Edit the relevant runtime file.
- Update
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
tests/runtime/:
- Behaviour test for the new/changed function.
- Middleware-binding test if the locals shape changes (
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:
src/i18n/drift.ts — checkI18nDrift, loadAndCheckDrift.
src/i18n/sync.ts — key reconciliation; layout-aware JSON writer (formatLocaleFile).
src/i18n/ui-translate.ts — AI-fill orchestrator; parallel-locale execution; {{token}} validator + retry wrapper.
src/i18n/loader.ts, i18n/index.ts — content-layer loader, dictionary fetcher.
src/cli/check-ui.ts, sync-ui.ts, translate-ui.ts — 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.
- Parallel locales catch errors internally —
translate-ui runs locales in parallel via runWithConcurrency. Workers MUST catch every error and record it on the per-locale outcome — never re-throw. Re-throwing kills the whole run.
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:
const first = arr[0];
first.foo;
const first = arr[0];
if (first === undefined) continue;
first.foo;
const [first = defaultValue] = arr;
exactOptionalPropertyTypes
foo?: string is NOT the same as foo: string | undefined. Callers passing undefined explicitly need the latter:
interface Opts {
signal?: AbortSignal;
}
function foo(opts: { signal?: AbortSignal }) {
inner({ signal: opts.signal });
}
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;
}
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}`);
Testing conventions
- Tests live under
tests/<src-dir>/<basename>.test.ts. Top-level exceptions: tests/cli.test.ts (top-level dispatch + translate-subcommand parsing), tests/cli/ (per-subcommand handlers), tests/smoke.test.ts (end-to-end integration smoke).
- Vitest config in
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: use the in-memory R2 client at
tests/helpers/in-memory-r2.ts (or whatever the equivalent helper is).
- 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. tests/smoke.test.ts is the template.
- For the doc-claims test (
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 exec tsc --noEmit