From f2f5c8758b466be953c0abe3c3ef3cd532b2f107 Mon Sep 17 00:00:00 2001 From: Karol Kulesza Date: Fri, 30 Oct 2026 18:00:20 +0100 Subject: [PATCH 1/2] fix(core): honor TR35 quoted literals and EEE in formatDate The formatDate tokenizers in web_core, Python a2ui_core (v0.9 and v1.0) and Dart a2ui_core did not know TR35 quoted literals or EE/EEE, so "MMM d 'at' h:mm a" rendered "Feb 2 'PMt' 3:17 PM" and "EEE" rendered "MonMonMon". Each token regex now matches '' as an apostrophe and '...' as a literal (with '' inside it as an apostrophe; an unclosed quote runs to the end, as in date-fns), and maps EEE and EE to the short weekday. Add time-zone-neutral functions.yaml cases for a quoted literal, an escaped apostrophe and EEE/EE, plus unit tests in each SDK. Swift uses DateFormatter and passes the new cases unchanged. Fixes #2937 --- conformance/core/functions.yaml | 27 +++++++++++++++++++ dart/a2ui_core/CHANGELOG.md | 5 ++++ .../src/basic_catalog/locale_formatting.dart | 15 ++++++++--- dart/a2ui_core/test/basic_catalog_test.dart | 10 +++++++ python/a2ui_core/CHANGELOG.md | 5 ++++ .../core/basic_catalog/v0_9/function_impls.py | 15 +++++++++-- .../core/basic_catalog/v1_0/function_impls.py | 15 +++++++++-- python/a2ui_core/tests/test_functions.py | 26 ++++++++++++++++++ typescript/core/web_core/CHANGELOG.md | 1 + .../src/common/basic_functions.test.ts | 10 +++++++ .../web_core/src/common/basic_functions.ts | 15 +++++++++-- 11 files changed, 134 insertions(+), 10 deletions(-) diff --git a/conformance/core/functions.yaml b/conformance/core/functions.yaml index 2a36e16520..ac1bd57b31 100644 --- a/conformance/core/functions.yaml +++ b/conformance/core/functions.yaml @@ -680,6 +680,33 @@ format: "yyyy-MM-dd" expect: "" +- name: test_evaluate_function_format_date_quoted_literal + description: Verifies formatDate copies text in single quotes through as a literal, so letters inside it are not read as pattern tokens. + action: evaluate_function + function: formatDate + args: + value: "2025-12-15T12:00:00Z" + format: "EEEE, MMM d 'at' yyyy" + expect: "Monday, Dec 15 at 2025" + +- name: test_evaluate_function_format_date_escaped_apostrophe + description: Verifies formatDate renders two single quotes as one apostrophe, both inside and outside a quoted literal. + action: evaluate_function + function: formatDate + args: + value: "2025-12-15T12:00:00Z" + format: "d 'o''clock', MMM''yy" + expect: "15 o'clock, Dec'25" + +- name: test_evaluate_function_format_date_short_weekday + description: Verifies formatDate reads EEE and EE as the abbreviated weekday, the same as E. + action: evaluate_function + function: formatDate + args: + value: "2026-02-02T12:00:00Z" + format: "EEE, MMM d (EE, E)" + expect: "Mon, Feb 2 (Mon, Mon)" + # --- FormatStringFunction (D18, D28) --- - name: test_evaluate_function_format_string_basic description: Verifies formatString evaluates static template string. diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 640a2273bc..24a99728ed 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -36,6 +36,11 @@ `$id`, title, description or instructions, which describe the source; pass its own `instructions`. A name the source has no entry of throws `A2uiCatalogError` ([#3597](https://github.com/a2ui-project/a2ui/issues/3597)). +- `formatDate` copies text in single quotes through as a literal and renders + `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` + instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the + abbreviated weekday instead of repeating it + ([#2937](https://github.com/a2ui-project/a2ui/issues/2937)). ## 0.4.0 diff --git a/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart b/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart index e066e8fded..077421d233 100644 --- a/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart +++ b/dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart @@ -109,8 +109,12 @@ String formatCurrency( return format.format(number); } +/// TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text +/// in single quotes (with `''` inside it standing for an apostrophe) is copied +/// through. An unclosed quote runs to the end of the pattern, as in web_core. final RegExp _dateTokens = RegExp( - 'yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a', + r"''|'((?:''|[^'])+)(?:'|$)" + '|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a', ); /// The offset at the end of a timestamp's time part: `Z`, `+hh`, `+hhmm` or @@ -212,8 +216,9 @@ DateSymbols _dateSymbolsFor(String locale) { /// Formats an ISO 8601 timestamp [value] with a TR35 [pattern]. /// -/// Supports the tokens `yyyy yy MMMM MMM MM M EEEE E dd d HH H hh h mm ss a`; -/// other text is copied through. A timestamp with an offset keeps its +/// Supports the tokens +/// `yyyy yy MMMM MMM MM M EEEE EEE EE E dd d HH H hh h mm ss a` and quoted +/// literals; other text is copied through. A timestamp with an offset keeps its /// wall-clock fields, so `2026-09-04T23:30:00-05:00` formats as `2026-09-04`. /// The pattern `ISO` emits the UTC instant as `YYYY-MM-DDTHH:mm:ss.sssZ`. /// Returns an empty string for a missing or unparseable [value]. @@ -238,6 +243,8 @@ String formatDate( return (format.isEmpty ? 'yyyy-MM-dd' : format).replaceAllMapped( _dateTokens, (match) => switch (match[0]) { + _ when match[1] != null => match[1]!.replaceAll("''", "'"), + "''" => "'", 'yyyy' => '${shifted.year}', 'yy' => pad(shifted.year % 100), 'MMMM' => names.STANDALONEMONTHS[shifted.month - 1], @@ -245,7 +252,7 @@ String formatDate( 'MM' => pad(shifted.month), 'M' => '${shifted.month}', 'EEEE' => names.STANDALONEWEEKDAYS[weekday], - 'E' => names.STANDALONESHORTWEEKDAYS[weekday], + 'EEE' || 'EE' || 'E' => names.STANDALONESHORTWEEKDAYS[weekday], 'dd' => pad(shifted.day), 'd' => '${shifted.day}', 'HH' => pad(shifted.hour), diff --git a/dart/a2ui_core/test/basic_catalog_test.dart b/dart/a2ui_core/test/basic_catalog_test.dart index d56b82cb57..898c3f0bab 100644 --- a/dart/a2ui_core/test/basic_catalog_test.dart +++ b/dart/a2ui_core/test/basic_catalog_test.dart @@ -808,6 +808,16 @@ void main() { ); }); + test('copies quoted literals and reads EEE as the short weekday', () { + const value = '2026-02-02T15:17:09Z'; + expect(format(value, "MMM d 'at' h:mm a"), 'Feb 2 at 3:17 PM'); + expect(format(value, "h 'o''clock'"), "3 o'clock"); + expect(format(value, "h''mm"), "3'17"); + expect(format(value, 'EEE, EE, E'), 'Mon, Mon, Mon'); + // An unclosed quote runs to the end of the pattern. + expect(format(value, "h 'at end"), '3 at end'); + }); + test('keeps the wall-clock time of an offset timestamp', () { expect(format('2026-09-04T23:30:00-05:00', 'yyyy-MM-dd'), '2026-09-04'); expect(format('2026-09-04T23:30:00-05:00', 'HH:mm'), '23:30'); diff --git a/python/a2ui_core/CHANGELOG.md b/python/a2ui_core/CHANGELOG.md index 9e5b021173..4a9095fd52 100644 --- a/python/a2ui_core/CHANGELOG.md +++ b/python/a2ui_core/CHANGELOG.md @@ -118,6 +118,11 @@ a component the surface already held, so a strict config now reports an item naming a missing component as a dangling reference, and error paths read `name[i]` instead of `name.[i]`. +- `formatDate` (v0.9 and v1.0) copies text in single quotes through as a + literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives + `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and + `EE` as the abbreviated weekday instead of repeating it. + [#2937](https://github.com/a2ui-project/a2ui/issues/2937) ## 0.4.0 (2026-10-17) diff --git a/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py b/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py index 7c8994f961..60d62d081b 100644 --- a/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py +++ b/python/a2ui_core/src/a2ui/core/basic_catalog/v0_9/function_impls.py @@ -277,7 +277,13 @@ def _format_currency( FormatCurrencyImplementation = create_format_currency_implementation(None) -_DATE_TOKENS = re.compile(r"yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a|%") +# TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in +# single quotes (with `''` inside it standing for an apostrophe) is copied +# through. An unclosed quote runs to the end of the pattern, as in web_core. +_DATE_TOKENS = re.compile( + r"''|'((?:''|[^'])+)(?:'|$)" + r"|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a|%" +) def create_format_date_implementation( @@ -306,7 +312,12 @@ def _format_date( loc = get_locale(locale) def _sub(m: re.Match[str]) -> str: + literal = m.group(1) + if literal is not None: + return literal.replace("''", "'") tok = m.group(0) + if tok == "''": + return "'" if tok == "yyyy": return str(dt.year) if tok == "yy": @@ -321,7 +332,7 @@ def _sub(m: re.Match[str]) -> str: return str(dt.month) if tok == "EEEE": return str(loc.days["format"]["wide"][dt.weekday()]) - if tok == "E": + if tok in ("EEE", "EE", "E"): return str(loc.days["format"]["abbreviated"][dt.weekday()]) if tok == "dd": return f"{dt.day:02d}" diff --git a/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py b/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py index 9a4239ef45..04afa794e6 100644 --- a/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py +++ b/python/a2ui_core/src/a2ui/core/basic_catalog/v1_0/function_impls.py @@ -317,7 +317,13 @@ def _format_currency( FormatCurrencyImplementation = create_format_currency_implementation(None) -_DATE_TOKENS = re.compile(r"yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a|%") +# TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in +# single quotes (with `''` inside it standing for an apostrophe) is copied +# through. An unclosed quote runs to the end of the pattern, as in web_core. +_DATE_TOKENS = re.compile( + r"''|'((?:''|[^'])+)(?:'|$)" + r"|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a|%" +) def create_format_date_implementation( @@ -350,7 +356,12 @@ def _format_date( loc = get_locale(locale) def _sub(m: re.Match[str]) -> str: + literal = m.group(1) + if literal is not None: + return literal.replace("''", "'") tok = m.group(0) + if tok == "''": + return "'" if tok == "yyyy": return str(dt.year) if tok == "yy": @@ -365,7 +376,7 @@ def _sub(m: re.Match[str]) -> str: return str(dt.month) if tok == "EEEE": return str(loc.days["format"]["wide"][dt.weekday()]) - if tok == "E": + if tok in ("EEE", "EE", "E"): return str(loc.days["format"]["abbreviated"][dt.weekday()]) if tok == "dd": return f"{dt.day:02d}" diff --git a/python/a2ui_core/tests/test_functions.py b/python/a2ui_core/tests/test_functions.py index 7e2bb7c536..7adc263d28 100644 --- a/python/a2ui_core/tests/test_functions.py +++ b/python/a2ui_core/tests/test_functions.py @@ -610,6 +610,32 @@ def test_formatting_falls_back_for_unusable_locale(version, locale): ) +@pytest.mark.parametrize("version", ["v0_9", "v1_0"]) +@pytest.mark.parametrize( + ("pattern", "expected"), + [ + ("MMM d 'at' h:mm a", "Feb 2 at 3:17 PM"), + ("h 'o''clock'", "3 o'clock"), + ("h''mm", "3'17"), + ("EEE, EE, E", "Mon, Mon, Mon"), + # An unclosed quote runs to the end of the pattern. + ("h 'at end", "3 at end"), + ], +) +def test_format_date_quoted_literals_and_short_weekday(version, pattern, expected): + import importlib + + module = importlib.import_module( + f"a2ui.core.basic_catalog.{version}.function_impls" + ) + format_date = module.create_format_date_implementation(None) + + assert ( + format_date.execute({"value": "2026-02-02T15:17:09Z", "format": pattern}) + == expected + ) + + def test_validation_return_types_v09_vs_v10(): from a2ui.core.basic_catalog import v0_9, v1_0 diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index 8610544659..89c6bcb1f1 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -36,6 +36,7 @@ - (v1_0) `metadata.extensions` keys containing characters outside UAX #31 identifier syntax (such as `:` or `-`) are now rejected during message and component validation ([#2931](https://github.com/a2ui-project/a2ui/pull/2931)). - (v0_8) Children attached via `Card.child`, `Modal.entryPointChild` or `Modal.contentChild` on v0.8 surfaces validated against a catalog loaded with `Catalog.fromJson` are now recognized as reachable from `root` instead of failing topology validation as orphaned components ([#2884](https://github.com/a2ui-project/a2ui/pull/2884)). - `Catalog.toJson()` and `Catalog.validationSchema` now include `args` in the outer function call schema's `required` list when a function's argument schema is a union (`oneOf` or `anyOf`) whose branches all require arguments, an `allOf` composition with a branch that requires arguments, or a local `$ref` to a definition that requires arguments ([#3363](https://github.com/a2ui-project/a2ui/issues/3363)). +- `formatDate` copies text in single quotes through as a literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it. [#2937](https://github.com/a2ui-project/a2ui/issues/2937) ## 0.14.0 diff --git a/typescript/core/web_core/src/common/basic_functions.test.ts b/typescript/core/web_core/src/common/basic_functions.test.ts index 826cdd3aec..b3d66097ca 100644 --- a/typescript/core/web_core/src/common/basic_functions.test.ts +++ b/typescript/core/web_core/src/common/basic_functions.test.ts @@ -154,6 +154,16 @@ describe('Common Basic Functions', () => { assert.strictEqual(executeFormatDate('invalid-date'), ''); }); + it('executeFormatDate copies quoted literals and reads EEE as the short weekday', () => { + const value = '2026-02-02T15:17:09Z'; + assert.strictEqual(executeFormatDate(value, "MMM d 'at' h:mm a"), 'Feb 2 at 3:17 PM'); + assert.strictEqual(executeFormatDate(value, "h 'o''clock'"), "3 o'clock"); + assert.strictEqual(executeFormatDate(value, "h''mm"), "3'17"); + assert.strictEqual(executeFormatDate(value, 'EEE, EE, E'), 'Mon, Mon, Mon'); + // An unclosed quote runs to the end of the pattern. + assert.strictEqual(executeFormatDate(value, "h 'at end"), '3 at end'); + }); + it('parseTimestamp', () => { const parsed = parseTimestamp('2025-01-01T12:00:00+02:00'); assert.ok(parsed); diff --git a/typescript/core/web_core/src/common/basic_functions.ts b/typescript/core/web_core/src/common/basic_functions.ts index 5449374276..66d0ac1b78 100644 --- a/typescript/core/web_core/src/common/basic_functions.ts +++ b/typescript/core/web_core/src/common/basic_functions.ts @@ -311,7 +311,13 @@ export function getDateNames(locale: string): DateNames { return names; } -export const DATE_TOKENS = /yyyy|yy|MMMM|MMM|MM|M|EEEE|E|dd|d|HH|H|hh|h|mm|ss|a/g; +/** + * TR35 pattern tokens, plus quoted literals: `''` is an apostrophe, and text in + * single quotes (with `''` inside it standing for an apostrophe) is copied + * through. As in date-fns, an unclosed quote runs to the end of the pattern. + */ +export const DATE_TOKENS = + /''|'((?:''|[^'])+)(?:'|$)|yyyy|yy|MMMM|MMM|MM|M|EEEE|EEE|EE|E|dd|d|HH|H|hh|h|mm|ss|a/g; export const ISO_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/; /** @@ -525,8 +531,11 @@ export function executeFormatDate( const hours = shifted.getUTCHours(); const hours12 = hours % 12 || 12; - return pattern.replace(DATE_TOKENS, (token: string) => { + return pattern.replace(DATE_TOKENS, (token: string, literal?: string) => { + if (literal !== undefined) return literal.replace(/''/g, "'"); switch (token) { + case "''": + return "'"; case 'yyyy': return String(shifted.getUTCFullYear()); case 'yy': @@ -541,6 +550,8 @@ export function executeFormatDate( return String(shifted.getUTCMonth() + 1); case 'EEEE': return names.weekdaysLong[weekdayIndex]; + case 'EEE': + case 'EE': case 'E': return names.weekdaysShort[weekdayIndex]; case 'dd': From e4e54b2bf7be23942513dc1a45c67a7c6f76f870 Mon Sep 17 00:00:00 2001 From: Karol Kulesza Date: Fri, 30 Oct 2026 18:31:36 +0100 Subject: [PATCH 2/2] docs(changelog): link the formatDate entries to #3787 --- dart/a2ui_core/CHANGELOG.md | 2 +- python/a2ui_core/CHANGELOG.md | 2 +- typescript/core/web_core/CHANGELOG.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index b79db2654e..161e4dd76a 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -45,7 +45,7 @@ `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it - ([#2937](https://github.com/a2ui-project/a2ui/issues/2937)). + ([#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787)). ## 0.4.0 diff --git a/python/a2ui_core/CHANGELOG.md b/python/a2ui_core/CHANGELOG.md index d8da197335..27dd51764a 100644 --- a/python/a2ui_core/CHANGELOG.md +++ b/python/a2ui_core/CHANGELOG.md @@ -123,7 +123,7 @@ literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it. - [#2937](https://github.com/a2ui-project/a2ui/issues/2937) + [#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787) ## 0.4.0 (2026-10-17) diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index 217faac1db..b0c401d17d 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -37,7 +37,7 @@ - (v1_0) `metadata.extensions` keys containing characters outside UAX #31 identifier syntax (such as `:` or `-`) are now rejected during message and component validation ([#2931](https://github.com/a2ui-project/a2ui/pull/2931)). - (v0_8) Children attached via `Card.child`, `Modal.entryPointChild` or `Modal.contentChild` on v0.8 surfaces validated against a catalog loaded with `Catalog.fromJson` are now recognized as reachable from `root` instead of failing topology validation as orphaned components ([#2884](https://github.com/a2ui-project/a2ui/pull/2884)). - `Catalog.toJson()` and `Catalog.validationSchema` now include `args` in the outer function call schema's `required` list when a function's argument schema is a union (`oneOf` or `anyOf`) whose branches all require arguments, an `allOf` composition with a branch that requires arguments, or a local `$ref` to a definition that requires arguments ([#3363](https://github.com/a2ui-project/a2ui/issues/3363)). -- `formatDate` copies text in single quotes through as a literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it. [#2937](https://github.com/a2ui-project/a2ui/issues/2937) +- `formatDate` copies text in single quotes through as a literal and renders `''` as an apostrophe, so `"MMM d 'at' h:mm a"` gives `Feb 2 at 3:17 PM` instead of `Feb 2 'PMt' 3:17 PM`, and reads `EEE` and `EE` as the abbreviated weekday instead of repeating it. [#2937](https://github.com/a2ui-project/a2ui/issues/2937), [#3787](https://github.com/a2ui-project/a2ui/pull/3787) ## 0.14.0