Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .agents/skills/translate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ metadata:

# Translate Studio

Studio's catalogs live in the repo (ADR-0034). **Only English is edited by hand** (`apps/studio/src/i18n/en.ts`, `lazy/<ns>.en.ts`); every other language is generated from translation files by `pnpm i18n:import`, and a hand edit fails `pnpm check`. This skill drafts those translation files. It uploads nothing and never approves anything: people review on the translation platform (or the owner, for now), and only they set `reviewed: true` in `locales.ts`.
Studio's catalogs live in the repo (ADR-0034), and so does one visitor-facing catalog that goes through the same exchange: the renderer's status pages (`packages/astro-renderer/src/status-strings/`, RFC-0043 §1.5). **Only English is edited by hand** (`apps/studio/src/i18n/en.ts`, `lazy/<ns>.en.ts`, `packages/astro-renderer/src/status-strings/en.ts`); every other language is generated from translation files by `pnpm i18n:import`, and a hand edit fails `pnpm check`. This skill drafts those translation files. It uploads nothing and never approves anything: people review on the translation platform (or the owner, for now), and only they set `reviewed: true` in `locales.ts`.

## Steps

1. `pnpm i18n:export` writes `.i18n/<locale>/<file>.json` (nested, ICU MessageFormat 1 values) for every language and `.i18n/descriptions.json` (each key's English text and its note). `<file>` is `core` or a namespace.
1. `pnpm i18n:export` writes `.i18n/<locale>/<file>.json` (nested, ICU MessageFormat 1 values) for every language and `.i18n/descriptions.json` (each key's English text and its note). `<file>` is `core`, a namespace, or `status` (the status pages).
2. For the target language, translate what is missing or changed — compare `.i18n/en/<file>.json` with `.i18n/<locale>/<file>.json` (a new language: copy the English files and translate every value). Read the key's note in `descriptions.json`.
3. Write the translated JSON back to `.i18n/<locale>/`. Same keys, same nesting, same order as English.
4. `pnpm i18n:import --locale <locale>`: checks keys and ICU syntax, rewrites the catalogs, runs `test/i18n.test.tsx` and `test/i18n-messages.test.ts`. Fix what they report and import again.
4. `pnpm i18n:import --locale <locale>`: checks keys and ICU syntax, rewrites the catalogs (for `status`: `status-strings/<locale>.ts` and the index `status-strings/catalog.ts`), runs `test/i18n.test.tsx` and `test/i18n-messages.test.ts`. After a `status` change also run `pnpm exec vitest run test/status-page.test.ts` in `packages/astro-renderer` (every state in every language within 8 KB, no `plural`/`select`/`number`). Fix what they report and import again.
5. `pnpm design:lint` and the Studio typecheck must pass; open a PR that says the strings are AI drafts awaiting review.

## Rules for the messages (DESIGN.md §1 "Message syntax", RFC-0032)
Expand All @@ -26,10 +26,11 @@ Studio's catalogs live in the repo (ADR-0034). **Only English is edited by hand*
- Translate whole sentences; when a word must change with a value, keep or write a `select` with whole sentences — never glue words.
- A literal `{`, `}` or (inside a plural) `#` is quoted: `'{}'`, `'#'`.
- Keep product and technical names as they are (Cloudhal, Studio, Snapshot, Release, MCP, Markdown, URL, API, SEO). Use the language's usual UI register (Turkish and German Studio use the informal "sen"/"du"; privacy notices to site visitors are formal).
- Status-page messages (`status.json`) take simple `{name}` arguments only (`{time}`, `{reference}`): no `plural`, `select` or `number`, since the site Worker substitutes them without a compiler. Site visitors read them, so use the language's neutral public register.
- An argument the language cannot use at all (a case suffix on an arbitrary name) is declared in that language's `dropped` export, with the reason — the import keeps it.

## Never

- Edit `apps/studio/src/i18n/<locale>.ts` or `lazy/<ns>.<locale>.ts` by hand for any locale but `en`.
- Edit `apps/studio/src/i18n/<locale>.ts`, `lazy/<ns>.<locale>.ts` or `packages/astro-renderer/src/status-strings/<locale>.ts` (or its `catalog.ts`) by hand for any locale but `en`.
- Set `reviewed: true`, or present a draft as reviewed.
- Upload to a translation platform or send strings outside the repo (platform hosting is the owner's decision, ADR-0034).
5 changes: 5 additions & 0 deletions .changeset/status-page-fixes-167.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/astro-renderer": patch
---

Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page (HTML, or its empty fallback when no 500 page renders) and thrown errors become the `error` page. A theme page that throws now gets that page with a reference and a `cloudhal: request failed` log line: the blog starter's catch-all route rethrows when Astro renders it as the error route, instead of answering its own 404 for the page's path (theme copies made from an earlier starter can add the same two lines at the top of `[...path].astro`). A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500; so does a multi-site Worker whose hostname map cannot be read (health `503 { ok: false }`, pages the English `unavailable` page). The `error` page uses the site's language and name when the request has already read the site, taken from the published snapshot (an unpublished rename never shows). An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`).
2 changes: 1 addition & 1 deletion docs/adr/0012-cloudflare-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Sprint 6 puts a Cloudhal site on Cloudflare. Publishing and rollback must be ins
- *Site Worker* — the project's Astro build with `@astrojs/cloudflare` (`CLOUDHAL_TARGET=cloudflare`). Every project route renders on demand (`astro:route:setup` sets `prerender = false`). `@cloudhal/astro-renderer/runtime` has a `workerd` export condition that reads D1/R2 bindings through `SiteResolver`: the site's `publishedSnapshotId` decides what renders, so **publish and rollback are pointer flips** — no rebuild, no redeploy. Snapshot sources are immutable and cached per isolate; the pointer is read on every cache miss.
- *Admin Worker* (`@cloudhal/cloudflare/admin`) — Studio SPA as static assets plus the Hono API, Better Auth (D1 binding, D1-backed rate limiting, `cf-connecting-ip`), `/mcp` and the push/pull endpoints, on one origin. `run_worker_first: ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"]` because SPA navigation requests otherwise never reach the Worker.
2. **Workers Cache, not `caches.default`.** Published pages carry `Cloudflare-CDN-Cache-Control: public, max-age=31536000`, `Cache-Control: public, max-age=0, must-revalidate` and `Cache-Tag: cloudhal-site-<siteId>`. A hit never runs the Worker. Publish/rollback purge the tag: purges are scoped to the Worker that cached, so the admin Worker calls the site Worker's authenticated `POST /__cloudhal/purge` over the `SITE` service binding (`SiteGraphStore.onPublished`). `caches.default` was rejected: no effect on workers.dev, per-colo only.
3. **Previews never touch the cache.** The cache key ignores the hostname and cookies, so previews are signed URL tokens (`?cloudhal-preview=`, HMAC, site-bound, expiring) answered with `no-store`; the entry rewrites internal links with HTMLRewriter to keep the token while navigating. Invalid tokens fall back to the published page, uncached.
3. **Previews never touch the cache.** The cache key ignores the hostname and cookies, so previews are signed URL tokens (`?cloudhal-preview=`, HMAC, site-bound, expiring) answered with `no-store`; the entry rewrites internal links with HTMLRewriter to keep the token while navigating. An invalid token (expired, forged, for another site, malformed, or previews off) never falls back to the published page: pages answer the 410 `preview-expired` status page and feeds, sitemaps and JSON paths a 410 plain answer (RFC-0043 §1.2, decision 12); media keep serving the published file, uncached. No answer says why the token failed.
4. **The deployment is the source of truth after the first deploy.** `cloudhal deploy` provisions D1/R2 (ids recorded in the wrangler configs), generates secrets once (`.cloudhal/deploy/secrets.json`, 0600), deploys site then admin with `wrangler deploy --secrets-file`, and pushes the local site only if the deployment does not have it. `cloudhal push` refuses to overwrite diverged content (agents edit production); `cloudhal pull` brings it home. Transfers are JSON site bundles verified by content hash, through `/api/admin/sites/:id/bundle` behind a deployment token — no Cloudflare API token handling in Cloudhal; wrangler uses the user's own login.
5. **Migrations run from the admin Worker** (our `_migrations` ledger, once per isolate), not `wrangler d1 migrations apply` — one ledger only.
6. **Testing:** both Workers run on workerd through wrangler's `createTestHarness` with local D1/R2 (`packages/cli/test/cloudflare*.test.ts`). `@cloudflare/vitest-pool-workers` is not used (needs Vitest 4). **Correction to ADR-0008:** Miniflare 5 alpha *does* expose `getD1Database`/`getR2Bucket`; its constructor options changed shape. We pin the last stable **Miniflare 4.20260730.0**, which needs no code change.
Expand Down
8 changes: 7 additions & 1 deletion docs/adr/0034-translation-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- XLIFF 2.1: https://docs.oasis-open.org/xliff/xliff-core/v2.1/xliff-core-v2.1.html

## Context
Studio's catalogs are TypeScript modules: `apps/studio/src/i18n/<locale>.ts` and `lazy/<ns>.<locale>.ts`.
Studio's catalogs are TypeScript modules: `apps/studio/src/i18n/<locale>.ts` and `lazy/<ns>.<locale>.ts`. Since RFC-0043 §1.5 the renderer's status-page catalog (`packages/astro-renderer/src/status-strings/<locale>.ts`) goes through the same exchange (see the amendment below).
- English is the source and the type. The other languages are typed against it, and since ADR-0032 they are ICU MessageFormat 1.
- Checks guard keys, argument kinds, plural categories, declared forms and drops, unused keys, and English sentences left untranslated (`test/i18n*.test.ts`).
- German was added as an AI draft (`reviewed: false`). Turkish is maintained by the owner.
Expand Down Expand Up @@ -84,6 +84,12 @@ The owner delegated the open questions; decided in the session that coordinates
- **Until then:** an AI second-pass consistency review (glossary, register, terms) may correct the drafts through the `translate` skill and `pnpm i18n:import`. It never sets `reviewed: true`.
- **Visitor-facing strings** (themes, notices) stay out of scope for now. The catalogs remain Studio-only.

### Amendment (2026-11-08, RFC-0043 §1.5)
- One visitor-facing catalog joins the exchange: the site status pages (`packages/astro-renderer/src/status-strings/`). `pnpm i18n:export` writes it as `.i18n/<locale>/status.json` beside Studio's files, `pnpm i18n:import` writes `status-strings/<locale>.ts` and the index `catalog.ts` with the same hash guard, and only `en.ts` is edited by hand.
- Its languages are Studio's `LOCALE_REGISTRY`, with the same review state (AI drafts stay `reviewed: false`). It is one more Weblate component (`docs/guides/translations-weblate.md`).
- Its messages take simple `{name}` arguments only, so the site Worker needs no message compiler.
- Theme strings and notices stay out of scope.

## Open questions
None left from this ADR; the questions below were answered above.
- *(answered)* Hosting: Hosted Weblate, Libre plan.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Keyboard parity with the dashboard: `⌘K` / `/` open the palette; `t s` toggles
- **Languages.** `i18n/locales.ts` is the registry: one entry per language, keyed by its BCP 47 tag (also `<html lang>`, every `Intl` formatter and `calendarLocaleProps`, so dates and numbers follow the Studio language, never the browser's), with its native `name`, `dir` (set on `<html dir>`), `fallback` chain (always ending in English) `reviewed` and, where formatting needs it, an `intl` tag (Arabic and Persian `…-u-nu-latn`, Western digits, ADR-0033; `setIntlTags` makes every formatter of `@cloudhal/messages` use it). Besides English (the source) and Turkish (the owner's), every language is an AI translation awaiting review by a person who speaks it (`reviewed: false`, ADR-0034; the first right-to-left one is Arabic). The language menus show each name in itself only; that a language is an unreviewed AI translation is a comment on its registry entry, for maintainers. Loaders are not written by hand: `import.meta.glob` finds `i18n/<locale>.ts` (`export const messages: Messages`) and `i18n/lazy/<ns>.<locale>.ts`; `bundle-budget.ts` keeps every core catalog but English out of the first load. Studio opens in English, its global default, for everyone: the browser's language is not consulted; a person's own choice in the language menu is stored and wins (`matchLocale` remains for matching a tag to a registered language).
- **Pseudo-locale and catalog checks** (the i18n review's Phase 5). On a dev server (and in tests) `?pseudo` or `localStorage["cloudhal.studio.pseudo"] = "1"` shows every catalog message as "[Šààṽéé]" — accented, about a third longer, bracketed — generated from the compiled English messages (`@cloudhal/messages/pseudo`) with every argument and plural kept: plain English on screen is a hard-coded word, a missing "]" is a cut-off label, a broken layout has no room for German. It is never a language in the menus, and production builds do not contain it (`import.meta.env.DEV`; the bundle budget fails if the module reaches a chunk). `SHOTS_PSEUDO=1 pnpm design:shots -- …` takes the screenshots in it. `test/i18n-messages.test.ts` also fails on an **unused key**: `apps/studio/scripts/i18n-usage.ts` collects every literal and template key in Studio's code (string constants resolved, a literal naming a group reaches the group); `node apps/studio/scripts/i18n-usage.ts` lists them. Missing keys and placeholder parity were already checked (types, `test/i18n.test.tsx`).
- **Right-to-left readiness** (ADR-0033 D and B). Studio and the `@cloudhal/ui` blocks use logical utilities only — `ms`/`me`, `ps`/`pe`, `start`/`end`, `text-start`/`text-end`, `border-s`/`border-e`, `rounded-s`/`rounded-e`/`rounded-ss`… — never `ml`/`mr`, `pl`/`pr`, `left`/`right`, `text-left`/`text-right`, `border-l`/`border-r`, `rounded-l`/`rounded-r` (lint `rtl-logical`; `scripts/i18n-codemod/logical-utilities.ts` converts; an element placed by Floating UI in physical coordinates keeps `left-0` with an `i18n-ignore` reason). `<html dir>` and Base UI's `DirectionProvider` (in `KumoLocale`, through `@cloudhal/ui`) follow the locale registry's `dir`. `SHOTS_PSEUDO=rtl pnpm design:shots -- …` (or `?pseudo=rtl` on a dev server) renders the right-to-left pseudo-locale to find what does not mirror. Kumo's own components are made logical by the generated patch (`node scripts/kumo-patch.ts`, ADR-0033 A1; `kumo-upstream-notes.md` §7); Icons that mean a direction in reading order — back and forward, previous and next, "from → to", a closed tree row, opening a row, reply, indent, opening elsewhere, signing in and out, sending — go through `DirectionalIcon` (`<DirectionalIcon icon={ArrowRight} />`, or `directional(ArrowRight)` where a prop or table takes an icon component), which sets Phosphor's `mirrored` from Base UI's direction (ADR-0033 C1, lint `rtl-icon`). Physical and temporal directions stay as they are: up and down, sort order, clocks, refresh and undo circles, a chart's trend; such an icon among the matched names carries an `i18n-ignore` reason. Kumo's own ten directional icons (submenu, sidebar, pagination, date range, command palette) and the breadcrumb separator mirror through `rtl:-scale-x-100` from the hand-written `patches/kumo/rtl-icons.patch`, which reads `<html dir>` rather than Base UI's context (`kumo-upstream-notes.md` §7). Arrow keys follow the direction too: blocks whose Left and Right mean previous and next, open and close, or nest and move out (`MenuTree`, `CalendarMonth`) read the key through `readingKey(key, useDirection())`, and their help text names the keys as they work in the language (`menus.tree.instructionsRtl`).
- **Translation workflow** (ADR-0034). **Only English is edited by hand** (`en.ts`, `lazy/<ns>.en.ts`). Every other catalog is generated by `pnpm i18n:import` from translation files — nested ICU JSON, `.i18n/<locale>/<core|namespace>.json`, written by `pnpm i18n:export` with `.i18n/descriptions.json` (English text and the note above each key) — and carries a hash of its messages: a hand edit fails `pnpm check` (`node scripts/i18n-exchange.ts check`, run by `test/i18n-messages.test.ts`). The import checks keys and ICU syntax before writing anything, keeps a catalog's own exports after its messages (Turkish `dropped`), formats the files and runs the catalog tests. New English keys: export, translate the new keys in each language's JSON (by hand or with the `translate` skill, `.agents/skills/translate`), import. The translation platform (Weblate or Tolgee) and its hosting wait for the owner; the exchange format is the one it will read.
- **Translation workflow** (ADR-0034). **Only English is edited by hand** (`en.ts`, `lazy/<ns>.en.ts`, and the renderer's status-page catalog `packages/astro-renderer/src/status-strings/en.ts`, RFC-0043 §1.5, which visitors read and which goes through the same exchange as `status`). Every other catalog is generated by `pnpm i18n:import` from translation files — nested ICU JSON, `.i18n/<locale>/<core|namespace|status>.json`, written by `pnpm i18n:export` with `.i18n/descriptions.json` (English text and the note above each key) — and carries a hash of its messages: a hand edit fails `pnpm check` (`node scripts/i18n-exchange.ts check`, run by `test/i18n-messages.test.ts`). The import checks keys and ICU syntax before writing anything, keeps a catalog's own exports after its messages (Turkish `dropped`), formats the files and runs the catalog tests. New English keys: export, translate the new keys in each language's JSON (by hand or with the `translate` skill, `.agents/skills/translate`), import. The translation platform (Weblate or Tolgee) and its hosting wait for the owner; the exchange format is the one it will read.
- **Adding a language** (the i18n review's Phase 6): (1) **prerequisites** — a language with plural categories beyond one/other (ar, ru, uk, pl, cs, …: `Intl.PluralRules(…).resolvedOptions().pluralCategories`) waits for ICU plurals (the review's Phase 1), a right-to-left one (ar, he, fa, ur) for logical CSS and Kumo's direction support (Phase 3); (2) add the registry entry with `reviewed: false`; (3) `pnpm i18n:export`, write `.i18n/<locale>/*.json` (the `translate` skill drafts them; an AI translation is fine as a first draft; its registry entry says so in a comment until reviewed), `pnpm i18n:import --locale <locale>` — it generates `i18n/<locale>.ts` and every `lazy/<ns>.<locale>.ts`; (4) `pnpm --filter @cloudhal/studio typecheck` and `test/i18n.test.tsx` (keys, placeholders, no English sentences left, every namespace present); (5) `vite build` — the bundle budget must not move (the language loads only for its users); (6) `pnpm design:shots` in the language at 1440 and 390 px (German and Finnish run long); (7) a speaker reviews in context, then `reviewed: true`.

## 2. Page templates
Expand Down
Loading
Loading