From fc2ca7e7dfef184ef19fb5adad68159ebcf90d27 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Wed, 25 Mar 2026 17:51:50 +1100 Subject: [PATCH] docs: add i18n design doc --- .gitignore | 1 + internal/design/i18n.md | 1041 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1042 insertions(+) create mode 100644 internal/design/i18n.md diff --git a/.gitignore b/.gitignore index d6723a328..ff05ce71f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Dependencies & Logs # ------------------------- node_modules/ +.pnpm-store/ logs/ .yarn/* *.log diff --git a/internal/design/i18n.md b/internal/design/i18n.md new file mode 100644 index 000000000..121c933e3 --- /dev/null +++ b/internal/design/i18n.md @@ -0,0 +1,1041 @@ +--- +status: draft +date: 2026-03-25 +--- + +# Internationalization (i18n) + +Opaque-key translation system with a global registry and ES module locale files for CDN, typed hooks for React. + +## Problem + +All `aria-label`, `aria-valuetext`, tooltip labels, and time unit strings in Video.js 10 are hardcoded English. `PlayButtonCore.getLabel()` returns `'play'`. `formatDuration` returns `'2 minutes, 30 seconds'`. There is no mechanism to supply translated strings without overriding each component's `label` prop individually, and no single language switch for skins. + +Requirements: + +- Single entry point — one API replaces all English defaults regardless of layer (HTML, React, CDN) +- Stable keys — renaming an English label must not require updating every locale file +- Typed keys — TypeScript autocomplete for all keys +- Decoupled — i18n providers are not embedded in skins; any skin works with any provider +- Works without skins — standalone VJS components (buttons, sliders, time elements used outside of ``) can consume a provider directly +- CDN-compatible — ES module locale files self-register with no bundler and no global namespace pollution +- No side-effect locale imports — bundlers don't accidentally include all languages +- Framework-agnostic core — the registry and translator live in `@videojs/core/i18n`, re-exported to consumers as `@videojs/html/i18n` and `@videojs/react/i18n` +- Locale-aware — native `Intl` APIs handle duration, number, and plural formatting + +## API + +### Registering translations + +The single entry point for both HTML and React consumers: + +```ts +// HTML +import { registerI18n } from '@videojs/html/i18n'; +import es from '@videojs/html/i18n/locales/es'; + +// React +import { registerI18n } from '@videojs/react'; +import es from '@videojs/react/i18n/locales/es'; + +registerI18n('es', es); +``` + +`registerI18n` merges (does not replace) — multiple calls for the same language are additive. English defaults (`en.ts`) are pre-registered as the base layer on module import. + +### HTML + +`` reads from the nearest ancestor `lang` attribute, matching the inheritance model of the native HTML `lang` attribute. Setting `lang` on `` — or any ancestor — is enough: + +```html + + + + + + + + + + + +``` + +An explicit `lang` attribute on the provider overrides the inherited value — useful when a single page hosts players in different languages: + +```html + + + + + + + + + +``` + +The element reads from the registry for whichever `lang` is active. No changes to `` or any other skin element. + +**CDN** + +Locale files are self-registering ES modules. They import `registerI18n` from the same CDN module URL, sharing the same registry instance — no global namespace pollution: + +```html + + + + + + + + + + + + +``` + +```js +// es.js — self-registering, no window touch +import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js'; +registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ }); +``` + +**Custom registration** + +```ts +// Override individual keys +registerI18n('es', { play: 'Comenzar' }); + +// Or override a built-in pack entirely +import myEs from './my-es'; +registerI18n('es', myEs); +``` + +### React + +`I18nProvider` is a standalone component. Consumers add it explicitly inside the player's `Provider` — the skin itself has no i18n awareness. When `locale` is omitted, it reads from the nearest ancestor `lang` attribute (typically ``): + +```tsx +import { I18nProvider } from '@videojs/react'; +import { VideoSkin, Video } from '@videojs/react/video'; + +// locale inherited from + + + + + + +``` + +Pass `locale` explicitly to override inheritance, or `translations` to bypass the registry entirely: + +```tsx +import es from '@videojs/react/i18n/locales/es'; + + + + + + + +``` + +**SSR / no flash-of-English** — pass `translations` directly on first render: + +```tsx +// Server component +const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`); + + + + + + + +``` + +**Dynamic switching** + +Just flip `locale` — the provider lazy-loads the built-in pack for the new locale: + +```tsx +const [locale, setLocale] = useState('en'); + + + + + + + +``` + +Or for zero-flash switching, pre-import the locale and pass `translations` directly: + +```tsx +const [{ locale, translations }, setLocale] = useState({ locale: 'en', translations: undefined }); + +async function switchTo(next: string) { + const { default: translations } = await import(`@videojs/react/i18n/locales/${next}`); + setLocale({ locale: next, translations }); +} +``` + +**With next-intl** + +```tsx +import { getLocale } from 'next-intl/server'; + +const locale = await getLocale(); +const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`); + + + + + + + +``` + +### Translation keys + +Keys are opaque camelCase identifiers. The English string is the value in `en.ts` — not the key. All keys are optional; missing keys fall back to the English default. + +| Key | English default | Params | Used by | +| --- | --------------- | ------ | ------- | +| `play` | `'Play'` | — | `PlayButtonCore` | +| `pause` | `'Pause'` | — | `PlayButtonCore` | +| `replay` | `'Replay'` | — | `PlayButtonCore` | +| `mute` | `'Mute'` | — | `MuteButtonCore` | +| `unmute` | `'Unmute'` | — | `MuteButtonCore` | +| `seek` | `'Seek'` | — | `TimeSliderCore` (aria-label) | +| `volume` | `'Volume'` | — | `VolumeSliderCore` (aria-label) | +| `muted` | `'muted'` | — | `VolumeSliderCore` (aria-valuetext suffix) | +| `enterFullscreen` | `'Enter fullscreen'` | — | `FullscreenButtonCore` | +| `exitFullscreen` | `'Exit fullscreen'` | — | `FullscreenButtonCore` | +| `enableCaptions` | `'Enable captions'` | — | `CaptionsButtonCore` | +| `disableCaptions` | `'Disable captions'` | — | `CaptionsButtonCore` | +| `enterPictureInPicture` | `'Enter picture-in-picture'` | — | `PipButtonCore` | +| `exitPictureInPicture` | `'Exit picture-in-picture'` | — | `PipButtonCore` | +| `currentTime` | `'Current time'` | — | `TimeCore` | +| `duration` | `'Duration'` | — | `TimeCore` | +| `remaining` | `'Remaining'` | — | `TimeCore` | +| `seekForward` | `'Seek forward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` | +| `seekBackward` | `'Seek backward {seconds} seconds'` | `{seconds}` | `SeekButtonCore` | +| `playbackRate` | `'Playback rate {rate}'` | `{rate}` | `PlaybackRateButtonCore` | +| `timePosition` | `'{current} of {duration}'` | `{current}`, `{duration}` | `TimeSliderCore` (aria-valuetext) | +| `remaining` | `'remaining'` | — | `formatDuration` (negative time suffix) | + +> `timePosition` params are already-formatted time phrases from `Intl.DurationFormat`, not raw numbers. + +> `Intl.DurationFormat` handles all duration unit labels; `Intl.NumberFormat` handles percent formatting. Only `muted` and `remaining` are translation keys because `Intl` has no concept of those suffixes. + +## Architecture + +### Layers + +``` +@videojs/core/i18n + Translations · Translator · createTranslator + registerI18n · getI18nTranslations · onI18nRegistryChange + Registry (singleton Map) · en.ts (base layer, always present) + │ + ├── @videojs/react/i18n + │ createI18n() → { I18nContext, I18nProvider, useTranslator, useLocale } + │ Consumer wraps skin: + │ + └── @videojs/html/i18n + createI18n() → { context, I18nController, ProviderMixin, TextMixin } + = ProviderMixin(ReactiveElement) + = TextMixin(ReactiveElement) + MediaButtonElement uses I18nController → t(core.getLabel(state)) + +@videojs/utils/i18n pluralize(count, forms, locale?) +@videojs/utils/time formatDuration(seconds, options?) +``` + +### Core types + +```ts +// @videojs/core/i18n/types.ts + +export type BuiltInLocale = + | 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja' + | 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh'; + +/** Any BCP 47 tag; named built-in locales autocomplete in editors. */ +export type Locale = BuiltInLocale | (string & {}); + +/** Helper — string type that must contain a given literal substring. */ +type Contains = `${string}${Needle}${string}`; + +/** Per-key parameter contract — maps each key to the exact params it requires (or `never`). */ +export interface TranslationParams { + play: never; + pause: never; + replay: never; + mute: never; + unmute: never; + seek: never; + volume: never; + muted: never; + enterFullscreen: never; + exitFullscreen: never; + enableCaptions: never; + disableCaptions: never; + enterPictureInPicture: never; + exitPictureInPicture: never; + currentTime: never; + duration: never; + remaining: never; + seekForward: { seconds: string | number }; + seekBackward: { seconds: string | number }; + playbackRate: { rate: string | number }; + timePosition: { current: string | number; duration: string | number }; +} + +/** All player translation keys. All keys are optional — missing keys fall back to English. + * Value types enforce the `{param}` placeholders required by `TranslationParams`. */ +export interface Translations { + play?: string; + pause?: string; + replay?: string; + mute?: string; + unmute?: string; + seek?: string; + volume?: string; + muted?: string; + enterFullscreen?: string; + exitFullscreen?: string; + enableCaptions?: string; + disableCaptions?: string; + enterPictureInPicture?: string; + exitPictureInPicture?: string; + currentTime?: string; + duration?: string; + remaining?: string; + seekForward?: Contains<'{seconds}'>; + seekBackward?: Contains<'{seconds}'>; + playbackRate?: Contains<'{rate}'>; + timePosition?: Contains<'{current}'> & Contains<'{duration}'>; +} + +type SimpleKeys = { [K in keyof TranslationParams]: TranslationParams[K] extends never ? K : never }[keyof TranslationParams]; +type ParamKeys = Exclude; + +/** Callable translator. Keys with params require the exact params object; keys without params take no second argument. */ +export type Translator = { + (key: SimpleKeys): string; + (key: K, params: TranslationParams[K]): string; + readonly locale?: Locale; +}; +``` + +The `Contains` helper makes locale files compile-error when they forget a `{param}` placeholder. `Translator`'s overloaded signature catches two classes of mistake at call sites: + +```ts +t('play'); // ✓ no params +t('play', { foo: 1 }); // ✗ TS error — no params accepted +t('seekForward'); // ✗ TS error — missing { seconds } +t('seekForward', { seconds: 10 }); // ✓ +t('seekForward', { second: 10 }); // ✗ TS error — typo, wrong key +``` + +### Registry + +```ts +// @videojs/core/i18n/registry.ts +const registry = new Map>(); +const subscribers = new Set<() => void>(); + +// en.ts is the base layer — pre-registered at module init +import en from './locales/en'; +registry.set('en', en); + +export function registerI18n(locale: string, translations: Partial): void { + const existing = registry.get(locale) ?? {}; + registry.set(locale, { ...existing, ...translations }); + subscribers.forEach(fn => fn()); +} + +export function getI18nTranslations(locale: string): Partial { + const en = registry.get('en') ?? {}; + // Walk up the BCP 47 subtag chain: zh-Hant-HK → zh-Hant → zh → en + const parts = locale.split('-'); + for (let i = parts.length; i > 0; i--) { + const tag = parts.slice(0, i).join('-'); + const pack = registry.get(tag); + if (pack) return { ...en, ...pack }; + } + return { ...en }; +} + +export function onI18nRegistryChange(callback: () => void): () => void { + subscribers.add(callback); + return () => subscribers.delete(callback); +} +``` + +`getI18nTranslations` always returns at least the English defaults (from `en.ts`). No key is ever missing — the per-key fallback is `registry[locale][key] → registry[parent-subtag][key] → … → en[key] → key`. See the [BCP 47 fallback](#bcp-47-fallback) section for the full subtag-truncation rules. + +### `createTranslator` + +```ts +export function createTranslator( + translations: Partial = {}, + locale?: Locale +): Translator { + // Overload signatures are type-only; implementation uses the loose callable form. + function t(key: keyof Translations, params?: Record): string { + let value = (translations[key] ?? key) as string; + if (params) { + for (const [k, v] of Object.entries(params)) { + value = value.replace(`{${k}}`, String(v)); + } + } + return value; + } + t.locale = locale; + return t as Translator; +} +``` + +### Locale files + +Locale files are TypeScript source files. They use `satisfies Partial` so typos in key names are caught at build time — a misspelled key is a compile error, not a silent runtime miss: + +```ts +// locales/en.ts +import type { Translations } from '../types'; + +export default { + play: 'Play', + pause: 'Pause', + replay: 'Replay', + mute: 'Mute', + unmute: 'Unmute', + seek: 'Seek', + volume: 'Volume', + muted: 'muted', + enterFullscreen: 'Enter fullscreen', + exitFullscreen: 'Exit fullscreen', + enableCaptions: 'Enable captions', + disableCaptions: 'Disable captions', + enterPictureInPicture: 'Enter picture-in-picture', + exitPictureInPicture: 'Exit picture-in-picture', + currentTime: 'Current time', + duration: 'Duration', + remaining: 'Remaining', + seekForward: 'Seek forward {seconds} seconds', + seekBackward: 'Seek backward {seconds} seconds', + playbackRate: 'Playback rate {rate}', + timePosition: '{current} of {duration}', +} satisfies Partial; +``` + +```ts +// locales/es.ts — partial locale, falls back to en.ts for missing keys +import type { Translations } from '../types'; + +export default { + // Play controls + play: 'Reproducir', + pause: 'Pausa', + replay: 'Repetir', + // Mute + mute: 'Silenciar', + unmute: 'Activar sonido', + // … +} satisfies Partial; +``` + +The `satisfies` constraint checks key names and value types without widening the type — `keyof typeof es` remains the narrow set of keys present, not `keyof Translations`. Comments above groups of keys are useful context for translators. + +For CDN, locale files are self-registering ES modules. The build emits a separate CDN entry point alongside the bundler-importable output: + +```ts +// locales/es.cdn.ts — CDN entry, self-registers +import { registerI18n } from '../registry'; +import translations from './es'; + +registerI18n('es', translations); +``` + +```js +// Built output: es.js (CDN) +import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js'; +registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ }); +``` + +### React: `createI18n` + +A factory that closes over a React context so `useTranslator()` returns `Translator` with no explicit type annotation at call sites. Called once in `@videojs/react/i18n`; the result is exported directly. + +When `locale` is omitted, `I18nProvider` reads `document.documentElement.lang` and subscribes to changes via `MutationObserver`: + +```tsx +export function createI18n() { + const I18nContext = createContext(null); + + function I18nProvider({ locale: explicitLocale, translations, children }: { + locale?: Locale; + translations?: Partial; + children: ReactNode; + }) { + // Subscribe to ambient DOM lang attribute when no explicit prop is given + const ambientLocale = useSyncExternalStore( + subscribeToDocumentLang, + () => document.documentElement.lang || undefined, + () => undefined, // SSR: no ambient locale + ); + const locale = explicitLocale ?? ambientLocale; + + // Lazy-load built-in pack when locale is set + const [builtIn, setBuiltIn] = useState>({}); + useEffect(() => { + if (!locale) { setBuiltIn({}); return; } + const tags = [...new Set([locale, locale.split('-')[0]])]; + const load = (i = 0): Promise => + import(`@videojs/core/i18n/locales/${tags[i]}`) + .then(m => { registerI18n(tags[i], m.default); setBuiltIn(m.default); }) + .catch(() => i + 1 < tags.length ? load(i + 1) : setBuiltIn({})); + load(); + }, [locale]); + + // Browser Translation API — background fallback, pre-installed model only. + // Enumerates the full key set from en.ts (no consumer input needed). + const [browserTranslated, setBrowserTranslated] = useState>({}); + useEffect(() => { + if (!locale) { setBrowserTranslated({}); return; } + let cancelled = false; + getBrowserTranslations(locale).then(result => { + if (!cancelled) setBrowserTranslated(result); + }); + return () => { cancelled = true; }; + }, [locale]); + + // Priority: browser API < registry/built-in < consumer translations + const translator = useMemo( + () => createTranslator( + { ...browserTranslated, ...getI18nTranslations(locale ?? 'en'), ...builtIn, ...translations }, + locale + ), + [browserTranslated, builtIn, translations, locale] + ); + + return {children}; + } + + function useTranslator(): Translator { + return useContext(I18nContext) ?? createTranslator(getI18nTranslations('en')); + } + + function useLocale(): Locale | undefined { + return useTranslator().locale; + } + + return { I18nContext, I18nProvider, useTranslator, useLocale }; +} + +/** Shared MutationObserver subscription for document.documentElement.lang changes. */ +function subscribeToDocumentLang(onChange: () => void): () => void { + const observer = new MutationObserver(onChange); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }); + return () => observer.disconnect(); +} +``` + +### HTML: `createI18n` factory + +Parallel to React's `createI18n`, `createI18n` bundles the context, controller, provider element, and text element into one call. `@videojs/html/i18n` invokes it once and re-exports the result. Consumer-facing API is a single side-effect import that registers `` / ``: + +```ts +// @videojs/html/i18n/create-i18n.ts + +export function createI18n() { + const context = createContext(Symbol('@videojs/i18n')); + + /** Controller for any element that consumes translated strings (e.g. button labels). */ + class I18nController implements ReactiveController { /* subscribes to context */ } + + /** Mixin: adds lang attribute + DOM lang resolution + ContextProvider for i18nContext. */ + const ProviderMixin = >(base: Base) => + class extends base { /* see "Provider mixin" below */ }; + + /** Mixin: adds key attribute + subscribes to i18nContext + writes textContent. */ + const TextMixin = >(base: Base) => + class extends base { /* see "Text mixin" below */ }; + + return { context, I18nController, ProviderMixin, TextMixin }; +} +``` + +Mirrors `createPlayer` — the factory returns building blocks (context, controller, mixins). Define files compose them onto concrete bases and register the custom elements: + +```ts +// @videojs/html/src/define/media-i18n-provider.ts +import { createI18n } from '@videojs/html/i18n'; + +const { ProviderMixin } = createI18n(); +export class MediaI18nProviderElement extends ProviderMixin(ReactiveElement) {} +safeDefine('media-i18n-provider', MediaI18nProviderElement); +``` + +```ts +// @videojs/html/src/define/media-text.ts +import { createI18n } from '@videojs/html/i18n'; + +const { TextMixin } = createI18n(); +export class MediaTextElement extends TextMixin(ReactiveElement) {} +safeDefine('media-text', MediaTextElement); +``` + +### HTML: Provider mixin + +Reads from the registry, provides via `i18nContext`. Resolves `lang` from the element's own attribute if set, otherwise from the nearest ancestor's `lang` attribute (mirroring native HTML inheritance). Subscribes to both registry changes and DOM `lang` attribute mutations so live elements re-render when either changes: + +```ts +const ProviderMixin = >(base: Base) => + class extends base { + static override properties = { ...base.properties, lang: { type: String, reflect: true } }; + + /** Explicit override. When unset, resolves from DOM ancestor chain. */ + lang: Locale | undefined = undefined; + + readonly #provider = new ContextProvider(this, { + context, + initialValue: createTranslator(getI18nTranslations('en')), + }); + + #unsubscribeRegistry: (() => void) | null = null; + #langObserver: MutationObserver | null = null; + + /** Resolve effective locale: explicit lang attribute → nearest ancestor[lang] → documentElement.lang → undefined. */ + get #effectiveLocale(): Locale | undefined { + if (this.lang) return this.lang; + const ancestor = this.parentElement?.closest('[lang]'); + return (ancestor?.getAttribute('lang') || document.documentElement.lang) || undefined; + } + + override connectedCallback(): void { + super.connectedCallback(); + this.#unsubscribeRegistry = onI18nRegistryChange(() => this.#refresh()); + // Observe document-wide lang attribute changes so ambient updates re-render descendants. + this.#langObserver = new MutationObserver(() => this.#refresh()); + this.#langObserver.observe(document.documentElement, { + subtree: true, attributes: true, attributeFilter: ['lang'], + }); + this.#refresh(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#unsubscribeRegistry?.(); + this.#langObserver?.disconnect(); + this.#unsubscribeRegistry = this.#langObserver = null; + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + if (changed.has('lang')) this.#refresh(); + } + + async #refresh(): Promise { + const locale = this.#effectiveLocale; + if (!locale) { this.#updateProvider(undefined); return; } + + if (!hasRegisteredI18n(locale)) { + const tags = [...new Set([locale, locale.split('-')[0]])]; + for (const tag of tags) { + try { + const { default: data } = await import(`@videojs/core/i18n/locales/${tag}`); + registerI18n(tag, data); + break; + } catch { /* try next tag */ } + } + } + + // Browser Translation API — background, pre-installed model only + if (!hasRegisteredI18n(locale) && TRANSLATION_KEYS.length) { + getBrowserTranslations(locale, TRANSLATION_KEYS).then(result => { + if (Object.keys(result).length) registerI18n(locale, result); + }); + } + + this.#updateProvider(locale); + } + + #updateProvider(locale: Locale | undefined): void { + this.#provider.setValue(createTranslator(getI18nTranslations(locale ?? 'en'), locale)); + } + }; +``` + +### HTML: Text mixin + +Renders a translated string for a `key` attribute. Subscribes to `i18nContext`: + +```ts +const TextMixin = >(base: Base) => + class extends base { + static override properties = { ...base.properties, key: { type: String } }; + + key: string = ''; + readonly #i18n = new I18nController(this); + + protected override update(changed: PropertyValues): void { + super.update(changed); + this.textContent = this.#i18n.value(this.key); + } + }; +``` + +`` is for any dynamic translated content that isn't already wired to a component state (free-floating tooltip copy, dialog bodies, custom labels in ejected skins). Built-in button elements already auto-forward their `aria-label` from the registry via `I18nController` — no `` wrapping required for those. + +```html + + + + +``` + +### HTML: `I18nController` and `MediaButtonElement` + +`I18nController` is returned from `createI18n()` alongside the mixins, closing over the same `context` symbol so it reads the value set by any `ProviderMixin`-backed element. Added to `MediaButtonElement` and `MediaUIElement` for auto-forwarded labels: + +```ts +// Returned from createI18n() — closes over the factory's context +class I18nController implements ReactiveController { + readonly #consumer: ContextConsumer; + + constructor(host: ReactiveElement) { + this.#consumer = new ContextConsumer(host, { context, subscribe: true }); + host.addController(this); + } + + get value(): Translator { + return this.#consumer.value ?? createTranslator(getI18nTranslations('en')); + } + + hostConnected(): void {} + hostDisconnected(): void {} +} +``` + +```ts +// MediaButtonElement.update() +readonly #i18n = new I18nController(this); + +protected override update(changed: PropertyValues): void { + super.update(changed); + const media = this.mediaState.value; + if (!media) return; + + this.core.setMedia(media); + const state = this.core.getState(); + const t = this.#i18n.value; + + const key = this.core.getLabel(state); // opaque key, e.g. 'play' + const params = this.core.getLabelParams?.(state); + this.setAttribute('aria-label', t(key, params)); + + applyElementProps(this, this.core.getAttrs(state)); + applyStateDataAttrs(this, state, this.stateAttrMap); +} +``` + +When no `` ancestor is present, `I18nController.value` falls back to a translator seeded from the English registry — English strings are rendered by default. + +### Locale resolution + +The provider resolves its active locale from the first source that yields a value: + +``` +1. Explicit locale prop (React) / lang attribute (HTML) on the provider +2. Nearest ancestor element with a [lang] attribute (HTML only; document.documentElement.lang in React) +3. undefined → English defaults +``` + +`` walks the DOM with `this.parentElement?.closest('[lang]')` and falls back to `document.documentElement.lang`. Both the explicit attribute and ambient sources are tracked — a `MutationObserver` watches `document.documentElement`'s subtree for `lang` attribute changes, so any update to `` (or any intermediate wrapper's `lang`) re-renders descendants automatically. + +React's `I18nProvider` uses `useSyncExternalStore` over `document.documentElement.lang` for the ambient value. SSR returns `undefined` from the server snapshot; on hydration the client reads the rendered `` attribute, keeping server and client in sync. + +This matches the inheritance semantics of the native HTML `lang` attribute — set it once at the page level and every player picks it up without extra wiring. + +### BCP 47 fallback + +Locale values are [BCP 47 language tags](https://datatracker.ietf.org/doc/html/rfc5646). The registry lookup in `getI18nTranslations(locale)` applies a left-truncation fallback — progressively dropping the rightmost subtag until a registered entry is found: + +``` +es-419-u-nu-latn → es-419 → es → en +zh-Hant-HK → zh-Hant → zh → en +pt-BR → pt-BR → pt → en +en-GB-scotland → en-GB-scotland → en-GB → en +sr-Latn → sr-Latn → sr → en +``` + +The algorithm treats each hyphen as a subtag boundary and truncates from the right. Script subtags (`Hant`, `Latn`), region subtags (`419`, `HK`, `GB`), and extension subtags (`u-nu-latn`) are all handled uniformly — no special cases. + +**What it does *not* do.** Sibling fallback is not implemented: a request for `es-AR` will not fall back to `es-MX` even if only `es-MX` is registered. The chain is strictly up the parent hierarchy. Consumers who need sibling negotiation should register the base language (`es`) or the specific variants they want to cover. + +`` applies the same fallback when lazy-loading built-in packs: it tries `pt-BR.ts`, then `pt.ts`, before giving up. Only full subtag matches succeed — `en-GB-scotland` requires a literal `en-GB-scotland.ts` file; otherwise it falls through to `en-GB` and finally `en`. + +The Browser Translation API path uses the native `Intl.Locale` minimization where available for cross-referencing user preferences against the registered pack list. + +### Browser Translation API + +The [Translator API](https://developer.chrome.com/docs/ai/translator-api) (WICG draft, Chrome 138+ origin trial) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered pack. + +Only activates when `Translator.availability()` returns `'available'` — model already present, no network cost. `'downloadable'` / `'downloading'` / `'unavailable'` are silently skipped. + +Since keys are opaque, the browser API translates the *English values* from `en.ts`, then maps the results back to keys: + +```ts +const en = getI18nTranslations('en'); +const englishValues = keys.map(k => en[k] ?? k); +const translated = await Promise.all(englishValues.map(v => translator.translate(v))); +return Object.fromEntries(keys.map((k, i) => [k, translated[i]])); +``` + +Results are cached by locale at module level — repeated mounts of the same skin do not re-trigger translation. + +**Priority merge:** + +``` +English defaults (en.ts — always present) + ↑ +Browser API (auto-translated, background, pre-installed only) + ↑ +Registry pack (registerI18n / built-in locale) + ↑ +Consumer prop (translations — always wins) +``` + +### Intl API integration + +**`Intl.DurationFormat`** — drives `formatDuration`. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The `remaining` suffix still uses `translate?.('remaining')` since `Intl.DurationFormat` has no concept of remaining time. + +**`Intl.NumberFormat`** with `style: 'percent'` — formats volume values. No translation key needed. + +**`Intl.PluralRules`** — powers the `pluralize` utility for skin authors who need locale-correct plurals in custom components: + +```ts +import { pluralize } from '@videojs/utils/i18n'; + +const label = `${count} ${pluralize(count, { one: t('minute'), other: t('minutes') }, locale)}`; +``` + +### SSR & hydration safety + +`Intl` APIs produce locale-specific output. If the active locale differs between server and client, hydration mismatches occur. The fix: always derive the locale from the same source on both sides (URL routing, cookie, or `Accept-Language` header passed explicitly). + +``` +HTTP request + → read locale from cookie / URL / Accept-Language + → set on the server-rendered document + → load translations + → server render: … (locale inherited from ) + → client hydrate: … ← identical = no mismatch +``` + +Passing `locale` explicitly to `I18nProvider` also works but is only required when overriding the document locale for a subtree. The ambient-locale pattern makes single-locale pages zero-config. + +### CDN build strategy + +The CDN bundle is an ES module. Locale files are separate self-registering ES modules — they import `registerI18n` from the same CDN URL, sharing the singleton registry instance with no global namespace touch: + +```html + + +``` + +```js +// https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js +import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js'; +registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ }); +``` + +`` reads from the registry regardless of how data arrived — same element, same attribute, whether the translations came from an inline `registerI18n` call or a CDN locale module. + +## Decisions + +### Opaque camelCase keys, not English strings + +**Decision.** Keys are camelCase identifiers (`play`, `seekForward`). The English string is the value in `en.ts`. + +**Alternatives considered.** + +- *English as key* — `t('Play')` falls back to `'Play'` automatically. Self-documenting. Renaming `'Play'` to `'Start'` in English breaks all other locale files. Established by VJS v7/8 and [Media Chrome](https://www.media-chrome.org/docs/en/internationalization/adding-language-support). +- *Numeric keys* — stable, compact, completely opaque. No useful fallback. +- *Namespaced paths* — `t('controls.play')`. Common in i18next; adds indirection. + +**Rationale.** Stable keys decouple English UX copy from locale file versioning. Adding a new language or updating an English label are independent operations. The English string is still the default — it lives in `en.ts`, which is always pre-registered as the base layer. Consistent with i18next, FormatJS, iOS `NSLocalizedString`, and Android `strings.xml`. + +### Global registry, not lazy-loading on skin element + +**Decision.** A singleton `Map` in `@videojs/core/i18n`. `registerI18n(locale, translations)` is the imperative entry point. `` reads from it. + +**Alternatives considered.** + +- *JSON attribute on skin element* — ``. Couples translations to the skin element; JSON attributes are fragile and lose type safety. +- *Side-effect locale imports* — `import '@videojs/core/i18n/locales/es'` auto-registers. Media Chrome uses this pattern (`import 'media-chrome/lang/es.js'`). Simple for CDN, but bundlers may include unwanted locales, and there's no explicit registration call for overrides. +- *Lazy-load only inside provider* — each provider independently loads its pack. Multiple players for the same locale each trigger a separate network request. + +**Rationale.** Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import `registerI18n` from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers call `registerI18n` explicitly after importing the JSON. `` can still lazy-load built-in packs as a convenience fallback (triggered only when the locale is not already registered). + +### Standalone providers — skins are i18n-unaware + +**Decision.** Neither HTML skins nor React skins own the i18n provider. Consumers add `` or `` explicitly and place them wherever they need. + +**Alternatives considered.** + +- *`I18nMixin` on the skin element* — skin element owns `translations` and `lang` attributes. Simpler consumer API but couples skins to i18n. Adding `lang` to `` creates an attribute collision with the native HTML `lang` attribute. +- *`locale`/`translations` props on ``* — skin internally wraps itself with provider. Zero friction, but skin must know about i18n; harder to share a provider across multiple skins. + +**Rationale.** Skins are presentation layers. i18n is a cross-cutting concern. Decoupling follows the same principle as `` for the store — skins consume context, providers supply it, and neither owns the other. Consumers can wrap multiple skins in one provider, or give each its own. The HTML `lang` attribute conflict disappears since `` is a distinct element. + +### Provider inherits locale from the DOM + +**Decision.** When no explicit `locale` prop (React) / `lang` attribute (HTML) is set on the provider, it resolves the locale from the nearest ancestor `[lang]` attribute (HTML) or `document.documentElement.lang` (React). A `MutationObserver` watches for changes so updates to `` propagate automatically. + +**Alternatives considered.** + +- *Explicit locale always required on the provider* — previous revision behavior. Ergonomic friction: every app must pass the locale into the provider even when `` already declares the page language. +- *Skin reads DOM `lang` directly, no provider* — would eliminate the provider for simple cases, but the provider is needed anyway for context distribution, registry subscription, and inline `translations`. +- *Read `navigator.language`* — reflects the user's browser preference, not the page's declared language. Can be wrong (e.g., a Spanish-language page viewed by a French user). + +**Rationale.** The HTML `lang` attribute is the standard mechanism for declaring content language — search engines, screen readers, and spell-checkers already read it. Making the i18n provider honor the same attribute means zero extra config for the common case: set `` once and every player on the page uses Spanish. The provider remains explicit and required; only its locale input becomes optional. React uses `locale` as the prop name (aligning with `Intl` and the rest of the TypeScript surface) while HTML uses the native `lang` attribute. + +### `createI18n()` factory, not direct exports + +**Decision.** A factory that closes over a React context. Returns `{ I18nContext, I18nProvider, useTranslator, useLocale }`. Called once in `@videojs/react/i18n` and re-exported. + +**Alternatives considered.** + +- *Direct module-level exports* — `I18nProvider` and `useTranslator` exported without a factory. Works but commits to a single context instance, preventing independent provider trees. +- *`useTranslator()` with call-site generic* — verbose; every component needs an explicit type argument. + +**Rationale.** The factory pattern allows consumers to create independent provider trees if needed (e.g., two players with different languages and fully isolated contexts). It also keeps the provider and hooks as a cohesive unit — impossible to import `useTranslator` without the matching context. + +### `` for template strings + +**Decision.** A `` element renders translated text inside shadow DOM templates. + +**Alternatives considered.** + +- *`` self-translating* — tooltip knows its translation key. Couples tooltip semantics to i18n; translation key becomes a tooltip API concern. +- *Skin re-renders full template on locale change* — not viable; shadow DOM templates are static HTML cloned once on element creation. + +**Rationale.** `` is the minimal reactive primitive for translated text inside static shadow DOM. It subscribes to `i18nContext` independently and updates only `textContent` — no parent re-render needed. The `key` attribute is the only i18n contract in ejected skin templates, making them easy to audit and override. + +### `{param}` interpolation, not ICU message format + +**Decision.** Simple `{key}` replacement, as in `t('seekForward', { seconds: 10 })`. + +**Rationale.** The player has a small, known set of interpolated strings; none require plural rules at the interpolation site. ICU format requires a runtime library (~20 KB). `{key}` replacement adds zero runtime weight and is established in VJS v8/9. + +### Native `Intl` APIs for locale-aware formatting + +**Decision.** `Intl.DurationFormat` for time phrases, `Intl.NumberFormat` with `style: 'percent'` for volume, and `Intl.PluralRules` via `pluralize` for plural selection. + +**Rationale.** `Intl.DurationFormat` became baseline in 2024 and handles unit labels, pluralization, and locale-specific ordering — zero translation keys. `Intl.NumberFormat` handles percent symbols and digit forms. Only `remaining` and `muted` need translation keys because `Intl` has no concept of those suffixes. + +### Browser Translation API — pre-installed model only + +**Decision.** Only activate when `Translator.availability()` returns `'available'`. Skip silently for all other states. + +**Rationale.** `'downloadable'` triggers a ~100 MB background download without consumer awareness. `'available'` means the model is already on-device — zero cost, free bonus. The feature is not reliable enough to be the primary translation mechanism; built-in locale packs are. + +## Prior Art + +| System | Key type | Distribution | Registry? | +| --- | --- | --- | --- | +| VJS v7/8 | English string | Built-in JSON + consumer | No — set via `videojs.addLanguage` | +| i18next | Opaque string | Consumer JSON | Yes — `i18next.addResourceBundle` | +| FormatJS / react-intl | Opaque string | Consumer JSON | No — prop-based | +| GNU gettext | English string | `.po` files | No | +| Android `strings.xml` | Opaque XML ID | Resource files | No | +| iOS `NSLocalizedString` | Opaque string | `.strings` files | No | +| Media Chrome | English string | Side-effect `import "media-chrome/lang/es.js"` | Yes — auto-registered on import; `lang` attribute on `` | + +VJS v8's `addLanguage` is the closest precedent. This design replaces English-as-key (VJS v7/8) with opaque keys (consistent with i18next/Android/iOS) while keeping the imperative registration API. + +## Edge Cases + +**Multiple players, different languages.** Each `` or `` scopes its locale independently. The registry holds all registered locales simultaneously — providers read from it without interfering with each other. An explicit `locale` prop (React) or `lang` attribute (HTML) on the provider overrides any ambient ``. + +**`registerI18n` called after element mounts.** `` subscribes to `onI18nRegistryChange`. Calling `registerI18n` after mount triggers a re-render of all descendant elements. + +**`` changed at runtime.** Both HTML and React providers observe `document.documentElement` via `MutationObserver`. Dynamic locale switching works without remounting — update `document.documentElement.lang` and all providers without an explicit override re-render. + +**SSR flash-of-English.** React `I18nProvider` starts with `builtIn: {}` — first render is English unless `translations` is passed. Import the locale module directly and pass as `translations` for zero flash (see SSR section above). + +**`en.ts` bundle footprint.** `en.ts` is imported as a module-level side effect of `@videojs/core/i18n`. Consumers who never use i18n should not import this module — they will not pay the cost if they use only `@videojs/core/ui` or similar. + +**Tooltip keys in ejected skins.** `` inside `` is unusual markup compared to hardcoded text. This is an acceptable tradeoff — the `key` attribute makes the translation contract explicit and the element is thin enough to not be confusing. + +## Descoped + +- **Right-to-left layout** — RTL support (mirroring, bidirectional text) is a separate concern from string translation and is not covered here. +- **Plural forms in translation strings** — e.g., `{count, plural, one {# result} other {# results}}`. ICU format is not supported. Skin authors who need locale-correct plurals use the `pluralize` utility from `@videojs/utils/i18n`. +- **Locale negotiation** — determining which locale to use from `Accept-Language`, cookies, or user preference is left to the consumer. Video.js accepts an explicit locale value. +- **Caption auto-selection by locale** — matching a `textTrack` against the active locale and toggling it on is a separate concern (application vs. user vs. stream-default language preferences all overlap here). Can be revisited as its own feature. +- **Translation memory / CAT tools** — locale files are TypeScript source; a compile step to JSON for CAT tooling compatibility is not provided. +- **Translator perf optimizations** — `createTranslator` re-parses `{param}` templates on every call via `String.prototype.replace`. Future work, not required for v1: + - *Run-time* — parse each template once on first use, cache tokens keyed by source string, reuse thereafter (similar to i18next's format cache). + - *Build-time* — AOT-compile locale entries into functions (`seekForward: ({ seconds }) => \`Seek forward ${seconds} seconds\``); inline static keys with dead-code elimination for the active locale (similar to`babel-plugin-formatjs` / `lingui`). +- **Custom translator implementations** — swapping `createTranslator` for a consumer-supplied translator (e.g., to delegate to `i18next` or FormatJS). The current `Translator` type is a callable, so this is feasible, but no public extension point is wired up. + +## File Structure + +``` +packages/ +├── core/src/i18n/ +│ ├── types.ts ← BuiltInLocale, Locale, Translations, Translator +│ ├── translator.ts ← createTranslator +│ ├── registry.ts ← registerI18n, getI18nTranslations, onI18nRegistryChange +│ ├── index.ts ← re-exports +│ └── locales/ +│ ├── en.ts ← English defaults (pre-registered at module init) +│ ├── ar.ts … zh.ts ← built-in locale packs +│ └── *.cdn.ts ← CDN self-registering entry points (one per locale) +│ +├── react/src/i18n/ +│ ├── create-i18n.tsx ← createI18n +│ ├── browser-translation.ts +│ ├── locales/ ← re-exports of core/locales/*.ts +│ └── index.ts ← public entry: registerI18n, I18nProvider, useTranslator, useLocale, Translations, Translator, Locale +│ +└── html/src/i18n/ + ├── create-i18n.ts ← createI18n → { context, I18nController, ProviderMixin, TextMixin } + ├── browser-translation.ts + ├── locales/ ← re-exports of core/locales/*.ts + ├── define/ + │ ├── media-i18n-provider.ts ← ProviderMixin(ReactiveElement) + customElements.define + │ └── media-text.ts ← TextMixin(ReactiveElement) + customElements.define + └── index.ts ← public entry: registerI18n, Translations, Translator, Locale (side-effect registers elements) + +packages/utils/src/i18n/ +└── pluralize.ts + +packages/utils/src/time/ +└── format.ts ← formatDuration, TimeFormatOptions, TimeTranslate +``` + +### Modified files + +| File | Change | +| ---- | ------ | +| `packages/core/src/core/ui/seek-button/seek-button-core.ts` | `getLabel` returns `'seekForward'`/`'seekBackward'`; add `getLabelParams` | +| `packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts` | Same | +| `packages/utils/src/time/format.ts` | Add `TimeTranslate`, `TimeFormatOptions`, optional `translate` param | +| `packages/html/src/ui/media-button-element.ts` | Add `I18nController`; apply `t()` in `update()` | +| `packages/html/src/define/video/skin.ts` | Replace hardcoded tooltip strings with `` children | +| `packages/react/src/i18n/index.ts` | Export `{ I18nProvider, useTranslator, useLocale }` from `createI18n()` |