From 6cef1a2a2156d45875c88d5e615285d3c0bc24da Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 15:10:23 -0700 Subject: [PATCH 1/6] feat(web-core): implement locale support and fix pluralize - Add optional `locale` to `SurfaceModel` and `DataContext`. - Update `pluralize`, `formatNumber`, and `formatCurrency` to use context locale instead of hardcoded 'en-US'. - Remove `.passthrough()` from `PluralizeApi` schema. - Add tests for pluralize with Welsh locale covering all categories. --- .../functions/basic_functions.test.ts | 29 ++++++++++++++++ .../functions/basic_functions.ts | 34 +++++++++++-------- .../functions/basic_functions_api.ts | 20 +++++------ .../src/v0_9/rendering/data-context.ts | 7 ++++ .../web_core/src/v0_9/state/surface-model.ts | 2 ++ 5 files changed, 67 insertions(+), 25 deletions(-) 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..4b674f7e7 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,33 @@ 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', + ); + }); }); 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..8d1ec386b 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 @@ -271,14 +271,17 @@ export const FormatStringImplementation = createFunctionImplementation( * 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 ''; + return new Intl.NumberFormat(context.locale, { + minimumFractionDigits: args.decimals, + maximumFractionDigits: args.decimals, + useGrouping: args.grouping, + }).format(args.value); + }, +); /** * Implementation of the currency formatting function. * Formats a number as currency using Intl.NumberFormat. @@ -286,10 +289,10 @@ 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', { + return new Intl.NumberFormat(context.locale, { style: 'currency', currency: args.currency, minimumFractionDigits: args.decimals, @@ -322,10 +325,13 @@ export const FormatDateImplementation = createFunctionImplementation(FormatDateA * 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) => { + const rule = new Intl.PluralRules(context.locale).select(args.value); + return String((args as Record)[rule] ?? 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(); From abc6c8b3ca3fb52e52b857631489f2acfd60ffd4 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 15:11:12 -0700 Subject: [PATCH 2/6] docs(web-core): update changelog for locale support and pluralize fix --- renderers/web_core/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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`. From 02ecd3af06de60b17a8550b5defdba97473a5709 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 15:18:04 -0700 Subject: [PATCH 3/6] perf(web-core): cache Intl instances and improve error handling - Cache `Intl.NumberFormat` and `Intl.PluralRules` instances to improve efficiency. - Add try-catch block to `FormatNumberImplementation` to prevent crashes on invalid locales, falling back to `toFixed` or `String`. - Add try-catch block to `PluralizeImplementation` for safety. --- .../functions/basic_functions.ts | 77 +++++++++++++++---- 1 file changed, 62 insertions(+), 15 deletions(-) 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 8d1ec386b..d1228ca2b 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,6 +267,22 @@ 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. @@ -275,13 +291,32 @@ export const FormatNumberImplementation = createFunctionImplementation( FormatNumberApi, (args, context) => { if (isNaN(args.value)) return ''; - return new Intl.NumberFormat(context.locale, { - minimumFractionDigits: args.decimals, - maximumFractionDigits: args.decimals, - useGrouping: args.grouping, - }).format(args.value); + 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. @@ -292,14 +327,9 @@ export const FormatCurrencyImplementation = createFunctionImplementation( (args, context) => { if (isNaN(args.value)) return ''; try { - return new Intl.NumberFormat(context.locale, { - style: 'currency', - currency: args.currency, - minimumFractionDigits: args.decimals, - maximumFractionDigits: args.decimals, - useGrouping: args.grouping, - }).format(args.value); - } catch { + 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); } }, @@ -321,6 +351,18 @@ 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. @@ -328,8 +370,13 @@ export const FormatDateImplementation = createFunctionImplementation(FormatDateA export const PluralizeImplementation = createFunctionImplementation( PluralizeApi, (args, context) => { - const rule = new Intl.PluralRules(context.locale).select(args.value); - return String((args as Record)[rule] ?? args.other ?? ''); + 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 ?? ''); + } }, ); From 18d28736f13e5f355a0c11f5004dc1300f2f7435 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 15:20:25 -0700 Subject: [PATCH 4/6] style(web-core): fix prettier formatting issues in basic_functions.ts --- .../basic_catalog/functions/basic_functions.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 d1228ca2b..3ba59db20 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 @@ -269,7 +269,11 @@ export const FormatStringImplementation = createFunctionImplementation( ); const numberFormatCache = new Map(); -function getNumberFormat(locale: string | undefined, decimals?: number, grouping?: boolean): Intl.NumberFormat { +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) { @@ -301,7 +305,12 @@ export const FormatNumberImplementation = createFunctionImplementation( ); const currencyFormatCache = new Map(); -function getCurrencyFormat(locale: string | undefined, currency: string, decimals?: number, grouping?: boolean): Intl.NumberFormat { +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) { @@ -327,7 +336,9 @@ export const FormatCurrencyImplementation = createFunctionImplementation( (args, context) => { if (isNaN(args.value)) return ''; try { - return getCurrencyFormat(context.locale, args.currency, args.decimals, args.grouping).format(args.value); + 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); From 672963dd78de8ee293b999a71e694e528bb22e1d Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 16:13:35 -0700 Subject: [PATCH 5/6] Update renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/v0_9/basic_catalog/functions/basic_functions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3ba59db20..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 @@ -341,7 +341,7 @@ export const FormatCurrencyImplementation = createFunctionImplementation( ); } catch (e) { console.warn('Error formatting currency:', e); - return args.value.toFixed(args.decimals || 2); + return args.value.toFixed(args.decimals ?? 2); } }, ); From afd4a823cc7634b83965ad0dcc1b30567dae5974 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 13 May 2026 17:22:44 -0700 Subject: [PATCH 6/6] test(web-core): add more fallback cases for pluralize - Add tests for values 0 and 1 falling back to 'other' when specific categories are missing. --- .../src/v0_9/basic_catalog/functions/basic_functions.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 4b674f7e7..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 @@ -382,6 +382,8 @@ describe('BASIC_FUNCTIONS', () => { 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'); }); });