diff --git a/renderers/web_core/CHANGELOG.md b/renderers/web_core/CHANGELOG.md index 9edae9df0..4d8994ed8 100644 --- a/renderers/web_core/CHANGELOG.md +++ b/renderers/web_core/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Add locale support to `SurfaceModel` and `DataContext` in v0.9. +- Update `pluralize`, `formatNumber`, and `formatCurrency` to use the context locale instead of hardcoding 'en-US'. +- Remove `.passthrough()` from `PluralizeApi` schema for stricter validation. + ## 0.10.0 - **BREAKING CHANGE**: Rename Icon `path` property to `svgPath` to fix type collision with `DataBindingType`. diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index 6b6589605..62a76820d 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -34,11 +34,13 @@ const createTestDataContext = ( model: DataModel, path: string, functionInvoker: any = testCatalog.invoker, + locale?: string, ) => { const mockSurface = { dataModel: model, catalog: {invoker: functionInvoker}, dispatchError: () => {}, + locale: locale, } as any; return new DataContext(mockSurface, path); }; @@ -354,6 +356,35 @@ describe('BASIC_FUNCTIONS', () => { 'apples', ); }); + + it('pluralize with Welsh locale', () => { + const cyContext = createTestDataContext(dataModel, '/', testCatalog.invoker, 'cy'); + // Welsh for various numbers of "cat". Welsh because all six cases have different rules. + const args = { + zero: 'cathod', + one: 'gath', + two: 'gath', + few: 'cath', + many: 'chath', + other: 'cath', + }; + + assert.strictEqual(invoke('pluralize', {...args, value: 0}, cyContext), 'cathod'); + assert.strictEqual(invoke('pluralize', {...args, value: 1}, cyContext), 'gath'); + assert.strictEqual(invoke('pluralize', {...args, value: 2}, cyContext), 'gath'); + assert.strictEqual(invoke('pluralize', {...args, value: 3}, cyContext), 'cath'); + assert.strictEqual(invoke('pluralize', {...args, value: 6}, cyContext), 'chath'); + assert.strictEqual(invoke('pluralize', {...args, value: 4}, cyContext), 'cath'); + }); + + it('pluralize fallback to other', () => { + assert.strictEqual( + invoke('pluralize', {value: 5, one: 'apple', other: 'apples'}, context), + 'apples', + ); + assert.strictEqual(invoke('pluralize', {value: 1, other: 'apples'}, context), 'apples'); + assert.strictEqual(invoke('pluralize', {value: 0, other: 'apples'}, context), 'apples'); + }); }); describe('Actions', () => { diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts index d77169d5a..492a04f94 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts @@ -267,18 +267,65 @@ export const FormatStringImplementation = createFunctionImplementation( }); }, ); +const numberFormatCache = new Map(); + +function getNumberFormat( + locale: string | undefined, + decimals?: number, + grouping?: boolean, +): Intl.NumberFormat { + const key = `${locale ?? 'default'}:${decimals ?? 'undef'}:${grouping ?? 'true'}`; + let formatter = numberFormatCache.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }); + numberFormatCache.set(key, formatter); + } + return formatter; +} + /** * Implementation of the number formatting function. * Formats a number using Intl.NumberFormat with specified decimals and grouping. */ -export const FormatNumberImplementation = createFunctionImplementation(FormatNumberApi, args => { - if (isNaN(args.value)) return ''; - return new Intl.NumberFormat('en-US', { - minimumFractionDigits: args.decimals, - maximumFractionDigits: args.decimals, - useGrouping: args.grouping, - }).format(args.value); -}); +export const FormatNumberImplementation = createFunctionImplementation( + FormatNumberApi, + (args, context) => { + if (isNaN(args.value)) return ''; + try { + return getNumberFormat(context.locale, args.decimals, args.grouping).format(args.value); + } catch (e) { + console.warn('Error formatting number:', e); + return args.decimals !== undefined ? args.value.toFixed(args.decimals) : String(args.value); + } + }, +); +const currencyFormatCache = new Map(); + +function getCurrencyFormat( + locale: string | undefined, + currency: string, + decimals?: number, + grouping?: boolean, +): Intl.NumberFormat { + const key = `${locale ?? 'default'}:${currency}:${decimals ?? 'undef'}:${grouping ?? 'true'}`; + let formatter = currencyFormatCache.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: grouping, + }); + currencyFormatCache.set(key, formatter); + } + return formatter; +} + /** * Implementation of the currency formatting function. * Formats a number as currency using Intl.NumberFormat. @@ -286,18 +333,15 @@ export const FormatNumberImplementation = createFunctionImplementation(FormatNum */ export const FormatCurrencyImplementation = createFunctionImplementation( FormatCurrencyApi, - args => { + (args, context) => { if (isNaN(args.value)) return ''; try { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: args.currency, - minimumFractionDigits: args.decimals, - maximumFractionDigits: args.decimals, - useGrouping: args.grouping, - }).format(args.value); - } catch { - return args.value.toFixed(args.decimals || 2); + return getCurrencyFormat(context.locale, args.currency, args.decimals, args.grouping).format( + args.value, + ); + } catch (e) { + console.warn('Error formatting currency:', e); + return args.value.toFixed(args.decimals ?? 2); } }, ); @@ -318,14 +362,34 @@ export const FormatDateImplementation = createFunctionImplementation(FormatDateA return date.toISOString(); } }); +const pluralRulesCache = new Map(); + +function getPluralRules(locale: string | undefined): Intl.PluralRules { + const key = locale ?? 'default'; + let rules = pluralRulesCache.get(key); + if (!rules) { + rules = new Intl.PluralRules(locale); + pluralRulesCache.set(key, rules); + } + return rules; +} + /** * Implementation of the pluralization function. * Selects the appropriate plural form based on the value using Intl.PluralRules. */ -export const PluralizeImplementation = createFunctionImplementation(PluralizeApi, args => { - const rule = new Intl.PluralRules('en-US').select(args.value); - return String((args as Record)[rule] ?? args.other ?? ''); -}); +export const PluralizeImplementation = createFunctionImplementation( + PluralizeApi, + (args, context) => { + try { + const rule = getPluralRules(context.locale).select(args.value); + return String((args as Record)[rule] ?? args.other ?? ''); + } catch (e) { + console.warn('Error in pluralize:', e); + return String(args.other ?? ''); + } + }, +); // Actions /** diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts index e36cf22e2..a6054d88f 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts @@ -432,17 +432,15 @@ export const FormatDateApi = { export const PluralizeApi = { name: 'pluralize' as const, returnType: 'string' as const, - schema: z - .object({ - value: z.coerce.number(), - zero: z.coerce.string().optional(), - one: z.coerce.string().optional(), - two: z.coerce.string().optional(), - few: z.coerce.string().optional(), - many: z.coerce.string().optional(), - other: z.coerce.string(), - }) - .passthrough(), + schema: z.object({ + value: z.coerce.number(), + zero: z.coerce.string().optional(), + one: z.coerce.string().optional(), + two: z.coerce.string().optional(), + few: z.coerce.string().optional(), + many: z.coerce.string().optional(), + other: z.coerce.string(), + }), }; // Actions diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index ea2f24723..508960604 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -53,6 +53,13 @@ export class DataContext { this.functionInvoker = surface.catalog.invoker; } + /** + * Gets the locale for this context, inherited from the surface. + */ + get locale(): string | undefined { + return this.surface.locale; + } + /** * Mutates the underlying DataModel at the specified path. * diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index 5686beb7e..9f1333235 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -53,12 +53,14 @@ export class SurfaceModel { * @param catalog The component catalog used by this surface. * @param theme The theme to apply to this surface. * @param sendDataModel If true, the client will send the full data model. + * @param locale The locale to use in locale-sensitive functions. */ constructor( readonly id: string, readonly catalog: Catalog, readonly theme: any = {}, readonly sendDataModel: boolean = false, + readonly locale?: string, ) { this.dataModel = new DataModel({}); this.componentsModel = new SurfaceComponentsModel();