polystella-contributor
Chỉnh sửa mã nguồn gói PolyStella. Sử dụng khi thêm bộ chuyển đổi định dạng tệp, thêm lệnh con CLI, thêm nhà cung cấp bản dịch, sửa đổi bộ nhớ đệm…
npx skills add https://github.com/cloudflare/polystella --skill polystella-contributorpolystella-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:
AGENTS.md— orientation, invariants, boundaries.ARCHITECTURE.md— subsystem reference.
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
- Add a CLI subcommand
- Add a translation provider
- Change the cache contract
- Debug a translation regression
- Modify a runtime API
- Edit UI-string handling
- Strict tsconfig patterns
- Testing conventions
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 underpackages/astro/src/parsing/adapters/, then register that wrapper inpackages/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.tsis the de-facto priority. -
Add portable tests under
packages/adapters/tests/and retain Astro-policy parity tests underpackages/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.tsorpackages/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>.tsfor a shared catalog command. Keep an Astro-only command underpackages/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.tsand both host CLIs. For an Astro-only command, wirepackages/astro/src/cli.ts:- Add to the
Subcommandunion type. - Add the literal to
parseSubcommand'sif (first === "translate" || ...)check. - Add a case to
main()'s switch statement. - Update
TOP_LEVEL_USAGEto mention the new verb.
- Add to the
-
Add tests:
packages/cli/tests/<name>.test.tsfor a shared parser + handler, orpackages/astro/tests/cli/<name>.test.tsfor an Astro-only command.- Extend
packages/astro/tests/cli.test.tsif the top-level dispatch needs new coverage (it usually does — add at least one "dispatchesmy-subcommandto the right handler" case).
-
If consumers typically wrap the subcommand in a
pnpmscript (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'screateTranslator: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 inpackages/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.
signalpropagation tofetch.
-
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(thecomputeSourceHashfunction). - Update the formula description in
ARCHITECTURE.md#cache-key. - Update
AGENTS.mdInvariant #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).
- Edit
-
Verify:
pnpm test pnpm typecheckThe 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
computeSourceHashorbuildR2Key. -
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, orlocalSkipped. Read the corresponding code path inpackages/astro/src/storage/cache.tsorpackages/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: nulltorunTranslationPass(test-only). Useful for isolating the translator from the cache layer. -
Common regression causes:
- Adapter
parsenot 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
maxTokenswas lowered — multi-segment translation truncated to invalid JSON. - Glossary YAML syntax error — silently ignored on load, term not applied.
noTranslate: trueaccidentally 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.
- Adapter
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-keyedglobalThisstate shared with sibling collections across Vite module reloads).packages/astro/src/runtime/locals.ts— TypeScript ambient declarations forAstro.locals. Waslocals.d.tsuntil the dist-emit rework; renamed so tsc emits both an empty.jsand the.d.tsdeclarations, andruntime/index.tspulls 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,useLocalizedHrefhooks.
Key contracts:
- Bridge timing (Invariant 5) — the bridge must be set in
astro:config:setupbefore sibling collections register. Edits that defer bridge setup will silently break sibling content loading. - Per-locale closures —
t,lhref,getLocalizedEntry,getLocalizedCollectionare pre-bound to the request's locale by the middleware. Don't expose unbound versions in.astrofiles — they're imported separately from@cloudflare/polystella-astro/runtimefor non-template contexts.
Steps:
- Edit the relevant runtime file.
- Update
packages/astro/src/runtime/locals.tsif you're changing the shape ofAstro.locals. - Update the
polystella-consumerskill'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/useLocalizedHrefand 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'sastro:config:setupdrift check and thecheck-uiCLI 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 OUTSIDEtranslateBatch— the orchestrator's retry wrapper setsmaxRetries: 0ontranslateBatch. Don't add a second retry layer.- Queued locales catch errors internally —
translate-uipre-scans locale JSONs, skips complete catalogs before provider setup, then runs queued locales in parallel viarunWithConcurrencywith 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/middlewarebindsAstro.locals.tandAstro.locals.lhrefonly. 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'srunCheckUi(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
translatorOverridestorunTranslationPasswith a fakeTranslator. - For smoke tests: drive
polystella(options)with stubbed Astro context against a real temp project.packages/astro/tests/smoke.test.tsis the template. - For the doc-claims test (
packages/astro/tests/docs.test.ts): pins file paths and command names referenced inAGENTS.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