- 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:
- [`AGENTS.md`](../../AGENTS.md) — orientation, invariants, boundaries.
- [`ARCHITECTURE.md`](../../ARCHITECTURE.md) — subsystem reference.
Then come back here for step-by-step task recipes.
Package ownership follows the direct in-process flow:
```text
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-adapter)
- [Add a CLI subcommand](#add-cli-subcommand)
- [Add a translation provider](#add-provider)
- [Change the cache contract](#change-cache-contract)
- [Debug a translation regression](#debug-translation)
- [Modify a runtime API](#modify-runtime-api)
- [Edit UI-string handling](#edit-ui-strings)
- [Strict tsconfig patterns](#strict-tsconfig)
- [Testing conventions](#testing)
---
## Add a file-format adapter
<a id="add-adapter"></a>
**When to use:** Supporting a new file extension (`.xml`, `.html`, `.po`, custom format).
**Contract:** `FileAdapter` in `packages/core/src/adapters/adapter.ts`; Astro policies wrap it in `packages/astro/src/parsing/adapter.ts`. See [#adapter-contract](../../ARCHITECTURE.md#adapter-contract).
**Steps:**
1. Implement the portable adapter at `packages/core/src/adapters/<name>.ts`:
```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
};
```
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`:
```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.
3. Add portable tests under `packages/core/tests/adapters/` 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.
4. **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.
5. Verify:
```sh
pnpm test
pnpm typecheck
```
6. Update the package README and any per-format docs.
---
## Add a CLI subcommand
<a id="add-cli-subcommand"></a>
**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/core/src/cli/`; host dispatchers stay thin.
**Steps:**
1. Create `packages/core/src/cli/<name>.ts` for a shared catalog command. Keep an Astro-only command under `packages/astro/src/cli/`:
```ts
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.
}
```
2. Register a shared catalog command in `packages/core/src/cli/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.
3. Add tests:
- `packages/core/tests/cli/<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).
4. 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.
5. Verify:
```sh
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
<a id="add-provider"></a>
**When to use:** Adding a third translator (e.g. OpenAI, Bedrock).
**Contract:** `Translator` in `packages/core/src/translator.ts`. Provider transports live in `packages/core/src/providers/`; `packages/astro/src/translation/provider.ts` only maps Astro config. See [#translator-contract](../../ARCHITECTURE.md#translator-contract).
**Steps:**
1. Add a config variant to the provider zod schema in `packages/astro/src/config/options.ts`:
```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]);
```
2. Implement a concrete-model factory in `packages/core/src/providers/<name>.ts`:
```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());
},
};
}
```
3. Export the factory from `packages/core/src/providers/index.ts`, then map the validated config in Astro's `createTranslator`:
```ts
if (provider.kind === "new-provider") {
return createNewProviderTranslator({
apiKey: provider.apiKey,
modelId: resolveModelId(provider.model, locale),
maxTokens: provider.maxTokens,
});
}
```
4. **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.
5. Add transport tests under `packages/core/tests/providers/` 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`.
6. Document the new provider in the package README and docs provider section.
---
## Change the cache contract
<a id="change-cache-contract"></a>
**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:**
1. Read [#cache-key](../../ARCHITECTURE.md#cache-key). The current formula is:
```
hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash)
```
2. **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.
3. 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).
4. Verify:
```sh
pnpm test
pnpm typecheck
```
The pinned-hash test will catch drift if you missed the test update.
---
## Debug a translation regression
<a id="debug-translation"></a>
**When to use:** A translation that used to work is wrong, missing, or failing.
**Diagnostic flow:**
1. **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.
2. **Inspect what the cache layer planned:**
```sh
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`.
3. **Inspect the staged output:**
```sh
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?
4. **Inspect the build report:**
```sh
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`.
5. **Crank up verbosity:**
```sh
LOG_LEVEL=debug polystella translate --file 'path/to/source.md'
```
Emits per-batch detail (segment count, batch count, oversize warnings, retry attempts).
6. **Bypass the cache:** delete the relevant R2 object, or delete the local index entry:
```sh
rm <root>/.astro/i18n-staging/.polystella-cache.json
```
7. **Bypass R2 entirely** by passing `r2Override: null` to `runTranslationPass` (test-only). Useful for isolating the translator from the cache layer.
8. **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
عرض على GitHub