@pithy-sh/i18n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // LOCALE es — an unreviewed first pass. Not American English by design.
5
+
6
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
7
+
8
+ /**
9
+ * Every kit error code, in Spanish. **Keyed by the code itself** — for an error the catalog key *is*
10
+ * the code, so there is no second identifier to keep in sync and `KitErrorCode` is the exhaustive
11
+ * checklist this file has to cover.
12
+ *
13
+ * There is no English twin. A payload already carries its English `message` on the wire, and a
14
+ * translating client renders `t.maybe(payload.code, payload.params) ?? payload.message` — so English is the
15
+ * fallback by construction and a duplicate of it here would be a second place for one sentence to
16
+ * drift.
17
+ *
18
+ * **These are the caller's words, not the operator's.** `message` is the only field on the wire;
19
+ * `action` names `pithy` commands and repository files and never leaves the terminal, so nothing here
20
+ * translates one. Placeholders match the `params` the throw sites pass.
21
+ *
22
+ * **Not one sentence below carries a placeholder, and that is a finding rather than a shortcut.** No
23
+ * throw site in this repository passes `params` today — `git grep "params: {"` over every package's
24
+ * `src` tree returns Workflow dispatch parameters and nothing else — so a `{name}` written here would
25
+ * have no value to receive and `interpolate` leaves an unsupplied placeholder as written. `Sala {code}
26
+ * llena.` on a caller's screen is worse than `Esa sala ya no admite más jugadores.`, which is why the
27
+ * generic clause wins until the English side has a value to send. `./errors.test.ts` pins the absence,
28
+ * and says there what would replace the pin the day a throw site starts naming one.
29
+ *
30
+ * Peninsular-neutral: one `es` serves es-ES, es-MX and es-AR, so a regionalism that reads as home to
31
+ * one of them and as foreign to the other two is the wrong word even when it is the better word. Where
32
+ * the English distinguishes an internal cause the Spanish states only the part a caller can act on —
33
+ * a caller who cannot read our logs is not helped by learning that a binding was missing.
34
+ */
35
+ export const esErrors: MessageCatalog = {
36
+ // Core: the codes every capability throws, and the ones a caller meets most.
37
+ "validation/invalid_input": "Los datos enviados no son válidos.",
38
+ "auth/invalid_token": "Tu sesión no es válida o ha caducado. Vuelve a iniciar sesión.",
39
+ "auth/forbidden": "No tienes permiso para hacer esto.",
40
+ "auth/provider_unavailable": "Este método de acceso no está disponible ahora. Prueba con otro.",
41
+ "core/not_found": "No existe el recurso solicitado.",
42
+ "core/conflict": "La solicitud entra en conflicto con el estado actual.",
43
+ "rate_limit/exceeded": "Demasiadas solicitudes. Inténtalo más tarde.",
44
+ "core/internal": "Se ha producido un error inesperado.",
45
+ "core/upstream_failed": "Un servicio externo ha fallado. Inténtalo de nuevo.",
46
+ "core/upstream_timeout": "Un servicio externo no ha respondido a tiempo.",
47
+ "core/invalid_workflow_params": "Los parámetros de la operación no son válidos.",
48
+ // The three workflow-dispatch 500s say the same thing to a caller on purpose: the difference between
49
+ // them is which part of the deployment is wrong, and that belongs to `action`, not here.
50
+ "core/missing_workflow_binding": "Esta operación no está disponible ahora.",
51
+ "core/unknown_workflow": "La operación solicitada no existe.",
52
+ "core/workflow_failed": "La operación no se ha completado.",
53
+ "core/webhook_unverified": "La firma de la notificación no es válida.",
54
+
55
+ // Cloudflare REST. Naming the provider is safe — it is the adopter's own account, not our infrastructure.
56
+ "cloudflare/not_configured": "Este servicio no está disponible ahora.",
57
+ "cloudflare/request_failed": "No se ha podido completar la llamada a Cloudflare.",
58
+ "cloudflare/invalid_response": "La respuesta de Cloudflare no tiene el formato esperado.",
59
+
60
+ // Secrets.
61
+ "secrets/not_found": "El secreto solicitado no existe.",
62
+ "secrets/already_exists": "Ya existe un secreto con ese nombre.",
63
+ "secrets/invalid_value": "El valor del secreto no es válido.",
64
+ "secrets/crypto_failed": "No se ha podido procesar el secreto.",
65
+ "secrets/rotation_unrecorded": "No se ha registrado la rotación del secreto.",
66
+ "secrets/rotation_unsupported": "Este secreto no se puede rotar por esta vía.",
67
+
68
+ // Email.
69
+ "email/template_not_found": "Esa plantilla de correo no existe.",
70
+ "email/invalid_payload": "Los datos de la plantilla no son válidos.",
71
+ "email/invalid_token": "El enlace del correo no es válido o ha caducado.",
72
+ "email/suppressed": "Ese destinatario ha dejado de recibir correos.",
73
+ "email/rate_limited": "Se ha alcanzado el límite de envío. Inténtalo más tarde.",
74
+ "email/send_failed": "No se ha podido enviar el correo.",
75
+
76
+ // Turnstile. A caller never learns whether the check failed or the deployment misconfigured it.
77
+ "turnstile/missing_token": "Falta la verificación de seguridad.",
78
+ "turnstile/failed": "No se ha superado la verificación de seguridad. Inténtalo de nuevo.",
79
+ "turnstile/config": "La verificación de seguridad no está disponible ahora.",
80
+
81
+ // Audit.
82
+ "audit/invalid_event": "El evento de auditoría no es válido.",
83
+ "audit/write_failed": "No se ha podido registrar el evento de auditoría.",
84
+
85
+ // Media.
86
+ "media/not_found": "Ese archivo no existe.",
87
+ "media/unsupported": "Ese tipo de archivo no es compatible con esta operación.",
88
+ "media/storage_failed": "No se ha podido guardar el archivo.",
89
+ "media/enrichment_failed": "No se ha podido analizar el archivo.",
90
+
91
+ // Leaderboards.
92
+ "leaderboard/board_not_found": "Esa clasificación no existe.",
93
+ "leaderboard/entry_not_found": "No hay ninguna puntuación en esta clasificación.",
94
+ "leaderboard/score_rejected": "La puntuación está fuera del rango permitido.",
95
+ "leaderboard/submit_forbidden": "No puedes enviar puntuaciones a esta clasificación.",
96
+ "leaderboard/board_immutable": "Esta clasificación ya no se puede modificar.",
97
+ "leaderboard/invalid_schedule": "Esta clasificación no está disponible ahora.",
98
+
99
+ // Multiplayer.
100
+ "multiplayer/game_not_found": "Ese juego no existe.",
101
+ "multiplayer/session_not_found": "Esa partida no existe.",
102
+ "multiplayer/not_a_member": "No participas en esta partida.",
103
+ "multiplayer/session_full": "La partida está completa.",
104
+ "multiplayer/invalid_transition": "Esta acción no es posible en la fase actual de la partida.",
105
+ "multiplayer/invalid_move": "Ese movimiento no es válido.",
106
+
107
+ // Ledger.
108
+ "ledger/currency_not_found": "Esa moneda no existe.",
109
+ "ledger/account_not_found": "No tienes cuenta en esa moneda.",
110
+ "ledger/hold_not_found": "Esa reserva de saldo no existe.",
111
+ "ledger/insufficient_funds": "El saldo no es suficiente.",
112
+ "ledger/hold_not_open": "Esa reserva de saldo ya está resuelta.",
113
+ "ledger/invalid_amount": "El importe debe ser un número entero positivo.",
114
+
115
+ // Rating.
116
+ "rating/unknown_algorithm": "Ese algoritmo de valoración no existe.",
117
+ "rating/unsupported_player_count": "Ese número de jugadores no es compatible con esta valoración.",
118
+ "rating/invalid_params": "Los parámetros de valoración no son válidos.",
119
+ "rating/game_not_found": "Ese juego no tiene valoración.",
120
+ "rating/pool_not_found": "Ese grupo de valoración no existe.",
121
+ "rating/record_forbidden": "No puedes registrar resultados valorados.",
122
+
123
+ // Matchmaking.
124
+ "matchmaking/room_not_found": "Ese código no corresponde a ninguna sala abierta.",
125
+ "matchmaking/room_full": "Esa sala ya no admite más jugadores.",
126
+ "matchmaking/invalid_code": "Ese código de sala no es válido.",
127
+ "matchmaking/invite_not_found": "Esa invitación no existe.",
128
+ "matchmaking/invite_forbidden": "No puedes actuar sobre esta invitación.",
129
+ "matchmaking/user_not_found": "No se ha encontrado a esa persona.",
130
+ "matchmaking/already_friends": "Ya hay una amistad o una solicitud con esa persona.",
131
+ "matchmaking/friend_request_not_found": "No hay ninguna solicitud de amistad pendiente.",
132
+ "matchmaking/not_queued": "No estás en la cola.",
133
+
134
+ // Storage. The 404 keeps the English hedge, because telling a stranger that a file exists is the leak
135
+ // the code was written to avoid, and a Spanish sentence that resolves the ambiguity undoes it.
136
+ "storage/not_found": "Ese archivo no existe o no tienes acceso a él.",
137
+ "storage/forbidden": "No tienes acceso a este archivo.",
138
+ "storage/quota_exceeded": "La subida supera tu espacio disponible.",
139
+ "storage/upload_incomplete": "La subida de este archivo no ha terminado.",
140
+ "storage/multipart_failed": "No se ha podido completar la subida.",
141
+ "storage/share_expired": "Este enlace compartido ha caducado.",
142
+ "storage/share_revoked": "Este enlace compartido se ha revocado.",
143
+
144
+ // Vector search.
145
+ "vector/metadata_index_drift": "Esta búsqueda no está disponible ahora.",
146
+ "vector/dimension_mismatch": "El vector no tiene las dimensiones del índice.",
147
+ "vector/topk_exceeded": "Has pedido más resultados de los permitidos en esta consulta.",
148
+ "vector/filter_too_large": "El filtro es demasiado grande.",
149
+ "vector/metadata_too_large": "Los metadatos del vector superan el tamaño permitido.",
150
+ "vector/index_not_found": "Ese índice no existe.",
151
+ "vector/unfilterable_field": "Por ese campo no se puede filtrar.",
152
+
153
+ // Payments.
154
+ "payments/invalid_receipt": "Ese recibo no tiene un formato válido.",
155
+ "payments/verification_failed": "La tienda ha rechazado el recibo.",
156
+ "payments/webhook_unverified": "La firma de la notificación de pago no es válida.",
157
+ "payments/rail_not_configured": "Ese método de pago no está disponible.",
158
+ "payments/product_not_found": "Ese producto no está en el catálogo.",
159
+ "payments/environment_mismatch": "Esa compra pertenece a otro entorno de la tienda.",
160
+ "payments/subscription_change_refused": "El estado de esa suscripción no permite el cambio solicitado.",
161
+ "payments/receipt_already_owned": "Esa compra ya pertenece a otra cuenta.",
162
+ "payments/provider_unavailable": "No se ha podido contactar con la tienda. Inténtalo más tarde.",
163
+ "payments/entitlement_required": "Necesitas una compra activa para esto.",
164
+ "payments/clawback_failed": "No se ha podido recuperar el importe del reembolso.",
165
+ "payments/discount_invalid": "Ese código de descuento no es válido.",
166
+ "payments/entitlement_not_in_catalog": "Ese derecho de acceso no está en el catálogo.",
167
+ "payments/subject_unresolved": "No se ha podido determinar en nombre de quién actúas.",
168
+ "payments/reconcile_not_provisioned": "La conciliación no está configurada en este despliegue.",
169
+
170
+ // Control plane. The caller here is a management client, never an end user — the words stay caller-facing
171
+ // all the same, because `clientError` strips `action` on this path exactly as it does on every other.
172
+ "controlplane/not_connected": "No hay conexión de administración para este entorno.",
173
+ "controlplane/invalid_credential": "La credencial de administración no es válida.",
174
+ "controlplane/insufficient_scope": "La credencial de administración no tiene permiso para esta operación.",
175
+ "controlplane/key_not_found": "Esa clave no existe.",
176
+ "controlplane/key_conflict": "Esa operación entra en conflicto con el estado de la conexión.",
177
+
178
+ // Support.
179
+ "support/not_found": "Esa conversación de soporte no existe.",
180
+ "support/invalid_category": "Esa categoría de soporte no es válida.",
181
+ "support/unparseable_message": "No se ha podido leer el mensaje.",
182
+ "support/rejected": "Has enviado demasiados mensajes. Inténtalo más tarde.",
183
+ "support/classification_failed": "No se ha podido clasificar el mensaje.",
184
+ "support/reply_failed": "No se ha podido enviar la respuesta.",
185
+
186
+ // Testers.
187
+ "testers/cohort_not_found": "Ese grupo de pruebas no existe.",
188
+ "testers/member_not_found": "No hay ningún participante con esos datos.",
189
+ "testers/invalid_token": "Ese enlace de invitación no es válido o ha caducado.",
190
+ "testers/roster_full": "El grupo de pruebas está completo.",
191
+ "testers/already_on_roster": "Esa dirección ya está en el grupo de pruebas.",
192
+ "testers/cohort_closed": "Ese grupo de pruebas está cerrado.",
193
+ "testers/withdrawn": "Esa dirección se ha dado de baja de este grupo de pruebas.",
194
+ "testers/nudge_cooldown": "Se ha avisado a estas personas hace poco. Espera para volver a hacerlo.",
195
+ "testers/copy_not_allowed": "No se admite un mensaje propio en los avisos.",
196
+ "testers/not_configured": "Las pruebas no están disponibles ahora.",
197
+ };
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // LOCALE es — an unreviewed first pass. Not American English by design.
5
+
6
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
7
+ import { esErrors } from "./errors";
8
+ import { esScreens } from "./screens";
9
+
10
+ /**
11
+ * The kit's Spanish, in one catalog.
12
+ *
13
+ * Two files rather than one because they answer to different gates: the error keys are pinned to
14
+ * `KitErrorCode` and the screen keys to what the templates render. Split, a failure names which of
15
+ * the two drifted.
16
+ *
17
+ * **The email copy is not here, and that is deliberate (#442).** `@pithy-sh/email` carries its own
18
+ * `email/` translations beside its English, because the send Worker has to be *built* with them —
19
+ * anything it does not bundle is stamped into it as configuration, every provision run, against a
20
+ * 5 KB per-variable ceiling. A capability owning its own domain in every language is also what the
21
+ * domain rule already says; this package holds what no capability can, which is the error taxonomy
22
+ * and the copied screens.
23
+ *
24
+ * **This copy is a marked first pass and no native-speaker review blocks it.** Every file in this
25
+ * directory says so in its head, in the marker `docs/I18N.md` publishes. The brand voice is
26
+ * load-bearing in English and a literal translation does not carry it; saying so in the file is
27
+ * honest, and holding the machinery hostage to a reviewer who does not yet exist is not. Corrections
28
+ * arrive by contribution.
29
+ */
30
+ export const es: MessageCatalog = { ...esErrors, ...esScreens };
@@ -0,0 +1,149 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // LOCALE es — an unreviewed first pass. Not American English by design.
5
+
6
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
7
+
8
+ /**
9
+ * The kit's screens, in Spanish — every string `@pithy-sh/ui-react` renders.
10
+ *
11
+ * The English twin of this catalog is not here and is not in this package: it is baked into each
12
+ * template, because a template is **copied** into the adopter's repository and its own English is the
13
+ * only catalog that survives the copy. That is what makes a project which never composes `i18n` render
14
+ * byte for byte as it did before, with no dependency on this package at all.
15
+ *
16
+ * **The keys are the join, and they are the templates'.** A key here that no template renders is a
17
+ * sentence nobody will ever read; a template key missing here is a Spanish reader meeting English. Both
18
+ * are invisible to `tsc` — a `MessageCatalog` is `Record<string, string>` and every typo satisfies it —
19
+ * so the way to check a change is to read the template beside it.
20
+ *
21
+ * **Two invariants are per-locale and survive translation, so they are checked here rather than only in
22
+ * English.** `auth/sign_in.provider.label` is the provider buttons' accessible name and must **contain**
23
+ * the visible word, which is the brand name and is never translated — voice control matches what a
24
+ * reader can see (WCAG 2.5.3, Label in Name). And nothing a sign-in button says may name a code: the
25
+ * screen offers one way in, the link, and `código` on that screen is a second passwordless path being
26
+ * advertised by a translation. The word appears here only under `auth/otp.*`, which is a different
27
+ * screen and the one place it is right.
28
+ *
29
+ * **Neutral where English is neutral.** English `You're subscribed.` carries no gender and
30
+ * `Estás suscrito.` does, so the Spanish says what the subscription is doing rather than what the
31
+ * reader is. The same argument moves `Welcome.` off `Bienvenido.`.
32
+ */
33
+ export const esScreens: MessageCatalog = {
34
+ // ── auth: the sign-in screen ───────────────────────────────────────────────
35
+ "auth/sign_in.title": "Te damos la bienvenida.",
36
+ "auth/sign_in.provider.label": "Continuar con {provider}",
37
+ "auth/sign_in.provider_unconfigured": "{provider} no está configurado aquí. Usa el enlace.",
38
+ "auth/sign_in.provider_silent": "{provider} no respondió. Usa el enlace.",
39
+ "auth/sign_in.divider": "o",
40
+ "auth/sign_in.email.label": "Correo electrónico",
41
+ "auth/sign_in.submit": "Envíame un enlace",
42
+ "auth/sign_in.signup.prompt": "¿Aún no tienes cuenta?",
43
+ "auth/sign_in.signup.answer": "Al entrar se crea una.",
44
+ "auth/sign_in.signup.closed": "Solo cuentas existentes.",
45
+ "auth/sign_in.sent.title": "Mira tu correo.",
46
+ "auth/sign_in.sent.body": "Si esa dirección puede entrar, el enlace ya va en camino. Caduca pronto.",
47
+
48
+ // ── auth: the one-time code screen ─────────────────────────────────────────
49
+ "auth/otp.title": "Introduce el código.",
50
+ "auth/otp.sent.one": "Hemos enviado {count} dígito a {email}.",
51
+ "auth/otp.sent.other": "Hemos enviado {count} dígitos a {email}.",
52
+ "auth/otp.inbox": "tu correo",
53
+ "auth/otp.failed": "Ese código no funcionó. Inténtalo otra vez o pide uno nuevo.",
54
+ "auth/otp.submit": "Entrar",
55
+ "auth/otp.resend": "Enviar un código nuevo",
56
+
57
+ // ── auth: the magic link's landing screen ──────────────────────────────────
58
+ "auth/callback.title": "Entrando.",
59
+ "auth/callback.body": "Un momento.",
60
+
61
+ // ── payments: the paywall ──────────────────────────────────────────────────
62
+ "payments/paywall.title": "Ve más lejos.",
63
+ "payments/paywall.body": "Elige lo que necesitas. Puedes cambiar de idea más tarde.",
64
+ "payments/paywall.buy": "Comprar {product}",
65
+ "payments/paywall.in_app": "Disponible en la app.",
66
+ "payments/paywall.holdings": "¿Qué tengo ya?",
67
+ "payments/paywall.empty.title": "Nada a la venta.",
68
+ // The sentence continues into a `<code>pithy.config.ts</code>` the screen renders after it. A file
69
+ // name is not copy, so it is not a placeholder here and no translation may move it.
70
+ "payments/paywall.empty.body": "Este proyecto aún no tiene catálogo. Añade productos en",
71
+ "payments/paywall.done.title": "Todo listo.",
72
+ "payments/paywall.done.body": "Gracias. Tu compra ya está en tu cuenta.",
73
+ "payments/paywall.done.home": "Ir al inicio",
74
+
75
+ // ── payments: the pricing screen ───────────────────────────────────────────
76
+ "payments/pricing.title": "Lo que cuesta.",
77
+ "payments/pricing.body": "Los precios son para donde estás. Los impuestos los calcula Paddle, no nosotros.",
78
+ "payments/pricing.anonymous": "Cualquiera puede ver un precio. Comprar necesita cuenta.",
79
+ "payments/pricing.loading": "Buscando tu precio.",
80
+ "payments/pricing.estimated": "Estimado.",
81
+ "payments/pricing.unavailable": "No pudimos obtener un precio. Lo verás al pagar.",
82
+ "payments/pricing.sign_in": "Entra para comprar {product}",
83
+ "payments/pricing.buy": "Comprar {product}",
84
+ "payments/pricing.holdings": "¿Qué tengo ya?",
85
+ // `{interval}` is Paddle's own word for the billing period and arrives from their API in English, so
86
+ // it is left where it lands rather than half-translated into a sentence that then disagrees with the
87
+ // figure beside it. An adopter who wants `al mes` says so in `i18n({ messages })`.
88
+ "payments/pricing.every.one": "al {interval}",
89
+ "payments/pricing.every.other": "cada {count} {interval}s",
90
+ "payments/pricing.empty.title": "Aquí no hay precios.",
91
+ // Two halves of one sentence, with `<code>paddle</code>` between them and `<code>pithy.config.ts</code>`
92
+ // after. Both code words are identifiers and neither is translated.
93
+ "payments/pricing.empty.body": "Esta pantalla pone precio a lo que vendes por Paddle. Añade un bloque",
94
+ "payments/pricing.empty.body_end": "a un producto en",
95
+
96
+ // ── payments: what the reader already holds ────────────────────────────────
97
+ "payments/subscription.subscribed": "Tu suscripción está activa.",
98
+ "payments/subscription.empty": "Aún nada.",
99
+ "payments/subscription.unreadable": "No pudimos comprobarlo.",
100
+ "payments/subscription.nothing_held": "No tienes nada en esta cuenta.",
101
+ "payments/subscription.loading": "Un momento.",
102
+ "payments/subscription.holding.ended": "Finalizado.",
103
+ "payments/subscription.holding.ended_not_renewing": "Finalizado, y no se renueva.",
104
+ "payments/subscription.holding.kept": "Tuyo para siempre.",
105
+ // The date itself is `Intl`'s, rendered from the reader's formatting locale. This is the sentence
106
+ // around it, which is the only part a catalog can own.
107
+ "payments/subscription.holding.renews": "Se renueva el {date}.",
108
+ "payments/subscription.manage": "Gestionar la facturación",
109
+ "payments/subscription.apple": "Comprado en la App Store",
110
+ "payments/subscription.google": "Comprado en Google Play",
111
+ "payments/subscription.more": "Ver qué más hay",
112
+
113
+ // ── app: the adopter's own shell and home screen ───────────────────────────
114
+ //
115
+ // Under `app/` rather than a capability name because that is what these are: the router and the home
116
+ // screen are the adopter's files, seeded once and never rewritten. Translating them here is what
117
+ // makes a freshly scaffolded project speak Spanish on its first screen rather than its second.
118
+ "app/loading": "Un momento.",
119
+ "app/not_found.title": "Aquí no hay nada.",
120
+ "app/not_found.body": "Nada responde en {path}.",
121
+ "app/not_found.home": "Ir al inicio",
122
+ "app/home.title": "Ya estás dentro.",
123
+ "app/home.signed_in_as": "Has entrado como {email}.",
124
+ "app/home.someone": "alguien",
125
+ "app/home.sign_out": "Salir",
126
+ "app/home.bare.title": "Funciona.",
127
+ "app/home.bare.body": "El worker dice: {status}.",
128
+ "app/home.bare.checking": "comprobando",
129
+ "app/home.bare.unknown": "desconocido",
130
+ "app/home.bare.unreachable": "inaccesible",
131
+
132
+ // ── client: the failures a browser mints for itself, never sent by a Worker ─
133
+ //
134
+ // `client/*` is the one domain with no capability behind it, and that is deliberate: these are the
135
+ // codes an SDK writes when the request never reached a Worker at all — offline, a proxy's HTML page,
136
+ // no browser to redirect. They are also the failures a reader meets most, which is why leaving them
137
+ // untranslated made the whole feature read as half-finished: a Spanish sign-in page answering an
138
+ // offline phone in English is the first thing anybody would notice.
139
+ //
140
+ // **`@pithy-sh/auth` and `@pithy-sh/payments` mint the same two codes with different English** — "the
141
+ // server" against "the store" — and one key holds one string. The Spanish is written to be true of
142
+ // both rather than picking a side; the noun each English sentence names is not information the
143
+ // reader needed, and inventing `auth/client_unreachable` to keep it would put the same failure under
144
+ // two codes for the sake of one word.
145
+ "client/unreachable": "No hemos podido conectar.",
146
+ "client/unreadable": "La respuesta no se ha podido leer.",
147
+ "client/cross_origin": "Esa petición habría salido de este sitio, así que no se ha enviado.",
148
+ "client/no_browser": "No hay ningún navegador al que enviarte para pagar.",
149
+ };
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
5
+ import { es } from "./es/index";
6
+
7
+ /**
8
+ * The kit's own translations, keyed by locale — every locale but the one the kit is written in.
9
+ *
10
+ * **There is no `en` here, and that is not an omission.** English is the source: an error carries its
11
+ * English `message` on the wire already, a kit capability contributes its English through
12
+ * `Capability.messages`, and a copied screen carries the English it was scaffolded with. A second copy
13
+ * of it in this map would be a second place for the same sentence to drift.
14
+ *
15
+ * **These ship in the package and are never copied into an adopter's repository.** If the Spanish for
16
+ * `auth/sign_in.title` lived in their tree, a typo fix or a new locale could never reach them, and
17
+ * every adopter would become a fork on the day they scaffolded. An adopter overrides a sentence by
18
+ * passing one entry to `i18n({ messages })`; passing a whole locale object **is** the fork, which is
19
+ * why no eject command is needed for this and none is offered.
20
+ *
21
+ * Server-side this map is imported statically — it is text, it is small, and a Worker has no second
22
+ * round trip to spend. The browser reaches the same catalogs by dynamic import, one chunk per locale;
23
+ * see `./browser`.
24
+ */
25
+ export const KIT_CATALOGS: LocaleCatalogs = { es };
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What a browser may know about this project's languages — the shape of `virtual:pithy/i18n`.
6
+ *
7
+ * **Locale metadata only. Never catalogs.** `renderVirtualModule` emits the projection as an inline
8
+ * `JSON.stringify` literal in the main chunk, so a catalog carried here would be downloaded by every
9
+ * reader in every language before the first paint, and would defeat the per-locale code splitting the
10
+ * design is built on. Catalogs reach the browser by dynamic import, one Vite chunk per locale.
11
+ *
12
+ * **This declaration is the contract, and the projection is checked against it.** It is written here
13
+ * rather than inferred from the closure that builds it: an inferred type follows whatever the producer
14
+ * last happened to say, so a projection that dropped a field would take the type with it and nothing
15
+ * would go red. Declared, the arrow is what has to change.
16
+ *
17
+ * **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
18
+ * ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this
19
+ * type by `@pithy-sh/vite`'s `clientEnvDeclaration.ts`, doc comments and all. What is written here is
20
+ * what a screen author reads.
21
+ */
22
+ export type I18nClientProjection =
23
+ | {
24
+ /**
25
+ * The i18n capability is not composed. Every screen renders the English it was scaffolded with,
26
+ * byte for byte as it did before any of this landed — which is what makes the capability optional.
27
+ */
28
+ enabled: false;
29
+ }
30
+ | {
31
+ /** The i18n capability is composed, and these are the languages this project serves. */
32
+ enabled: true;
33
+ /** Every locale this project serves, as BCP-47 tags. The browser negotiates within this set. */
34
+ supportedLocales: string[];
35
+ /** The locale served when nothing in the browser chain answers. Always in `supportedLocales`. */
36
+ defaultLocale: string;
37
+ /** The query parameter an explicit choice arrives on — `?lang=es`. */
38
+ queryParam: string;
39
+ /** The `localStorage` key this device's remembered locale is written under. */
40
+ storageKey: string;
41
+ /**
42
+ * The browser chain, in the order it is asked: `query`, `account`, `storage`, `server`, `default`.
43
+ * Projected so the front end resolves in the order the project configured, not one it assumed.
44
+ */
45
+ browserResolvers: string[];
46
+ /**
47
+ * Language ranges the matcher cannot derive, as range → supported locale. Usually empty; it
48
+ * carries the historical pairs (`nb` meaning `no`) that no truncation of a tag would reach.
49
+ */
50
+ exceptions: Record<string, string>;
51
+ };
@@ -0,0 +1,152 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
5
+ import { Locale } from "@pithy-sh/core/src/i18n/locale";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * One link of the **server** resolver chain — the order a Worker asks "what language is this request in?".
10
+ *
11
+ * Split from the browser chain because `localStorage` does not exist in a Worker and
12
+ * `navigator.language` inside workerd is the constant `"en"`, carrying no request information at all.
13
+ * Two chains over one contract is honest; one chain with half its links inert is not.
14
+ */
15
+ export const ServerResolver = z
16
+ .enum(["param", "user", "cookie", "header", "default"])
17
+ .describe("A link of the server resolver chain: an explicit param, the account, a cookie, the header, the default.");
18
+ export type ServerResolver = z.infer<typeof ServerResolver>;
19
+
20
+ /**
21
+ * One link of the **browser** resolver chain.
22
+ *
23
+ * `account` sits above `storage` deliberately: `pithy_auth_users.locale` is where a person's locale
24
+ * lives, so a signed-in reader's choice must not silently diverge per device. See `docs/I18N.md`.
25
+ */
26
+ export const BrowserResolver = z
27
+ .enum(["query", "account", "storage", "navigator", "server", "default"])
28
+ .describe(
29
+ "A link of the browser resolver chain: `?lang=`, the account, local storage, the browser's own languages, the server, the default.",
30
+ );
31
+ export type BrowserResolver = z.infer<typeof BrowserResolver>;
32
+
33
+ /** The server chain's default order — explicit param, account, cookie, `Accept-Language`, project default. */
34
+ const DEFAULT_SERVER_RESOLVERS: readonly ServerResolver[] = ["param", "user", "cookie", "header", "default"];
35
+
36
+ /** The browser chain's default order — `?lang=`, account, local storage, the server's answer, default. */
37
+ /**
38
+ * The browser chain's default order — `?lang=`, account, this device's memory, the reader's own
39
+ * browser languages, what the server declared, the project default.
40
+ *
41
+ * **`navigator` sits above `server`, and that ordering is the whole reason the link exists.** The
42
+ * `server` link reads `document.documentElement.lang`, and a scaffolded SPA is served from a static
43
+ * `index.html` that says `lang="en"` with no substitution token — so without a browser link, a
44
+ * first-time Spanish visitor to a project shipping `es` resolved to the default and read English on
45
+ * every screen. Automatic negotiation worked on the server and nowhere else.
46
+ *
47
+ * Placing it above `server` costs a genuinely server-rendered app nothing: if that server negotiated,
48
+ * it negotiated from `Accept-Language`, which is the same preference `navigator.languages` reports —
49
+ * and anything it knew that the browser does not, the reader's account, is already ranked higher.
50
+ */
51
+ const DEFAULT_BROWSER_RESOLVERS: readonly BrowserResolver[] = [
52
+ "query",
53
+ "account",
54
+ "storage",
55
+ "navigator",
56
+ "server",
57
+ "default",
58
+ ];
59
+
60
+ /** The locale the kit writes in, and what every unconfigured project falls back to. */
61
+ const DEFAULT_LOCALE = "en";
62
+
63
+ /**
64
+ * The i18n capability's configuration.
65
+ *
66
+ * Everything an adopter decides about language lives here, including their own catalogs — **a config
67
+ * object passed to the capability, never files discovered on disk.** Two reasons, and both are hard
68
+ * constraints rather than preferences. `configEntrypoints.test.ts` imports every capability's config
69
+ * entry point in a plain Node process, so a catalog loader doing `readFileSync` at module scope breaks
70
+ * `pithy upgrade`, `migrate` and `deploy` for every project composing it. And a catalog file copied
71
+ * into an adopter's repository is a fork on the day they scaffold: a typo fix or a new locale could
72
+ * never reach them again.
73
+ */
74
+ export const I18nConfig = z
75
+ .object({
76
+ supportedLocales: z
77
+ .array(Locale)
78
+ .min(1)
79
+ .default([DEFAULT_LOCALE])
80
+ .describe("Every locale this project serves. Ship the least specific tag that is true — `es`, not `es-ES`."),
81
+ defaultLocale: Locale.default(DEFAULT_LOCALE).describe(
82
+ "The locale served when nothing else answers. Must be one of `supportedLocales`.",
83
+ ),
84
+ messages: LocaleCatalogs.default({}).describe(
85
+ "This project's own catalogs. Overriding one kit key is one entry; everything unmentioned keeps flowing from the package.",
86
+ ),
87
+ exceptions: z
88
+ .record(
89
+ z.string().describe("The language range a reader sends that no truncation of it would match."),
90
+ Locale.describe("The supported locale it means."),
91
+ )
92
+ .default({})
93
+ // **Keys lower-cased on the way in, because the lookup lower-cases the range.** `matchLocale`
94
+ // compares against a lower-cased range, so an exception written the way BCP-47 spells it —
95
+ // `nb-NO`, which is exactly how somebody writes the pair this field documents — never matched and
96
+ // failed silently, the reader falling through to the project default. Normalizing here rather
97
+ // than indexing case-insensitively at the lookup keeps one spelling in the resolved config, so
98
+ // what `pithy doctor` prints is what the matcher will use.
99
+ .transform((exceptions) =>
100
+ Object.fromEntries(Object.entries(exceptions).map(([range, locale]) => [range.toLowerCase(), locale])),
101
+ )
102
+ .describe("Language ranges the matcher cannot derive — historical pairs like `nb` meaning `no`."),
103
+ cookie: z
104
+ .string()
105
+ .min(1)
106
+ .default("pithy_locale")
107
+ .describe("The cookie the server chain's `cookie` link reads a chosen locale from."),
108
+ queryParam: z
109
+ .string()
110
+ .min(1)
111
+ .default("lang")
112
+ .describe("The query parameter an explicit choice arrives on, on both the server and the browser."),
113
+ storageKey: z
114
+ .string()
115
+ .min(1)
116
+ .default("pithy.locale")
117
+ .describe("The `localStorage` key the browser chain's `storage` link reads and writes."),
118
+ serverResolvers: z
119
+ .array(ServerResolver)
120
+ .min(1)
121
+ .default([...DEFAULT_SERVER_RESOLVERS])
122
+ .describe("The server chain, in the order it is asked. Reorder or shorten it; `default` is the last resort."),
123
+ browserResolvers: z
124
+ .array(BrowserResolver)
125
+ .min(1)
126
+ .default([...DEFAULT_BROWSER_RESOLVERS])
127
+ .describe("The browser chain, in the order it is asked."),
128
+ })
129
+ .check((ctx) => {
130
+ const { supportedLocales, defaultLocale } = ctx.value;
131
+ if (!supportedLocales.includes(defaultLocale)) {
132
+ ctx.issues.push({
133
+ code: "custom",
134
+ input: ctx.value,
135
+ path: ["defaultLocale"],
136
+ message: `\`${defaultLocale}\` is the default locale and is not in \`supportedLocales\`, so nothing would answer when negotiation fails.`,
137
+ });
138
+ }
139
+ for (const locale of Object.keys(ctx.value.messages)) {
140
+ if (!supportedLocales.includes(locale)) {
141
+ ctx.issues.push({
142
+ code: "custom",
143
+ input: ctx.value,
144
+ path: ["messages", locale],
145
+ message: `\`${locale}\` carries messages and is not in \`supportedLocales\`, so nothing would ever read them.`,
146
+ });
147
+ }
148
+ }
149
+ })
150
+ .describe("How this project negotiates and renders language, and the catalogs it adds of its own.");
151
+ export type I18nConfig = z.output<typeof I18nConfig>;
152
+ export type I18nConfigInput = z.input<typeof I18nConfig>;