@mcp-native/react-native 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -12
- package/dist/component-adapters.d.ts +86 -0
- package/dist/component-adapters.d.ts.map +1 -0
- package/dist/component-adapters.js +26 -0
- package/dist/component-adapters.js.map +1 -0
- package/dist/index.d.ts +24 -48
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -23
- package/dist/index.js.map +1 -1
- package/dist/v1.d.ts +22 -0
- package/dist/v1.d.ts.map +1 -1
- package/dist/v1.js +689 -21
- package/dist/v1.js.map +1 -1
- package/package.json +3 -3
package/dist/v1.js
CHANGED
|
@@ -12,13 +12,22 @@ export const A2UI_V1_NATIVE_COMPONENT_NAMES = Object.freeze([
|
|
|
12
12
|
]);
|
|
13
13
|
/** Maximum expanded native-plan nodes, including repeated component references. */
|
|
14
14
|
export const A2UI_V1_NATIVE_MAX_RENDER_NODES = A2UI_V1_MAX_COMPONENTS;
|
|
15
|
+
/** Maximum canonical HTTP(S) URL retained for one supported local action. */
|
|
16
|
+
export const A2UI_V1_NATIVE_MAX_OPEN_URL_LENGTH = 8_192;
|
|
15
17
|
/**
|
|
16
18
|
* Converts a policy-validated A2UI v1 surface into the existing host-owned
|
|
17
19
|
* native render plan. Unsupported renderer semantics fail closed.
|
|
18
20
|
*/
|
|
19
21
|
export function createA2uiV1NativeRenderPlan(surface, policy, options = {}) {
|
|
22
|
+
return createNativeRenderPlan(surface, policy, options, false);
|
|
23
|
+
}
|
|
24
|
+
/** Internal mounted-surface path that keeps temporary renderer-local URL edits non-dispatchable. */
|
|
25
|
+
export function createA2uiV1NativeRenderPlanForLocalEdits(surface, policy, options = {}) {
|
|
26
|
+
return createNativeRenderPlan(surface, policy, options, true);
|
|
27
|
+
}
|
|
28
|
+
function createNativeRenderPlan(surface, policy, options, tolerateInvalidLocalOpenUrls) {
|
|
20
29
|
const parsedOptions = parseRenderPlanOptions(options);
|
|
21
|
-
const context = createAdapterContext(surface, policy, parsedOptions.dataModel, parsedOptions.locale);
|
|
30
|
+
const context = createAdapterContext(surface, policy, parsedOptions.dataModel, parsedOptions.locale, tolerateInvalidLocalOpenUrls);
|
|
22
31
|
return adaptComponent("root", "root", context, undefined);
|
|
23
32
|
}
|
|
24
33
|
/** Resolves one validated event against the latest renderer-local data model. */
|
|
@@ -28,8 +37,13 @@ export function resolveA2uiV1NativeEvent(surface, policy, sourceComponentId, dat
|
|
|
28
37
|
}
|
|
29
38
|
const parsedOptions = parseEventResolutionOptions(options);
|
|
30
39
|
const context = createAdapterContext(surface, policy, dataModel, parsedOptions.locale);
|
|
31
|
-
const
|
|
40
|
+
const plan = adaptComponent("root", "root", context, undefined);
|
|
41
|
+
const events = findNativeEvents(plan, sourceComponentId, parsedOptions.instanceKey);
|
|
32
42
|
if (events.length === 0) {
|
|
43
|
+
const disabledEvents = findNativeEvents(plan, sourceComponentId, parsedOptions.instanceKey, true);
|
|
44
|
+
if (disabledEvents.length > 0) {
|
|
45
|
+
throw new A2uiParseError(`A2UI native event source ${JSON.stringify(sourceComponentId)} is disabled by failed renderer checks`);
|
|
46
|
+
}
|
|
33
47
|
throw new A2uiParseError(`A2UI native event source ${JSON.stringify(sourceComponentId)} is not a reachable supported Button`);
|
|
34
48
|
}
|
|
35
49
|
if (events.length > 1) {
|
|
@@ -37,19 +51,54 @@ export function resolveA2uiV1NativeEvent(surface, policy, sourceComponentId, dat
|
|
|
37
51
|
}
|
|
38
52
|
return events[0];
|
|
39
53
|
}
|
|
40
|
-
|
|
54
|
+
/** Resolves one supported local URL action against the latest renderer-local data model. */
|
|
55
|
+
export function resolveA2uiV1NativeOpenUrl(surface, policy, sourceComponentId, dataModel, options = {}) {
|
|
56
|
+
if (typeof sourceComponentId !== "string" || sourceComponentId.length === 0) {
|
|
57
|
+
throw new A2uiParseError("Expected a non-empty A2UI openUrl source component id");
|
|
58
|
+
}
|
|
59
|
+
const parsedOptions = parseOpenUrlResolutionOptions(options);
|
|
60
|
+
const context = createAdapterContext(surface, policy, dataModel, parsedOptions.locale);
|
|
61
|
+
const plan = adaptComponent("root", "root", context, undefined);
|
|
62
|
+
const openUrls = findNativeOpenUrls(plan, sourceComponentId, parsedOptions.instanceKey);
|
|
63
|
+
if (openUrls.length === 0) {
|
|
64
|
+
const disabledOpenUrls = findNativeOpenUrls(plan, sourceComponentId, parsedOptions.instanceKey, true);
|
|
65
|
+
if (disabledOpenUrls.length > 0) {
|
|
66
|
+
throw new A2uiParseError(`A2UI native openUrl source ${JSON.stringify(sourceComponentId)} is disabled by failed renderer checks`);
|
|
67
|
+
}
|
|
68
|
+
throw new A2uiParseError(`A2UI native openUrl source ${JSON.stringify(sourceComponentId)} is not a reachable supported Button`);
|
|
69
|
+
}
|
|
70
|
+
if (openUrls.length > 1) {
|
|
71
|
+
throw new A2uiParseError(`A2UI native openUrl source ${JSON.stringify(sourceComponentId)} is ambiguous without its template instance key`);
|
|
72
|
+
}
|
|
73
|
+
return openUrls[0];
|
|
74
|
+
}
|
|
75
|
+
function findNativeEvents(element, sourceComponentId, instanceKey, includeDisabled = false) {
|
|
41
76
|
const events = [];
|
|
42
77
|
const event = element.props.event;
|
|
43
78
|
if (event?.sourceComponentId === sourceComponentId &&
|
|
79
|
+
(includeDisabled || element.props.disabled !== true) &&
|
|
44
80
|
(instanceKey === undefined || event.instanceKey === instanceKey)) {
|
|
45
81
|
events.push(event);
|
|
46
82
|
}
|
|
47
83
|
for (const child of element.children ?? []) {
|
|
48
|
-
events.push(...findNativeEvents(child, sourceComponentId, instanceKey));
|
|
84
|
+
events.push(...findNativeEvents(child, sourceComponentId, instanceKey, includeDisabled));
|
|
49
85
|
}
|
|
50
86
|
return events;
|
|
51
87
|
}
|
|
52
|
-
function
|
|
88
|
+
function findNativeOpenUrls(element, sourceComponentId, instanceKey, includeDisabled = false) {
|
|
89
|
+
const openUrls = [];
|
|
90
|
+
const openUrl = element.props.openUrl;
|
|
91
|
+
if (openUrl?.sourceComponentId === sourceComponentId &&
|
|
92
|
+
(includeDisabled || element.props.disabled !== true) &&
|
|
93
|
+
(instanceKey === undefined || openUrl.instanceKey === instanceKey)) {
|
|
94
|
+
openUrls.push(openUrl);
|
|
95
|
+
}
|
|
96
|
+
for (const child of element.children ?? []) {
|
|
97
|
+
openUrls.push(...findNativeOpenUrls(child, sourceComponentId, instanceKey, includeDisabled));
|
|
98
|
+
}
|
|
99
|
+
return openUrls;
|
|
100
|
+
}
|
|
101
|
+
function createAdapterContext(surface, policy, dataModel, locale, tolerateInvalidLocalOpenUrls = false) {
|
|
53
102
|
const localDataModel = dataModel === undefined
|
|
54
103
|
? parseJsonObject(surface.dataModel, "surface.dataModel")
|
|
55
104
|
: parseJsonObject(dataModel, "options.dataModel");
|
|
@@ -58,12 +107,17 @@ function createAdapterContext(surface, policy, dataModel, locale) {
|
|
|
58
107
|
surface: validated,
|
|
59
108
|
dataModel: parseJsonObject(validated.dataModel, "surface.dataModel"),
|
|
60
109
|
locale,
|
|
110
|
+
dateFormats: new Map(),
|
|
61
111
|
numberFormats: new Map(),
|
|
62
112
|
pluralRules: new Map(),
|
|
63
113
|
visiting: new Set(),
|
|
114
|
+
tolerateInvalidLocalOpenUrls,
|
|
64
115
|
formatStringExpressionCount: 0,
|
|
65
116
|
formattedStringLength: 0,
|
|
117
|
+
openUrlLength: 0,
|
|
66
118
|
renderNodeCount: 0,
|
|
119
|
+
validationCheckCount: 0,
|
|
120
|
+
validationOutputLength: 0,
|
|
67
121
|
};
|
|
68
122
|
}
|
|
69
123
|
function parseRenderPlanOptions(options) {
|
|
@@ -96,6 +150,22 @@ function parseEventResolutionOptions(options) {
|
|
|
96
150
|
: { locale: parseLocale(parsed.locale, "options.locale") }),
|
|
97
151
|
};
|
|
98
152
|
}
|
|
153
|
+
function parseOpenUrlResolutionOptions(options) {
|
|
154
|
+
const parsed = parseOptionsObject(options, "A2UI native openUrl resolution options", [
|
|
155
|
+
"instanceKey",
|
|
156
|
+
"locale",
|
|
157
|
+
]);
|
|
158
|
+
if (parsed.instanceKey !== undefined &&
|
|
159
|
+
(typeof parsed.instanceKey !== "string" || parsed.instanceKey.length === 0)) {
|
|
160
|
+
throw new A2uiParseError("Expected a non-empty A2UI native openUrl instance key");
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
...(parsed.instanceKey === undefined ? {} : { instanceKey: parsed.instanceKey }),
|
|
164
|
+
...(parsed.locale === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { locale: parseLocale(parsed.locale, "options.locale") }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
99
169
|
function parseOptionsObject(value, label, allowedKeys) {
|
|
100
170
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
101
171
|
throw new A2uiParseError(`Expected ${label} to be an object`);
|
|
@@ -116,10 +186,12 @@ function parseLocale(value, path) {
|
|
|
116
186
|
throw new A2uiParseError(`Expected a non-empty BCP 47 locale at ${path}`);
|
|
117
187
|
}
|
|
118
188
|
try {
|
|
119
|
-
|
|
189
|
+
const canonicalLocale = Intl.getCanonicalLocales(value)[0];
|
|
190
|
+
if (Intl.NumberFormat.supportedLocalesOf(canonicalLocale, { localeMatcher: "lookup" }).length ===
|
|
191
|
+
0) {
|
|
120
192
|
throw new A2uiParseError(`Unsupported BCP 47 locale ${JSON.stringify(value)} at ${path}`);
|
|
121
193
|
}
|
|
122
|
-
return
|
|
194
|
+
return canonicalLocale;
|
|
123
195
|
}
|
|
124
196
|
catch (cause) {
|
|
125
197
|
if (cause instanceof A2uiParseError) {
|
|
@@ -246,7 +318,6 @@ function adaptText(component, key, context, scope) {
|
|
|
246
318
|
return { key, component: "Text", props };
|
|
247
319
|
}
|
|
248
320
|
function adaptButton(component, key, context, scope) {
|
|
249
|
-
rejectUnsupportedChecks(component);
|
|
250
321
|
const childId = expectString(component.child, `components.${component.id}.child`);
|
|
251
322
|
const child = context.surface.components.get(childId);
|
|
252
323
|
if (child?.component !== "Text") {
|
|
@@ -254,19 +325,21 @@ function adaptButton(component, key, context, scope) {
|
|
|
254
325
|
}
|
|
255
326
|
const props = {
|
|
256
327
|
title: resolveDynamicString(child.text, `components.${child.id}.text`, context, scope),
|
|
257
|
-
|
|
328
|
+
// Validate action input even while checks disable dispatch, so inactive state cannot conceal
|
|
329
|
+
// malformed or host-denied dynamic semantics.
|
|
330
|
+
...resolveButtonAction(component, key, context, scope),
|
|
258
331
|
};
|
|
259
332
|
if (component.variant !== undefined) {
|
|
260
333
|
props.variant = component.variant;
|
|
261
334
|
}
|
|
262
335
|
addCommonProps(component, props, context, scope);
|
|
336
|
+
addValidationProps(component, props, context, scope, "button");
|
|
263
337
|
if (props.accessibilityLabel === undefined) {
|
|
264
338
|
props.accessibilityLabel = props.title;
|
|
265
339
|
}
|
|
266
340
|
return { key, component: "Button", props };
|
|
267
341
|
}
|
|
268
342
|
function adaptTextField(component, key, context, scope) {
|
|
269
|
-
rejectUnsupportedChecks(component);
|
|
270
343
|
const componentPath = `components.${component.id}`;
|
|
271
344
|
const label = resolveDynamicString(component.label, `${componentPath}.label`, context, scope);
|
|
272
345
|
const props = {
|
|
@@ -285,20 +358,233 @@ function adaptTextField(component, key, context, scope) {
|
|
|
285
358
|
props.variant = component.variant;
|
|
286
359
|
}
|
|
287
360
|
addCommonProps(component, props, context, scope);
|
|
361
|
+
addValidationProps(component, props, context, scope, "input");
|
|
288
362
|
if (props.accessibilityLabel === undefined) {
|
|
289
363
|
props.accessibilityLabel = label;
|
|
290
364
|
}
|
|
291
365
|
return { key, component: "TextInput", props };
|
|
292
366
|
}
|
|
293
|
-
function
|
|
294
|
-
if (component.checks
|
|
295
|
-
|
|
367
|
+
function addValidationProps(component, props, context, scope, target) {
|
|
368
|
+
if (component.checks === undefined) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!Array.isArray(component.checks)) {
|
|
372
|
+
throw new A2uiParseError(`Expected an array at components.${component.id}.checks`);
|
|
373
|
+
}
|
|
374
|
+
const messages = [];
|
|
375
|
+
let valid = true;
|
|
376
|
+
for (const [index, value] of component.checks.entries()) {
|
|
377
|
+
context.validationCheckCount += 1;
|
|
378
|
+
if (context.validationCheckCount > JSON_MAX_VALUES) {
|
|
379
|
+
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum of ${JSON_MAX_VALUES} renderer checks`);
|
|
380
|
+
}
|
|
381
|
+
const path = `components.${component.id}.checks[${index}]`;
|
|
382
|
+
const check = expectObject(value, path);
|
|
383
|
+
// The pinned Candidate's CheckRule prose says ValidationResult object, but its Checkable
|
|
384
|
+
// contract and reference implementation use a boolean. Follow that executable contract.
|
|
385
|
+
if (!resolveDynamicBoolean(check.condition, `${path}.condition`, context, scope)) {
|
|
386
|
+
valid = false;
|
|
387
|
+
if (check.message !== undefined) {
|
|
388
|
+
messages.push(expectString(check.message, `${path}.message`));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (valid) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
props[target === "button" ? "disabled" : "invalid"] = true;
|
|
396
|
+
if (messages.length === 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const existingHint = typeof props.accessibilityHint === "string" ? props.accessibilityHint : undefined;
|
|
400
|
+
const validationHintLength = messages.reduce((length, message, index) => length + message.length + (index === 0 ? 0 : 1), 0);
|
|
401
|
+
const outputLength = validationHintLength + (existingHint === undefined ? 0 : existingHint.length + 1);
|
|
402
|
+
if (outputLength > JSON_MAX_STRING_LENGTH) {
|
|
403
|
+
throw new A2uiParseError(`A2UI validation output at components.${component.id}.checks exceeds maximum length of ${JSON_MAX_STRING_LENGTH}`);
|
|
404
|
+
}
|
|
405
|
+
context.validationOutputLength += outputLength;
|
|
406
|
+
if (context.validationOutputLength > A2UI_V1_MAX_SOURCE_LENGTH) {
|
|
407
|
+
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum validation-output length of ${A2UI_V1_MAX_SOURCE_LENGTH}`);
|
|
408
|
+
}
|
|
409
|
+
props.validationMessages = Object.freeze(messages);
|
|
410
|
+
const validationHint = messages.join(" ");
|
|
411
|
+
props.accessibilityHint =
|
|
412
|
+
existingHint === undefined ? validationHint : `${existingHint} ${validationHint}`;
|
|
413
|
+
}
|
|
414
|
+
const VALIDATION_FUNCTION_NAMES = new Set([
|
|
415
|
+
"required",
|
|
416
|
+
"regex",
|
|
417
|
+
"length",
|
|
418
|
+
"numeric",
|
|
419
|
+
"email",
|
|
420
|
+
]);
|
|
421
|
+
const A2UI_V1_MAX_REGEX_PATTERN_LENGTH = 256;
|
|
422
|
+
const A2UI_V1_MAX_REGEX_INPUT_LENGTH = 4_096;
|
|
423
|
+
const A2UI_V1_MAX_REGEX_REPEAT = 4_096;
|
|
424
|
+
const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
|
425
|
+
function resolveValidationFunction(call, path, context, scope) {
|
|
426
|
+
const args = expectObject(call.args, `${path}.args`);
|
|
427
|
+
if (call.call === "required") {
|
|
428
|
+
const value = resolveDynamicValue(args.value, `${path}.args.value`, context, scope);
|
|
429
|
+
return !(value === null ||
|
|
430
|
+
(typeof value === "string" && value.length === 0) ||
|
|
431
|
+
(Array.isArray(value) && value.length === 0));
|
|
432
|
+
}
|
|
433
|
+
if (call.call === "regex") {
|
|
434
|
+
const value = resolveDynamicString(args.value, `${path}.args.value`, context, scope);
|
|
435
|
+
const pattern = expectString(args.pattern, `${path}.args.pattern`);
|
|
436
|
+
if (value.length > A2UI_V1_MAX_REGEX_INPUT_LENGTH) {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
return compileValidationRegex(pattern, path).test(value);
|
|
440
|
+
}
|
|
441
|
+
if (call.call === "length") {
|
|
442
|
+
const value = resolveDynamicString(args.value, `${path}.args.value`, context, scope);
|
|
443
|
+
const bounds = parseValidationBounds(args, path, true);
|
|
444
|
+
return ((bounds.min === undefined || value.length >= bounds.min) &&
|
|
445
|
+
(bounds.max === undefined || value.length <= bounds.max));
|
|
446
|
+
}
|
|
447
|
+
if (call.call === "numeric") {
|
|
448
|
+
const value = resolveDynamicNumber(args.value, `${path}.args.value`, context, scope);
|
|
449
|
+
const bounds = parseValidationBounds(args, path, false);
|
|
450
|
+
return ((bounds.min === undefined || value >= bounds.min) &&
|
|
451
|
+
(bounds.max === undefined || value <= bounds.max));
|
|
452
|
+
}
|
|
453
|
+
const value = resolveDynamicString(args.value, `${path}.args.value`, context, scope);
|
|
454
|
+
return value.length <= 320 && EMAIL_PATTERN.test(value);
|
|
455
|
+
}
|
|
456
|
+
function parseValidationBounds(args, path, integer) {
|
|
457
|
+
const parseBound = (name) => {
|
|
458
|
+
if (args[name] === undefined) {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
const value = expectFiniteNumber(args[name], `${path}.args.${name}`);
|
|
462
|
+
if (integer && (!Number.isSafeInteger(value) || value < 0)) {
|
|
463
|
+
throw new A2uiParseError(`Expected a non-negative safe integer at ${path}.args.${name}`);
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
};
|
|
467
|
+
const min = parseBound("min");
|
|
468
|
+
const max = parseBound("max");
|
|
469
|
+
if (min === undefined && max === undefined) {
|
|
470
|
+
throw new A2uiParseError(`Expected min or max at ${path}.args`);
|
|
471
|
+
}
|
|
472
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
473
|
+
throw new A2uiParseError(`Expected min not to exceed max at ${path}.args`);
|
|
296
474
|
}
|
|
475
|
+
return { min, max };
|
|
297
476
|
}
|
|
298
|
-
function
|
|
477
|
+
function compileValidationRegex(pattern, path) {
|
|
478
|
+
if (pattern.length > A2UI_V1_MAX_REGEX_PATTERN_LENGTH) {
|
|
479
|
+
throw new A2uiParseError(`A2UI regex pattern at ${path}.args.pattern exceeds maximum length of ${A2UI_V1_MAX_REGEX_PATTERN_LENGTH}`);
|
|
480
|
+
}
|
|
481
|
+
let escaped = false;
|
|
482
|
+
let inCharacterClass = false;
|
|
483
|
+
let variableRepeatCount = 0;
|
|
484
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
485
|
+
const character = pattern[index];
|
|
486
|
+
if (escaped) {
|
|
487
|
+
if (/[1-9kPp]/.test(character)) {
|
|
488
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
489
|
+
}
|
|
490
|
+
escaped = false;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (character === "\\") {
|
|
494
|
+
escaped = true;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (inCharacterClass) {
|
|
498
|
+
if (character === "]") {
|
|
499
|
+
inCharacterClass = false;
|
|
500
|
+
}
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (character === "[") {
|
|
504
|
+
inCharacterClass = true;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (character === "(" || character === ")" || character === "|") {
|
|
508
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
509
|
+
}
|
|
510
|
+
if (character === "*" || character === "+" || character === "?") {
|
|
511
|
+
variableRepeatCount += 1;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (character === "{") {
|
|
515
|
+
const repeat = /^\{(0|[1-9][0-9]*)(?:,(0|[1-9][0-9]*)?)?\}/.exec(pattern.slice(index));
|
|
516
|
+
if (repeat === null) {
|
|
517
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
518
|
+
}
|
|
519
|
+
const min = Number(repeat[1]);
|
|
520
|
+
const max = repeat[2] === undefined || repeat[2] === "" ? undefined : Number(repeat[2]);
|
|
521
|
+
if (min > A2UI_V1_MAX_REGEX_REPEAT ||
|
|
522
|
+
(max !== undefined && (max < min || max > A2UI_V1_MAX_REGEX_REPEAT))) {
|
|
523
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
524
|
+
}
|
|
525
|
+
if (repeat[0].includes(",") && max !== min) {
|
|
526
|
+
variableRepeatCount += 1;
|
|
527
|
+
}
|
|
528
|
+
index += repeat[0].length - 1;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (character === "}") {
|
|
532
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (variableRepeatCount > 1) {
|
|
536
|
+
throwUnsupportedValidationRegex(pattern, path);
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
return new RegExp(pattern);
|
|
540
|
+
}
|
|
541
|
+
catch (cause) {
|
|
542
|
+
throw new A2uiParseError(`Invalid regex pattern at ${path}.args.pattern`, { cause });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function throwUnsupportedValidationRegex(pattern, path) {
|
|
546
|
+
throw new A2uiParseError(`Unsupported potentially expensive regex pattern ${JSON.stringify(pattern)} at ${path}.args.pattern`);
|
|
547
|
+
}
|
|
548
|
+
function resolveButtonAction(component, key, context, scope) {
|
|
299
549
|
const action = expectObject(component.action, `components.${component.id}.action`);
|
|
550
|
+
if (Object.hasOwn(action, "functionCall")) {
|
|
551
|
+
const call = expectObject(action.functionCall, `components.${component.id}.action.functionCall`);
|
|
552
|
+
const name = expectString(call.call, `components.${component.id}.action.functionCall.call`);
|
|
553
|
+
if (name !== "openUrl") {
|
|
554
|
+
throw new A2uiParseError(`A2UI native Button ${JSON.stringify(component.id)} does not support local function ${JSON.stringify(name)}`);
|
|
555
|
+
}
|
|
556
|
+
const args = expectObject(call.args, `components.${component.id}.action.functionCall.args`);
|
|
557
|
+
const path = `components.${component.id}.action.functionCall.args.url`;
|
|
558
|
+
const resolvedUrl = resolveDynamicString(args.url, path, context, scope);
|
|
559
|
+
let normalizedUrl;
|
|
560
|
+
try {
|
|
561
|
+
normalizedUrl = normalizeOpenUrl(resolvedUrl, path);
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (!context.tolerateInvalidLocalOpenUrls || !(error instanceof A2uiParseError)) {
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
disabled: true,
|
|
569
|
+
invalidLocalOpenUrl: {
|
|
570
|
+
surfaceId: context.surface.surfaceId,
|
|
571
|
+
sourceComponentId: component.id,
|
|
572
|
+
instanceKey: key,
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const url = recordOpenUrl(normalizedUrl, path, context);
|
|
577
|
+
return {
|
|
578
|
+
openUrl: {
|
|
579
|
+
url,
|
|
580
|
+
surfaceId: context.surface.surfaceId,
|
|
581
|
+
sourceComponentId: component.id,
|
|
582
|
+
instanceKey: key,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
300
586
|
if (!Object.hasOwn(action, "event")) {
|
|
301
|
-
throw new A2uiParseError(`A2UI native Button ${JSON.stringify(component.id)}
|
|
587
|
+
throw new A2uiParseError(`A2UI native Button ${JSON.stringify(component.id)} requires an event or supported local function action`);
|
|
302
588
|
}
|
|
303
589
|
const event = expectObject(action.event, `components.${component.id}.action.event`);
|
|
304
590
|
const eventName = expectString(event.name, `components.${component.id}.action.event.name`);
|
|
@@ -311,14 +597,88 @@ function resolveButtonEvent(component, key, context, scope) {
|
|
|
311
597
|
? undefined
|
|
312
598
|
: resolveDynamicString(event.userMessage, `components.${component.id}.action.event.userMessage`, context, scope);
|
|
313
599
|
return {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
600
|
+
event: {
|
|
601
|
+
name: eventName,
|
|
602
|
+
surfaceId: context.surface.surfaceId,
|
|
603
|
+
sourceComponentId: component.id,
|
|
604
|
+
instanceKey: key,
|
|
605
|
+
...(userMessage === undefined ? {} : { userMessage }),
|
|
606
|
+
context: parseJsonObject(resolvedContext, `components.${component.id}.action.event.context`),
|
|
607
|
+
},
|
|
320
608
|
};
|
|
321
609
|
}
|
|
610
|
+
function normalizeOpenUrl(value, path) {
|
|
611
|
+
if (value.length === 0 || value.length > A2UI_V1_NATIVE_MAX_OPEN_URL_LENGTH) {
|
|
612
|
+
throw new A2uiParseError(`Expected an HTTP(S) URL up to ${A2UI_V1_NATIVE_MAX_OPEN_URL_LENGTH} characters at ${path}`);
|
|
613
|
+
}
|
|
614
|
+
if (/\s|\p{Cf}/u.test(value) || hasAsciiControlCharacter(value)) {
|
|
615
|
+
throw new A2uiParseError(`Expected an HTTP(S) URL without whitespace, control, or Unicode format characters at ${path}`);
|
|
616
|
+
}
|
|
617
|
+
const UrlConstructor = globalThis.URL;
|
|
618
|
+
if (UrlConstructor === undefined) {
|
|
619
|
+
throw new A2uiParseError(`The host runtime cannot validate an openUrl value at ${path}`);
|
|
620
|
+
}
|
|
621
|
+
let parsed;
|
|
622
|
+
try {
|
|
623
|
+
parsed = new UrlConstructor(value);
|
|
624
|
+
}
|
|
625
|
+
catch (cause) {
|
|
626
|
+
throw new A2uiParseError(`Expected an absolute HTTP(S) URL at ${path}`, { cause });
|
|
627
|
+
}
|
|
628
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
629
|
+
throw new A2uiParseError(`Expected an HTTP(S) URL at ${path}`);
|
|
630
|
+
}
|
|
631
|
+
if (parsed.username.length > 0 || parsed.password.length > 0) {
|
|
632
|
+
throw new A2uiParseError(`A2UI openUrl does not allow URL credentials at ${path}`);
|
|
633
|
+
}
|
|
634
|
+
if (parsed.href.length > A2UI_V1_NATIVE_MAX_OPEN_URL_LENGTH) {
|
|
635
|
+
throw new A2uiParseError(`Canonical A2UI openUrl at ${path} exceeds maximum length of ${A2UI_V1_NATIVE_MAX_OPEN_URL_LENGTH}`);
|
|
636
|
+
}
|
|
637
|
+
return parsed.href;
|
|
638
|
+
}
|
|
639
|
+
function hasAsciiControlCharacter(value) {
|
|
640
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
641
|
+
const code = value.charCodeAt(index);
|
|
642
|
+
if (code <= 31 || code === 127) {
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
function recordOpenUrl(value, path, context) {
|
|
649
|
+
context.openUrlLength += value.length;
|
|
650
|
+
if (context.openUrlLength > A2UI_V1_MAX_SOURCE_LENGTH) {
|
|
651
|
+
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum openUrl length of ${A2UI_V1_MAX_SOURCE_LENGTH} at ${path}`);
|
|
652
|
+
}
|
|
653
|
+
return value;
|
|
654
|
+
}
|
|
655
|
+
/** Revalidates an untrusted URL descriptor before it crosses into a host component callback. */
|
|
656
|
+
export function parseA2uiV1NativeOpenUrlDescriptor(value, path) {
|
|
657
|
+
const descriptor = parseJsonObject(value, path);
|
|
658
|
+
const allowedKeys = new Set(["instanceKey", "sourceComponentId", "surfaceId", "url"]);
|
|
659
|
+
for (const key of Object.keys(descriptor)) {
|
|
660
|
+
if (!allowedKeys.has(key)) {
|
|
661
|
+
throw new A2uiParseError(`Unexpected field ${JSON.stringify(key)} at ${path}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const instanceKey = descriptor.instanceKey;
|
|
665
|
+
if (instanceKey !== undefined && (typeof instanceKey !== "string" || instanceKey.length === 0)) {
|
|
666
|
+
throw new A2uiParseError(`Expected a non-empty string at ${path}.instanceKey`);
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
url: normalizeOpenUrl(expectString(descriptor.url, `${path}.url`), `${path}.url`),
|
|
670
|
+
surfaceId: expectNonEmptyString(descriptor.surfaceId, `${path}.surfaceId`),
|
|
671
|
+
sourceComponentId: expectNonEmptyString(descriptor.sourceComponentId, `${path}.sourceComponentId`),
|
|
672
|
+
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function expectNonEmptyString(value, path) {
|
|
676
|
+
const result = expectString(value, path);
|
|
677
|
+
if (result.length === 0) {
|
|
678
|
+
throw new A2uiParseError(`Expected a non-empty string at ${path}`);
|
|
679
|
+
}
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
322
682
|
function addCommonProps(component, props, context, scope) {
|
|
323
683
|
if (component.weight !== undefined) {
|
|
324
684
|
const weight = expectFiniteNumber(component.weight, `components.${component.id}.weight`);
|
|
@@ -370,9 +730,15 @@ function resolveDynamicValue(value, path, context, scope) {
|
|
|
370
730
|
: resolveDynamicNumber(args.offset, `${path}.args.offset`, context, scope);
|
|
371
731
|
return scope.index + offset;
|
|
372
732
|
}
|
|
733
|
+
if (VALIDATION_FUNCTION_NAMES.has(value.call)) {
|
|
734
|
+
return resolveValidationFunction(value, path, context, scope);
|
|
735
|
+
}
|
|
373
736
|
if (value.call === "formatNumber" || value.call === "formatCurrency") {
|
|
374
737
|
return resolveNumberFormat(value, path, context, scope);
|
|
375
738
|
}
|
|
739
|
+
if (value.call === "formatDate") {
|
|
740
|
+
return resolveDateFormat(value, path, context, scope);
|
|
741
|
+
}
|
|
376
742
|
if (value.call === "pluralize") {
|
|
377
743
|
return resolvePluralize(value, path, context, scope);
|
|
378
744
|
}
|
|
@@ -435,6 +801,308 @@ function resolveNumberFormat(call, path, context, scope) {
|
|
|
435
801
|
throw new A2uiParseError(`A2UI native adapter could not execute ${JSON.stringify(call.call)} at ${path}`, { cause });
|
|
436
802
|
}
|
|
437
803
|
}
|
|
804
|
+
const DATE_NUMBER = /^[+-]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/;
|
|
805
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
806
|
+
const RFC_3339_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/;
|
|
807
|
+
const A2UI_V1_MAX_DATE_PATTERN_TOKENS = 128;
|
|
808
|
+
const DATE_PATTERN_TOKENS = Object.freeze([
|
|
809
|
+
"MMMM",
|
|
810
|
+
"EEEE",
|
|
811
|
+
"yyyy",
|
|
812
|
+
"MMM",
|
|
813
|
+
"MM",
|
|
814
|
+
"dd",
|
|
815
|
+
"hh",
|
|
816
|
+
"HH",
|
|
817
|
+
"mm",
|
|
818
|
+
"ss",
|
|
819
|
+
"yy",
|
|
820
|
+
"M",
|
|
821
|
+
"d",
|
|
822
|
+
"E",
|
|
823
|
+
"h",
|
|
824
|
+
"H",
|
|
825
|
+
"a",
|
|
826
|
+
]);
|
|
827
|
+
const DATE_PATTERN_TOKEN_SET = new Set(DATE_PATTERN_TOKENS);
|
|
828
|
+
function resolveDateFormat(call, path, context, scope) {
|
|
829
|
+
const args = expectObject(call.args, `${path}.args`);
|
|
830
|
+
const value = resolveDynamicValue(args.value, `${path}.args.value`, context, scope);
|
|
831
|
+
const pattern = resolveDynamicString(args.format, `${path}.args.format`, context, scope);
|
|
832
|
+
const date = parseDateValue(value, `${path}.args.value`);
|
|
833
|
+
const result = formatDatePattern(date, pattern, path, context);
|
|
834
|
+
return recordFormattedString(result, path, context);
|
|
835
|
+
}
|
|
836
|
+
function parseDateValue(value, path) {
|
|
837
|
+
if (typeof value === "number") {
|
|
838
|
+
return dateFromEpoch(value, path);
|
|
839
|
+
}
|
|
840
|
+
if (typeof value !== "string") {
|
|
841
|
+
throw new A2uiParseError(`Expected a date string or finite epoch number at ${path}`);
|
|
842
|
+
}
|
|
843
|
+
if (DATE_NUMBER.test(value)) {
|
|
844
|
+
return dateFromEpoch(Number(value), path);
|
|
845
|
+
}
|
|
846
|
+
const dateOnly = DATE_ONLY.exec(value);
|
|
847
|
+
if (dateOnly !== null) {
|
|
848
|
+
const year = Number(dateOnly[1]);
|
|
849
|
+
const month = Number(dateOnly[2]);
|
|
850
|
+
const day = Number(dateOnly[3]);
|
|
851
|
+
validateCalendarDate(year, month, day, path);
|
|
852
|
+
const date = new Date(0);
|
|
853
|
+
date.setFullYear(year, month - 1, day);
|
|
854
|
+
date.setHours(0, 0, 0, 0);
|
|
855
|
+
return date;
|
|
856
|
+
}
|
|
857
|
+
const timestamp = RFC_3339_TIMESTAMP.exec(value);
|
|
858
|
+
if (timestamp === null) {
|
|
859
|
+
throw new A2uiParseError(`Expected an RFC 3339 timestamp, yyyy-MM-dd date, or finite epoch number at ${path}`);
|
|
860
|
+
}
|
|
861
|
+
const year = Number(timestamp[1]);
|
|
862
|
+
const month = Number(timestamp[2]);
|
|
863
|
+
const day = Number(timestamp[3]);
|
|
864
|
+
const hour = Number(timestamp[4]);
|
|
865
|
+
const minute = Number(timestamp[5]);
|
|
866
|
+
const second = Number(timestamp[6]);
|
|
867
|
+
validateCalendarDate(year, month, day, path);
|
|
868
|
+
if (hour > 23 || minute > 59 || second > 59) {
|
|
869
|
+
throw new A2uiParseError(`Invalid RFC 3339 time at ${path}`);
|
|
870
|
+
}
|
|
871
|
+
const fraction = timestamp[7] ?? "";
|
|
872
|
+
const millisecond = Number(fraction.slice(0, 3).padEnd(3, "0"));
|
|
873
|
+
const date = new Date(0);
|
|
874
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
875
|
+
date.setUTCHours(hour, minute, second, millisecond);
|
|
876
|
+
if (timestamp[8] !== "Z") {
|
|
877
|
+
const offsetHour = Number(timestamp[10]);
|
|
878
|
+
const offsetMinute = Number(timestamp[11]);
|
|
879
|
+
if (offsetHour > 23 || offsetMinute > 59) {
|
|
880
|
+
throw new A2uiParseError(`Invalid RFC 3339 offset at ${path}`);
|
|
881
|
+
}
|
|
882
|
+
const direction = timestamp[9] === "+" ? 1 : -1;
|
|
883
|
+
date.setTime(date.getTime() - direction * (offsetHour * 60 + offsetMinute) * 60_000);
|
|
884
|
+
}
|
|
885
|
+
if (!Number.isFinite(date.getTime())) {
|
|
886
|
+
throw new A2uiParseError(`Date value is outside the supported range at ${path}`);
|
|
887
|
+
}
|
|
888
|
+
return date;
|
|
889
|
+
}
|
|
890
|
+
function dateFromEpoch(value, path) {
|
|
891
|
+
if (!Number.isFinite(value)) {
|
|
892
|
+
throw new A2uiParseError(`Expected a date string or finite epoch number at ${path}`);
|
|
893
|
+
}
|
|
894
|
+
const milliseconds = Math.abs(value) > 10_000_000_000 ? value : value * 1_000;
|
|
895
|
+
const date = new Date(milliseconds);
|
|
896
|
+
if (!Number.isFinite(date.getTime())) {
|
|
897
|
+
throw new A2uiParseError(`Epoch value is outside the supported date range at ${path}`);
|
|
898
|
+
}
|
|
899
|
+
return date;
|
|
900
|
+
}
|
|
901
|
+
function validateCalendarDate(year, month, day, path) {
|
|
902
|
+
const candidate = new Date(0);
|
|
903
|
+
candidate.setUTCFullYear(year, month - 1, day);
|
|
904
|
+
candidate.setUTCHours(0, 0, 0, 0);
|
|
905
|
+
if (year < 1 ||
|
|
906
|
+
month < 1 ||
|
|
907
|
+
month > 12 ||
|
|
908
|
+
day < 1 ||
|
|
909
|
+
candidate.getUTCFullYear() !== year ||
|
|
910
|
+
candidate.getUTCMonth() !== month - 1 ||
|
|
911
|
+
candidate.getUTCDate() !== day) {
|
|
912
|
+
throw new A2uiParseError(`Invalid calendar date at ${path}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function formatDatePattern(date, pattern, path, context) {
|
|
916
|
+
const parts = parseDatePattern(pattern, path);
|
|
917
|
+
const hasDay = parts.some((part) => part.kind === "token" && (part.value === "d" || part.value === "dd"));
|
|
918
|
+
return parts
|
|
919
|
+
.map((part) => part.kind === "literal"
|
|
920
|
+
? part.value
|
|
921
|
+
: formatDateToken(date, part.value, hasDay, path, context))
|
|
922
|
+
.join("");
|
|
923
|
+
}
|
|
924
|
+
function parseDatePattern(pattern, path) {
|
|
925
|
+
const parts = [];
|
|
926
|
+
let tokenCount = 0;
|
|
927
|
+
for (let index = 0; index < pattern.length;) {
|
|
928
|
+
if (pattern[index] === "'") {
|
|
929
|
+
const literal = readQuotedDateLiteral(pattern, index, path);
|
|
930
|
+
parts.push({ kind: "literal", value: literal.value });
|
|
931
|
+
index = literal.end;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
const character = pattern[index];
|
|
935
|
+
if (/[A-Za-z]/.test(character)) {
|
|
936
|
+
let end = index + 1;
|
|
937
|
+
while (pattern[end] === character) {
|
|
938
|
+
end += 1;
|
|
939
|
+
}
|
|
940
|
+
const field = pattern.slice(index, end);
|
|
941
|
+
if (!DATE_PATTERN_TOKEN_SET.has(field)) {
|
|
942
|
+
throw new A2uiParseError(`Unsupported Unicode date pattern token ${JSON.stringify(field)} at ${path}.args.format`);
|
|
943
|
+
}
|
|
944
|
+
tokenCount += 1;
|
|
945
|
+
if (tokenCount > A2UI_V1_MAX_DATE_PATTERN_TOKENS) {
|
|
946
|
+
throw new A2uiParseError(`A2UI date pattern at ${path}.args.format exceeds maximum of ${A2UI_V1_MAX_DATE_PATTERN_TOKENS} tokens`);
|
|
947
|
+
}
|
|
948
|
+
parts.push({ kind: "token", value: field });
|
|
949
|
+
index = end;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const previous = parts.at(-1);
|
|
953
|
+
if (previous?.kind === "literal") {
|
|
954
|
+
parts[parts.length - 1] = { kind: "literal", value: previous.value + character };
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
parts.push({ kind: "literal", value: character });
|
|
958
|
+
}
|
|
959
|
+
index += 1;
|
|
960
|
+
}
|
|
961
|
+
if (parts.some((part) => part.kind === "token" && (part.value === "h" || part.value === "hh")) &&
|
|
962
|
+
!parts.some((part) => part.kind === "token" && part.value === "a")) {
|
|
963
|
+
throw new A2uiParseError(`A2UI date pattern at ${path}.args.format requires token "a" when using "h" or "hh"`);
|
|
964
|
+
}
|
|
965
|
+
return parts;
|
|
966
|
+
}
|
|
967
|
+
function readQuotedDateLiteral(pattern, start, path) {
|
|
968
|
+
if (pattern[start + 1] === "'") {
|
|
969
|
+
return { end: start + 2, value: "'" };
|
|
970
|
+
}
|
|
971
|
+
let value = "";
|
|
972
|
+
for (let index = start + 1; index < pattern.length; index += 1) {
|
|
973
|
+
if (pattern[index] !== "'") {
|
|
974
|
+
value += pattern[index];
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (pattern[index + 1] === "'") {
|
|
978
|
+
value += "'";
|
|
979
|
+
index += 1;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
return { end: index + 1, value };
|
|
983
|
+
}
|
|
984
|
+
throw new A2uiParseError(`Unterminated quoted literal at ${path}.args.format`);
|
|
985
|
+
}
|
|
986
|
+
function formatDateToken(date, token, hasDay, path, context) {
|
|
987
|
+
const cacheKey = `${token}:${hasDay ? "day" : "standalone"}`;
|
|
988
|
+
const cached = context.dateFormats.get(cacheKey);
|
|
989
|
+
const formatter = cached ?? createDateTokenFormatter(token, hasDay, path, context.locale);
|
|
990
|
+
context.dateFormats.set(cacheKey, formatter);
|
|
991
|
+
const partType = datePartType(token);
|
|
992
|
+
const part = formatter.formatToParts(date).find((candidate) => candidate.type === partType);
|
|
993
|
+
if (part === undefined) {
|
|
994
|
+
throw new A2uiParseError(`A2UI native adapter could not format token ${token} at ${path}`);
|
|
995
|
+
}
|
|
996
|
+
if (token === "yyyy") {
|
|
997
|
+
return normalizeLocalizedDateNumber(part.value, 4, path, context);
|
|
998
|
+
}
|
|
999
|
+
if (token === "MM" ||
|
|
1000
|
+
token === "dd" ||
|
|
1001
|
+
token === "hh" ||
|
|
1002
|
+
token === "HH" ||
|
|
1003
|
+
token === "mm" ||
|
|
1004
|
+
token === "ss") {
|
|
1005
|
+
return normalizeLocalizedDateNumber(part.value, 2, path, context);
|
|
1006
|
+
}
|
|
1007
|
+
if (token === "M" || token === "d" || token === "h" || token === "H") {
|
|
1008
|
+
return normalizeLocalizedDateNumber(part.value, 1, path, context);
|
|
1009
|
+
}
|
|
1010
|
+
return part.value;
|
|
1011
|
+
}
|
|
1012
|
+
function normalizeLocalizedDateNumber(value, width, path, context) {
|
|
1013
|
+
const length = Array.from(value).length;
|
|
1014
|
+
if ((width === 1 && length === 1) || (width > 1 && length >= width)) {
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
const key = JSON.stringify([context.locale ?? null, "date-zero"]);
|
|
1018
|
+
let formatter = context.numberFormats.get(key);
|
|
1019
|
+
if (formatter === undefined) {
|
|
1020
|
+
try {
|
|
1021
|
+
formatter = new Intl.NumberFormat(context.locale, { useGrouping: false });
|
|
1022
|
+
context.numberFormats.set(key, formatter);
|
|
1023
|
+
}
|
|
1024
|
+
catch (cause) {
|
|
1025
|
+
throw new A2uiParseError(`Invalid year-format options at ${path}`, { cause });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const zero = formatter.formatToParts(0).find((part) => part.type === "integer")?.value;
|
|
1029
|
+
if (zero === undefined) {
|
|
1030
|
+
throw new A2uiParseError(`A2UI native adapter could not localize a padded year at ${path}`);
|
|
1031
|
+
}
|
|
1032
|
+
if (width === 1) {
|
|
1033
|
+
let normalized = value;
|
|
1034
|
+
while (Array.from(normalized).length > 1 && normalized.startsWith(zero)) {
|
|
1035
|
+
normalized = normalized.slice(zero.length);
|
|
1036
|
+
}
|
|
1037
|
+
return normalized;
|
|
1038
|
+
}
|
|
1039
|
+
return `${zero.repeat(width - length)}${value}`;
|
|
1040
|
+
}
|
|
1041
|
+
function createDateTokenFormatter(token, hasDay, path, locale) {
|
|
1042
|
+
const options = token === "yy"
|
|
1043
|
+
? { year: "2-digit" }
|
|
1044
|
+
: token === "yyyy"
|
|
1045
|
+
? { year: "numeric" }
|
|
1046
|
+
: token === "M"
|
|
1047
|
+
? { month: "numeric", ...(hasDay ? { day: "numeric" } : {}) }
|
|
1048
|
+
: token === "MM"
|
|
1049
|
+
? { month: "2-digit", ...(hasDay ? { day: "numeric" } : {}) }
|
|
1050
|
+
: token === "MMM"
|
|
1051
|
+
? { month: "short", ...(hasDay ? { day: "numeric" } : {}) }
|
|
1052
|
+
: token === "MMMM"
|
|
1053
|
+
? { month: "long", ...(hasDay ? { day: "numeric" } : {}) }
|
|
1054
|
+
: token === "d"
|
|
1055
|
+
? { day: "numeric" }
|
|
1056
|
+
: token === "dd"
|
|
1057
|
+
? { day: "2-digit" }
|
|
1058
|
+
: token === "E"
|
|
1059
|
+
? { weekday: "short" }
|
|
1060
|
+
: token === "EEEE"
|
|
1061
|
+
? { weekday: "long" }
|
|
1062
|
+
: token === "h" || token === "hh" || token === "a"
|
|
1063
|
+
? {
|
|
1064
|
+
hour: token === "hh" ? "2-digit" : "numeric",
|
|
1065
|
+
hourCycle: "h12",
|
|
1066
|
+
}
|
|
1067
|
+
: token === "H" || token === "HH"
|
|
1068
|
+
? {
|
|
1069
|
+
hour: token === "HH" ? "2-digit" : "numeric",
|
|
1070
|
+
hourCycle: "h23",
|
|
1071
|
+
}
|
|
1072
|
+
: token === "mm"
|
|
1073
|
+
? { minute: "2-digit" }
|
|
1074
|
+
: { second: "2-digit" };
|
|
1075
|
+
try {
|
|
1076
|
+
return new Intl.DateTimeFormat(locale, options);
|
|
1077
|
+
}
|
|
1078
|
+
catch (cause) {
|
|
1079
|
+
throw new A2uiParseError(`Invalid date-format options at ${path}`, { cause });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function datePartType(token) {
|
|
1083
|
+
if (token === "yy" || token === "yyyy") {
|
|
1084
|
+
return "year";
|
|
1085
|
+
}
|
|
1086
|
+
if (token === "M" || token === "MM" || token === "MMM" || token === "MMMM") {
|
|
1087
|
+
return "month";
|
|
1088
|
+
}
|
|
1089
|
+
if (token === "d" || token === "dd") {
|
|
1090
|
+
return "day";
|
|
1091
|
+
}
|
|
1092
|
+
if (token === "E" || token === "EEEE") {
|
|
1093
|
+
return "weekday";
|
|
1094
|
+
}
|
|
1095
|
+
if (token === "h" || token === "hh" || token === "H" || token === "HH") {
|
|
1096
|
+
return "hour";
|
|
1097
|
+
}
|
|
1098
|
+
if (token === "mm") {
|
|
1099
|
+
return "minute";
|
|
1100
|
+
}
|
|
1101
|
+
if (token === "ss") {
|
|
1102
|
+
return "second";
|
|
1103
|
+
}
|
|
1104
|
+
return "dayPeriod";
|
|
1105
|
+
}
|
|
438
1106
|
const PLURAL_CATEGORIES = Object.freeze(["zero", "one", "two", "few", "many", "other"]);
|
|
439
1107
|
function resolvePluralize(call, path, context, scope) {
|
|
440
1108
|
const args = expectObject(call.args, `${path}.args`);
|