Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions conformance/core/functions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,33 @@
format: "yyyy-MM-dd HH:mm"
expect: "2026-09-04 23:30"

- 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.
Expand Down
5 changes: 5 additions & 0 deletions dart/a2ui_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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), [#3787](https://github.com/a2ui-project/a2ui/pull/3787)).

## 0.4.0

Expand Down
15 changes: 11 additions & 4 deletions dart/a2ui_core/lib/src/basic_catalog/locale_formatting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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].
Expand All @@ -238,14 +243,16 @@ 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],
'MMM' => names.STANDALONESHORTMONTHS[shifted.month - 1],
'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),
Expand Down
10 changes: 10 additions & 0 deletions dart/a2ui_core/test/basic_catalog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions python/a2ui_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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), [#3787](https://github.com/a2ui-project/a2ui/pull/3787)

## 0.4.0 (2026-10-17)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand All @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand All @@ -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}"
Expand Down
26 changes: 26 additions & 0 deletions python/a2ui_core/tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions typescript/core/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,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), [#3787](https://github.com/a2ui-project/a2ui/pull/3787)

## 0.14.0

Expand Down
10 changes: 10 additions & 0 deletions typescript/core/web_core/src/common/basic_functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions typescript/core/web_core/src/common/basic_functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})$/;

/**
Expand Down Expand Up @@ -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':
Expand All @@ -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':
Expand Down
Loading