<< All versions

Skill v1.0.3

Trusted Publisher100/100
cloudflare/polystella/polystella-contributor

Merge pull request #45 from cloudflare/update-docs

──Details
PublishedSeptember 13, 2026 at 04:50 AM
Content Hashsha256:8e54bb5a334de8d3...
Git SHAa136cc1b41a5
Compare with v1.0.2
──Files
Files (1 file, 21.2 KB)
SKILL.md21.2 KBactive
SKILL.md · 536 lines · 21.2 KB

version: "1.0.3" 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.

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

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

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 }; ```

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

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

  1. 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.
  1. Verify:

``sh pnpm test pnpm typecheck ``

  1. 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. } ```

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

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]); ```

  1. 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()); }, }; } ```

  1. 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, }); } ``

  1. 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.
  1. 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.
  1. 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. The current formula is:

`` hash = sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash) ``

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

  1. 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?

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

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

  1. Bypass the cache: delete the relevant R2 object, or delete the local index entry:

``sh rm <root>/.astro/i18n-staging/.polystella-cache.json ``

  1. Bypass R2 entirely by passing r2Override: null to runTranslationPass (test-only). Useful for isolating the translator from the cache layer.
  1. 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

<a id="modify-runtime-api"></a>

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:

  1. Edit the relevant runtime file.
  2. Update packages/astro/src/runtime/locals.ts if you're changing the shape of Astro.locals.
  3. Update the polystella-consumer skill's "Runtime APIs" section.
  4. 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).
  1. Don't forget the React side — useTranslations / useLocalizedHref and their consumer-side wiring (getDictionary).

Edit UI-string handling

<a id="edit-ui-strings"></a>

When to use: Changing drift detection rules, sync writer behaviour, AI-fill orchestration, or the {{token}} validator.

Files:

  • packages/core/src/cli/drift.ts — checkI18nDrift, loadAndCheckDrift.
  • packages/core/src/cli/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/core/src/cli/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

<a id="strict-tsconfig"></a>

All four stricter TypeScript flags are on (noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitReturns, noFallthroughCasesInSwitch). Patterns that come up repeatedly:

noUncheckedIndexedAccess

Indexed access returns T | undefined. Patterns:

ts
// ❌ 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:

ts
// ❌ 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:

ts
function foo(): number {
if (cond) {
sideEffect();
return 0;
} // explicit return
return 1;
}

Replacing ! and any

! and any are banned outside test code. Replace with:

ts
// ❌
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

<a id="testing"></a>

  • 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:

sh
pnpm test
pnpm typecheck
← v1.0.2All versionsv1.0.4 →