Skip to main content

polystella-contributor

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).

Source facts

Repository
cloudflare/polystella
Last source activity
September 15, 2026 at 14:57
Detected SKILL.md language
English
Stars
16
Forks
2

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub