| name | polystella-consumer |
| description | Add or maintain PolyStella in an existing Astro project. Use when integrating AI-driven build-time content localization, configuring R2 caching, wiring locale-prefixed routes, or debugging an existing PolyStella setup in a downstream project. |
polystella-consumer
You are working in an Astro project that consumes the polystella
package. This skill covers integration, configuration, common
pitfalls, and the debug flow.
If you are working on the polystella package source itself (adding
adapters, editing translators, modifying the cache layer), STOP and
load polystella-contributor instead.
What PolyStella does
Build-time content localization for Astro:
- Translates content collections (
.md, .mdx, .toml, .json, .yaml) into additional locales using AI (Workers AI or Anthropic).
- Caches translations in Cloudflare R2, content-addressed by source bytes + glossary + model. Unchanged content costs zero on rebuild.
- Injects routes under
/[lang]/... for each non-default locale.
- Provides runtime APIs on
Astro.locals (t, lhref, getLocalizedEntry, getLocalizedCollection) and React hooks (useTranslations, useLocalizedHref).
- Maintains UI strings via per-locale JSON dicts with drift detection, sync, and AI-fill subcommands.
Visitors get static bytes — no runtime AI calls.
Installation
Pre-1.0 the package isn't on npm. Install from GitHub:
pnpm add github:cloudflare/polystella#vX.Y.Z
The package has a prepare script that compiles src/ → dist/ on install (pnpm build → dist/cli.js plus the library tree). pnpm runs prepare automatically for github: installs as long as polystella is listed in the host project's onlyBuiltDependencies (pnpm-workspace.yaml).
Peer dependency: astro ^6.0.0.
Four-file integration
Set up these four files. The locale set lives in astro.config.mjs
ONLY — everything else derives from it.
1. astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
import polystella, { astroSitemapI18n } from "polystella";
import polystellaConfig from "./polystella.config.mjs";
const i18n = {
defaultLocale: "en-US",
locales: ["en-US", "pt-BR", "ja-JP"],
routing: { prefixDefaultLocale: false },
};
export default defineConfig({
i18n,
integrations: [sitemap(astroSitemapI18n(i18n, { hreflang: { en: "en-US" } })), polystella(polystellaConfig)],
});
2. polystella.config.mjs
Where provider, glossary, R2, format-specific keys live. Schema source of truth is src/config/options.ts in the package; everything is zod-validated at the boundary.
Skeleton:
import "dotenv/config";
export default {
r2: {
accountId: process.env.R2_ACCOUNT_ID,
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
bucket: "your-bucket-name",
prefix: "i18n/",
},
provider: {
kind: "workers-ai",
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN,
model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
},
glossaryDir: "i18n/glossary",
overridesDir: "i18n/overrides",
frontmatter: {
"content/publications/**/*.md": ["title", "description"],
"content/people/**/*.md": ["position", "bio"],
},
markdown: {
contextKeys: {
"content/publications/**/*.md": ["title", "excerpt"],
},
},
routes: [
"src/pages/index.astro",
"src/pages/[slug].astro",
],
routesImports: ["./src/styles/global.css"],
};
3. src/content.config.ts
import { defineCollection } from "astro:content";
import { polystellaCollections } from "polystella/content";
import { i18nLoader, i18nSchema } from "polystella/i18n";
import { publications, people } from "./content-schemas";
export const collections = {
...polystellaCollections({
source: { publications, people },
}),
i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }),
};
4. src/env.d.ts
Picks up types for PolyStella's virtual modules (polystella:runtime-config).
UI strings
Chrome text (nav, footer, accessibility strings) lives in
src/content/i18n/<locale>.json as flat key→string dicts. The
default-locale file is the single source of truth; non-default
locales must match its key set.
Workflow:
- Edit
src/content/i18n/en-US.json — add, remove, or change keys.
- Run
polystella translate-ui to propagate changes through other locales.
- Spot-check translations; hand-edit any keys where you want exact wording. The AI step only fills empty values, so a hand-written value stays untouched on subsequent runs.
- Commit. The pre-commit hook should run
polystella check-ui automatically when src/content/i18n/ is staged.
Wire the pre-commit hook (.githooks/pre-commit):
if printf '%s\n' "$STAGED" | grep -qE '^src/content/i18n/'; then
pnpm exec polystella check-ui
fi
Runtime APIs
In .astro files:
---
const { t, lhref, getLocalizedEntry, getLocalizedCollection } = Astro.locals;
const { slug } = Astro.params;
const entry = await getLocalizedEntry("publications", slug);
const activePeople = await getLocalizedCollection(
"people",
({ data }) => data.type === "active",
);
---
<a href={lhref("/foo")}>{t("nav.foo")}</a>
Outside .astro (utility scripts, getStaticPaths, React islands):
import { getLocalizedEntry, getLocalizedCollection, localizedHref } from "polystella/runtime";
import { useTranslations, useLocalizedHref } from "polystella/react";
import { getDictionary } from "polystella/i18n";
Branch-isolated R2 cache
Three modes, dispatched automatically by polystella.config.mjs:
| Mode | Env signals | r2.prefix | Writes? |
|---|
| Local build | neither var set | i18n/ (read-only against main) | NO |
| CI build (main) | WORKERS_CI_BRANCH=main | i18n/ | YES |
| CI build (preview) | WORKERS_CI_BRANCH=<other> | previews/<branch>/i18n/ + fallback | YES (preview prefix only) |
| Explicit CLI | POLYSTELLA_CLI=1 (set by cli.ts) | per resolved branch | YES |
Key mental model: a developer's local pnpm build can NEVER overwrite production. To populate R2 from outside CI, use the explicit polystella translate CLI.
Configure a lifecycle rule on the R2 bucket to expire previews/* after 30 days. The package only prunes within its configured prefix, so cross-build cleanup of orphan preview prefixes needs the lifecycle rule.
Override files (hand translations)
Drop a file at i18n/overrides/{locale}/<mirrored-path> and it wins over AI output verbatim. Overrides go through the URL rewriter (idempotent) but are NOT written to R2 — they live in your repo, not in the cache.
Use overrides for content you want to control exactly (legal copy, brand names, marketing taglines).
Glossary
YAML file per locale at <glossaryDir>/<locale>.yaml:
- term: "Cloudflare"
translation: "Cloudflare"
- term: "edge computing"
translation: "edge computing"
notes: "Keep English; widely understood as a technical term in <locale>."
- term: "free tier"
translation: "<locale-specific preferred phrasing>"
Editing the glossary re-translates only pages mentioning the changed term (the glossary hash folds into the cache key).
Build report
After every translation pass, astro build (and polystella translate) writes dist/i18n-r2-report.json with per-pair outcomes: cache hits, AI translations, overrides, errors, locally-skipped pairs, prune actions. Check it when something looks wrong.
Common pitfalls (top 10)
- Locale set drift —
astro.config.mjs's i18n.locales is the only source of truth. Don't duplicate it in polystella.config.mjs. The astroSitemapI18n(i18n, ...) helper takes the same i18n block. Sitemap config that doesn't match Astro's i18n ships locale-prefixed URLs with no hreflang annotations — search engines treat them as duplicate content.
- Empty preview cache panic — A PR preview's R2 prefix is
previews/<branch>/i18n/, initially empty. The fallback to i18n/ means cache hits still come from main; new translations write to the preview prefix. This is correct behaviour.
- Workers AI
maxTokens default — The schema default is 8192. Lowering it truncates multi-segment translations into invalid JSON. Keep it at 8192 unless you've measured single-segment files and know what you're doing.
pnpm i18n:sync alone is not enough — Sync only reconciles keys; it leaves new keys as "" placeholders. The build's drift check fails on "" in a non-default locale when the source value is non-empty. Run pnpm i18n:translate (or hand-edit) to fill placeholders before committing.
- CSS missing on translated routes — Translated pages render via shims; Astro's per-route stylesheet injection doesn't follow CSS through child
<SourcePage /> components. List your global CSS in routesImports (or per-route via the object form) so shims emit the import directly.
prettier --write collapses sync writer's blank lines — The UI-string sync writer preserves blank-line section breaks between key groups. prettier --write collapses them. The pre-commit hook should use prettier --check (not --write).
{{token}} placeholders dropped by AI — Validated post-translation; if a token is missing or renamed after all retries, the key is left empty for manual fix-up. Hand-edit the locale JSON in that case.
- Override files don't get cache-invalidated — Edits to overrides aren't reflected in the cache (overrides aren't cached). The override is read fresh every build.
- MDX vs MD —
remark-mdx disables indented code, autolinks, and raw-HTML blocks. If your .md files use any of these, don't accidentally rename them to .mdx.
- R2 credentials in repo — Never commit credentials. Use
.env (gitignored) + dotenv/config at the top of polystella.config.mjs. Workers Builds inject credentials via env vars; local development reads from .env.
CLI quick reference
polystella translate
polystella translate --branch main
polystella translate --locale pt-BR
polystella translate --file 'foo.md'
polystella translate --dry-run
polystella translate --prefix 'custom/i18n/'
polystella check-ui
polystella sync-ui
polystella sync-ui --check
polystella translate-ui
polystella translate-ui --locale pt-BR
polystella translate-ui --sync-only
Exit codes: 0 clean, 1 config error, 2 translation/sync work failed.
Typical host-project package.json wrappers:
{
"scripts": {
"translate": "polystella translate",
"translate:dry": "polystella translate --dry-run",
"i18n:check": "polystella check-ui",
"i18n:sync": "polystella sync-ui",
"i18n:translate": "polystella translate-ui"
}
}
Debug flow
When a translation is wrong:
- Run
polystella translate --dry-run --file '<source-path>' to see the planned R2 key. Verify the key is what you expect.
- Inspect the staged file at
<root>/.astro/i18n-staging/<locale>/<source-path>. Did the AI output land there? Is the marker (aiTranslated: true) in the frontmatter?
- Check the build report (
dist/i18n-r2-report.json) — was it a hit, miss, override, or error?
- If a glossary entry should have applied:
cat <glossaryDir>/<locale>.yaml and confirm the term is listed.
- If an override should have applied: confirm the path
i18n/overrides/<locale>/<exact-mirror-of-source> exists.
- Re-run with
LOG_LEVEL=debug polystella translate --file '...' for batch-level detail (segment count, batch count, oversize warnings).
- To force re-translation: bump the source file (any edit changes its hash), or delete the R2 object directly, or delete the local cache index entry at
<root>/.astro/i18n-staging/.polystella-cache.json.
What never to do
- Commit R2 credentials, Workers AI API tokens, or Anthropic API keys.
- Run
pnpm i18n:sync and commit without pnpm i18n:translate — the build will fail on empty placeholders.
- Manually write to R2 from outside CI without setting
POLYSTELLA_CLI=1 (the CLI does this automatically; this is a warning if you're scripting against the R2 client directly).
- Move translation out of
astro:config:setup — sibling collections will see empty staging dirs.
- Hardcode locale lists in multiple places. The
astro.config.mjs i18n block is the single source of truth.
Where to look
| You want to | Look at |
|---|
| Understand the system | node_modules/polystella/ARCHITECTURE.md |
| See config schema | node_modules/polystella/src/config/options.ts |
| See available exports | node_modules/polystella/package.json (exports field) |
| See CLI flags | polystella --help, polystella <subcommand> --help |
| Debug a translation | dist/i18n-r2-report.json, <root>/.astro/i18n-staging/<locale>/... |
| File an issue | https://github.com/cloudflare/polystella/issues |