feat(packages): i18n (#1708)

This commit is contained in:
Sam Potts
2026-07-10 12:58:47 +10:00
committed by GitHub
parent 5f48fcc6d6
commit 028dadb385
561 changed files with 13059 additions and 1320 deletions
+6
View File
@@ -12,6 +12,11 @@ export {
isInteractiveTarget,
} from './interactive';
export { listen } from './listen';
export { effectiveLocale } from './locale/effective-locale';
export { findNearestLang, findNearestLang as nearestLang } from './locale/find-nearest-lang';
export { mergeLocaleOverlays } from './locale/merge-locale-overlays';
export { resolveLangAttr } from './locale/resolve-lang-attr';
export { subscribeAmbientLang } from './locale/subscribe-ambient-lang';
export { isMacOS } from './platform';
export { tryHidePopover, tryShowPopover } from './popover';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
@@ -41,6 +46,7 @@ export type {
EventType,
QueriedElement,
} from './types';
export { walkAncestors } from './walk-ancestors';
export {
isWebKitAirPlayCapable,
supportsWebKitAirPlay,
@@ -0,0 +1,16 @@
import { isUndefined } from '../../predicate';
/** Resolves locale: explicit non-empty value → ambient `lang` → {@link fallback}. */
export function effectiveLocale<Locale extends string = string>(
explicitLocale: Locale | undefined,
ambientLang: Locale | undefined,
fallback = 'en' as Locale
): Locale {
if (!isUndefined(explicitLocale) && explicitLocale.trim() !== '') {
return explicitLocale;
}
if (!isUndefined(ambientLang) && ambientLang.trim() !== '') {
return ambientLang;
}
return fallback;
}
@@ -0,0 +1,20 @@
import { walkAncestors } from '../walk-ancestors';
function getElementLang(node: Element): string | undefined {
const fromAttribute = node.getAttribute('lang')?.trim();
if (fromAttribute) {
return fromAttribute;
}
if ('lang' in node && typeof node.lang === 'string') {
const fromProperty = node.lang.trim();
if (fromProperty) {
return fromProperty;
}
}
return undefined;
}
/** First non-empty `lang` on `start` or an ancestor (HTML language inheritance). */
export function findNearestLang(start: Element | null): string | undefined {
return walkAncestors(start, getElementLang);
}
@@ -0,0 +1,27 @@
/**
* Loads overlay layers for each tag in {@link localeLookupChain}, least-specific first, then merges
* most-specific-last (same semantics as the core i18n registry).
*/
export async function mergeLocaleOverlays<Overlay extends object>(
locale: string,
load: (tag: string) => Promise<Partial<Overlay> | undefined>,
localeLookupChain: (locale: string) => string[]
): Promise<{ merged: Partial<Overlay>; loadedTags: string[] }> {
const chain = localeLookupChain(locale);
const layers = await Promise.all(chain.map((tag) => load(tag)));
const loadedTags: string[] = [];
const merged: Partial<Overlay> = {};
for (let i = 0; i < chain.length; i++) {
const layer = layers[i];
if (layer && Object.keys(layer).length > 0) {
loadedTags.push(chain[i]!);
}
}
for (let i = chain.length - 1; i >= 0; i--) {
const layer = layers[i];
if (layer) {
Object.assign(merged, layer);
}
}
return { merged, loadedTags };
}
@@ -0,0 +1,12 @@
import { isUndefined } from '../../predicate';
/**
* Normalizes a raw `lang` string (e.g. from {@link findNearestLang}): empty or whitespace-only →
* `undefined`, otherwise the trimmed value.
*/
export function resolveLangAttr<Locale extends string = string>(raw: string | undefined): Locale | undefined {
if (isUndefined(raw) || raw.trim() === '') {
return undefined;
}
return raw.trim() as Locale;
}
@@ -0,0 +1,59 @@
const subscribers = new Set<() => void>();
let observer: MutationObserver | undefined;
let queued = false;
const flush = (): void => {
queued = false;
for (const cb of subscribers) {
cb();
}
};
const schedule = (): void => {
if (!queued) {
queued = true;
queueMicrotask(flush);
}
};
function start(): void {
if (observer || typeof document === 'undefined') {
return;
}
observer = new MutationObserver(schedule);
observer.observe(document.documentElement, {
subtree: true,
attributes: true,
attributeFilter: ['lang'],
childList: true,
});
}
function stop(): void {
if (subscribers.size || !observer) {
return;
}
observer.disconnect();
observer = undefined;
queued = false;
}
/**
* Subscribes to DOM updates that can change inherited `lang`: any `lang` attribute edit,
* or subtree structural changes under `<html>` (which can move nodes between labeled ancestors).
*/
export function subscribeAmbientLang(onStoreChange: () => void): () => void {
if (typeof document === 'undefined') {
return () => {};
}
subscribers.add(onStoreChange);
start();
return () => {
subscribers.delete(onStoreChange);
stop();
};
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { effectiveLocale } from '../locale/effective-locale';
describe('effectiveLocale', () => {
it('prefers explicit locale over ambient', () => {
expect(effectiveLocale('fr', 'de')).toBe('fr');
});
it('uses ambient when explicit is undefined', () => {
expect(effectiveLocale(undefined, 'es-MX')).toBe('es-MX');
});
it('falls back to en when both missing or blank', () => {
expect(effectiveLocale(undefined, undefined)).toBe('en');
expect(effectiveLocale(' ', undefined)).toBe('en');
expect(effectiveLocale(undefined, ' ')).toBe('en');
});
it('respects custom fallback', () => {
expect(effectiveLocale(undefined, undefined, 'xx')).toBe('xx');
});
it('can resolve a caller locale type', () => {
type Locale = 'en' | 'fr';
const locale: Locale = effectiveLocale<Locale>(undefined, undefined);
expect(locale).toBe('en');
});
});
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from 'vitest';
import { findNearestLang } from '../locale/find-nearest-lang';
describe('findNearestLang', () => {
afterEach(() => {
document.body.innerHTML = '';
document.documentElement.removeAttribute('lang');
});
it('returns undefined for null start', () => {
expect(findNearestLang(null)).toBeUndefined();
});
it('reads lang on the start element', () => {
const el = document.createElement('div');
el.setAttribute('lang', 'fr');
document.body.appendChild(el);
expect(findNearestLang(el)).toBe('fr');
});
it('walks ancestors and prefers closest lang', () => {
const outer = document.createElement('section');
outer.setAttribute('lang', 'de');
const inner = document.createElement('div');
inner.setAttribute('lang', 'fr');
outer.appendChild(inner);
document.body.appendChild(outer);
expect(findNearestLang(inner)).toBe('fr');
});
it('inherits from an ancestor when start has no lang', () => {
const outer = document.createElement('section');
outer.setAttribute('lang', 'de');
const inner = document.createElement('div');
outer.appendChild(inner);
document.body.appendChild(outer);
expect(findNearestLang(inner)).toBe('de');
});
it('ignores empty lang and continues walking', () => {
const outer = document.createElement('section');
outer.setAttribute('lang', 'de');
const inner = document.createElement('div');
inner.setAttribute('lang', ' ');
outer.appendChild(inner);
document.body.appendChild(outer);
expect(findNearestLang(inner)).toBe('de');
});
it('reads lang IDL property on html when set via documentElement.lang', () => {
document.documentElement.lang = 'fr';
const inner = document.createElement('div');
document.body.appendChild(inner);
expect(findNearestLang(inner)).toBe('fr');
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { mergeLocaleOverlays } from '../locale/merge-locale-overlays';
describe('mergeLocaleOverlays', () => {
it('merges layers least-specific to most-specific', async () => {
const chain = (_locale: string) => ['es', 'en'];
const load = async (tag: string): Promise<Partial<Record<'a' | 'b' | 'c', string>> | undefined> =>
tag === 'en' ? { a: 'en-a', b: 'en-b' } : tag === 'es' ? { b: 'es-b', c: 'es-c' } : undefined;
const { merged, loadedTags } = await mergeLocaleOverlays('es', load, chain);
expect(merged).toEqual({ a: 'en-a', b: 'es-b', c: 'es-c' });
expect(loadedTags).toEqual(['es', 'en']);
});
it('skips undefined layers', async () => {
const chain = () => ['xx', 'en'];
const load = async (tag: string) => (tag === 'en' ? { k: 'v' } : undefined);
const { merged, loadedTags } = await mergeLocaleOverlays('xx', load, chain);
expect(merged).toEqual({ k: 'v' });
expect(loadedTags).toEqual(['en']);
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { resolveLangAttr } from '../locale/resolve-lang-attr';
describe('resolveLangAttr', () => {
it('returns undefined for missing or blank', () => {
expect(resolveLangAttr(undefined)).toBeUndefined();
expect(resolveLangAttr('')).toBeUndefined();
expect(resolveLangAttr(' ')).toBeUndefined();
});
it('returns trimmed language tag', () => {
expect(resolveLangAttr(' fr ')).toBe('fr');
expect(resolveLangAttr('de-DE')).toBe('de-DE');
});
it('can return a caller locale type', () => {
type Locale = 'en' | 'fr';
const locale: Locale | undefined = resolveLangAttr<Locale>('fr');
expect(locale).toBe('fr');
});
});
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { subscribeAmbientLang } from '../locale/subscribe-ambient-lang';
describe('subscribeAmbientLang', () => {
afterEach(() => {
document.documentElement.removeAttribute('lang');
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('invokes callback when html lang changes', async () => {
const spy = vi.fn();
const off = subscribeAmbientLang(spy);
document.documentElement.setAttribute('lang', 'de');
await Promise.resolve();
await Promise.resolve();
expect(spy).toHaveBeenCalled();
off();
});
it('invokes callback when html lang property changes', async () => {
const spy = vi.fn();
const off = subscribeAmbientLang(spy);
document.documentElement.lang = 'fr';
await Promise.resolve();
await Promise.resolve();
expect(spy).toHaveBeenCalled();
off();
});
it('unsubscribe stops notifications', async () => {
const spy = vi.fn();
const off = subscribeAmbientLang(spy);
off();
spy.mockClear();
document.documentElement.setAttribute('lang', 'fr');
await Promise.resolve();
await Promise.resolve();
expect(spy).not.toHaveBeenCalled();
});
it('shares one observer across subscribers', () => {
const RealObserver = globalThis.MutationObserver;
const disconnect = vi.fn();
const Observer = vi.fn(function (this: MutationObserver, callback: MutationCallback) {
const observer = new RealObserver(callback);
vi.spyOn(observer, 'disconnect').mockImplementation(disconnect);
return observer;
});
vi.stubGlobal('MutationObserver', Observer);
const offA = subscribeAmbientLang(vi.fn());
const offB = subscribeAmbientLang(vi.fn());
expect(Observer).toHaveBeenCalledTimes(1);
offA();
expect(disconnect).not.toHaveBeenCalled();
offB();
expect(disconnect).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { walkAncestors } from '../walk-ancestors';
describe('walkAncestors', () => {
it('returns undefined for null start', () => {
expect(walkAncestors(null, () => 'value')).toBeUndefined();
});
it('returns the first defined callback value', () => {
const outer = document.createElement('section');
const middle = document.createElement('div');
const inner = document.createElement('span');
outer.appendChild(middle);
middle.appendChild(inner);
document.body.appendChild(outer);
expect(
walkAncestors(inner, (node) => {
if (node === middle) return 'middle';
if (node === outer) return 'outer';
return undefined;
})
).toBe('middle');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { isUndefined } from '../predicate';
export function walkAncestors<Value>(
start: Element | null,
callback: (node: Element) => Value | undefined
): Value | undefined {
if (!start || typeof document === 'undefined') {
return undefined;
}
let node: Element | null = start;
while (node) {
const value = callback(node);
if (!isUndefined(value)) {
return value;
}
node = node.parentElement;
}
return undefined;
}