mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add i18n foundation with English locale and UI wiring (#1589)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Wesley Luyten <me@wesleyluyten.com>
This commit is contained in:
co-authored by
Cursor
Wesley Luyten
parent
fa768da757
commit
768bf09da0
@@ -0,0 +1,16 @@
|
||||
import { isUndefined } from '../predicate';
|
||||
|
||||
/** Resolves locale: explicit non-empty value → ambient `lang` → {@link fallback}. */
|
||||
export function effectiveLocale(
|
||||
explicitLocale: string | undefined,
|
||||
ambientLang: string | undefined,
|
||||
fallback = 'en'
|
||||
): string {
|
||||
if (!isUndefined(explicitLocale) && String(explicitLocale).trim() !== '') {
|
||||
return explicitLocale;
|
||||
}
|
||||
if (!isUndefined(ambientLang) && ambientLang.trim() !== '') {
|
||||
return ambientLang;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { animationFrame } from './animation-frame';
|
||||
export { namedNodeMapToObject, serializeAttributes } from './attributes';
|
||||
export { isRTL } from './direction';
|
||||
export { effectiveLocale } from './effective-locale';
|
||||
export { type OnEventOptions, onEvent, resolveEventTarget } from './event';
|
||||
export { idleCallback } from './idle-callback';
|
||||
export {
|
||||
@@ -12,6 +13,9 @@ export {
|
||||
isInteractiveTarget,
|
||||
} from './interactive';
|
||||
export { listen } from './listen';
|
||||
export { localeFromDomLang } from './locale-from-dom-lang';
|
||||
export { mergeLocaleOverlays } from './merge-locale-overlays';
|
||||
export { nearestLang } from './nearest-lang';
|
||||
export { isMacOS } from './platform';
|
||||
export { tryHidePopover, tryShowPopover } from './popover';
|
||||
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
|
||||
@@ -25,6 +29,7 @@ export {
|
||||
} from './shadow-styles';
|
||||
export { getSlottedElement, querySlot } from './slotted';
|
||||
export { applyStyles, resolveCSSLength } from './style';
|
||||
export { subscribeAmbientLang } from './subscribe-ambient-lang';
|
||||
export { supportsAnchorPositioning, supportsAnimationFrame, supportsIdleCallback } from './supports';
|
||||
export { createTemplate, renderTemplate } from './template';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isUndefined } from '../predicate';
|
||||
|
||||
/**
|
||||
* Normalizes a raw `lang` string (e.g. from {@link nearestLang}): empty or whitespace-only →
|
||||
* `undefined`, otherwise the trimmed value.
|
||||
*/
|
||||
export function localeFromDomLang(raw: string | undefined): string | undefined {
|
||||
if (isUndefined(raw) || raw.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
return raw.trim();
|
||||
}
|
||||
@@ -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,29 @@
|
||||
function readLang(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 nearestLang(start: Element | null): string | undefined {
|
||||
if (!start || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
let node: Element | null = start;
|
||||
while (node) {
|
||||
const lang = readLang(node);
|
||||
if (lang) {
|
||||
return lang;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 () => {};
|
||||
}
|
||||
let disconnected = false;
|
||||
let queued = false;
|
||||
const flush = (): void => {
|
||||
queued = false;
|
||||
if (disconnected) {
|
||||
return;
|
||||
}
|
||||
onStoreChange();
|
||||
};
|
||||
const schedule = (): void => {
|
||||
if (!queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const root = document.documentElement;
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(root, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['lang'],
|
||||
childList: true,
|
||||
});
|
||||
return () => {
|
||||
disconnected = true;
|
||||
observer.disconnect();
|
||||
queued = false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { effectiveLocale } from '../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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { localeFromDomLang } from '../locale-from-dom-lang';
|
||||
|
||||
describe('localeFromDomLang', () => {
|
||||
it('returns undefined for missing or blank', () => {
|
||||
expect(localeFromDomLang(undefined)).toBeUndefined();
|
||||
expect(localeFromDomLang('')).toBeUndefined();
|
||||
expect(localeFromDomLang(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns trimmed language tag', () => {
|
||||
expect(localeFromDomLang(' fr ')).toBe('fr');
|
||||
expect(localeFromDomLang('de-DE')).toBe('de-DE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeLocaleOverlays } from '../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,57 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { nearestLang } from '../nearest-lang';
|
||||
|
||||
describe('nearestLang', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('lang');
|
||||
});
|
||||
|
||||
it('returns undefined for null start', () => {
|
||||
expect(nearestLang(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads lang on the start element', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('lang', 'fr');
|
||||
document.body.appendChild(el);
|
||||
expect(nearestLang(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(nearestLang(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(nearestLang(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(nearestLang(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(nearestLang(inner)).toBe('fr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { subscribeAmbientLang } from '../subscribe-ambient-lang';
|
||||
|
||||
describe('subscribeAmbientLang', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('lang');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user