feat(client): typed locale standard seat in the slot framework

Registrations declare a dictionary namespace (locale: NS) and the renderer
synthesizes a typed t prop for the entry's component from the installed
LocaleFace; the seat binding is re-derived per locale revision, so a language
switch hands out fresh t references and memoized consumers re-render through
ordinary shallow comparison. LocaleNamespaceMap is the declare-merge table
(namespace -> dictionary key union); TranslateNS<'ns'> is the
namespace-addressed translate type (namespace keys plus the shared common
vocabulary), carried by the t seat and by the locale service's typed bind.

LocaleService implements the face (lookup ns -> common -> zh -> key,
revision-carrying snapshots with subscriber isolation) and installs it
through the boot-once slots.installLocale seam, mirroring the renderer
install. The typed register(ns, {zh, en}) overload checks each dictionary
against the namespace's key union and requires every shipped locale, so a
missing or extra key and an unbalanced translation are compile errors.
Dictionary registration bumps the face revision without emitting
locale/change — the event now means exactly 'the active locale switched',
so registration-heavy boot cannot storm event listeners.
This commit is contained in:
imccyu
2026-07-30 01:04:56 +08:00
parent 1f242753ec
commit c317fbc489
44 changed files with 925 additions and 189 deletions
+84 -8
View File
@@ -24,6 +24,67 @@ export * from './deferred.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
/**
* Locale namespace table. Dictionary owners extend via declaration merging
* (exactly like {@link SlotMap}, and declared in this entry module for the
* same lexical-merge reason): the key is the namespace string, the value is
* the union of its dictionary keys. Register sites declare one of these
* namespaces (`locale:`), which puts the typed `t` standard seat on the
* component props.
*/
export interface LocaleNamespaceMap {}
/**
* Translate a dictionary key with optional `{name}` template params.
* `K` narrows the accepted keys to the owning namespace's dictionary union
* (plus the shared common vocabulary where composed).
*/
export type Translate<K extends string = string> =
(key: K, params?: Record<string, unknown>) => string
/**
* The shared `common` vocabulary keys as merged by the locale plugin;
* resolves to `never` in programs without the merge (this package's tests),
* keeping the union collapse harmless.
*/
export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never
/**
* Key domain of a namespace-bound translate: the namespace's own dictionary
* union plus the shared common vocabulary (the lookup chain consults common
* after the namespace misses).
*/
export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> =
(LocaleNamespaceMap[N] & string) | CommonKeyOf
/**
* Namespace-addressed translate — the developer-facing alias over
* {@link Translate}: `TranslateNS<'model'>` is the translate function of the
* `model` namespace (key domain = its dictionary union plus the shared
* common vocabulary), the exact type of the framework-injected `t` seat and
* of the locale service's typed `bind`.
*/
export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>
/**
* Dictionary shape for a declared namespace: exactly the keys the namespace
* merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed
* registration site is a compile error.
*/
export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> =
Record<LocaleNamespaceMap[N] & string, string>
/**
* Locale share of the composed component props: the framework-injected `t`
* seat, present exactly on entries whose registration declares `locale:`.
*/
export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string
? {
/** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */
t: TranslateNS<N>
}
: object
/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
@@ -244,10 +305,11 @@ export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
/**
* The four-share component props intersection: runtime share (SlotMap) +
* The composed component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face (its hooks compartment bound, see
* {@link InjectFace}). Each share derives from its single source of truth;
* {@link InjectFace}) + the locale `t` seat (declared namespace, see
* {@link PropsLocale}). Each share derives from its single source of truth;
* components reference this composition, never re-type it.
*/
export type ComposedProps<
@@ -256,7 +318,8 @@ export type ComposedProps<
H,
I extends object,
M = never,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
N = undefined,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
/**
* Inject factory parameter list, derived from the registration's declaration:
@@ -303,13 +366,20 @@ type RendersCheck<C, D> =
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/**
* Dictionary namespace of this entry's copy. Declaring it puts the
* framework-synthesized `t` seat (typed to the namespace's dictionary
* union) on the component props; rendering requires an installed locale
* face — fails loud otherwise.
*/
locale?: N
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K], M>
@@ -330,6 +400,8 @@ export interface StoredEntry {
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */
locale?: string | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
@@ -350,6 +422,7 @@ interface ErasedOptions {
priority?: number | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
locale?: string | undefined
/* eslint-disable-next-line @typescript-eslint/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
@@ -432,11 +505,12 @@ export class SlotCore {
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject?: undefined },
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/**
@@ -455,11 +529,12 @@ export class SlotCore {
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
register(options: ErasedOptions, component: unknown): () => void {
@@ -523,6 +598,7 @@ export class SlotCore {
...(options.inject !== undefined ? { inject: options.inject } : {}),
...(options.children !== undefined ? { children: options.children } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.locale !== undefined ? { locale: options.locale } : {}),
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
}
const next = [...rec.entries, entry]
+32 -1
View File
@@ -1,6 +1,31 @@
/** React-free contracts between the slot host and an installed renderer. */
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
/**
* The locale face the render machinery consumes: namespace binding plus an
* observable revision (getSnapshot/subscribe pair — the same HostObservable
* currency as every other standard-kit source). The revision moves on every
* active-locale or registry change; the renderer re-derives each entry's `t`
* from (namespace, revision), so a locale switch hands out NEW function
* references and memoized components re-render naturally. Implemented by the
* locale plugin, installed through the runtime SlotsService (installLocale).
* Install before the first render that needs the seat: outlets bind their
* revision subscription at mount, and a face appearing later has no channel
* to notify already-mounted outlets (the locale plugin is immediately-tier
* infrastructure, so normal compositions install during boot).
*/
export interface LocaleFace extends HostObservable<{ revision: number }> {
/**
* Bind a namespace to a translate function reading the active locale at
* call time. Identity may be stable per namespace — freshness of rendered
* text is carried by the renderer's (ns, revision) seat derivation, not by
* this binding.
* @param ns - dictionary namespace.
* @returns the namespace-bound translate function.
*/
bind(ns: string): Translate
}
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
@@ -128,6 +153,12 @@ export interface SlotRendererHost {
/** Workspace list source backing the useWorkspaces standard hook. */
list: HostObservable<unknown>
}
/**
* Installed locale face backing the `t` standard seat (absent until the
* locale plugin installs one; rendering an entry that declared `locale:`
* without it is an assembly failure).
*/
locale?: LocaleFace | undefined
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */