Skip to content

Commit 4e62e59

Browse files
committed
feat(rosetta): add ICU MessageFormat, locale fallback chain, and validation
- Add ICU MessageFormat support (plural, select patterns) - Implement locale fallback chain (zh-TW → zh → en) - Add locale validation with BCP 47 format checking - Add module scope t() warning in development - Add scheduleFlush for better timing control - Add comprehensive tests for new features (67 tests passing) - Update client-side t() to support ICU patterns BREAKING CHANGE: RosettaContext now requires localeChain and initialized properties
1 parent 557d241 commit 4e62e59

File tree

7 files changed

+731
-85
lines changed

7 files changed

+731
-85
lines changed

packages/rosetta-next/src/client.tsx

Lines changed: 174 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { hashText, interpolate } from '@sylphx/rosetta';
44
import type React from 'react';
5-
import { type ReactNode, createContext, useContext } from 'react';
5+
import { type ReactNode, createContext, useContext, useMemo } from 'react';
66

77
// ============================================
88
// Types
@@ -48,6 +48,151 @@ export interface RosettaClientProviderProps {
4848
translations: Record<string, string>;
4949
}
5050

51+
// ============================================
52+
// ICU MessageFormat Support
53+
// ============================================
54+
55+
/**
56+
* Format message with ICU-like syntax support
57+
* Supports: {name}, {count, plural, one {...} other {...}}, {gender, select, ...}
58+
*/
59+
function formatMessage(text: string, params?: Record<string, string | number>): string {
60+
if (!params) return text;
61+
62+
// Check for ICU patterns
63+
if (text.includes(', plural,') || text.includes(', select,')) {
64+
return formatICU(text, params);
65+
}
66+
67+
// Simple interpolation
68+
return interpolate(text, params);
69+
}
70+
71+
/**
72+
* Basic ICU MessageFormat support
73+
* Handles plural and select patterns
74+
*/
75+
function formatICU(text: string, params: Record<string, string | number>): string {
76+
let result = text;
77+
let startIndex = 0;
78+
79+
while (startIndex < result.length) {
80+
// Find pattern start: {varName, plural/select,
81+
const patternMatch = result.slice(startIndex).match(/\{(\w+),\s*(plural|select),\s*/);
82+
if (!patternMatch || patternMatch.index === undefined) break;
83+
84+
const matchStart = startIndex + patternMatch.index;
85+
const varName = patternMatch[1]!;
86+
const type = patternMatch[2]!;
87+
const optionsStart = matchStart + patternMatch[0].length;
88+
89+
// Find matching closing brace by counting brace depth
90+
let braceCount = 1;
91+
let i = optionsStart;
92+
while (i < result.length && braceCount > 0) {
93+
if (result[i] === '{') braceCount++;
94+
else if (result[i] === '}') braceCount--;
95+
i++;
96+
}
97+
98+
if (braceCount !== 0) {
99+
startIndex = matchStart + 1;
100+
continue;
101+
}
102+
103+
const matchEnd = i;
104+
const options = result.slice(optionsStart, matchEnd - 1);
105+
const value = params[varName];
106+
107+
if (value === undefined) {
108+
startIndex = matchEnd;
109+
continue;
110+
}
111+
112+
// Parse options like "one {text}" or "=0 {text}"
113+
const optionMap = parseICUOptions(options);
114+
let replacement: string;
115+
116+
if (type === 'plural') {
117+
const count = Number(value);
118+
// Try exact match first (=0, =1, etc.)
119+
if (optionMap[`=${count}`]) {
120+
replacement = replaceHash(optionMap[`=${count}`]!, count);
121+
} else {
122+
// Then try plural category
123+
const category = getPluralCategory(count);
124+
const template = optionMap[category] ?? optionMap['other'];
125+
replacement = template ? replaceHash(template, count) : result.slice(matchStart, matchEnd);
126+
}
127+
} else if (type === 'select') {
128+
const key = String(value);
129+
replacement = optionMap[key] ?? optionMap['other'] ?? result.slice(matchStart, matchEnd);
130+
} else {
131+
startIndex = matchEnd;
132+
continue;
133+
}
134+
135+
result = result.slice(0, matchStart) + replacement + result.slice(matchEnd);
136+
startIndex = matchStart + replacement.length;
137+
}
138+
139+
return result;
140+
}
141+
142+
/**
143+
* Parse ICU options string into a map
144+
* Handles nested braces in option values
145+
*/
146+
function parseICUOptions(options: string): Record<string, string> {
147+
const result: Record<string, string> = {};
148+
let i = 0;
149+
150+
while (i < options.length) {
151+
// Skip whitespace
152+
while (i < options.length && /\s/.test(options[i]!)) i++;
153+
if (i >= options.length) break;
154+
155+
// Find key (word or =N)
156+
const keyMatch = options.slice(i).match(/^([\w=]+)\s*\{/);
157+
if (!keyMatch) break;
158+
159+
const key = keyMatch[1]!;
160+
i += keyMatch[0].length;
161+
162+
// Find matching closing brace
163+
let braceCount = 1;
164+
const valueStart = i;
165+
while (i < options.length && braceCount > 0) {
166+
if (options[i] === '{') braceCount++;
167+
else if (options[i] === '}') braceCount--;
168+
i++;
169+
}
170+
171+
if (braceCount === 0) {
172+
result[key] = options.slice(valueStart, i - 1);
173+
}
174+
}
175+
176+
return result;
177+
}
178+
179+
/**
180+
* Get plural category for a number
181+
*/
182+
function getPluralCategory(count: number): string {
183+
if (typeof Intl !== 'undefined' && Intl.PluralRules) {
184+
return new Intl.PluralRules('en').select(count);
185+
}
186+
return count === 1 ? 'one' : 'other';
187+
}
188+
189+
/**
190+
* Replace # with the count in plural templates
191+
*/
192+
function replaceHash(template: string, count: number): string {
193+
return template.replace(/#/g, String(count));
194+
}
195+
51196
// ============================================
52197
// Context
53198
// ============================================
@@ -56,12 +201,12 @@ export const RosettaContext = createContext<TranslationContextValue>({
56201
locale: 'en',
57202
defaultLocale: 'en',
58203
t: (text, paramsOrOptions) => {
59-
// Default fallback: just interpolate without translation
204+
// Default fallback: format without translation
60205
const params =
61206
paramsOrOptions && 'params' in paramsOrOptions
62207
? (paramsOrOptions as TranslateOptions).params
63208
: (paramsOrOptions as Record<string, string | number> | undefined);
64-
return interpolate(text, params);
209+
return formatMessage(text, params);
65210
},
66211
});
67212

@@ -87,29 +232,33 @@ export function RosettaClientProvider({
87232
translations,
88233
children,
89234
}: RosettaClientProviderProps): React.ReactElement {
90-
const t: TranslateFunction = (text, paramsOrOptions) => {
91-
// Determine if paramsOrOptions is TranslateOptions or direct interpolation params
92-
const isTranslateOptions =
93-
paramsOrOptions &&
94-
('context' in paramsOrOptions || 'params' in paramsOrOptions) &&
95-
Object.keys(paramsOrOptions).every((k) => k === 'context' || k === 'params');
96-
97-
let context: string | undefined;
98-
let params: Record<string, string | number> | undefined;
99-
100-
if (isTranslateOptions) {
101-
const opts = paramsOrOptions as TranslateOptions;
102-
context = opts.context;
103-
params = opts.params;
104-
} else {
105-
params = paramsOrOptions as Record<string, string | number> | undefined;
106-
}
235+
// Memoize t function to prevent unnecessary re-renders
236+
const t = useMemo<TranslateFunction>(() => {
237+
return (text, paramsOrOptions) => {
238+
// Determine if paramsOrOptions is TranslateOptions or direct interpolation params
239+
const isTranslateOptions =
240+
paramsOrOptions &&
241+
('context' in paramsOrOptions || 'params' in paramsOrOptions) &&
242+
Object.keys(paramsOrOptions).every((k) => k === 'context' || k === 'params');
243+
244+
let context: string | undefined;
245+
let params: Record<string, string | number> | undefined;
246+
247+
if (isTranslateOptions) {
248+
const opts = paramsOrOptions as TranslateOptions;
249+
context = opts.context;
250+
params = opts.params;
251+
} else {
252+
params = paramsOrOptions as Record<string, string | number> | undefined;
253+
}
107254

108-
// Use same hash-based lookup as server (with context support)
109-
const hash = hashText(text, context);
110-
const translated = translations[hash] ?? text;
111-
return interpolate(translated, params);
112-
};
255+
// Use same hash-based lookup as server (with context support)
256+
const hash = hashText(text, context);
257+
const translated = translations[hash] ?? text;
258+
// Use formatMessage for ICU support (plural, select)
259+
return formatMessage(translated, params);
260+
};
261+
}, [translations]);
113262

114263
return (
115264
<RosettaContext.Provider value={{ locale, defaultLocale, t }}>{children}</RosettaContext.Provider>

packages/rosetta-next/src/server.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from 'react';
22
import type { Rosetta } from '@sylphx/rosetta/server';
3-
import { runWithRosetta, flushCollectedStrings } from '@sylphx/rosetta/server';
3+
import { runWithRosetta, scheduleFlush, buildLocaleChain } from '@sylphx/rosetta/server';
44
import { RosettaClientProvider } from './client';
55

66
// ============================================
@@ -96,24 +96,27 @@ export async function RosettaProvider({
9696
hashes,
9797
}: RosettaProviderProps): Promise<React.ReactElement> {
9898
// Load translations - fine-grained if hashes provided, otherwise all
99+
// Translations are already merged from fallback chain (zh-TW → zh → en)
99100
const translations = hashes
100101
? await rosetta.loadTranslationsByHashes(locale, hashes)
101102
: await rosetta.loadTranslations(locale);
102103
const defaultLocale = rosetta.getDefaultLocale();
104+
const localeChain = buildLocaleChain(locale, defaultLocale);
103105

104106
// Run within AsyncLocalStorage context for server components
105107
// and provide React context for client components
106108
return runWithRosetta(
107109
{
108110
locale,
109111
defaultLocale,
112+
localeChain,
110113
translations,
111114
storage: rosetta.getStorage(),
112115
},
113116
() => {
114117
// Schedule flush at end of request (non-blocking)
115-
// Using Promise.resolve().then() to defer without blocking render
116-
Promise.resolve().then(() => flushCollectedStrings());
118+
// Uses setImmediate/setTimeout to defer without blocking render
119+
scheduleFlush();
117120

118121
return (
119122
<RosettaClientProvider

0 commit comments

Comments
 (0)