22
33import { hashText , interpolate } from '@sylphx/rosetta' ;
44import 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 * ( p l u r a l | s e l e c t ) , \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 >
0 commit comments