@voltro/i18n 0.50.1 → 0.52.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.
- package/CHANGELOG.md +231 -0
- package/dist/cookieNames-CqdYWVpp.js +11 -0
- package/dist/index.d.ts +155 -23
- package/dist/index.js +96 -81
- package/dist/server.d.ts +77 -2
- package/dist/server.js +37 -23
- package/package.json +1 -1
- package/dist/cookieNames-BlfAjNqu.js +0 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,237 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.52.0] — 2026-08-25
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/web, @voltro/plugin-storage, @voltro/plugin-ratelimit** — The HTTP surface is complete — seven gaps closed, each proven against the real listener.
|
|
47
|
+
|
|
48
|
+
**Binary byte streams.** REST handlers return `bytes(stream, { contentType, contentLength?, contentDisposition? })` (lazy thunk form defers opening the source); plugin routes return `byteStream`. Piped, never buffered (a 256 MiB export streams with bounded heap), never compressed. Idempotency × stream is DECIDED: `streaming: true` on an idempotent method with an idempotency binding is a mount error (a stream cannot cache a replayable body — retries would 409 until the TTL); an undeclared stream releases its claim at return. Storage's full-object download rides the generic path now.
|
|
49
|
+
|
|
50
|
+
**Negotiated compression + conditional GET.** brotli/gzip negotiated with a compressible-type allowlist, 1 KiB threshold, `Vary: Accept-Encoding` always on compressible types — on the api's buffered responses AND `voltro start`'s HTML (`http.compression.{enabled,minBytes}`). The BREACH position is structural: `POST /rpc` responses are NEVER compressed. ISR keeps ONE uncompressed entry and compresses per hit. `voltro start` answers `If-None-Match` with 304 (weak md5 tags over the uncompressed body); REST routes can declare `etag: true` (GET, weak SHA-1 content tag).
|
|
51
|
+
|
|
52
|
+
**Raw WebSocket gateways.** `defineWebSocket({ path, auth, onConnection })` in a `*.ws.ts` file — for FOREIGN protocols (a Yjs provider, a device fleet); app realtime stays the subscription protocol. `auth` is required with no default: `'subject'` runs the same chain as rpc/SSR BEFORE the upgrade (401 while it is still http) and binds the connection to the credential's expiry (close code 4001); `'public'` is a written-down decision. Every gateway path joins the upgrade origin guard (cross-site WebSocket hijacking → 403). Teardown at construction; plain GET → 426; duplicate paths refuse the boot.
|
|
53
|
+
|
|
54
|
+
**Body caps everywhere.** The 8 MiB cap used to guard only `/rpc`; plugin routes read uncapped and webhooks read uncapped AND UTF-8-round-tripped (corrupting binary bodies — fixed, proven byte-for-byte). `http.maxBodyBytes` (env `VOLTRO_MAX_BODY_BYTES`), per-route overrides on `defineRestRoute` and webhook handlers; a shared path takes its group's widest override. 413 for both request shapes.
|
|
55
|
+
|
|
56
|
+
**Full method unions.** PATCH/HEAD/OPTIONS are first-class on plugin and REST routes; HEAD is admitted wherever GET is (RFC 9110) with the transport dropping the body.
|
|
57
|
+
|
|
58
|
+
**BREAKING — the interceptor chain is fail-closed.** A throwing `onHttpRequest` interceptor is a 500 + a log line now; it used to be swallowed, which let a crashed security gate silently stop guarding. The manual codemod tells interceptor authors where the decision lives: propagate (a gate) or catch-and-degrade-loudly (protection with a dependency) — plugin-ratelimit's httpShield now does the latter, so a Redis outage cannot become a self-inflicted API outage. No app-authored call sites change shape, hence `apiSurface: compatible` — the break is the error POLARITY of one plugin-author hook, carried by the manual note.
|
|
59
|
+
|
|
60
|
+
**Middleware response headers + CSP nonce.** `middleware.ts` can return `responseHeaders` (applied on every render response shape, both boot paths; prerendered static files are the documented proxy-side limit) and `cspNonce` — the framework stamps every script tag of that render (React's own included) while the policy header stays the middleware's. `isr` + `cspNonce` is refused loudly: a cached nonce is a lie the browser enforces.
|
|
61
|
+
|
|
62
|
+
Deliberate limits: no multipart parser on REST/webhook routes (the storage upload routes are the sanctioned file path); static-file response headers belong at the proxy.
|
|
63
|
+
- **@voltro/plugin-row-history, @voltro/cli** — `@voltro/plugin-versioning` is renamed to `@voltro/plugin-row-history` — the name now says what it does.
|
|
64
|
+
|
|
65
|
+
The old name collided head-on with API versioning (versioned REST routes, `/v1` → `/v2`, sunset flow), which `defineRestRoute` now supports as a first-class `version:` field. What this plugin does is row history + time travel (`rowHistory` / `rowAsOf` / `restoreAsOf` / `diffVersions` over `_voltro_row_history`); every comparison table that filed it under API versioning was reading the name, not the feature.
|
|
66
|
+
|
|
67
|
+
Renamed with it: the factory (`versioningPlugin` → `rowHistoryPlugin`), the options type (`VersioningPluginOptions` → `RowHistoryPluginOptions`), and the default instance alias (`versioning` → `row-history` — the inspect endpoint path and boot-log lines; an explicit `alias:` you passed is untouched). The table name (`_voltro_row_history`) and `VOLTRO_ROW_HISTORY_TTL_HOURS` already carried the new name: no data movement, no env change, no migration.
|
|
68
|
+
|
|
69
|
+
The codemod rewrites imports, the factory call sites and the options-type references, and prints the one step it cannot do — swapping the dependency in package.json.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- **@voltro/cli** — `voltro doctor` now reports every declared `@voltro/*` dependency with no import site — the residue a migration off a framework package leaves in `package.json`, where it keeps getting installed, walked by `voltro update`, and read as evidence the package is in use, its breaking-change notes included.
|
|
74
|
+
|
|
75
|
+
Scoped to `@voltro/*` deliberately (third-party packages have too many legitimate no-import shapes), and three states are distinguished and printed: exempt by name with a reason (`@voltro/cli` is the binary, `@voltro/devtools` is mounted by `voltro dev`, `@voltro/sql-*` drivers are loaded from config); not-measurable-yet for `@voltro/client`/`@voltro/web` on a tree where codegen has never run (a missing measurement, not a dead dependency); and unimported — advisory, never fatal. A mention in a comment or an error string does not count as an import, and a commented-out import counts least of all: it is the artefact the rule exists to see past. Also in `voltro doctor --json` under `unimportedDeps`, `null` when no `package.json` could be read.
|
|
76
|
+
- **@voltro/web, @voltro/cli** — Islands now save real bytes — every `interactive: 'islands'` page gets its OWN browser entry.
|
|
77
|
+
|
|
78
|
+
Measured on the reference fixture (pinned in `bundle-budget.json`, 2026-08-25): an islands page's first load is **59.6 KB gz** against **181.9 KB gz** for a fully hydrated page — react + the island runtime + that page's islands, no router, no Effect runtime, no subscription cache. The bundle-budget gate pins a hard <70 KB bound AND the ratio (<50 % of a full page), so a regression that re-couples the entries fails loudly.
|
|
79
|
+
|
|
80
|
+
- **`@voltro/web/islands`** is the new react-only subpath — `island()` and the hydration runtime import only react + react-dom/client. Importing the `@voltro/web` BARREL (or `@voltro/i18n`) anywhere in an island's import graph is now a BUILD error naming file and specifier: an island hydrates provider-less, router hooks and `useT()` throw there anyway, and the barrel would pull the Effect runtime into the slim entry. - **The build finds each page's islands** through its relative import graph (transitively, through components in between) and emits one shell + entry per islands page. `interactive` must be a source LITERAL to select the slim entry — a computed value ships the full entry as before, and the build says so. - **All three paths**: `voltro build` (SSG renders into the per-page shell, main-shell stylesheets folded in), `voltro start` (ssr/isr islands routes serve their shell), `voltro dev` (same mechanism on demand — violations fire in dev, not first in CI). Fixed on the way: a STATIC islands page fell through dev's render gate to the SPA fallback and loaded the full app entry — dev now server-renders it like production. - **Framework islands**: an island importing `@voltro/client` (`useSubscription`, …) is detected — that page's entry boots the rpc client and wraps each island root in `VoltroRuntimeProvider`, so the island receives live data. Presentational pages never pay for the client core. - **`hydrate: 'only'`** (Astro's `client:only`): the server renders an empty placeholder — a browser-only lib touching `window` in render no longer crashes the SSR pass — and the client mounts fresh with `createRoot`. - **Island props are declared lossy where they are**: props cross an HTML attribute as JSON, so a `Date` arrives as an ISO string and `Map`/`Set`/ functions do not arrive at all — dev warns naming the island and prop. - The per-island `manualChunks` rule is gone: with per-page entries as additional rollup inputs it MERGED the shared react modules into the island group (a second-React-instance shape); per-island chunks were also redundant — the entry already scopes to the page's islands, and they were statically preloaded anyway.
|
|
81
|
+
|
|
82
|
+
Limits: an islands page reached via SPA navigation from a full page runs in the already-loaded app bundle (the saving applies to hard loads of the islands page); a page's islands share one entry (hydrate strategies control WHEN each hydrates, not when it downloads).
|
|
83
|
+
|
|
84
|
+
Why the golden churn is compatible: `hydrate: 'only'` widens a union, `hydrateIslandsOnPage` gains an optional options argument, and the subpath is a new export.
|
|
85
|
+
- **@voltro/ui, @voltro/client, @voltro/web, @voltro/cli** — Forms without JavaScript — `<AutoForm>` on a server-rendered page now works with JS disabled, end to end.
|
|
86
|
+
|
|
87
|
+
The form always renders `action="/form/<mutationTag>"` + `method="post"`; with JS alive, `onSubmit` intercepts exactly as before (optimistic rpc path unchanged). The `/form/*` endpoint is mounted by the WEB listener on BOTH boot paths (`voltro dev` and `voltro start`, one shared builder):
|
|
88
|
+
|
|
89
|
+
- **Origin-checked at the door** with the same `classifyRequestOrigin` the api's rpc listener runs — a cross-origin form POST is a 403 before a byte of the body is parsed. (The server-side forward reaches the api as a no-browser-origin request, so the web listener's check is the one that guards this surface.) - **One validation path.** The urlencoded body maps through the schema-driven `formDataToInput` (checkbox present/absent → true/false, `''` on number/date omits the field — never a silent 0 —, repeated keys → arrays, non-numeric strings pass through RAW so the decode fails honestly instead of minting NaN, unknown keys dropped) and validates with the SAME `validateFields` the client-side submit runs — byte-identical field errors. - **PRG on success**: 303 back to the submitting page, or to `<AutoForm redirectTo>` (same-origin relative paths only — anything else is refused, a hidden field must not become an open redirect). Reloading the redirected-to page cannot resubmit. - **422 re-render on validation failure**: the referring page renders in the same response with field errors + submitted values in the SAME error UI (`role="alert"`, aria unchanged), `cache-control: no-store`, bypassing the ISR cache in both directions. An rpc refusal after valid input (guard, server error) renders as a form-level error. The flash also embeds as a JSON script, so a page whose bundle arrives late hydrates to the identical state. - **Valid submits forward server-side over `POST /rpc`** with the request's cookie — auth middleware, guards and the rpc interceptor chain run identically to every other mutation.
|
|
90
|
+
|
|
91
|
+
New `<AutoForm>` props: `formKey` (several forms per page — the 422 re-render re-fills only the submitted one), `redirectTo`, and `action={false}` for purely static deploys where `/form/*` does not exist. Headless: `useFormBinding` gains `flash` + `formError`, `@voltro/web` gains `useFormFlash(formKey)`.
|
|
92
|
+
|
|
93
|
+
Also fixed on the way: a required `Schema.Boolean` field with no default used to block the JS submit as "missing" while its checkbox rendered visibly unchecked — boolean fields now seed `false`, agreeing with what the user sees (and with the no-JS mapping).
|
|
94
|
+
|
|
95
|
+
Deliberate limits: the no-JS error re-render needs an `ssr`/`isr` page (a static page cannot be re-rendered with request state; a minimal error page is the fallback); a purely static deploy has no `/form/*` endpoint (use `action={false}`); file uploads stay JS-only (multipart → 415). On an ssr page pass `schema` explicitly — descriptor resolution is a client-runtime feature and the SSR render would otherwise show no fields.
|
|
96
|
+
|
|
97
|
+
Bundle note: the `serverContext` chunk group is renamed `serverRequest` by the context's move to @voltro/client — identical 190 B gz, re-pinned in `bundle-budget.json` (fresh full measurement 2026-08-25: firstLoad 185,494 B gz, was 185,309 — +185 B from the form-flash read in the request context).
|
|
98
|
+
|
|
99
|
+
Why the golden churn is compatible: every addition is a new export or an optional prop; `FormBinding.formError` is a new member of the hook's RETURN type (nothing in the public API accepts a caller-built `FormBinding`), and `ServerRequestContextValue.formFlash` is optional.
|
|
100
|
+
- **@voltro/runtime, @voltro/cli, @voltro/cms, @voltro/plugin-broadcast** — On-demand ISR revalidation — the third invalidation axis next to time (`revalidate`) and CDC (`cacheInvalidatesOn`). Server code in the api process calls `revalidatePath('/blog/[slug]')` / `revalidateTable('posts')` / `revalidateTag('pricing')` (exported from `@voltro/runtime`; callable from mutations, actions, webhook receivers and REST routes) and the matching ISR cache entries fall on EVERY `voltro start` replica — including on dialects with no CDC at all, which is the case this exists for.
|
|
101
|
+
|
|
102
|
+
Transport is dialect-shaped, either or both: on postgres a `pg_notify` rides the SAME LISTEN connection the CDC invalidator already holds (no broker needed); everywhere else the broadcast broker carries it (`BROADCAST_URL` on both deployments; channel namespaced by `VOLTRO_BROADCAST_NAMESPACE` — deliberately env-derived, because this channel pairs an api with its WEB app and no shared name is derivable). A web process with isr routes and neither transport warns loudly at boot; under `voltro dev` the calls are documented debug-logged no-ops. Tags share ONE mechanism with tables — `cacheInvalidatesOn` accepts free strings, so `revalidateTag` is the same sink under another name.
|
|
103
|
+
|
|
104
|
+
Correctness edges built in: `revalidatePath` against a `static` route is a NAMED error on the web process (never a silent no-op); a purge landing while an SWR refresh or miss fill renders is guarded by a per-key generation counter on BOTH cache backends — the pre-purge page cannot be written back with a full TTL, and a refused write also suppresses the postgres backend's fire-and-forget upsert so no replica resurrects a deleted row. A content type declares `revalidate: { paths, tags }` and `publish()`/`unpublish()` fire them after commit.
|
|
105
|
+
|
|
106
|
+
Proven end-to-end (`scripts/revalidate-e2e.mjs`): 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
|
|
107
|
+
|
|
108
|
+
`apiSurface: compatible` — additive only: the new `@voltro/runtime` revalidation exports, an optional `revalidate` on `ContentTypeSpec`, the optional `onRevalidate`/`revalidateChannel` on the CDC invalidator options, a widened `BroadcastChannelKind`, and `IsrCache.set`'s new optional generation guard (plus `generation()`).
|
|
109
|
+
- **@voltro/protocol, @voltro/plugin-openapi** — `defineRestRoute` takes an opt-in `version:` — versioned REST APIs with a sunset flow.
|
|
110
|
+
|
|
111
|
+
`version: 'v2'` + `path: '/customers'` mounts the route at `/v2/customers`, the same `/vN/` convention the `publicApi:` projection and the built-in `/v1/api-keys` surface already use. The path is normalised ONCE at definition time, so every consumer — the mount, path-param matching, the idempotency scope, tracing spans, the OpenAPI generator — sees the mounted path and none can disagree.
|
|
112
|
+
|
|
113
|
+
Deliberate edges:
|
|
114
|
+
|
|
115
|
+
- **Opt-in, no auto-prefix.** A route without `version:` keeps its literal path untouched — an automatic prefix would silently move every deployed route. A path that already starts with `/vN/` AND declares `version:` is refused loudly at definition (both spellings at once is never what the author meant). - **Two versions are two descriptors.** The old version is ordinary code — visible, testable, deletable — carrying `deprecated:` (the replacement pointer) and `sunset:` (the date it starts answering `410 Gone`; the 410 body now also names which `version` died). - **One OpenAPI document for every version.** The `/vN/` prefix already separates the paths; each versioned operation is grouped under a version tag and carries `x-voltro-api-version` for tooling. No `?version=` filtered spec — a second document shape for information the paths already state. - The rpc socket stays outside URL versioning on purpose: the generated client is versioned with the server it was generated from. A stale browser tab runs the previous client until reload — that skew window exists and is documented, not solved by URLs.
|
|
116
|
+
- **@voltro/web, @voltro/cli** — Opt-in View Transitions for SPA navigations. `router.viewTransitions: true` in a web `app.config.ts` runs every route swap — `<Link>` clicks, `navigate(...)`, back/forward — through `document.startViewTransition`; individual navigations override the default in either direction with `navigate(to, { transition })` / `<Link transition>`.
|
|
117
|
+
|
|
118
|
+
The visual swap is the router's deferred-navigation commit, flushed synchronously inside the transition callback — by that point the target's lazy chunk and loaders have settled, so the flushed tree renders with data in hand. Three deliberate behaviors: `defer()` fields (and an explicit `Pending` skeleton's settled content) resolve AFTER the transition as ordinary updates, never a second animation; navigating while a transition is animating skips the running one (last navigation wins, nothing queues); overlay/dialog state changes never trigger one — a root snapshot would cross-fade the whole viewport for a one-layer change.
|
|
119
|
+
|
|
120
|
+
Fallback is exact: a browser without the API, and any user with `prefers-reduced-motion: reduce`, gets today's untransitioned swap — same timing, nothing to feature-detect. Styling is plain `::view-transition-*` CSS (no animation DSL); cross-document transitions for static/MPA pages are a one-line `@view-transition` CSS opt-in with no framework involvement.
|
|
121
|
+
|
|
122
|
+
Proven in a real chromium (`scripts/browser-view-transitions.mjs`): called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
|
|
123
|
+
|
|
124
|
+
`apiSurface: compatible` — additive only: a new optional `viewTransitions` on `RouterProps`, optional `transition` on `NavigateOptions`/`LinkProps`, and the optional `router` block on the web app config.
|
|
125
|
+
- **@voltro/web, @voltro/cli** — Schema-typed search params — the query-string half of the URL is now part of the type graph.
|
|
126
|
+
|
|
127
|
+
A page declares its contract once:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
export const searchParams = Schema.Struct({
|
|
131
|
+
q: Schema.optionalWith(Schema.String, { default: () => '' }),
|
|
132
|
+
page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
and gets, end to end:
|
|
137
|
+
|
|
138
|
+
- **Typed reads** — `useSearchParams(searchParams)` returns the decoded, defaulted shape, SSR-aware; the zero-arg call keeps returning the raw `URLSearchParams`. Decoding is TOTAL: an invalid query falls back to the schema's defaults instead of crashing a render; only a schema that cannot even decode `{}` (a required field with no default) throws, naming the fix. - **Typed links** — the generated `routes` builder brands the route's URL with the schema's shape through a TYPE-ONLY page import (zero value edges: code-splitting is untouched, pinned by test), and `withQuery` type-checks against it — a misspelt key or wrong value type is a compile error. The link-side encode is canonical and schema-free (strings/numbers/booleans, arrays as repeated keys); a roundtrip test pins that it produces exactly what the schema's decode accepts. Deliberately ONE generic signature rather than overloads: with overloads, a wrong key would silently fall through to the permissive untyped form and the compile error would never fire. - **Typed writes** — `useSetSearchParams(searchParams)` returns the typed setter: its object form replaces the query (same semantics as the untyped form), and its updater form receives the CURRENT decoded params, so keeping `?filter` across a page flip is one explicit spread — `setParams((p) => ({ ...p, page: p.page + 1 }))` — instead of a hand-rolled merge. - **A fail-closed isr gate** — `renderMode: 'isr'` plus a `searchParams` export is refused at boot: the isr cache is keyed by path (+tenant+locale), not query, so the first variant would be cached for every query — and the gate catches the re-exported spelling (`export { searchParams } from …`, the mirror-route pattern) too, not only the local declaration. - **A doctor rule** — a page that exports the schema but keeps reading the query with zero-arg `useSearchParams()` is flagged with the typed spelling.
|
|
139
|
+
|
|
140
|
+
Deliberate limits: array fields decode a single occurrence as a one-element array (link shape stays stable); `siblingApps` routes stay untyped (their schemas live in a foreign compile graph); `static` pages see the defaults at build time and decode live on the client.
|
|
141
|
+
|
|
142
|
+
Why the golden churn is compatible: `VoltroRouteUrl` gains a type parameter with a DEFAULT (`<TSearch = unknown>`), so every existing bare `VoltroRouteUrl` spelling still compiles, and the new brand member is an OPTIONAL phantom property of type `unknown` — assignability in both directions is unchanged. `withQuery`'s parameter for the untyped case is strictly WIDER than before (adds `boolean` and array values); no call that compiled stops compiling.
|
|
143
|
+
|
|
144
|
+
### Fixed
|
|
145
|
+
|
|
146
|
+
- **@voltro/cli** — `voltro agents-md`'s copied `agent-docs/` mirror no longer keeps orphaned modules across a re-seed.
|
|
147
|
+
|
|
148
|
+
The copy fallback (projects where `@voltro/cli` isn't resolvable) merged into an existing `agent-docs/` directory: when a module was renamed upstream (`plugins/versioning.md` → `plugins/row-history.md`), a `--force` re-seed brought the new file and left the old one sitting beside it — a stale generated doc teaching a package name that no longer exists, which is exactly the claim≠code drift the guide exists to prevent. The mirror is wholly framework-owned, so a re-seed now replaces it (the generator prunes its own output dir the same way).
|
|
149
|
+
- **@voltro/cli** — `voltro check`'s observed-diff footer now accounts for every declared procedure, and no result line states a verdict without the count it is a verdict about.
|
|
150
|
+
|
|
151
|
+
Two defects, both found by readers doing arithmetic on the output:
|
|
152
|
+
|
|
153
|
+
- The diff computes TWO kinds of blindness — a procedure that never ran, and one that ran with no table access recorded — and the printer named only the first. The printed counts came out short of the total, with no way to tell an unshown category from a defect in `check` itself. The buckets are now derived from the result type, so a third one cannot be added without the label map failing to compile, and a partition that does not close prints as such instead of quietly under-counting. - `no declared/observed mismatches` was a bare verdict sitting under its denominator, and was quotable — and quoted — without it, as a clean bill of health for a surface where almost nothing had been exercised. Every result line now carries its scope (`no declared/observed mismatch among the 12 that ran`), and at zero coverage the section reports the ABSENCE of a comparison (`nothing was compared — a declaration is only checked against a procedure that RAN`) rather than the absence of findings. The two are different facts and only one of them is evidence.
|
|
154
|
+
- **@voltro/cli** — An `isr` render no longer sees the requesting visitor's credentials — in `voltro dev` and `voltro start` alike.
|
|
155
|
+
|
|
156
|
+
An isr page's HTML is cached under tenant+locale and served to every visitor inside the revalidate window, but the render itself ran with the FULL request: loaders received the session cookie, and `ctx.query` was bound to it. A loader that read subject-scoped data on an isr page therefore cached the first visitor's data and served it to everyone — cache poisoning by construction.
|
|
157
|
+
|
|
158
|
+
The render boundary is fail-closed now: before an isr render runs, the cookie jar (except `voltro:locale`), the `authorization` header and every `x-voltro-*` header are stripped, for the loaders, `ctx.query` AND the `useServerRequest()` snapshot. What survives is exactly what the cache key and locale resolution read: `x-tenant`, `accept-language`, and the locale cookie — so a `de` visitor's fill still lands under the `de` key. One shared helper (`isrCredentialStrip.ts`), called by both boot paths, so dev renders isr anonymously exactly as production does — a page can no longer look personalised in dev and silently serve shared HTML in production.
|
|
159
|
+
|
|
160
|
+
Behavioural consequence, on purpose: a subject-reading loader on an isr page now gets the anonymous answer. A page whose loader needs the signed-in subject belongs on `renderMode: 'ssr'`.
|
|
161
|
+
- **@voltro/cli, @voltro/runtime** — **`voltro serve` registers plugin rpc routes again — every plugin-contributed procedure answered `Unknown request tag` in production while `voltro dev` registered all of them.** The serve path mirrored dev's plugin-route block by hand and mirrored exactly half of it: the collision check ran (so nothing warned) and the returned routes were discarded as a bare expression statement, never reaching the buckets the rpc registry is built from. Every plugin route was indistinguishable from a tag that never existed — `useUpload`'s storage tags, presence, every inspect-less plugin rpc — while the app's own procedures answered normally, so the registry looked alive.
|
|
162
|
+
|
|
163
|
+
Three changes, each aimed at the way this stayed invisible:
|
|
164
|
+
|
|
165
|
+
- Both boot paths now call ONE shared builder (`mergePluginRoutesInto`) whose buckets are required parameters — a returned list can be discarded by a statement that typechecks; a function you cannot call without handing it the sinks cannot have its effect dropped. A reachability test drives a real socket with a plugin tag and an invented-tag control, and a source pin keeps the helper pair from being reassembled by hand in either path. - The boot line `plugin routes registered` prints WHENEVER plugins are installed, count included — zero is a finding, and silence is how this shipped. `voltro check` against a running server now also diffs source-declared tags against the live registry (`declared vs live:`), and its offline manifest includes plugin routes, which it previously did not. - Every `Defect` frame the server sends is now also a server log line (`rpc defect sent to client`, ws and http rpc). The defect string used to exist only inside the WebSocket frame — visible in whoever's browser console, invisible to the operator whose server produced it.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## [0.51.0] — 2026-08-24
|
|
170
|
+
|
|
171
|
+
### ⚠ BREAKING
|
|
172
|
+
|
|
173
|
+
- **@voltro/data-transfer, @voltro/cli** — The asset counts reported references as if they were blobs. `_voltro_storage_refs` holds one row per reference and several rows legitimately name one key, so a capture of 57 rows over 16 keys wrote `count: 57` into the stamp beside an `assets/` directory holding 16 files, and the restore reported "57 blob(s) restored" while 16 objects appeared. Nothing was lost; what was lost is the ability to check. Anyone answering "are all the blobs there?" after a restore compared the stamp's number against one they counted and found a 3.5x gap that was not one.
|
|
174
|
+
|
|
175
|
+
A key named by several references is now fetched once rather than downloaded, hashed and discarded once per row, and the three numbers are stated separately: `references` (rows enumerated), `count` (distinct keys), `objects` (distinct sha256 bodies), with `totalBytes` and `objectBytes` beside them. The stamp's existing fields keep their names and now mean what a reader always took them for; the new ones are optional, so a stamp written before them still parses.
|
|
176
|
+
|
|
177
|
+
**Breaking on one export.** `restoreAssetsFromCas` returns `{ count, objects }` instead of a bare `number` — one number could not answer both questions, which is the defect. `count` is what the old value was, so the migration that changes nothing is `.count`.
|
|
178
|
+
|
|
179
|
+
**`voltro update` carries you across this** — codemod `0.51.0/03_restore-assets-returns-counts`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.51.0).
|
|
180
|
+
- **@voltro/i18n** — `<I18nProvider>` takes `timeZone` as its own prop, and `intlConfig` no longer accepts one.
|
|
181
|
+
|
|
182
|
+
`intlConfig` exists to forward props to react-intl UNMODIFIED, and for every other member of `IntlConfig` that is the right shape. `timeZone` is the one member this package's own formatters read — and they did not read it: `useFormatDate` built `Intl.DateTimeFormat` itself and took only the locale from the provider. So a zone passed through `intlConfig` configured `<T>`'s ICU dates and NOT the `useFormatDate()` beside them. Measured under one provider, one instant (`2026-08-24T23:30:00Z`), `locale: 'de'`, `intlConfig: { timeZone: 'Europe/Berlin' }`, process zone UTC: react-intl rendered `25.08.26, 01:30` and the hook rendered `24.08.26, 23:30`. A different hour, and a different day.
|
|
183
|
+
|
|
184
|
+
The zone has to be a prop this package can see. It is validated once at the provider (an unusable zone — a stale cookie, a typo, a runtime with a trimmed ICU — is dropped, because `Intl` THROWS on an unknown zone and `useFormatDate` catches, which would degrade every timestamp in the app to a raw `Date` string). It is what the new `useTimeZone()` reports. And it is what the framework fills per request.
|
|
185
|
+
|
|
186
|
+
**Migration:** `intlConfig={{ timeZone: 'Europe/Berlin' }}` → `timeZone="Europe/Berlin"`. The codemod does it, including the case where the zone was the bag's only member. An `intlConfig` naming a variable is reported by file and line rather than guessed at — the property would otherwise stop being read with nothing red anywhere.
|
|
187
|
+
- **@voltro/cli** — A native restore whose bookkeeping store would not open ran anyway, with no in-progress marker and no word about it. `nativeBookkeeping` was `try { … } catch { return undefined }`, and that `undefined` guarded every branch below — including the refusal for a marker that could not be written. So the failure removed the precaution AND the sentence that would have reported it missing, and `markerState` was never computed, defaulting to "held".
|
|
188
|
+
|
|
189
|
+
What it produced was worse than silence: a failed restore printed "This database is now in an unknown state and the next boot will REFUSE, by design" over a database with zero marker rows. The next boot did not refuse, and `voltro data clear-replace-marker` had nothing to clear. The trigger is not exotic — wrong credentials, an unreachable database, a missing env var, no `app.config.ts` from here — and a restore is the operation you run against a target that is already unwell, so the guard fell away exactly when it was needed.
|
|
190
|
+
|
|
191
|
+
The reason now travels instead of being caught and dropped. A restore that cannot write the marker REFUSES and names which of the two reasons it was (the store would not open, or the table is not there); those were two separate refusals and are now one, because they are one decision for the operator. `--no-marker` is the deliberate way past it and warns every time. And the `recorded:` line no longer reports a local failure as a property of the target — it says where the failure was.
|
|
192
|
+
|
|
193
|
+
**This changes an exit code.** A restore that could not write the marker used to exit 0; it now exits 1. Two invocations are affected — the bookkeeping store will not open, or there is no `app.config.ts` from the working directory — and the second surprises people, because `restore` reads its target from the environment and so looks like it needs no project. It does not, for the dump; it needs one for the marker. `--no-marker` is the deliberate way through and warns every time. The note ships under `reach: 'beyond-source'` because the affected invocations live in cron entries, CI jobs and runbooks rather than in TypeScript.
|
|
194
|
+
|
|
195
|
+
**`voltro update` carries you across this** — codemod `0.51.0/02_restore-refuses-without-marker`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.51.0).
|
|
196
|
+
|
|
197
|
+
### Added
|
|
198
|
+
|
|
199
|
+
- **@voltro/i18n, @voltro/cli, @voltro/voltro** — `timeZone` in the web `app.config.ts` — the zone every date/time formatter renders in, resolved per request and published so the client agrees.
|
|
200
|
+
|
|
201
|
+
A formatter is deterministic given the value, the locale, the zone and the clock. The locale already came from the provider and was already agreed across the hydration boundary — the server publishes it as `<html lang>` and the client reads that attribute rather than `navigator.languages`, precisely because the browser's own answer can differ from what the server saw. The zone had no such source. `Intl` fell back to the zone of whichever runtime was formatting: the pod on the server (UTC on a container with no `TZ`), the viewer's machine in the browser. Every server-rendered timestamp was therefore a hydration mismatch waiting for a wide enough offset, and across midnight it was a different calendar day.
|
|
202
|
+
|
|
203
|
+
timeZone: 'Europe/Berlin' // one zone for every viewer timeZone: 'viewer' // per request, from the `voltro:tz` cookie defaultTimeZone: 'UTC' // before the viewer's zone is known
|
|
204
|
+
|
|
205
|
+
Whatever it resolves to is stamped on the document as `<html data-voltro-tz>`, and the generated client entry reads that attribute. Both sides then format against one value — which is the property that removes the mismatch, whether or not the value is the viewer's true zone: being wrong together is repairable after mount, being different is not.
|
|
206
|
+
|
|
207
|
+
Under `'viewer'` the framework injects a script that seeds `voltro:tz` from the browser when the cookie is absent, so the server renders in the viewer's zone from the second request with no login. It never overwrites an existing value — the APP is the authoritative writer, at login, from the zone it holds for the signed-in user (`TIMEZONE_COOKIE` and `isSupportedTimeZone` are exported for that). Unset, nothing changes: each runtime keeps using its own zone, and `useTimeZone()` returns `undefined` to say so.
|
|
208
|
+
|
|
209
|
+
This is the RENDER zone. The server-side compute zone — what `startOfDay` resolves against inside a handler — is still `@voltro/datetime/context`'s seam, unwired.
|
|
210
|
+
|
|
211
|
+
**`apiSurface: compatible`** covers the two golden lines that moved, and they are the same change twice: `makeSsgWrap`'s returned wrapper, and the `wraps` record on the SSG shell input, each gained an OPTIONAL second parameter (the per-request zone + render instant; the wrapper is built once per locale, so they cannot live in the factory). A function with an optional extra parameter is assignable wherever the one-parameter type was expected, so no call site that compiled stops compiling — and both are the framework's SSG bridge, documented as never imported by app code. Everything else this release adds to these packages is a pure addition; the one genuine break in `@voltro/i18n` is the `timeZone` prop, which has its own entry and its own codemod.
|
|
212
|
+
|
|
213
|
+
### Changed
|
|
214
|
+
|
|
215
|
+
- **@voltro/cli** — A `BREAKING` entry's changelog footer now tells a reader who pins versions by hand how to print the codemod's note without upgrading anything:
|
|
216
|
+
|
|
217
|
+
voltro update --codemods-only --from <your current version> --dry-run
|
|
218
|
+
|
|
219
|
+
The footer used to stop at the codemod's id, which is enough for anyone who runs `voltro update` and nothing at all for anyone who does not. A deployment said so plainly: they pin every `@voltro/*` version from their own container scripts, have never run the command, and `CHANGELOG.md` out of the tarball is the only channel anything reaches them through. So a note deliberately filed under an unreached version — our one mechanism for correcting guidance that can no longer be corrected in place — reached that population not at all, and the id told them a fix existed without telling them what it was.
|
|
220
|
+
|
|
221
|
+
No new surface: every flag in that invocation is already parsed, which is what lets `check-message-apis.mjs` verify the line rather than trust it.
|
|
222
|
+
- **@voltro/cli** — Two fingerprint labels now say what they compare.
|
|
223
|
+
|
|
224
|
+
The restore's skew warning said the backup's schema "differs from what this code declares". It does not: the value it compares against is the TARGET database's live schema, read by introspection at restore time. Bringing a target to the backup's shape makes the warning disappear while the declared fingerprint is a third value entirely, which is how the mislabel was caught. The comparison is the useful one and is unchanged; the sentence sent readers looking for a code change where a database differed.
|
|
225
|
+
|
|
226
|
+
`voltro db plan` prints `fingerprint: live … · declared …` instead of `from … → to …`, plus a line saying the two are not meant to match. A hash of a live database never equals the hash of the declaration it came from — introspection cannot recover generated expressions, `maxLength` or sensitivity markers — which is why `db drift` keeps a separate live baseline. Printed as `from → to`, `0 operations` under two differing hashes read as a contradiction.
|
|
227
|
+
|
|
228
|
+
### Fixed
|
|
229
|
+
|
|
230
|
+
- **@voltro/cli** — `voltro check` reported a reactivity CHANNEL as a missing table, at `error` severity — so it set the exit code:
|
|
231
|
+
|
|
232
|
+
✗ error reference/dangling-source query(presence.list) reads table 'channel:presence' which does not exist fix: declare a 'channel:presence.entity.ts' table or fix the query's source
|
|
233
|
+
|
|
234
|
+
A `source:` entry is a table name OR a channel's routing key (`channel:<name>`), and every rule resolved entries against the table set. The advice cannot be followed — a channel exists precisely because no table is meant — and because it is an error rather than a warning, `voltro check` could not be a CI gate for any app that uses a channel. That includes an app whose only channel comes from `@voltro/plugin-presence`, whose own `presence.list` declares one: a first-party feature meeting a rule that did not know about it, inside a first-party plugin.
|
|
235
|
+
|
|
236
|
+
Channels are filtered in the ONE helper every table rule reads, rather than at each rule, because a per-rule filter is how the next rule joins without one. The same cause was live one rule over: `observed/declared-but-unobserved` reported "declares source 'channel:presence' but never read it while running" for every exercised procedure that declares a channel. Both are covered, each with a negative control — a filter that dropped the whole source list would have silenced the rules instead of narrowing them.
|
|
237
|
+
- **@voltro/data-transfer** — A native dump no longer carries `_voltro_data_transfers`, for the same reason it stopped carrying the in-progress marker one release ago. The restore opens its own run row there BEFORE the tool runs; the dump then dropped the table mid-flight, and the update recording the outcome wrote into a table that no longer held the row. Measured downstream: after a deliberately failed native restore, `voltro data transfers` showed no restore at all — only the `backup` row the dump had carried over from the SOURCE database. The command that answers "did the restore finish" could not see the run asking the question.
|
|
238
|
+
|
|
239
|
+
Exactly two tables are excluded and the line is deliberate: a native restore into the same deployment should bring the migration ledger, the stored plans, the CDC offsets and the schedule claims, because they describe the data being restored. These two describe the RESTORE, and a record of an operation must not be overwritten by the operation it records. Covered per dialect against real servers and real vendor tools, including a non-vacuity check that a table which SHOULD travel still does.
|
|
240
|
+
- **@voltro/i18n, @voltro/cli** — `useRelativeTime` used `Date.now()` as its base, which under SSR is two different numbers. The server rendered at T and wrote "3 minutes ago" into the HTML; the browser hydrated at T+Δ and rendered "4 minutes ago" whenever a unit boundary fell in the gap. The gap is network latency, so it reproduced on a slow connection and never on the developer's machine, and it had nothing to do with timezones — a correctly zoned app hit it just the same.
|
|
241
|
+
|
|
242
|
+
The server states its render instant (`<html data-voltro-now>`, `renderedAt` on the provider), the first client render uses that same number, and the clock goes live once hydration commits. Server markup and hydration markup are therefore identical BY CONSTRUCTION — the property `await.tsx` and `deferred.ts` already hold, rather than `suppressHydrationWarning`, which would hide a real mismatch along with this one. An explicit `{ now }` still wins.
|
|
243
|
+
|
|
244
|
+
The mount state lives in the provider, not in the hook: a table of ten thousand rows would otherwise pay a state hook and a passive effect each to learn one fact that is true for the whole document. An app that never renders on the server publishes no stamp and takes no second render pass.
|
|
245
|
+
- **@voltro/cli** — `closeNativeRun` writes the transfer row BACK when the restore's own artefact dropped the table it lives in, instead of issuing an `UPDATE` that matches nothing and returning happily. Excluding `_voltro_data_transfers` from our own dumps shortens that window; it does nothing for a dump taken before that change, for a hand-made one, or for mssql and sqlite, whose restores have no per-table exclusion at all. The write-back covers every dialect and every artefact, which is why it is the rule and the exclusion is the optimisation.
|
|
246
|
+
|
|
247
|
+
The row is read back rather than trusted — an update that matched nothing is indistinguishable from one that matched — and a read that itself fails writes nothing, because a duplicate row invented on a guess is its own defect in a history somebody reads under pressure.
|
|
248
|
+
- **@voltro/cli** — A prerendered page shipped the shell's baked `lang="en"` whatever locale it was rendered in.
|
|
249
|
+
|
|
250
|
+
`voltro dev` and `voltro start` both set `<html lang>` per request; the prerender never did. So a `/de/...` artefact — rendered with the German catalog, handed `locale: 'de'` in its `meta` — served `<html lang="en">`. That attribute is what a screen reader pronounces in, what Chrome offers to translate FROM, and what hyphenation uses, so the failure was silent to whoever shipped it and loud only to the people it excluded. The same shape as the 0.30.0 cookie-name drift, one document path over.
|
|
251
|
+
|
|
252
|
+
It surfaced while giving the zone somewhere to travel: `<html lang>` was set by four hand-written copies of one `.replace(/<html…/)` and by nothing in the prerender, and adding a second attribute to that arrangement is how the next one reaches three paths out of five. There is one `applyDocumentAttrs` now, and the prerender is one of its callers — which fixes the locale as a side effect of having somewhere to put the zone.
|
|
253
|
+
|
|
254
|
+
### Internal (no consumer-facing effect)
|
|
255
|
+
|
|
256
|
+
- **@voltro/cli** — `voltro data backup --assets` / `restore --assets` are now driven against a REAL S3 API (MinIO in the test stack), over the network, with a real backup and a real restore into a second bucket and the bytes compared.
|
|
257
|
+
|
|
258
|
+
The asset half rests on one field: a provider must map "there is no object at that key" to `status === 404`, and nothing looser — a 403 from a rotated credential is also non-transient, and calling that "the object is gone" turns a recoverable outage into a backup that quietly contains nothing. That mapping was measured against memory, filesystem and database live, and against the s3/azure SDK error SHAPES constructed. A constructed shape is a claim about an SDK, not about a round trip: nothing in it exercises signing, path-style addressing, or what the SDK actually raises when a server answers `NoSuchKey`. Two deployments listed exactly this as the gap they could not close either.
|
|
259
|
+
|
|
260
|
+
Both directions are covered against the real server: a dangling reference is stepped over and reported, and a bad credential fails the capture rather than being read as a missing object.
|
|
261
|
+
|
|
262
|
+
The native dialect lane also stops being silent about mssql. It was absent from the array entirely — an absent lane and a covered one look identical from the outside — and it is now listed with a written reason for why it does not register here (`sqlpackage` is a separate Microsoft download on a .NET runtime, absent from `mcr.microsoft.com/mssql-tools`). It is registered rather than skipped-forever, because a skip present on every healthy run teaches readers to ignore skip lines; and an assertion fails if any lane drops out WITHOUT a written reason, or if a reason names a lane that is in fact running.
|
|
263
|
+
- **@voltro/cli, @voltro/plugin-storage** — Coverage for the data commands, at the level the defects actually live.
|
|
264
|
+
|
|
265
|
+
`dataDirectFlags.e2e.test.ts` drives every DIRECT-target flag of `voltro data export` / `import` through the real binary against a real sqlite database, and carries the same `DATA_FLAGS`-driven self-check the native suite has: a new direct flag has to be driven there or the file goes red. The api-only flags are listed explicitly with the reason they are not here, and that list is asserted against `DATA_FLAGS` so it cannot become a place to hide an untested flag.
|
|
266
|
+
|
|
267
|
+
`missingObjectIs404.test.ts` pins the contract the dangling-reference skip rests on: every provider maps "no object at that key" to `status === 404`, and nothing looser. Five providers, three of them live, s3 and azure through their SDK's real error shapes — which read DIFFERENT fields (`$metadata.httpStatusCode` vs a bare `statusCode`), so a mapping copied from one to the other would turn dangling references back into hard capture failures on that backend alone.
|
|
268
|
+
|
|
269
|
+
`codegenFeatureTables.integration.test.ts` measures the count a report was about: `voltro codegen` must carry the tables a `*.cron.tsx` contributes, which the entity walk cannot see. The structural guards beside it were TRUE while that count was wrong. `frameworkSourceTypo.integration.test.ts` measures what catches a misspelled `_voltro_*` source given that the type deliberately does not — `voltro check` reports it as a dangling source and exits 1, with a negative control so the check is not merely flagging every framework name.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
42
273
|
## [0.50.1] — 2026-08-24
|
|
43
274
|
|
|
44
275
|
### Added
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/timeZone.ts
|
|
2
|
+
var e = "viewer", t = "UTC", n = (e) => {
|
|
3
|
+
if (!e) return !1;
|
|
4
|
+
try {
|
|
5
|
+
return new Intl.DateTimeFormat("en-US", { timeZone: e }), !0;
|
|
6
|
+
} catch {
|
|
7
|
+
return !1;
|
|
8
|
+
}
|
|
9
|
+
}, r = "voltro:locale", i = "voltro:theme", a = "voltro:tz";
|
|
10
|
+
//#endregion
|
|
11
|
+
export { e as a, t as i, i as n, n as o, a as r, r as t };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ComponentProps } from 'react';
|
|
2
|
+
import { Context } from 'react';
|
|
2
3
|
import { FormattedMessage } from 'react-intl';
|
|
3
4
|
import { IntlConfig } from 'react-intl';
|
|
4
5
|
import { ReactNode } from 'react';
|
|
@@ -61,6 +62,10 @@ export declare const createTypedMessages: <C extends MessageCatalog>() => TypedM
|
|
|
61
62
|
|
|
62
63
|
export declare type DateInput = Date | number | string;
|
|
63
64
|
|
|
65
|
+
/** The zone used when nothing else resolves. Always valid, never surprising —
|
|
66
|
+
* and, unlike the process zone, the SAME on the server and in the browser. */
|
|
67
|
+
export declare const DEFAULT_TIME_ZONE = "UTC";
|
|
68
|
+
|
|
64
69
|
/**
|
|
65
70
|
* Identity function with type narrowing. Wrap your BASE catalog in
|
|
66
71
|
* this so other locales can `extends` its key set.
|
|
@@ -105,7 +110,7 @@ export declare interface I18nInstanceReport {
|
|
|
105
110
|
* never loaded the package (reports zeroes). */
|
|
106
111
|
export declare const i18nInstanceReport: () => I18nInstanceReport;
|
|
107
112
|
|
|
108
|
-
export declare const I18nProvider: ({ locale, messages, defaultLocale, silenceMissingTranslations, intlConfig, children, }: I18nProviderProps) => ReactNode;
|
|
113
|
+
export declare const I18nProvider: ({ locale, messages, defaultLocale, silenceMissingTranslations, timeZone, renderedAt, intlConfig, children, }: I18nProviderProps) => ReactNode;
|
|
109
114
|
|
|
110
115
|
declare interface I18nProviderProps {
|
|
111
116
|
readonly locale: string;
|
|
@@ -119,9 +124,41 @@ declare interface I18nProviderProps {
|
|
|
119
124
|
* rollout missing strings will fall back to the source-of-truth
|
|
120
125
|
* locale, no need to flood the console. */
|
|
121
126
|
readonly silenceMissingTranslations?: boolean;
|
|
122
|
-
/**
|
|
123
|
-
*
|
|
124
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The IANA zone every date/time formatter under this provider renders in.
|
|
129
|
+
*
|
|
130
|
+
* **Under SSR this is not a preference, it is the correctness knob.** Omit it
|
|
131
|
+
* and each side falls back to the zone of the runtime it happens to be in —
|
|
132
|
+
* the pod for the server (UTC on a container with no `TZ`), the viewer's
|
|
133
|
+
* machine for the browser. The two then disagree by an offset, the day can
|
|
134
|
+
* differ across midnight, and React reports it as a hydration mismatch.
|
|
135
|
+
*
|
|
136
|
+
* The framework resolves and passes this per request when the web app sets
|
|
137
|
+
* `timeZone` in `app.config.ts`; it also publishes the same value as
|
|
138
|
+
* `<html data-voltro-tz>` so the client's first render agrees by
|
|
139
|
+
* construction. Hand-built providers pass it themselves.
|
|
140
|
+
*
|
|
141
|
+
* An unusable zone (a stale cookie, a typo'd config, a runtime with a trimmed
|
|
142
|
+
* ICU) is DROPPED rather than forwarded — otherwise `Intl.DateTimeFormat`
|
|
143
|
+
* throws and every timestamp in the app degrades to a raw `Date` string.
|
|
144
|
+
*/
|
|
145
|
+
readonly timeZone?: string;
|
|
146
|
+
/**
|
|
147
|
+
* The instant this render reasons about, in epoch milliseconds — the server's
|
|
148
|
+
* render time, so that a relative formatter ("3 minutes ago") produces the
|
|
149
|
+
* SAME string on the server and in the hydration pass.
|
|
150
|
+
*
|
|
151
|
+
* Live again as soon as hydration commits, so a mounted page does not sit
|
|
152
|
+
* frozen at the server's clock. Omit it outside SSR: with no stamp the clock
|
|
153
|
+
* is simply live, which is what a client-only app wants and costs it no
|
|
154
|
+
* second render pass.
|
|
155
|
+
*/
|
|
156
|
+
readonly renderedAt?: number;
|
|
157
|
+
/** Forward any other IntlProvider prop unmodified (formats,
|
|
158
|
+
* textComponent, …). `timeZone` has its own prop above — it is
|
|
159
|
+
* read by this package's formatters as well as by react-intl, so
|
|
160
|
+
* it cannot be an opaque passthrough. */
|
|
161
|
+
readonly intlConfig?: Partial<Omit<IntlConfig, 'locale' | 'messages' | 'defaultLocale' | 'onError' | 'timeZone'>>;
|
|
125
162
|
readonly children: ReactNode;
|
|
126
163
|
}
|
|
127
164
|
|
|
@@ -143,6 +180,16 @@ export declare type ICUVars<S extends string> = string extends S ? never : S ext
|
|
|
143
180
|
* a plural/select BRANCH's text (`"# days total duration"`) is rejected. */
|
|
144
181
|
declare type IsICUIdentifier<S extends string> = S extends '' ? false : S extends `${infer C}${infer Rest}` ? C extends ICUIdentChar ? Rest extends '' ? true : IsICUIdentifier<Rest> : false : false;
|
|
145
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Is `name` an IANA zone THIS runtime can format in?
|
|
185
|
+
*
|
|
186
|
+
* Runtime-scoped on purpose: the question that matters is not whether the name
|
|
187
|
+
* is in the tzdata spec but whether `Intl.DateTimeFormat` here accepts it. A
|
|
188
|
+
* Node built with a trimmed ICU answers differently from a browser, and the
|
|
189
|
+
* one that has to render is the one that gets asked.
|
|
190
|
+
*/
|
|
191
|
+
export declare const isSupportedTimeZone: (name: string | null | undefined) => name is string;
|
|
192
|
+
|
|
146
193
|
export declare interface LazyCatalogs<L extends string = string> {
|
|
147
194
|
/** Locales this app declares, in declaration order. */
|
|
148
195
|
readonly locales: ReadonlyArray<L>;
|
|
@@ -206,21 +253,7 @@ export declare const LOCALE_COOKIE = "voltro:locale";
|
|
|
206
253
|
*/
|
|
207
254
|
export declare type LooseTFunction = (id: string, values?: Readonly<Record<string, MessageValue>>) => string;
|
|
208
255
|
|
|
209
|
-
|
|
210
|
-
* Build a children-to-tree wrapper that mounts `<I18nProvider>` with
|
|
211
|
-
* the supplied locale + catalog. The framework's SSG pipeline calls
|
|
212
|
-
* this once per build and passes the returned function as
|
|
213
|
-
* `renderPageToHtml({ outerWrap })`.
|
|
214
|
-
*
|
|
215
|
-
* For a multi-locale build the SSG emits per-locale HTML variants:
|
|
216
|
-
* `voltro build` calls this once per locale and, for each rendered
|
|
217
|
-
* page, picks `localeWraps.get(renderLocale)` — a `[locale]/…` route
|
|
218
|
-
* renders under its own `locale` param, a bare-path route under the
|
|
219
|
-
* `defaultLocale`. The client still swaps to the user's locale on
|
|
220
|
-
* hydration via the runtime's provider in `.framework/app.tsx`, but the
|
|
221
|
-
* emitted HTML is already locale-correct (right catalog + `meta({ locale })`).
|
|
222
|
-
*/
|
|
223
|
-
export declare const makeSsgWrap: (locale: string, messages: MessageCatalog, defaultLocale: string) => ((children: ReactNode) => ReactNode);
|
|
256
|
+
export declare const makeSsgWrap: (locale: string, messages: MessageCatalog, defaultLocale: string) => ((children: ReactNode, request?: SsgWrapRequest) => ReactNode);
|
|
224
257
|
|
|
225
258
|
/**
|
|
226
259
|
* The positional `values` a message key requires: none for a plain message, a
|
|
@@ -278,6 +311,42 @@ export declare type PluralForms = {
|
|
|
278
311
|
readonly other: string;
|
|
279
312
|
};
|
|
280
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The instant the CURRENT render is reasoning about, in epoch milliseconds —
|
|
316
|
+
* or `undefined` for "the clock is live, read it yourself".
|
|
317
|
+
*
|
|
318
|
+
* `undefined` is the steady state. A number appears only between the server's
|
|
319
|
+
* render and the client's hydration commit, which is exactly the window in
|
|
320
|
+
* which the two sides must agree.
|
|
321
|
+
*/
|
|
322
|
+
export declare const RenderClockContext: Context<number | undefined>;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Build a children-to-tree wrapper that mounts `<I18nProvider>` with
|
|
326
|
+
* the supplied locale + catalog. The framework's SSG pipeline calls
|
|
327
|
+
* this once per build and passes the returned function as
|
|
328
|
+
* `renderPageToHtml({ outerWrap })`.
|
|
329
|
+
*
|
|
330
|
+
* For a multi-locale build the SSG emits per-locale HTML variants:
|
|
331
|
+
* `voltro build` calls this once per locale and, for each rendered
|
|
332
|
+
* page, picks `localeWraps.get(renderLocale)` — a `[locale]/…` route
|
|
333
|
+
* renders under its own `locale` param, a bare-path route under the
|
|
334
|
+
* `defaultLocale`. The client still swaps to the user's locale on
|
|
335
|
+
* hydration via the runtime's provider in `.framework/app.tsx`, but the
|
|
336
|
+
* emitted HTML is already locale-correct (right catalog + `meta({ locale })`).
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* Per-request facts the wrapper stamps onto the provider. Both vary per REQUEST
|
|
340
|
+
* while the wrapper itself is built once per LOCALE, which is why they arrive
|
|
341
|
+
* at call time rather than in the factory above.
|
|
342
|
+
*/
|
|
343
|
+
export declare interface SsgWrapRequest {
|
|
344
|
+
/** The IANA zone this request renders in — see `I18nProvider`'s `timeZone`. */
|
|
345
|
+
readonly timeZone?: string | undefined;
|
|
346
|
+
/** The server's render instant, so relative times survive hydration. */
|
|
347
|
+
readonly renderedAt?: number | undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
281
350
|
export declare const T: ({ id, ...rest }: TProps) => ReactNode;
|
|
282
351
|
|
|
283
352
|
/**
|
|
@@ -290,6 +359,28 @@ export declare const T: ({ id, ...rest }: TProps) => ReactNode;
|
|
|
290
359
|
*/
|
|
291
360
|
export declare const THEME_COOKIE = "voltro:theme";
|
|
292
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The cookie carrying the IANA timezone to render in (`Europe/Berlin`).
|
|
364
|
+
*
|
|
365
|
+
* Read by `resolveTimeZone` when the app configures `timeZone: 'viewer'`, and
|
|
366
|
+
* therefore by every SSR path — it is what lets the SERVER render in the
|
|
367
|
+
* viewer's zone instead of the pod's. Two writers are expected and both are
|
|
368
|
+
* correct:
|
|
369
|
+
*
|
|
370
|
+
* - the framework's pre-paint script, which seeds it from
|
|
371
|
+
* `Intl.DateTimeFormat().resolvedOptions().timeZone` when it is absent, so
|
|
372
|
+
* the zone is known from the second request onward with no login; and
|
|
373
|
+
* - the APP, at login, overwriting it with the zone it holds for the signed-in
|
|
374
|
+
* user (a profile field, an identity provider's `timeZone` claim). That is
|
|
375
|
+
* the authoritative one — a user travelling with a laptop keeps the zone
|
|
376
|
+
* their account says, not the airport's.
|
|
377
|
+
*
|
|
378
|
+
* Absent means "no answer yet", never "UTC": the resolver falls through to the
|
|
379
|
+
* app's `defaultTimeZone` so the two sides still agree on a value neither of
|
|
380
|
+
* them guessed.
|
|
381
|
+
*/
|
|
382
|
+
export declare const TIMEZONE_COOKIE = "voltro:tz";
|
|
383
|
+
|
|
293
384
|
declare type TProps = Omit<ComponentProps<typeof FormattedMessage>, 'id'> & {
|
|
294
385
|
readonly id: string;
|
|
295
386
|
};
|
|
@@ -328,11 +419,20 @@ export declare type TypedTProps<C extends MessageCatalog> = Omit<ComponentProps<
|
|
|
328
419
|
export declare const useFormatCurrency: (currency: string) => ((value: number, options?: Intl.NumberFormatOptions) => string);
|
|
329
420
|
|
|
330
421
|
/**
|
|
331
|
-
* Locale-aware date/time formatting bound to the active locale
|
|
422
|
+
* Locale-aware date/time formatting bound to the active locale AND the active
|
|
423
|
+
* zone.
|
|
332
424
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
425
|
+
* The zone comes from the provider (`useTimeZone`), which the framework fills
|
|
426
|
+
* from `app.config.ts`'s `timeZone` — either a fixed IANA name for everyone, or
|
|
427
|
+
* `'viewer'` to resolve each request against the signed-in user's own zone.
|
|
428
|
+
* Passing `timeZone` in the options overrides it for one call, which is what a
|
|
429
|
+
* value that genuinely belongs to a fixed zone wants (a store's opening hours,
|
|
430
|
+
* a scheduled broadcast).
|
|
431
|
+
*
|
|
432
|
+
* **With no zone pinned anywhere, each runtime uses its own** — the pod's on the
|
|
433
|
+
* server, the viewer's in the browser. That is fine for a client-rendered page
|
|
434
|
+
* and is a hydration mismatch under SSR, because the two are not the same zone
|
|
435
|
+
* and across midnight not even the same day.
|
|
336
436
|
*/
|
|
337
437
|
export declare const useFormatDate: () => ((value: DateInput, options?: Intl.DateTimeFormatOptions) => string);
|
|
338
438
|
|
|
@@ -342,6 +442,9 @@ export declare const useFormatNumber: () => ((value: number, options?: Intl.Numb
|
|
|
342
442
|
/** All formatters at once, for components that need several. */
|
|
343
443
|
export declare const useFormatters: () => {
|
|
344
444
|
readonly locale: string;
|
|
445
|
+
/** The pinned IANA zone, or `undefined` when the ambient runtime zone is in
|
|
446
|
+
* use — see `useTimeZone`. */
|
|
447
|
+
readonly timeZone: string | undefined;
|
|
345
448
|
readonly formatDate: ReturnType<typeof useFormatDate>;
|
|
346
449
|
readonly relativeTime: ReturnType<typeof useRelativeTime>;
|
|
347
450
|
readonly formatNumber: ReturnType<typeof useFormatNumber>;
|
|
@@ -369,11 +472,26 @@ export declare const usePlural: () => ((count: number, forms: PluralForms, optio
|
|
|
369
472
|
* Picks the largest unit that fits, so a 90-minute delta reads "1 hour ago", not
|
|
370
473
|
* "90 minutes ago". Anything under a second is "now" (via the `second` unit at
|
|
371
474
|
* 0), which avoids the "0 seconds ago" flicker hand-rolled versions produce.
|
|
475
|
+
*
|
|
476
|
+
* **Hydration-safe by construction.** The base instant is the server's render
|
|
477
|
+
* time for the server render and the hydration pass that has to match it, then
|
|
478
|
+
* the live clock once hydration commits — so the string cannot change between
|
|
479
|
+
* the HTML and the first client render, and does not stay frozen afterwards.
|
|
480
|
+
* Pass `{ now }` to state the base yourself; that always wins.
|
|
372
481
|
*/
|
|
373
482
|
export declare const useRelativeTime: () => ((value: DateInput, options?: Intl.RelativeTimeFormatOptions & {
|
|
374
483
|
readonly now?: DateInput;
|
|
375
484
|
}) => string);
|
|
376
485
|
|
|
486
|
+
/**
|
|
487
|
+
* The pinned render instant, or `undefined` when the clock is live.
|
|
488
|
+
*
|
|
489
|
+
* Call sites treat `undefined` as "use `Date.now()`" rather than defaulting to
|
|
490
|
+
* one, so a component outside any provider behaves exactly as it did before
|
|
491
|
+
* this context existed.
|
|
492
|
+
*/
|
|
493
|
+
export declare const useRenderClock: () => number | undefined;
|
|
494
|
+
|
|
377
495
|
export declare const useT: (id: string, values?: Record<string, string | number | boolean | Date | null | undefined>) => string;
|
|
378
496
|
|
|
379
497
|
/**
|
|
@@ -392,10 +510,24 @@ export declare const useT: (id: string, values?: Record<string, string | number
|
|
|
392
510
|
*/
|
|
393
511
|
export declare const useTFn: () => (id: string, values?: Record<string, string | number | boolean | Date | null | undefined>) => string;
|
|
394
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The IANA zone the active provider renders in, or `undefined` when none is
|
|
515
|
+
* pinned and the ambient runtime zone is in use.
|
|
516
|
+
*
|
|
517
|
+
* `undefined` is a real answer and worth branching on: it means the server and
|
|
518
|
+
* the browser are each using their OWN zone, so a server-rendered timestamp is
|
|
519
|
+
* not guaranteed to survive hydration. Set `timeZone` in the web
|
|
520
|
+
* `app.config.ts` to make it a value.
|
|
521
|
+
*/
|
|
522
|
+
export declare const useTimeZone: () => string | undefined;
|
|
523
|
+
|
|
395
524
|
/** A validated ICU arg name, or `never` when the candidate isn't a real
|
|
396
525
|
* identifier. This is the guard that keeps the type-level parse from emitting a
|
|
397
526
|
* plural/select branch's TEXT as a phantom required var — an ICU arg name can't
|
|
398
527
|
* contain spaces/`#`/`—`, so anything that does is dropped. */
|
|
399
528
|
declare type ValidICUName<S extends string> = IsICUIdentifier<S> extends true ? S : never;
|
|
400
529
|
|
|
530
|
+
/** The `app.config.ts` sentinel meaning "resolve per request from the viewer". */
|
|
531
|
+
export declare const VIEWER_TIME_ZONE = "viewer";
|
|
532
|
+
|
|
401
533
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { a as e, i as t, n, o as r, r as i, t as a } from "./cookieNames-CqdYWVpp.js";
|
|
2
|
+
import { createContext as o, createElement as s, useCallback as c, useContext as l, useEffect as u, useMemo as d, useState as f } from "react";
|
|
3
|
+
import { FormattedMessage as p, IntlProvider as m, useIntl as h } from "react-intl";
|
|
4
|
+
import { jsx as g } from "react/jsx-runtime";
|
|
5
5
|
//#region src/instanceRegistry.ts
|
|
6
|
-
var
|
|
7
|
-
let e = globalThis, t = e[
|
|
6
|
+
var _ = "__voltro_i18n_instances__", v = () => {
|
|
7
|
+
let e = globalThis, t = e[_];
|
|
8
8
|
if (t !== void 0) return t;
|
|
9
9
|
let n = { records: [] };
|
|
10
|
-
return e[
|
|
11
|
-
},
|
|
10
|
+
return e[_] = n, n;
|
|
11
|
+
}, y = () => {
|
|
12
12
|
try {
|
|
13
13
|
let e = String((/* @__PURE__ */ Error()).stack ?? "").split("\n");
|
|
14
14
|
for (let t of e.slice(3)) {
|
|
@@ -17,60 +17,71 @@ var d = "__voltro_i18n_instances__", f = () => {
|
|
|
17
17
|
}
|
|
18
18
|
} catch {}
|
|
19
19
|
return "unknown";
|
|
20
|
-
},
|
|
21
|
-
|
|
22
|
-
file:
|
|
20
|
+
}, b = (e) => {
|
|
21
|
+
v().records.push({
|
|
22
|
+
file: y(),
|
|
23
23
|
intlProvider: e
|
|
24
24
|
});
|
|
25
|
-
},
|
|
26
|
-
let e = globalThis[
|
|
25
|
+
}, x = () => {
|
|
26
|
+
let e = globalThis[_]?.records ?? [];
|
|
27
27
|
return {
|
|
28
28
|
instances: e.length,
|
|
29
29
|
reactIntlCopies: new Set(e.map((e) => e.intlProvider)).size,
|
|
30
30
|
files: [...new Set(e.map((e) => e.file))]
|
|
31
31
|
};
|
|
32
|
-
};
|
|
32
|
+
}, S = o(void 0), C = () => l(S);
|
|
33
33
|
//#endregion
|
|
34
34
|
//#region src/provider.tsx
|
|
35
|
-
m
|
|
36
|
-
var
|
|
37
|
-
let
|
|
35
|
+
b(m);
|
|
36
|
+
var w = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations: i = !0, timeZone: a, renderedAt: o, intlConfig: s, children: c }) => {
|
|
37
|
+
let l = i ? (e) => {
|
|
38
38
|
e.code !== "MISSING_TRANSLATION" && console.error(e);
|
|
39
|
-
} : void 0;
|
|
40
|
-
|
|
39
|
+
} : void 0, [d, p] = f(!1);
|
|
40
|
+
u(() => {
|
|
41
|
+
o !== void 0 && p(!0);
|
|
42
|
+
}, [o]);
|
|
43
|
+
let h = d ? void 0 : o, _ = r(a) ? a : void 0;
|
|
44
|
+
return /* @__PURE__ */ g(m, {
|
|
41
45
|
locale: e,
|
|
42
46
|
defaultLocale: n,
|
|
43
47
|
messages: t,
|
|
44
|
-
...
|
|
45
|
-
...
|
|
46
|
-
|
|
48
|
+
...l ? { onError: l } : {},
|
|
49
|
+
..._ ? { timeZone: _ } : {},
|
|
50
|
+
...s,
|
|
51
|
+
children: /* @__PURE__ */ g(S.Provider, {
|
|
52
|
+
value: h,
|
|
53
|
+
children: c
|
|
54
|
+
})
|
|
47
55
|
});
|
|
48
|
-
},
|
|
56
|
+
}, T = ({ id: e, ...t }) => /* @__PURE__ */ g(p, {
|
|
49
57
|
id: e,
|
|
50
58
|
...t
|
|
51
|
-
}),
|
|
52
|
-
let e =
|
|
53
|
-
return
|
|
54
|
-
},
|
|
59
|
+
}), E = (e, t) => h().formatMessage({ id: e }, t), D = () => h().locale, O = () => h().messages, k = () => {
|
|
60
|
+
let e = h();
|
|
61
|
+
return c((t, n) => e.formatMessage({ id: t }, n), [e]);
|
|
62
|
+
}, A = (e, t, n, r) => {
|
|
55
63
|
if (t === 0 && n.zero !== void 0) return n.zero.replace(/\{count\}/g, String(t));
|
|
56
64
|
let i = "other";
|
|
57
65
|
try {
|
|
58
66
|
i = new Intl.PluralRules(e, r).select(t);
|
|
59
67
|
} catch {}
|
|
60
68
|
return (n[i] ?? n.other).replace(/\{count\}/g, String(t));
|
|
61
|
-
},
|
|
62
|
-
let e =
|
|
63
|
-
return
|
|
64
|
-
},
|
|
65
|
-
let e =
|
|
66
|
-
return
|
|
69
|
+
}, j = () => {
|
|
70
|
+
let e = D();
|
|
71
|
+
return c((t, n, r) => A(e, t, n, r), [e]);
|
|
72
|
+
}, M = (e) => e instanceof Date ? e : new Date(e), N = () => h().timeZone, P = () => {
|
|
73
|
+
let e = D(), t = N();
|
|
74
|
+
return c((n, r) => {
|
|
67
75
|
try {
|
|
68
|
-
return new Intl.DateTimeFormat(e,
|
|
76
|
+
return new Intl.DateTimeFormat(e, {
|
|
77
|
+
...t ? { timeZone: t } : {},
|
|
78
|
+
...r
|
|
79
|
+
}).format(M(n));
|
|
69
80
|
} catch {
|
|
70
|
-
return String(
|
|
81
|
+
return String(n);
|
|
71
82
|
}
|
|
72
|
-
}, [e]);
|
|
73
|
-
},
|
|
83
|
+
}, [e, t]);
|
|
84
|
+
}, F = [
|
|
74
85
|
["year", 31536e6],
|
|
75
86
|
["month", 2592e6],
|
|
76
87
|
["week", 6048e5],
|
|
@@ -78,67 +89,69 @@ var g = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
|
|
|
78
89
|
["hour", 36e5],
|
|
79
90
|
["minute", 6e4],
|
|
80
91
|
["second", 1e3]
|
|
81
|
-
],
|
|
82
|
-
let e =
|
|
83
|
-
return
|
|
84
|
-
let { now:
|
|
92
|
+
], I = () => {
|
|
93
|
+
let e = D(), t = C();
|
|
94
|
+
return c((n, r) => {
|
|
95
|
+
let { now: i, ...a } = r ?? {}, o = M(n).getTime() - (i === void 0 ? t ?? Date.now() : M(i).getTime());
|
|
85
96
|
try {
|
|
86
97
|
let t = new Intl.RelativeTimeFormat(e, {
|
|
87
98
|
numeric: "auto",
|
|
88
|
-
...
|
|
99
|
+
...a
|
|
89
100
|
});
|
|
90
|
-
for (let [e, n] of
|
|
101
|
+
for (let [e, n] of F) if (Math.abs(o) >= n || e === "second") return t.format(Math.round(o / n), e);
|
|
91
102
|
return t.format(0, "second");
|
|
92
103
|
} catch {
|
|
93
|
-
return String(
|
|
104
|
+
return String(n);
|
|
94
105
|
}
|
|
95
|
-
}, [e]);
|
|
96
|
-
},
|
|
97
|
-
let e =
|
|
98
|
-
return
|
|
106
|
+
}, [e, t]);
|
|
107
|
+
}, L = () => {
|
|
108
|
+
let e = D();
|
|
109
|
+
return c((t, n) => {
|
|
99
110
|
try {
|
|
100
111
|
return new Intl.NumberFormat(e, n).format(t);
|
|
101
112
|
} catch {
|
|
102
113
|
return String(t);
|
|
103
114
|
}
|
|
104
115
|
}, [e]);
|
|
105
|
-
},
|
|
106
|
-
let t =
|
|
107
|
-
return
|
|
116
|
+
}, R = (e) => {
|
|
117
|
+
let t = L();
|
|
118
|
+
return c((n, r) => t(n, {
|
|
108
119
|
style: "currency",
|
|
109
120
|
currency: e,
|
|
110
121
|
...r
|
|
111
122
|
}), [t, e]);
|
|
112
|
-
},
|
|
113
|
-
let e =
|
|
114
|
-
return
|
|
123
|
+
}, z = () => {
|
|
124
|
+
let e = D(), t = N(), n = P(), r = I(), i = L(), a = j();
|
|
125
|
+
return d(() => ({
|
|
115
126
|
locale: e,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
timeZone: t,
|
|
128
|
+
formatDate: n,
|
|
129
|
+
relativeTime: r,
|
|
130
|
+
formatNumber: i,
|
|
131
|
+
plural: a
|
|
120
132
|
}), [
|
|
121
133
|
e,
|
|
122
134
|
t,
|
|
123
135
|
n,
|
|
124
136
|
r,
|
|
125
|
-
i
|
|
137
|
+
i,
|
|
138
|
+
a
|
|
126
139
|
]);
|
|
127
|
-
},
|
|
140
|
+
}, B = (e) => e, V = () => (e) => e, H = (e, t, n) => e[t ?? n] ?? e[n], U = /\{\s*([A-Za-z0-9_]+)/g, W = (e) => {
|
|
128
141
|
let t = /* @__PURE__ */ new Set();
|
|
129
|
-
for (let n of e.matchAll(
|
|
142
|
+
for (let n of e.matchAll(U)) t.add(n[1]);
|
|
130
143
|
return t;
|
|
131
|
-
},
|
|
144
|
+
}, G = (e, t) => {
|
|
132
145
|
let n = Object.keys(e);
|
|
133
146
|
if (n.length < 2) return;
|
|
134
147
|
let r = t?.baseLocale ?? n[0], i = e[r] ?? {}, a = [];
|
|
135
148
|
for (let [t, o] of Object.entries(i)) {
|
|
136
|
-
let i =
|
|
149
|
+
let i = W(o);
|
|
137
150
|
for (let o of n) {
|
|
138
151
|
if (o === r) continue;
|
|
139
152
|
let n = e[o]?.[t];
|
|
140
153
|
if (n === void 0) continue;
|
|
141
|
-
let s =
|
|
154
|
+
let s = W(n), c = [...i].filter((e) => !s.has(e)), l = [...s].filter((e) => !i.has(e));
|
|
142
155
|
if (c.length > 0 || l.length > 0) {
|
|
143
156
|
let e = [c.length > 0 ? `missing {${c.join("}, {")}}` : "", l.length > 0 ? `unexpected {${l.join("}, {")}}` : ""].filter(Boolean);
|
|
144
157
|
a.push(`${o} '${t}': ${e.join("; ")} (base ${r} has {${[...i].join("}, {")}})`);
|
|
@@ -152,21 +165,21 @@ var g = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
|
|
|
152
165
|
return;
|
|
153
166
|
}
|
|
154
167
|
throw Error(o);
|
|
155
|
-
},
|
|
168
|
+
}, K = (e) => {
|
|
156
169
|
let t = e;
|
|
157
170
|
return t.dynamic === void 0 && (t.dynamic = (t, n) => e(t, n)), t;
|
|
158
|
-
},
|
|
159
|
-
useT:
|
|
160
|
-
useTFn: (() =>
|
|
161
|
-
T
|
|
162
|
-
}),
|
|
171
|
+
}, q = () => ({
|
|
172
|
+
useT: K((e, t) => E(e, t)),
|
|
173
|
+
useTFn: (() => K(k())),
|
|
174
|
+
T
|
|
175
|
+
}), J = (e) => "default" in e && typeof e.default == "object" ? e.default : e, Y = (e, t) => {
|
|
163
176
|
let n = /* @__PURE__ */ new Map(), r = /* @__PURE__ */ new Map(), i = (n) => n && n in e ? n : t, a = (t) => {
|
|
164
177
|
let a = i(t), o = n.get(a);
|
|
165
178
|
if (o) return Promise.resolve(o);
|
|
166
179
|
let s = r.get(a);
|
|
167
180
|
if (s) return s;
|
|
168
181
|
let c = e[a], l = c().then((e) => {
|
|
169
|
-
let t =
|
|
182
|
+
let t = J(e);
|
|
170
183
|
return n.set(a, t), r.delete(a), t;
|
|
171
184
|
}).catch((e) => {
|
|
172
185
|
throw r.delete(a), e;
|
|
@@ -182,33 +195,35 @@ var g = ({ locale: e, messages: t, defaultLocale: n, silenceMissingTranslations:
|
|
|
182
195
|
a(e).catch(() => {});
|
|
183
196
|
}
|
|
184
197
|
};
|
|
185
|
-
},
|
|
186
|
-
let [
|
|
187
|
-
return
|
|
198
|
+
}, X = ({ catalogs: e, locale: t, fallback: n = null, silenceMissingTranslations: r, children: i }) => {
|
|
199
|
+
let [a, o] = f(() => e.peek(t));
|
|
200
|
+
return u(() => {
|
|
188
201
|
let n = e.peek(t);
|
|
189
202
|
if (n) {
|
|
190
|
-
|
|
203
|
+
o(n);
|
|
191
204
|
return;
|
|
192
205
|
}
|
|
193
206
|
let r = !0;
|
|
194
|
-
return
|
|
195
|
-
r &&
|
|
207
|
+
return o(void 0), e.load(t).then((e) => {
|
|
208
|
+
r && o(e);
|
|
196
209
|
}), () => {
|
|
197
210
|
r = !1;
|
|
198
211
|
};
|
|
199
|
-
}, [e, t]),
|
|
212
|
+
}, [e, t]), a ? /* @__PURE__ */ g(w, {
|
|
200
213
|
locale: t,
|
|
201
|
-
messages:
|
|
214
|
+
messages: a,
|
|
202
215
|
defaultLocale: e.defaultLocale,
|
|
203
216
|
...r === void 0 ? {} : { silenceMissingTranslations: r },
|
|
204
217
|
children: i
|
|
205
218
|
}) : n;
|
|
206
|
-
},
|
|
219
|
+
}, Z = (e, t, n) => (r, i) => s(w, {
|
|
207
220
|
locale: e,
|
|
208
221
|
messages: t,
|
|
209
222
|
defaultLocale: n,
|
|
210
223
|
silenceMissingTranslations: !0,
|
|
224
|
+
...i?.timeZone === void 0 ? {} : { timeZone: i.timeZone },
|
|
225
|
+
...i?.renderedAt === void 0 ? {} : { renderedAt: i.renderedAt },
|
|
211
226
|
children: r
|
|
212
227
|
});
|
|
213
228
|
//#endregion
|
|
214
|
-
export {
|
|
229
|
+
export { t as DEFAULT_TIME_ZONE, w as I18nProvider, a as LOCALE_COOKIE, X as LazyI18nProvider, S as RenderClockContext, T, n as THEME_COOKIE, i as TIMEZONE_COOKIE, e as VIEWER_TIME_ZONE, G as assertCatalogParity, q as createTypedMessages, B as defineCatalog, Y as defineCatalogs, V as defineLocale, x as i18nInstanceReport, r as isSupportedTimeZone, Z as makeSsgWrap, H as pickCatalog, A as plural, R as useFormatCurrency, P as useFormatDate, L as useFormatNumber, z as useFormatters, D as useLocale, O as useMessages, j as usePlural, I as useRelativeTime, C as useRenderClock, E as useT, k as useTFn, N as useTimeZone };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/** The zone used when nothing else resolves. Always valid, never surprising —
|
|
2
|
+
* and, unlike the process zone, the SAME on the server and in the browser. */
|
|
3
|
+
export declare const DEFAULT_TIME_ZONE = "UTC";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Is `name` an IANA zone THIS runtime can format in?
|
|
7
|
+
*
|
|
8
|
+
* Runtime-scoped on purpose: the question that matters is not whether the name
|
|
9
|
+
* is in the tzdata spec but whether `Intl.DateTimeFormat` here accepts it. A
|
|
10
|
+
* Node built with a trimmed ICU answers differently from a browser, and the
|
|
11
|
+
* one that has to render is the one that gets asked.
|
|
12
|
+
*/
|
|
13
|
+
export declare const isSupportedTimeZone: (name: string | null | undefined) => name is string;
|
|
14
|
+
|
|
1
15
|
/**
|
|
2
16
|
* Build the per-request resolver: cookie > Accept-Language > default, then the
|
|
3
17
|
* matching wrapper.
|
|
@@ -11,6 +25,13 @@ export declare const makeSsrI18nResolver: (args: SsrI18nResolverArgs) => ((cooki
|
|
|
11
25
|
/** What a renderer needs to render one request in the right locale. */
|
|
12
26
|
export declare interface ResolvedSsrI18n {
|
|
13
27
|
readonly locale: string;
|
|
28
|
+
/**
|
|
29
|
+
* The IANA zone this request renders in, or `undefined` when the app pins
|
|
30
|
+
* none. The renderer PUBLISHES it (`<html data-voltro-tz>`) so the client's
|
|
31
|
+
* first render formats against the same value the server did — the same
|
|
32
|
+
* contract `<html lang>` has for the locale.
|
|
33
|
+
*/
|
|
34
|
+
readonly timeZone: string | undefined;
|
|
14
35
|
/** Mounts `<I18nProvider>` around the tree — `renderPageToHtml`'s `outerWrap`. */
|
|
15
36
|
readonly outerWrap: (children: unknown) => unknown;
|
|
16
37
|
}
|
|
@@ -32,13 +53,67 @@ export declare interface ResolveLocaleArgs {
|
|
|
32
53
|
readonly defaultLocale: string;
|
|
33
54
|
}
|
|
34
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the IANA zone this request renders in, or `undefined` when the app
|
|
58
|
+
* pins none.
|
|
59
|
+
*
|
|
60
|
+
* Every candidate is validated before it is returned, so an unusable value
|
|
61
|
+
* (stale cookie naming a zone this runtime's ICU does not carry, a typo in
|
|
62
|
+
* `app.config.ts`) falls THROUGH to the next signal rather than reaching
|
|
63
|
+
* `Intl.DateTimeFormat` — which would throw and degrade every date in the app
|
|
64
|
+
* to a raw `Date` string. Same treatment `resolveLocale` gives a cookie naming
|
|
65
|
+
* a locale we no longer ship.
|
|
66
|
+
*/
|
|
67
|
+
export declare const resolveTimeZone: (args: ResolveTimeZoneArgs) => string | undefined;
|
|
68
|
+
|
|
69
|
+
export declare interface ResolveTimeZoneArgs {
|
|
70
|
+
/** Raw `Cookie:` request header, or null/undefined when absent. */
|
|
71
|
+
readonly cookieHeader?: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* The web app's `timeZone` setting: an IANA name to pin one zone for every
|
|
74
|
+
* viewer, or `'viewer'` to resolve per request from the `voltro:tz` cookie.
|
|
75
|
+
* Absent means the app has not opted in — the result is `undefined` and each
|
|
76
|
+
* runtime keeps using its own zone, which is the pre-existing behaviour.
|
|
77
|
+
*/
|
|
78
|
+
readonly configured?: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* The answer when `'viewer'` yields nothing: the very first request of a
|
|
81
|
+
* session (no cookie yet) and every prerendered page (one document, all
|
|
82
|
+
* viewers). Defaults to `UTC` — arbitrary, but identical on both sides, which
|
|
83
|
+
* is the property that matters.
|
|
84
|
+
*/
|
|
85
|
+
readonly defaultTimeZone?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
export declare interface SsrI18nResolverArgs {
|
|
36
|
-
/**
|
|
37
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Per-locale `<I18nProvider>` wrappers, built by the caller with
|
|
91
|
+
* `makeSsgWrap`. The second parameter carries the per-REQUEST facts (zone,
|
|
92
|
+
* render instant); the wrapper itself is built once per LOCALE, which is why
|
|
93
|
+
* they cannot live in the factory.
|
|
94
|
+
*/
|
|
95
|
+
readonly wraps: Readonly<Record<string, (children: unknown, request?: {
|
|
96
|
+
readonly timeZone?: string | undefined;
|
|
97
|
+
readonly renderedAt?: number | undefined;
|
|
98
|
+
}) => unknown>>;
|
|
38
99
|
/** Locale codes the app ships catalogs for. */
|
|
39
100
|
readonly supported: ReadonlyArray<string>;
|
|
40
101
|
/** MUST be in `supported`. */
|
|
41
102
|
readonly defaultLocale: string;
|
|
103
|
+
/** The app's `timeZone` setting — an IANA name, or `'viewer'`. See
|
|
104
|
+
* `resolveTimeZone`. */
|
|
105
|
+
readonly timeZone?: string | null;
|
|
106
|
+
/** Fallback when `'viewer'` yields nothing. See `resolveTimeZone`. */
|
|
107
|
+
readonly defaultTimeZone?: string | null;
|
|
108
|
+
/**
|
|
109
|
+
* The render instant, injected for tests. Production leaves it unset and each
|
|
110
|
+
* request stamps its own `Date.now()` — the point of the stamp is that the
|
|
111
|
+
* CLIENT reuses the server's number, not that the number is fixed.
|
|
112
|
+
*/
|
|
113
|
+
readonly now?: () => number;
|
|
42
114
|
}
|
|
43
115
|
|
|
116
|
+
/** The `app.config.ts` sentinel meaning "resolve per request from the viewer". */
|
|
117
|
+
export declare const VIEWER_TIME_ZONE = "viewer";
|
|
118
|
+
|
|
44
119
|
export { }
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as e, i as t, o as n, r, t as i } from "./cookieNames-CqdYWVpp.js";
|
|
2
2
|
//#region src/server.ts
|
|
3
|
-
var
|
|
3
|
+
var a = i, o = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`), s = (e, t) => {
|
|
4
4
|
if (!e) return;
|
|
5
|
-
let
|
|
6
|
-
return
|
|
7
|
-
},
|
|
5
|
+
let n = e.match(o(t));
|
|
6
|
+
return n ? decodeURIComponent(n[1]) : void 0;
|
|
7
|
+
}, c = (e, t) => {
|
|
8
8
|
let n = e.toLowerCase();
|
|
9
9
|
for (; n.length > 0;) {
|
|
10
10
|
let e = t.find((e) => e.toLowerCase() === n);
|
|
@@ -13,7 +13,7 @@ var t = e, n = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$
|
|
|
13
13
|
if (r < 0) return;
|
|
14
14
|
n = n.slice(0, r);
|
|
15
15
|
}
|
|
16
|
-
},
|
|
16
|
+
}, l = (e) => e ? e.split(",").map((e) => {
|
|
17
17
|
let [t, ...n] = e.split(";").map((e) => e.trim());
|
|
18
18
|
if (!t || t === "*") return null;
|
|
19
19
|
let r = 1;
|
|
@@ -28,31 +28,45 @@ var t = e, n = (e) => RegExp(`(?:^|;\\s*)${e.replace(/[.*+?^${}()|[\]\\]/g, "\\$
|
|
|
28
28
|
tag: t,
|
|
29
29
|
q: r
|
|
30
30
|
};
|
|
31
|
-
}).filter((e) => e !== null).sort((e, t) => t.q - e.q) : [],
|
|
32
|
-
let { cookieHeader:
|
|
33
|
-
if (
|
|
34
|
-
let e =
|
|
31
|
+
}).filter((e) => e !== null).sort((e, t) => t.q - e.q) : [], u = (e) => {
|
|
32
|
+
let { cookieHeader: t, acceptLanguageHeader: n, supported: r, defaultLocale: i } = e, o = s(t, a);
|
|
33
|
+
if (o) {
|
|
34
|
+
let e = c(o, r);
|
|
35
35
|
if (e) return e;
|
|
36
36
|
}
|
|
37
|
-
for (let e of
|
|
38
|
-
let t =
|
|
37
|
+
for (let e of l(n)) {
|
|
38
|
+
let t = c(e.tag, r);
|
|
39
39
|
if (t) return t;
|
|
40
40
|
}
|
|
41
|
-
return
|
|
42
|
-
},
|
|
41
|
+
return i;
|
|
42
|
+
}, d = (e) => {
|
|
43
|
+
let { cookieHeader: t, configured: i, defaultTimeZone: a } = e;
|
|
44
|
+
if (!i) return;
|
|
45
|
+
if (i !== "viewer") return n(i) ? i : n(a) ? a : "UTC";
|
|
46
|
+
let o = s(t, r);
|
|
47
|
+
return n(o) ? o : n(a) ? a : "UTC";
|
|
48
|
+
}, f = (e) => {
|
|
43
49
|
let { wraps: t, supported: n, defaultLocale: r } = e;
|
|
44
|
-
return Object.keys(t).length === 0 ? () => void 0 : (
|
|
45
|
-
let
|
|
46
|
-
cookieHeader:
|
|
47
|
-
acceptLanguageHeader:
|
|
50
|
+
return Object.keys(t).length === 0 ? () => void 0 : (i, a) => {
|
|
51
|
+
let o = u({
|
|
52
|
+
cookieHeader: i ?? null,
|
|
53
|
+
acceptLanguageHeader: a ?? null,
|
|
48
54
|
supported: n,
|
|
49
55
|
defaultLocale: r
|
|
50
|
-
}), s =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
}), s = d({
|
|
57
|
+
cookieHeader: i ?? null,
|
|
58
|
+
configured: e.timeZone ?? null,
|
|
59
|
+
defaultTimeZone: e.defaultTimeZone ?? null
|
|
60
|
+
}), c = (e.now ?? Date.now)(), l = t[o] ?? t[r];
|
|
61
|
+
if (l !== void 0) return {
|
|
62
|
+
locale: o,
|
|
63
|
+
timeZone: s,
|
|
64
|
+
outerWrap: (e) => l(e, {
|
|
65
|
+
timeZone: s,
|
|
66
|
+
renderedAt: c
|
|
67
|
+
})
|
|
54
68
|
};
|
|
55
69
|
};
|
|
56
70
|
};
|
|
57
71
|
//#endregion
|
|
58
|
-
export {
|
|
72
|
+
export { t as DEFAULT_TIME_ZONE, e as VIEWER_TIME_ZONE, n as isSupportedTimeZone, f as makeSsrI18nResolver, u as resolveLocale, d as resolveTimeZone };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Voltro's i18n layer — thin opinionated wrap over react-intl. Provides the framework's locale-resolution conventions (voltro:locale cookie + Accept-Language fallback), a typed message-catalog helper, and a slim provider component. Power users can import directly from react-intl for advanced APIs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|