diff --git a/.changeset/internationalization-provider-contract.md b/.changeset/internationalization-provider-contract.md new file mode 100644 index 0000000000000..76125ff258bd4 --- /dev/null +++ b/.changeset/internationalization-provider-contract.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[docs] InternationalizationProvider documentation now names its nonvisual provider boundary so the new component contract can map its anatomy without implying rendered DOM. (#6048) + +@nynexman4464 diff --git a/docs/architecture/internationalization.md b/docs/architecture/internationalization.md new file mode 100644 index 0000000000000..a727217fee816 --- /dev/null +++ b/docs/architecture/internationalization.md @@ -0,0 +1,196 @@ +--- +schema_version: 1 +template_version: 1 +kind: architecture +id: architecture:internationalization +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +owners: [cixzhang, nynexman4464] +applies_to: + [ + packages/core/src/i18n/, + packages/core/locales/, + packages/core/src/utils/plainDate.ts, + packages/core/src/utils/dateParser.ts, + internal/eslint-plugin-astryx/, + scripts/check-i18n-catalog.mjs, + ] +verified_by: + [ + packages/core/src/i18n/__tests__/resolve.test.ts, + packages/core/src/i18n/__tests__/useLocale.test.tsx, + packages/core/src/i18n/__tests__/useDirection.test.tsx, + packages/core/src/i18n/__tests__/getLocaleDirection.test.ts, + packages/core/src/i18n/__tests__/useCollator.test.tsx, + packages/core/src/i18n/useTranslator.test.tsx, + scripts/check-i18n-catalog.test.mjs, + internal/eslint-plugin-astryx/no-hardcoded-i18n-string.test.mjs, + internal/eslint-plugin-astryx/no-raw-intl-locale.test.mjs, + ] +deciding_specs: [] +--- + +# Internationalization architecture + +## Purpose + +Astryx localizes the system-owned text, formatting, comparison, and text direction +of its components without becoming a general application internationalization +framework. Applications continue to own product copy and may use any application +i18n runtime alongside Astryx. + +This record captures the released contract that grew from RFC #3641 and the +implementation that followed. Consumer setup and examples remain in +`astryx docs internationalization`. + +## System model + +```text +consumer content prop + → wins when the component exposes semantic specialization + +InternationalizationProvider + → active locale + → optional locale catalogs + → optional per-locale Astryx overrides + → optional direction override + +Astryx-owned string or locale-sensitive operation + → provider-bound translator / locale / collator + → exact locale + → parent locale(s) + → shipped English fallback +``` + +`@astryxdesign/core/i18n` is the canonical public entry point. The released +surface consists of `InternationalizationProvider`, `InternationalizationContext`, +their public value/prop types, `useTranslator`, `TranslatorFn`, `useLocale`, +`useCollator`, `useDirection`, `getLocaleDirection`, catalog types, and the +`Translator` interface. + +## Boundaries and invariants + +- **INV1 — Astryx owns Astryx strings, not application copy.** Component-owned + labels, announcements, instructions, and assistive text use Astryx catalogs. + Product content remains caller-owned. A content prop wins when Astryx exposes + it for semantic specialization. +- **INV2 — The released provider and hooks are canonical.** + `InternationalizationProvider` plus the hooks under + `@astryxdesign/core/i18n` remain the supported runtime. A replacement provider, + hook, subpath, or catalog ownership model needs an explicit compatibility and + migration decision; a parallel public runtime is not added by convention. +- **INV3 — No provider is deterministic English.** Components used outside a + provider resolve Astryx strings from the shipped English catalog, expose locale + `en`, direction `ltr`, and do not read the browser or host locale implicitly. +- **INV4 — Runtime locale changes are live.** Re-rendering the provider with a new + locale, messages, overrides, or direction updates consumers in that subtree. + Astryx does not persist or select the application's locale. +- **INV5 — Fallback is ordered and silent for expected gaps.** For a valid BCP + 47 locale, resolution checks per-locale overrides from exact to parent locale, + then supplied catalogs from exact to parent locale, then shipped English. A + missing non-English translation silently falls back. A key absent from every + source including English is a defect: development warns once and renders the + key visibly. Malformed locale tags are outside this fallback guarantee and may + throw when platform formatters or collators are constructed. +- **INV6 — English is the source contract.** `packages/core/locales/en.json` + defines Astryx's key set, default messages, descriptions, and ICU runtime + contracts. Translation catalogs may omit keys and fall back to English; they + may not add stale keys or change the argument, select, plural, ordinal, tag, or + nesting contract of a translated entry. +- **INV7 — Messages produce strings.** Astryx catalogs use ICU MessageFormat 1 + and `intl-messageformat`. The supported translation result is a string suitable + for visible text and attributes such as `aria-label` and `title`. Rich React + nodes, functions, and application-owned message objects are outside this + runtime unless a later accepted contract adds them. +- **INV8 — Provider locale owns locale-sensitive behavior.** Astryx-owned date, + time, number, relative-time, list, collation, speech-recognition, and similar + operations receive the active provider locale explicitly. Production code does + not substitute `navigator.language`, an omitted `Intl` locale, or a hardcoded + locale for that value. +- **INV9 — Pure helpers have deterministic compatibility defaults.** A public pure + formatter that predates provider threading may keep an optional locale for + compatibility, but its omitted value resolves deterministically to English. + Astryx-owned call sites pass the provider locale explicitly. New pure helpers + require an explicit locale unless a current contract records another default. +- **INV10 — PlainDate component semantics remain Gregorian.** Locale changes + language, numbering, and field order; it MUST NOT silently change the calendar + used by Astryx `PlainDate` parsing, arithmetic, constraints, grids, or + navigation. The low-level `plainDateFormat` compatibility surface may honor an + explicitly supplied display calendar without claiming broader calendar + support. Instant-display utilities that currently delegate calendar selection + to `Intl` do not establish a first-class alternative-calendar contract; this + record does not decide that broader behavior. +- **INV11 — Rendered direction comes from the DOM.** The provider exposes a + semantic direction default, but Astryx layout, mirroring, and interaction SHOULD + resolve from the rendered region through CSS logical properties, + direction-conditioned CSS, or a lazy DOM read. `useDirection()` is a render-time + last resort and MUST NOT replace the DOM as the ordinary source for component + geometry or behavior, because provider direction can disagree with `` + and create first-paint or hydration errors. +- **INV12 — Locale and direction are related but distinct.** The provider derives + a default direction from locale and accepts an explicit override. It does not + mutate DOM `dir`. Applications own the page or region `dir`, and must keep it + aligned with the provider when they want Astryx layout and browser text flow to + share a direction. +- **INV13 — Client context is the current translation runtime.** The shipped + translator and locale hooks are client-context APIs. Pure helpers such as + `getLocaleDirection` remain server-safe. A server/RSC translation runtime is a + separate API decision, not an implied capability of the current hooks. +- **INV14 — External-runtime integration is additive.** Applications may run + another i18n provider alongside Astryx. A future adapter may delegate Astryx + formatting to an application runtime while preserving Astryx key lookup, + fallback, string results, and released provider compatibility. Astryx does not + absorb application catalogs merely to offer a general-purpose i18n framework. + +## Change coupling + +Changes to the provider, context, public i18n barrel, resolver, catalog schema, +source catalog, locale-aware helpers, catalog validation, or i18n lint rules must +review this record. A change preserves the architecture only when its fallback, +ownership, deterministic defaults, runtime-update behavior, and output type remain +true. + +A new public provider or hook, application-catalog ownership, rich-message output, +server translation runtime, implicit host-locale behavior, or non-Gregorian +component semantics is a new human API or ownership decision. It cannot be +inferred from an implementation pull request. + +## Owning code + +- `packages/core/src/i18n/` — provider, context, public hooks, locale-direction + derivation, catalog types, and lookup/formatting runtime. +- `packages/core/locales/en.json` — source key set, default English messages, and + translator descriptions. +- `packages/core/locales/*.json` — partial translated catalogs. +- `scripts/check-i18n-catalog.mjs` — source/translation key, syntax, runtime + contract, and plural validation. +- `internal/eslint-plugin-astryx/no-hardcoded-i18n-string.js` — routes + Astryx-owned user-facing strings through the catalog. +- `internal/eslint-plugin-astryx/i18n-key-format.js` — enforces the + `@astryx.` namespace and camelCase catalog-key segments. This rule currently + lacks its own focused test file; adding one is a verification gap. +- `internal/eslint-plugin-astryx/no-raw-intl-locale.js` — requires provider locale + at Astryx-owned locale-sensitive call sites. +- Component contracts — own which semantic content is Astryx-authored versus + caller-authored and which content props specialize a component. + +## Deciding specs + +No current system spec changes this shipped architecture. RFC #3641 is historical +design context; this record captures the released result and its deliberate +deltas. + +## Verification + +| Invariant | Evidence | Failure signal | +| ------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1, INV7 | hardcoded-string lint, catalog tests, component translation tests | Astryx-owned text bypasses catalogs, or non-string output reaches visible/AT attributes | +| INV2, INV13, INV14 | export drift checks, public i18n tests, compatibility review | A parallel/replacement runtime lands without migration, or client hooks claim server behavior | +| INV3–INV5 | resolver and provider rerender tests | no-provider output depends on the host, locale swaps stay stale, fallback order changes, or malformed-locale behavior is described as graceful | +| INV6 | `check:i18n-catalog`, key-format lint, and focused mutation tests | stale/malformed keys, malformed ICU, or source/translation runtime-contract drift passes CI | +| INV8, INV9 | raw-Intl-locale lint and provider-locale regression tests | Astryx output follows the host locale or an owned call omits locale | +| INV10 | PlainDate helper and component locale tests | locale selection changes PlainDate arithmetic/calendar semantics or an explicit display override is ignored | +| INV11, INV12 | direction helper/provider tests plus rendered RTL audit | component layout reads provider direction instead of the region, provider mutates DOM, or overrides stop composing | diff --git a/packages/core/src/i18n/InternationalizationProvider.doc.mjs b/packages/core/src/i18n/InternationalizationProvider.doc.mjs index 83741aa06ad6f..a6194e06461d4 100644 --- a/packages/core/src/i18n/InternationalizationProvider.doc.mjs +++ b/packages/core/src/i18n/InternationalizationProvider.doc.mjs @@ -21,6 +21,14 @@ export const docs = { usage: { description: 'Wraps your app to set the active locale and (optionally) merge additional translation catalogs + per-locale overrides. Astryx components inside the subtree resolve their strings against this context. If no provider is present, components fall back to the shipped English defaults.', + anatomy: [ + { + name: 'Provider boundary', + required: true, + description: + 'A nonvisual context boundary that supplies locale, message catalogs, overrides, and direction to descendant Astryx components. It renders no DOM element.', + }, + ], bestPractices: [ { guidance: true, @@ -40,7 +48,7 @@ export const docs = { { guidance: true, description: - 'Set the `dir` attribute on `` (or a wrapping element) yourself; the provider does not set it. Astryx components mirror layout and directional icons from the DOM `dir`, so an RTL locale won\'t visually mirror without it. Use `getLocaleDirection(locale)` to derive the value for both the provider and the DOM.', + "Set the `dir` attribute on `` (or a wrapping element) yourself; the provider does not set it. Astryx components mirror layout and directional icons from the DOM `dir`, so an RTL locale won't visually mirror without it. Use `getLocaleDirection(locale)` to derive the value for both the provider and the DOM.", }, { guidance: false, diff --git a/packages/core/src/i18n/InternationalizationProvider.spec.md b/packages/core/src/i18n/InternationalizationProvider.spec.md new file mode 100644 index 0000000000000..3a680724a0fc5 --- /dev/null +++ b/packages/core/src/i18n/InternationalizationProvider.spec.md @@ -0,0 +1,221 @@ +--- +schema_version: 3 +template_version: 4 +kind: component +id: component:InternationalizationProvider +authority: draft +archive_reason: null +superseded_by: null +approved_by: null +approved_at: null +owners: [cixzhang, nynexman4464] +review_triggers: [public-api, behavior, accessibility] +verified_by: + [ + packages/core/src/i18n/__tests__/resolve.test.ts, + packages/core/src/i18n/__tests__/useDirection.test.tsx, + packages/core/src/i18n/__tests__/getLocaleDirection.test.ts, + packages/core/src/i18n/__tests__/useLocale.test.tsx, + packages/core/src/i18n/useTranslator.test.tsx, + ] +modules: [] +families: [] +design_specs: [] +architecture: [architecture:internationalization] +contributing: [] +system_specs: [] +--- + +# InternationalizationProvider component contract + +## Intent + +InternationalizationProvider binds one subtree to Astryx's active locale, +translation catalogs, sparse Astryx string overrides, and direction default. It +lets Astryx components localize their own interface while applications keep +ownership of product copy and locale persistence. + +## Compatibility and migration + +- Released default preserved: `yes` — a tree with no provider continues to render + shipped English strings with locale `en` and direction `ltr`. +- Compatibility class: the released provider name, `@astryxdesign/core/i18n` + subpath, props, context behavior, and hooks are stable public API. +- Controlled/uncontrolled behavior: controlled. The caller supplies locale, + messages, overrides, and optional direction on every render. +- Migration decision: a replacement provider or hook requires an accepted + compatibility and migration decision under + `architecture:internationalization/INV2`. + +Consumer migration instructions belong in consumer docs and release notes. + +## Ownership boundary + +**Owns** + +- The active Astryx locale and optional explicit direction for one React subtree. +- Binding supplied locale catalogs and sparse overrides to a stable translator + function. +- Updating provider consumers when any provider input changes. +- The no-provider English/LTR context defaults exposed by the i18n entry point. + +**Does not own / non-goals** + +- Application locale selection, routing, persistence, or account preferences — + owned by the product. +- Product-authored strings and application catalogs — owned by product call sites + or their chosen i18n runtime. +- DOM `lang` or `dir` attributes — owned by the application document or region. +- A server/RSC translation context — unresolved separate API work. +- Rich React-node/function messages — outside the supported string catalog model. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| --------- | ------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Locale | Valid BCP 47 locale string | Selects lookup, ICU formatting, and provider-aware locale operations | Every provider subtree | Required on provider; `en` without one | `component:InternationalizationProvider` | stable | Callers must pass a valid tag; malformed tags may throw when formatting or collating, while static key lookup may still reach English | +| Messages | locale → catalog map | Adds translated or application-supplied catalogs without replacing shipped English | Every locale | `{}` | `component:InternationalizationProvider` | stable | Missing entries fall back through parent locales to English | +| Overrides | locale → sparse key/string map | Rewords individual Astryx strings before catalog lookup | Every locale | none | `component:InternationalizationProvider` | stable | `null` does not become visible content; unresolved keys continue through fallback | +| Direction | `ltr` or `rtl` | Exposes the provider's semantic direction default | Every provider subtree | derived from locale; `ltr` without provider | `component:InternationalizationProvider` | stable | Invalid values are rejected by the type contract | + +Consumer prop syntax and examples remain in +`InternationalizationProvider.doc.mjs` and `astryx docs internationalization`. + +## Behavioral and layout contract + +| ID | Candidate invariant | Basis | Draft review state | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------ | +| FR1 | The provider MUST resolve Astryx strings through exact-locale overrides, parent-locale overrides, exact/parent supplied catalogs, then shipped English. | RFC #3641, released resolver, tests | settled | +| FR2 | A missing translated key MUST fall back silently; a key absent from English MUST warn once in development and render the key visibly. | Released resolver and tests | settled | +| FR3 | Re-rendering with changed locale, messages, overrides, or direction MUST update the context value and translator used by descendants. | Released provider and rerender tests | settled | +| FR4 | The provider MUST NOT select, persist, or mutate application locale state. | Released API ownership | settled | +| FR5 | The provider MUST NOT set DOM `lang` or `dir`. Its direction value and the application's DOM direction are separate inputs the application keeps aligned. | `architecture:internationalization/INV12` | settled | +| FR6 | Without a provider, the public context and hooks MUST expose locale `en`, direction `ltr`, an empty supplied-catalog map, and a translator backed by shipped English. | Released context and tests | settled | +| FR7 | The provider MUST preserve a string-returning translator suitable for visible and assistive attributes. | Released catalog/runtime contract | settled | +| FR8 | A nested provider MUST replace locale, messages, overrides, and direction with values derived solely from the child provider's own props rather than merge parent context. | Current provider source; focused test still needed | settled | + +### Allowed variation + +- **AV1 — Catalog completeness.** A non-English supplied catalog may contain any + subset of English keys; missing entries use fallback. +- **AV2 — Locale specificity.** Callers may provide a regional, script, or base + locale. The resolver canonicalizes and walks the corresponding parent chain. +- **AV3 — Application integration.** Another i18n provider may wrap Astryx inside + or outside this provider as long as the application keeps locale state aligned. + +### Representative states + +| State | Required invariant | Allowed variation | +| --------------------------------- | ------------------ | ---------------------------------------- | +| no provider | FR6 | none | +| English provider | FR1–FR4, FR7 | messages and overrides may be omitted | +| exact translated locale | FR1–FR4, FR7 | complete or partial catalog | +| regional locale with base catalog | FR1–FR4, FR7 | number of parent tags | +| runtime locale swap | FR3 | application persistence mechanism | +| explicit direction override | FR3, FR5 | locale may remain LTR for testing | +| nested provider | FR1–FR8 | child props fully replace parent context | + +### Transformation and precedence order + +- **ORD1 — Message resolution.** Read exact-locale override → walk parent-locale + overrides → read exact-locale supplied catalog → walk parent supplied catalogs + → read shipped English → warn and render the key if absent everywhere. +- **ORD2 — Direction resolution.** Use explicit `dir` when supplied; otherwise + derive direction from locale; outside a provider use `ltr`. + +### Performance and resources + +- **PR1 — Stable translator identity.** A provider render with referentially + unchanged inputs SHOULD preserve its memoized context value and translator. +- **PR2 — Parsed message reuse.** ICU formatter instances MAY be cached by locale + and message while output remains equivalent to fresh formatting. + +## Accessibility contract + +- **AR1 — Assistive strings use the same resolution contract.** `aria-label`, + `title`, live announcements, and other AT-facing Astryx strings MUST use the + active translator and remain strings. +- **AR2 — Missing translations remain usable.** Expected non-English gaps MUST + fall back to meaningful English rather than remove an accessible name or + announcement. +- **AR3 — Direction remains available to browser semantics.** The provider MUST + document that applications set DOM `dir`; a context value alone does not fix + browser text flow, punctuation, focus order, or platform bidi behavior. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| -------------------- | ----------------------------------------------------------------------------- | ----------------------------------- | -------------- | ------------------ | +| Provider subtree | Product chooses the locale and region direction consistently. | application-owned | supporting | FR3–FR5 | +| Astryx system string | Astryx supplies a localized default; caller content may specialize semantics. | `architecture:internationalization` | supporting | FR1, FR7, AR1 | +| Missing translation | Interface remains understandable through English fallback. | `architecture:internationalization` | supporting | FR2, AR2 | + +### Theming anatomy + + + +```json +{ + "Provider boundary": { + "none": { + "reason": "intentional: InternationalizationProvider renders no DOM element and exposes no theming anatomy." + } + } +} +``` + +## Family and system relationships + +- `architecture:internationalization` owns cross-library catalog, locale, + formatter, date-calendar, output-type, and external-runtime boundaries. +- Component contracts own which strings are Astryx-authored and which content is + caller-authored. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------- | +| FR1, FR2, ORD1 | `resolve.test.ts` | exact, parent, English, missing source key | Reordering fallback or hiding a missing source key fails focused tests | `audit:InternationalizationProvider/behavior` | +| FR3, PR1 | provider/hook rerender tests | stable inputs and locale/message/override/dir swaps | A stale translator or needless identity churn fails hook tests | `audit:InternationalizationProvider/performance` | +| FR5, ORD2 | `useDirection.test.tsx`, `getLocaleDirection.test.ts` | derived, explicit, invalid locale, no provider | Setting DOM direction or ignoring explicit override fails source/tests | `audit:InternationalizationProvider/rtl` | +| FR6 | `useTranslator.test.tsx`, `useLocale.test.tsx`, `useDirection.test.tsx` | no provider | Host locale or missing English output fails | `audit:InternationalizationProvider/behavior` | +| FR7, AR1, AR2 | component translation tests and hardcoded-string lint | visible text, aria label, announcement, missing translation | Non-string output or untranslated owned text fails tests/lint | `audit:InternationalizationProvider/accessibility` | +| FR8 | focused nested-provider test to add before promotion | parent plus child provider | Parent messages, overrides, or direction leaking into the child fails | `audit:InternationalizationProvider/behavior` | + +## Decision log + +### DEC-1 — Keep the released Astryx runtime narrow + +**Reference:** `component:InternationalizationProvider/DEC-1` +**Decider:** `nynexman4464`, `2026年09月03日` + +Astryx keeps `InternationalizationProvider` and the released i18n hooks as its +canonical runtime. Astryx may support small applications, but it does not absorb +application-owned rich messages and functions to compete with full i18n +frameworks. Applications with larger needs use an established application i18n +runtime alongside Astryx; an adapter remains possible as additive follow-up. + +Rejected: the parallel `IntlProvider`/`useIntl` proposal in PR #5684. It replaced +a working released surface, blurred Astryx and application catalog ownership, +and added rich-message complexity without demonstrated Astryx need. + +## Open questions + +- **OQ1 — Nested provider regression test.** Add focused coverage proving that + child locale, messages, overrides, and direction replace rather than inherit + parent context before promotion. (`checkable`) +- **OQ2 — Malformed locale behavior.** Decide whether a future release should + reject invalid BCP 47 tags at the provider boundary or preserve today's + formatter-specific throws. (`human-api`) +- **OQ3 — External runtime adapter.** Decide the exact additive `Translator` + adapter contract, including who owns lookup, fallback, and formatter failures. + (`human-api`) +- **OQ4 — Server translation runtime.** Decide whether the small set of otherwise + server-safe consumers justifies a separate RSC translation API and request + isolation model. (`human-api`) + +## Content boundary + +This file does not duplicate consumer prop tables/examples, catalog validation +implementation, current translation coverage, or cross-library i18n rules. It +links to their owners.

AltStyle によって変換されたページ (->オリジナル) /