Skip to content

Commit 35332be

Browse files
sampottscursoragent
andcommitted
feat(react): add createI18n provider and ambient locale inheritance
Export localeLookupChain and @videojs/core/i18n/locales/en for builtin overlay merging. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 681bfd1 commit 35332be

9 files changed

Lines changed: 672 additions & 1 deletion

File tree

packages/core/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
"types": "./dist/dev/i18n.d.ts",
3939
"development": "./dist/dev/i18n.js",
4040
"default": "./dist/default/i18n.js"
41+
},
42+
"./i18n/locales/en": {
43+
"types": "./dist/dev/i18n/locales/en.d.ts",
44+
"development": "./dist/dev/i18n/locales/en.js",
45+
"default": "./dist/default/i18n/locales/en.js"
4146
}
4247
},
4348
"main": "dist/default/index.js",

packages/core/src/core/i18n/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { englishTranslations } from './locales/en';
22
export {
33
getI18nTranslations,
44
hasRegisteredI18n,
5+
localeLookupChain,
56
onI18nRegistryChange,
67
registerI18n,
78
resetI18nRegistryForTesting,

packages/core/tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const createConfig = (mode: PackageBuildMode): UserConfig => ({
88
entry: {
99
index: './src/core/index.ts',
1010
i18n: './src/core/i18n/index.ts',
11+
'i18n/locales/en': './src/core/i18n/locales/en.ts',
1112
dom: './src/dom/index.ts',
1213
'dom/media/dash/index': './src/dom/media/dash/index.ts',
1314
'dom/media/hls/index': './src/dom/media/hls/index.ts',

packages/react/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@
9393
"types": "./dist/dev/presets/live-audio/*.d.ts",
9494
"development": "./dist/dev/presets/live-audio/*.js",
9595
"default": "./dist/default/presets/live-audio/*.js"
96+
},
97+
"./i18n": {
98+
"types": "./dist/dev/i18n/index.d.ts",
99+
"development": "./dist/dev/i18n/index.js",
100+
"default": "./dist/default/i18n/index.js"
101+
},
102+
"./i18n/locales/en": {
103+
"types": "./dist/dev/i18n/locales/en/index.d.ts",
104+
"development": "./dist/dev/i18n/locales/en/index.js",
105+
"default": "./dist/default/i18n/locales/en/index.js"
96106
}
97107
},
98108
"scripts": {
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
'use client';
2+
3+
import {
4+
createTranslator,
5+
getI18nTranslations,
6+
type Locale,
7+
localeLookupChain,
8+
onI18nRegistryChange,
9+
type Translations,
10+
type Translator,
11+
} from '@videojs/core/i18n';
12+
import { isUndefined } from '@videojs/utils/predicate';
13+
import {
14+
type Context,
15+
createContext,
16+
type ReactNode,
17+
type RefObject,
18+
useContext,
19+
useEffect,
20+
useLayoutEffect,
21+
useMemo,
22+
useReducer,
23+
useRef,
24+
useState,
25+
useSyncExternalStore,
26+
} from 'react';
27+
28+
/** Built-ins omit English; defaults already live in the core registry via {@link getI18nTranslations}. */
29+
async function noopBuiltinPack(tag: string): Promise<Partial<Translations> | undefined> {
30+
void tag;
31+
return undefined;
32+
}
33+
34+
async function mergeBuiltinOverlays(
35+
locale: string,
36+
load: (tag: string) => Promise<Partial<Translations> | undefined>
37+
): Promise<Partial<Translations>> {
38+
const chain = localeLookupChain(locale);
39+
const layers = await Promise.all(chain.map((tag) => load(tag)));
40+
const merged: Partial<Translations> = {};
41+
for (let i = chain.length - 1; i >= 0; i--) {
42+
const layer = layers[i];
43+
if (layer) {
44+
Object.assign(merged, layer);
45+
}
46+
}
47+
return merged;
48+
}
49+
50+
/**
51+
* Subscribes to DOM updates that can change {@link nearestLang}: any `lang` attribute edit,
52+
* or subtree structural changes under `<html>` (which can move nodes between labeled ancestors).
53+
*/
54+
function subscribeAmbientLang(onStoreChange: () => void): () => void {
55+
if (typeof document === 'undefined') {
56+
return () => {};
57+
}
58+
let disconnected = false;
59+
let queued = false;
60+
const flush = (): void => {
61+
queued = false;
62+
if (disconnected) {
63+
return;
64+
}
65+
onStoreChange();
66+
};
67+
const schedule = (): void => {
68+
if (!queued) {
69+
queued = true;
70+
queueMicrotask(flush);
71+
}
72+
};
73+
const root = document.documentElement;
74+
const observer = new MutationObserver(schedule);
75+
observer.observe(root, {
76+
subtree: true,
77+
attributes: true,
78+
attributeFilter: ['lang'],
79+
childList: true,
80+
});
81+
return () => {
82+
disconnected = true;
83+
observer.disconnect();
84+
queued = false;
85+
};
86+
}
87+
88+
/** First non-empty `lang` on `start` or an ancestor (HTML language inheritance). */
89+
function nearestLang(start: Element | null): string | undefined {
90+
if (!start || typeof document === 'undefined') {
91+
return undefined;
92+
}
93+
let node: Element | null = start;
94+
while (node) {
95+
const trimmed = node.getAttribute('lang')?.trim();
96+
if (trimmed) {
97+
return trimmed;
98+
}
99+
node = node.parentElement;
100+
}
101+
return undefined;
102+
}
103+
104+
function ambientLangServerSnapshot(): string | undefined {
105+
return undefined;
106+
}
107+
108+
function effectiveLocale(localeProp: Locale | undefined, ambientLang: string | undefined): Locale {
109+
if (!isUndefined(localeProp) && String(localeProp).trim() !== '') {
110+
return localeProp;
111+
}
112+
if (!isUndefined(ambientLang) && ambientLang.trim() !== '') {
113+
return ambientLang;
114+
}
115+
return 'en';
116+
}
117+
118+
export interface CreateI18nOptions {
119+
/** Override built-in locale loading (tests or custom packs). */
120+
loadBuiltinLocale?: (tag: string) => Promise<Partial<Translations> | undefined>;
121+
}
122+
123+
export interface I18nProviderProps {
124+
/**
125+
* Forces the active locale. Omit to inherit the nearest non-empty `lang` by walking DOM
126+
* ancestors from {@link langRootRef} when set, otherwise from `document.documentElement`
127+
* (typically `<html lang>`). Updates when any `lang` attribute changes anywhere under `<html>`,
128+
* or when subtree moves alter which ancestor supplies `lang`. For SSR, pass `locale` explicitly.
129+
*/
130+
locale?: Locale;
131+
/**
132+
* Element whose ancestor chain is searched for a non-empty `lang` when {@link locale} is
133+
* omitted—for example a ref to your player shell `HTMLElement`.
134+
*/
135+
langRootRef?: RefObject<Element | null>;
136+
translations?: Partial<Translations>;
137+
children: ReactNode;
138+
/** Fires when the resolved locale changes (caption selection hooks may use this later). */
139+
onActiveLocaleChange?: (locale: Locale) => void;
140+
}
141+
142+
export interface I18nContextValue {
143+
translator: Translator;
144+
locale: Locale;
145+
}
146+
147+
export interface CreateI18nResult {
148+
I18nContext: Context<I18nContextValue | null>;
149+
I18nProvider: (props: I18nProviderProps) => ReactNode;
150+
useTranslator: () => Translator;
151+
useLocale: () => Locale;
152+
}
153+
154+
/**
155+
* Creates an isolated i18n context stack (`I18nProvider`, hooks) mirroring {@link createPlayer}.
156+
*
157+
* @param options - Optional hooks such as custom built-in locale loading.
158+
*/
159+
export function createI18n(options?: CreateI18nOptions): CreateI18nResult {
160+
const loadBuiltin = options?.loadBuiltinLocale ?? noopBuiltinPack;
161+
162+
const I18nContext = createContext<I18nContextValue | null>(null);
163+
164+
function I18nProvider({
165+
locale: localeProp,
166+
langRootRef,
167+
translations: translationsProp,
168+
children,
169+
onActiveLocaleChange,
170+
}: I18nProviderProps): ReactNode {
171+
const [, invalidateLangRoot] = useReducer((epoch: number) => epoch + 1, 0);
172+
173+
useLayoutEffect(() => {
174+
if (langRootRef) {
175+
invalidateLangRoot();
176+
}
177+
}, [langRootRef]);
178+
179+
const ambientLang = useSyncExternalStore(
180+
subscribeAmbientLang,
181+
() => {
182+
const langRoot = langRootRef?.current ?? (typeof document !== 'undefined' ? document.documentElement : null);
183+
return nearestLang(langRoot);
184+
},
185+
ambientLangServerSnapshot
186+
);
187+
188+
const resolvedLocale = useMemo(() => effectiveLocale(localeProp, ambientLang), [localeProp, ambientLang]);
189+
190+
useEffect(() => {
191+
onActiveLocaleChange?.(resolvedLocale);
192+
}, [resolvedLocale, onActiveLocaleChange]);
193+
194+
const [registryEpoch, invalidateRegistry] = useReducer((epoch: number) => epoch + 1, 0);
195+
useEffect(() => onI18nRegistryChange(() => invalidateRegistry()), []);
196+
197+
const [lazyLayer, setLazyLayer] = useState<Partial<Translations>>({});
198+
199+
// biome-ignore lint/correctness/useExhaustiveDependencies: rerun when `resolvedLocale` changes even though the reset callback does not reference it.
200+
useLayoutEffect(() => {
201+
setLazyLayer({});
202+
}, [resolvedLocale]);
203+
204+
useEffect(() => {
205+
let cancelled = false;
206+
void (async () => {
207+
const mergedLazy = await mergeBuiltinOverlays(resolvedLocale, loadBuiltin);
208+
if (!cancelled) {
209+
setLazyLayer(mergedLazy);
210+
}
211+
})();
212+
return () => {
213+
cancelled = true;
214+
};
215+
}, [resolvedLocale]);
216+
217+
// biome-ignore lint/correctness/useExhaustiveDependencies: `registryEpoch` bumps on registry mutations; `getI18nTranslations` reads mutable registry state.
218+
const translations = useMemo(() => {
219+
const registryLayer = getI18nTranslations(resolvedLocale);
220+
return {
221+
...registryLayer,
222+
...lazyLayer,
223+
...translationsProp,
224+
} as Translations;
225+
}, [resolvedLocale, lazyLayer, translationsProp, registryEpoch]);
226+
227+
const translator = useMemo(() => createTranslator(translations, resolvedLocale), [translations, resolvedLocale]);
228+
229+
const value = useMemo<I18nContextValue>(
230+
() => ({ translator, locale: resolvedLocale }),
231+
[translator, resolvedLocale]
232+
);
233+
234+
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
235+
}
236+
237+
function useTranslator(): Translator {
238+
const ctx = useContext(I18nContext);
239+
const fallbackRef = useRef<Translator | undefined>(undefined);
240+
if (!fallbackRef.current) {
241+
fallbackRef.current = createTranslator(getI18nTranslations('en'), 'en');
242+
}
243+
if (!ctx) {
244+
return fallbackRef.current;
245+
}
246+
return ctx.translator;
247+
}
248+
249+
function useLocale(): Locale {
250+
const ctx = useContext(I18nContext);
251+
return ctx?.locale ?? 'en';
252+
}
253+
254+
return { I18nContext, I18nProvider, useTranslator, useLocale };
255+
}

packages/react/src/i18n/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
export type {
4+
Contains,
5+
Locale,
6+
TranslationParams,
7+
Translations,
8+
Translator,
9+
} from '@videojs/core/i18n';
10+
11+
export {
12+
createTranslator,
13+
getI18nTranslations,
14+
hasRegisteredI18n,
15+
localeLookupChain,
16+
onI18nRegistryChange,
17+
registerI18n,
18+
} from '@videojs/core/i18n';
19+
20+
export type {
21+
CreateI18nOptions,
22+
CreateI18nResult,
23+
I18nContextValue,
24+
I18nProviderProps,
25+
} from './create-i18n';
26+
27+
export { createI18n } from './create-i18n';
28+
29+
import { createI18n as createDefaultI18n } from './create-i18n';
30+
31+
export const { I18nContext, I18nProvider, useLocale, useTranslator } = createDefaultI18n();
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { englishTranslations } from '@videojs/core/i18n/locales/en';

0 commit comments

Comments
 (0)