PolyStella — an Astro integration that translates content into additional locales at build time using AI, caches translations in Cloudflare R2, and injects locale-prefixed routes.
This file is the entry point for coding agents working on the PolyStella package itself. Three companion docs:
ARCHITECTURE.md— system design, invariants, glossary, per-subsystem reference. The "why" answers.skills/polystella-contributor/SKILL.md— step-by-step recipes for common contributor tasks (add an adapter, add a CLI subcommand, debug a translation, etc.).skills/polystella-consumer/SKILL.md— for agents working in a downstream Astro project that depends on this package.
All cross-references use stable slug anchors (#cache-key), not
section numbers. Inserting new sections never breaks links.
| Command | What it does |
|---|---|
pnpm test |
Run vitest (1104 tests / 56 files / ~1.2s at time of writing). |
pnpm test:watch |
Vitest in watch mode. |
pnpm build |
Compile src/ → dist/ via tsc -p tsconfig.build.json (mirrored layout, .js + .d.ts + sourcemaps + declaration maps). Produces the standalone polystella CLI at dist/cli.js and library entries. |
pnpm exec tsc --noEmit |
Typecheck against the root tsconfig.json (which includes tests). The build config (tsconfig.build.json) sets noEmit: false and narrows include to src/**. |
pnpm changeset |
Add a Changesets entry for package-affecting work. Use pnpm changeset add --empty only for changes that intentionally do not need a package release. |
No lint step yet.
Test counts age. The authoritative count is
pnpm test's output; the number here is a snapshot pinned bytests/docs.test.ts.
Task → entry-point file(s) → key contract → deep-dive link.
| Task | Entry point | Contract | See |
|---|---|---|---|
| Add a file-format adapter | src/parsing/adapters/<name>.ts; register in src/parsing/registry.ts |
FileTypeAdapter in src/parsing/adapter.ts |
#adapter-contract; recipe in contributor SKILL |
| Add a CLI subcommand | Handler in src/cli/<name>.ts; register in src/cli.ts (parseSubcommand + switch) |
Argv parser + run<Name>(args, deps) |
Recipe in contributor SKILL |
| Add a translation provider | New branch in createTranslator (src/translation/provider.ts) |
Translator interface; permanent vs retriable error classification |
#translator-contract; recipe in contributor SKILL |
| Change cache key formula | src/storage/hash.ts |
Invariant 1 — cache-wide invalidation | #cache-key |
| Edit translation batching | src/translation/batch.ts, src/translation/translate-segments.ts |
Invariant 2 — flat(groups) === segments |
#translation-batching |
| Modify cache/storage behaviour | src/storage/{cache,r2,prune,local-cache,report}.ts |
Apply-before-PUT (Invariant 3); index isolation (Invariant 4) | #cache-write-order, #local-staging-index |
| Modify runtime APIs (entry/collection/href/middleware) | src/runtime/* |
Bridge timing (Invariant 5); per-locale closures | #runtime-bridge |
| Modify routing shims | src/routing/{shim,expand-routes,walk-pages}.ts |
Stale shims nuked per build; CSS via routesImports |
#routing-shims |
| Edit UI-string handling | src/i18n/*, src/cli/{check,sync,translate}-ui.ts |
Three drift modes; layout-aware writer; {{token}} preservation |
#ui-strings |
| Edit catalog-only adoption | src/catalog/*, src/i18n/{translate,drift,sync}.ts |
Pure imports; middleware binds only t + lhref |
CATALOG_ONLY_PLAN.md; consumer SKILL |
| Edit content-collection wiring | src/content/* |
Sibling collections; custom-loader wrapper; bridge timing | #runtime-bridge |
| Debug a translation that's wrong | Start: pnpm translate --dry-run to inspect planned R2 keys; LOG_LEVEL=debug for batch detail |
— | Recipe in contributor SKILL |
| Tune cold-cache build performance | r2.bulkListOnStart, concurrency, batchInputTokenBudget knobs |
— | #bulk-prelist, #translation-batching |
If your task isn't on this list, the answer is in src/<area>/
matching one of the subsystem sections in
ARCHITECTURE.md.
Hard contracts. Don't violate without thinking carefully — link back to the explanatory section when adding code that touches one.
- Cache key formula —
sha256(body + selectedFrontmatterValues + glossaryHash + modelId + optionalExtractionPolicyHash). Changing any input is a cache-wide invalidation. → #cache-key - Group flattening —
flat(adapter.groupSegments(...)) === segments(reference-equal, order-preserved). Asserted at runtime. → #translation-batching - Apply before PUT —
applyTranslationsproduces the exact bytes PUT to R2; markers woven insideapply, never after. → #cache-write-order - Local cache index write isolation — workers write to
nextLocalCacheIndexonly; read only fromlocalCacheIndex. → #local-staging-index - Bridge timing — translation runs in
astro:config:setup, NOTbuild:start. → #hook-timing - URL-rewrite idempotence — both layers safe to apply twice. → #url-rewriting
- Path separators — R2 keys use
/, local paths usepath.sep. - Permanent vs retriable provider errors — only
PermanentProviderErrorshort-circuits the retry loop. → #translator-contract - Strict tsconfig — no
any, no!outside tests; useunknown+ type guards; declare callable-undefinedoptionals asfoo?: T | undefined.
- Run
pnpm testbefore pushing. Tests must stay green. - Add a Changesets entry for every change that affects the published
package, its documented behaviour, or release-facing contributor
guidance. Use
pnpm changesetfor release notes; usepnpm changeset add --emptyonly when the change deliberately does not require a package release (for example, docs-site-only or CI-only maintenance). - Bump the package version in
package.jsononly —POLYSTELLA_VERSION(insrc/version.ts) reads it at module-load time via a JSON import attribute, so the constant flows automatically through todist/version.jsafterpnpm build. → #version-constant - Mirror filesystem path semantics across OS: forward slashes for R2
keys,
path.sepfor local I/O. - Forward
signal: AbortSignalwhen adding a new async function on the hot path;signal?.throwIfAborted()at await boundaries that could otherwise run indefinitely. → #abortsignal
- Adding a new runtime dependency.
- Adding a new adapter (introduces a new file extension).
- Widening the permanent-provider-error set (HTTP status →
PermanentProviderError).
- Re-introduce long-form design comments that were removed; they
live in
ARCHITECTURE.mdnow. In-code comments stay tight ("why" only). - Commit R2 credentials or live API keys to fixtures.
- Widen the AI-translation marker contract without bumping the cache key formula. Cache hits return the marker verbatim, so an existing cache must remain interpretable.
- Use
any. Useunknownand narrow with type guards. - Use
!non-null assertions outside test code.
Tiered so you can scan the ones that matter for your change.
- Cache-key formula stability (#cache-key). Any change is a cache-wide invalidation; coordinate via changelog.
- Apply-before-PUT (#cache-write-order). The marker MUST be woven inside
apply. Doing it after the PUT lets cache hits return marker-less bytes. - Bridge timing (#hook-timing). Moving translation to
build:startmakes sibling collections see empty staging dirs at content-sync time. - Workers AI default
maxTokensis too low (#translator-contract). Default in our schema is8192. Lowering it truncates multi-segment translations into invalid JSON. {{token}}validation runs outsidetranslateBatch(#ui-strings). The orchestrator's retry wrapper setsmaxRetries: 0ontranslateBatchso the retry loop is single-layer. Don't add a second retry layer.
- Override files at
i18n/overrides/{locale}/<path>win over AI output verbatim. They run through the URL rewriter (idempotent) but are NOT written to R2. Cache key changes don't affect overrides. - MDX vs MD —
.mdxuses MDX syntax rules (imports/exports, JSX, expressions);.mdstays plain Markdown. Route by extension; never apply MDX rules to.md. - Drift check fails on empty placeholders (#ui-strings).
pnpm i18n:syncalone leaves the tree non-shippable untilpnpm i18n:translate(or a hand-edit) fills the placeholders. Intentionally-blank labels are supported via matching""in the source dict. - UI-string sync writer is layout-aware (#ui-strings). Running
prettier --writeon a synced file collapses the blank-line section breaks. The pre-commit hook only runsprettier --check, so it doesn't trip — but a manualpnpm formatwill churn diffs. translate-uipre-scans, then runs queued locales in parallel (#ui-strings). Complete locale JSONs are skipped before provider setup. Queued locales are capped at 3 concurrent workers; each locale is internally split into small sequential request batches. Workers MUST catch every error internally and record it on the per-locale outcome — never re-throw. Re-throwing kills the rest of the run.adapter.parseis called once per source per live run (#dry-run-vs-live). Adapterparseshould still be idempotent — calling it twice during debugging or in tests must not produce different output.
- R2 bulk pre-list (#bulk-prelist). Disable via
r2.bulkListOnStart: falsefor caches with >10k keys per locale. - Vitest
singleThread: trueis faster than multi-worker at current scale (~1.5s vs ~1.6s; per-worker startup dominates). Revisit when the suite outgrows the overhead. - Heartbeat
setIntervalisunref()'d (#heartbeat) so a stalled pool doesn't block process exit. Don't remove the unref.
Before pushing:
pnpm testmust pass.pnpm exec tsc --noEmitmust pass (strict mode).- For changes to the translation pipeline, run end-to-end against a
real consumer's fixtures:
polystella translate --dry-runwalks the full pipeline without hitting AI/R2. - For changes to UI-string sync / translate:
polystella check-ui(read-only),polystella sync-ui --check(read-only),polystella translate-ui --sync-only(offline, mutates JSONs).
ARCHITECTURE.md— system design and rationale.README.md— user-facing introduction.skills/polystella-contributor/SKILL.md— recipes for contributor tasks.skills/polystella-consumer/SKILL.md— for agents in downstream consumer repos.llms.txt— index for retrieval-time discovery.