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:
Sam Potts
2026-06-18 11:55:38 -07:00
committed by GitHub
co-authored by Cursor Wesley Luyten
parent fa768da757
commit 768bf09da0
79 changed files with 2237 additions and 455 deletions
@@ -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;
}
+5
View File
@@ -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 };
}
+29
View File
@@ -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();
});
});
+134
View File
@@ -1,11 +1,79 @@
import { isNumber } from '../predicate/predicate';
export type TimeFormatOptions = {
/** BCP 47 tag(s) for {@link Intl.DurationFormat} (and percent formatting where applicable). */
locale?: string | string[];
/** Called only when `seconds` is negative; formats the localized remaining-time phrase for the duration body. */
formatRemaining?: (duration: string) => string;
/** Passed to `Intl.DurationFormat`; defaults to `"long"`. */
style?: 'long' | 'short' | 'narrow' | 'digital';
};
const UNIT_LABELS = [
{ singular: 'hour', plural: 'hours' },
{ singular: 'minute', plural: 'minutes' },
{ singular: 'second', plural: 'seconds' },
] as const;
type DurationFormatConstructor = new (
locales?: string | string[],
options?: { style?: TimeFormatOptions['style'] }
) => { format: (duration: object) => string };
const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat;
const percentFormatters = new Map<string, Intl.NumberFormat>();
const durationFormatters = new Map<string, InstanceType<NonNullable<typeof DurationFormat>>>();
function localeCacheKey(locale?: string | string[]): string {
if (locale === undefined) return '';
return Array.isArray(locale) ? locale.join('\0') : locale;
}
function isEnglishLocale(locale?: string | string[]): boolean {
const tag = Array.isArray(locale) ? locale[0] : locale;
if (!tag) return true;
return tag === 'en' || tag.startsWith('en-');
}
function getPercentFormatter(locale?: string | string[]): Intl.NumberFormat | undefined {
const key = localeCacheKey(locale);
let formatter = percentFormatters.get(key);
if (!formatter) {
try {
formatter = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 });
percentFormatters.set(key, formatter);
} catch {
return undefined;
}
}
return formatter;
}
function formatVolumePercentFallback(fraction: number): string {
const percent = Math.round(Math.min(1, Math.max(0, fraction)) * 100);
return `${percent}%`;
}
function getDurationFormatter(
locale?: string | string[],
style: NonNullable<TimeFormatOptions['style']> = 'long'
): InstanceType<NonNullable<typeof DurationFormat>> | undefined {
if (!DurationFormat) return undefined;
const key = `${localeCacheKey(locale)}\0${style}`;
let formatter = durationFormatters.get(key);
if (!formatter) {
try {
formatter = new DurationFormat(locale, { style });
durationFormatters.set(key, formatter);
} catch {
return undefined;
}
}
return formatter;
}
function isValidTime(value: number): boolean {
return isNumber(value) && Number.isFinite(value);
}
@@ -118,3 +186,69 @@ export function secondsToIsoDuration(seconds: number): string {
return duration;
}
/**
* Human-readable duration using {@link Intl.DurationFormat} when available.
*
* Negative `seconds` denote remaining time: the absolute value is formatted, then wrapped in a
* localized phrase via {@link TimeFormatOptions.formatRemaining}; otherwise `{duration} remaining`.
*/
export function formatDuration(seconds: number, options?: TimeFormatOptions): string {
if (!isValidTime(seconds)) {
return '';
}
const negative = seconds < 0;
const positiveSeconds = Math.abs(seconds);
const totalSeconds = Math.floor(positiveSeconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secondsPart = totalSeconds % 60;
const record: Partial<{ hours: number; minutes: number; seconds: number }> = {};
if (hours > 0) record.hours = hours;
if (minutes > 0) record.minutes = minutes;
if (secondsPart > 0 || (hours === 0 && minutes === 0)) record.seconds = secondsPart;
let body: string;
try {
const durationFormatter = getDurationFormatter(options?.locale, options?.style ?? 'long');
if (durationFormatter) {
body = durationFormatter.format(record);
} else {
body = formatTimeAsPhrase(positiveSeconds);
}
} catch {
body = formatTimeAsPhrase(positiveSeconds);
}
// Some ICU builds return an empty string for a zero-length duration; fall back to the phrase formatter.
if (!body.trim()) {
body = formatTimeAsPhrase(positiveSeconds);
}
if (negative) {
const formatRemaining = options?.formatRemaining;
if (formatRemaining) return formatRemaining(body);
if (isEnglishLocale(options?.locale)) return `${body} remaining`;
return body;
}
return body;
}
/** Format a volume fraction (01) with {@link Intl.NumberFormat} `style: "percent"`. */
export function formatVolumePercent(fraction: number, locale?: string | string[]): string {
const value = !isNumber(fraction) || !Number.isFinite(fraction) ? 0 : Math.min(1, Math.max(0, fraction));
try {
const formatter = getPercentFormatter(locale) ?? getPercentFormatter(undefined);
if (formatter) {
return formatter.format(value);
}
} catch {
// fall through to simple percent string
}
return formatVolumePercentFallback(value);
}
+75 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
import { formatDuration, formatTime, formatTimeAsPhrase, formatVolumePercent, secondsToIsoDuration } from '../format';
describe('formatTime', () => {
it('formats seconds only', () => {
@@ -99,6 +99,80 @@ describe('formatTimeAsPhrase', () => {
});
});
describe('formatDuration', () => {
it('formats positive duration', () => {
expect(formatDuration(90)).toContain('1');
expect(formatDuration(90)).toMatch(/minute/i);
expect(formatDuration(90)).toMatch(/30/);
expect(formatDuration(300)).toMatch(/5/);
expect(formatDuration(300)).toMatch(/minute/i);
});
it('adds remaining suffix for negative seconds', () => {
expect(formatDuration(-30)).toMatch(/30/);
expect(formatDuration(-30)).toMatch(/remaining$/i);
});
it('uses formatRemaining only for negative durations', () => {
expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/^quedan /);
expect(formatDuration(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/30/);
expect(formatDuration(90, { formatRemaining: () => 'should-not-appear' })).toBe(formatDuration(90));
});
it('omits English remaining suffix for non-English locales without formatRemaining', () => {
const formatted = formatDuration(-30, { locale: 'es' });
expect(formatted).toMatch(/30/);
expect(formatted).not.toMatch(/remaining$/i);
});
it('uses Intl.DurationFormat when supported; otherwise matches formatTimeAsPhrase', () => {
const DurationFormatConstructor = (Intl as typeof Intl & { DurationFormat?: unknown }).DurationFormat;
const hasDurationFormat = typeof DurationFormatConstructor === 'function';
const phrase = formatTimeAsPhrase(125);
if (hasDurationFormat) {
const en = formatDuration(125, { locale: 'en' });
const de = formatDuration(125, { locale: 'de' });
expect(en.length).toBeGreaterThan(0);
expect(de.length).toBeGreaterThan(0);
expect(en).not.toBe(de);
} else {
expect(formatDuration(125, { locale: 'en' })).toBe(phrase);
expect(formatDuration(125, { locale: 'ja' })).toBe(phrase);
}
});
it('handles invalid values', () => {
expect(formatDuration(NaN)).toBe('');
expect(formatDuration(Infinity)).toBe('');
});
it('falls back to formatTimeAsPhrase when locale is invalid', () => {
const phrase = formatTimeAsPhrase(90);
expect(formatDuration(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toBe(phrase);
});
});
describe('formatVolumePercent', () => {
it('uses Intl percent style', () => {
expect(formatVolumePercent(0.75)).toMatch(/75/);
expect(formatVolumePercent(0.75)).toMatch(/%/);
});
it('clamps to 0100%', () => {
expect(formatVolumePercent(-1)).toBe(formatVolumePercent(0));
expect(formatVolumePercent(2)).toBe(formatVolumePercent(1));
});
it('handles invalid fraction', () => {
expect(formatVolumePercent(Number.NaN)).toMatch(/0/);
expect(formatVolumePercent(Number.NaN)).toMatch(/%/);
});
it('falls back when locale is invalid', () => {
expect(formatVolumePercent(0.75, 'not-a-invalid-bcp47-tag!!!')).toBe('75%');
});
});
describe('secondsToIsoDuration', () => {
it('formats seconds only', () => {
expect(secondsToIsoDuration(0)).toBe('PT0S');