@mcp-native/react-native 0.2.0 → 0.4.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 +161 -28
- 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 +44 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +519 -20
- package/dist/index.js.map +1 -1
- package/dist/iso-4217.d.ts +8 -0
- package/dist/iso-4217.d.ts.map +1 -0
- package/dist/iso-4217.js +187 -0
- package/dist/iso-4217.js.map +1 -0
- package/dist/v1.d.ts +39 -1
- package/dist/v1.d.ts.map +1 -1
- package/dist/v1.js +1068 -64
- package/dist/v1.js.map +1 -1
- package/package.json +3 -3
package/dist/v1.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { A2UI_V1_MAX_COMPONENTS, A2uiParseError, validateA2uiV1SurfaceState, } from "@mcp-native/a2ui";
|
|
2
|
-
import { parseJsonObject, parseJsonValue } from "@mcp-native/core";
|
|
1
|
+
import { A2UI_V1_MAX_COMPONENTS, A2UI_V1_MAX_SOURCE_LENGTH, A2uiParseError, evaluateA2uiV1FormatString, validateA2uiV1SurfaceState, } from "@mcp-native/a2ui";
|
|
2
|
+
import { JSON_MAX_STRING_LENGTH, JSON_MAX_VALUES, parseJsonObject, parseJsonValue, } from "@mcp-native/core";
|
|
3
|
+
import { ISO_4217_CURRENCY_CODES } from "./iso-4217.js";
|
|
3
4
|
export const A2UI_V1_NATIVE_COMPONENT_NAMES = Object.freeze([
|
|
4
5
|
"Button",
|
|
5
6
|
"Card",
|
|
@@ -11,21 +12,197 @@ export const A2UI_V1_NATIVE_COMPONENT_NAMES = Object.freeze([
|
|
|
11
12
|
]);
|
|
12
13
|
/** Maximum expanded native-plan nodes, including repeated component references. */
|
|
13
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;
|
|
14
17
|
/**
|
|
15
18
|
* Converts a policy-validated A2UI v1 surface into the existing host-owned
|
|
16
19
|
* native render plan. Unsupported renderer semantics fail closed.
|
|
17
20
|
*/
|
|
18
|
-
export function createA2uiV1NativeRenderPlan(surface, policy) {
|
|
19
|
-
|
|
20
|
-
|
|
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) {
|
|
29
|
+
const parsedOptions = parseRenderPlanOptions(options);
|
|
30
|
+
const context = createAdapterContext(surface, policy, parsedOptions.dataModel, parsedOptions.locale, tolerateInvalidLocalOpenUrls);
|
|
31
|
+
return adaptComponent("root", "root", context, undefined);
|
|
32
|
+
}
|
|
33
|
+
/** Resolves one validated event against the latest renderer-local data model. */
|
|
34
|
+
export function resolveA2uiV1NativeEvent(surface, policy, sourceComponentId, dataModel, options = {}) {
|
|
35
|
+
if (typeof sourceComponentId !== "string" || sourceComponentId.length === 0) {
|
|
36
|
+
throw new A2uiParseError("Expected a non-empty A2UI source component id");
|
|
37
|
+
}
|
|
38
|
+
const parsedOptions = parseEventResolutionOptions(options);
|
|
39
|
+
const context = createAdapterContext(surface, policy, dataModel, parsedOptions.locale);
|
|
40
|
+
const plan = adaptComponent("root", "root", context, undefined);
|
|
41
|
+
const events = findNativeEvents(plan, sourceComponentId, parsedOptions.instanceKey);
|
|
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
|
+
}
|
|
47
|
+
throw new A2uiParseError(`A2UI native event source ${JSON.stringify(sourceComponentId)} is not a reachable supported Button`);
|
|
48
|
+
}
|
|
49
|
+
if (events.length > 1) {
|
|
50
|
+
throw new A2uiParseError(`A2UI native event source ${JSON.stringify(sourceComponentId)} is ambiguous without its template instance key`);
|
|
51
|
+
}
|
|
52
|
+
return events[0];
|
|
53
|
+
}
|
|
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) {
|
|
76
|
+
const events = [];
|
|
77
|
+
const event = element.props.event;
|
|
78
|
+
if (event?.sourceComponentId === sourceComponentId &&
|
|
79
|
+
(includeDisabled || element.props.disabled !== true) &&
|
|
80
|
+
(instanceKey === undefined || event.instanceKey === instanceKey)) {
|
|
81
|
+
events.push(event);
|
|
82
|
+
}
|
|
83
|
+
for (const child of element.children ?? []) {
|
|
84
|
+
events.push(...findNativeEvents(child, sourceComponentId, instanceKey, includeDisabled));
|
|
85
|
+
}
|
|
86
|
+
return events;
|
|
87
|
+
}
|
|
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) {
|
|
102
|
+
const localDataModel = dataModel === undefined
|
|
103
|
+
? parseJsonObject(surface.dataModel, "surface.dataModel")
|
|
104
|
+
: parseJsonObject(dataModel, "options.dataModel");
|
|
105
|
+
const validated = validateA2uiV1SurfaceState(dataModel === undefined ? surface : { ...surface, dataModel: localDataModel }, policy);
|
|
106
|
+
return {
|
|
21
107
|
surface: validated,
|
|
22
108
|
dataModel: parseJsonObject(validated.dataModel, "surface.dataModel"),
|
|
109
|
+
locale,
|
|
110
|
+
dateFormats: new Map(),
|
|
111
|
+
numberFormats: new Map(),
|
|
112
|
+
pluralRules: new Map(),
|
|
23
113
|
visiting: new Set(),
|
|
114
|
+
tolerateInvalidLocalOpenUrls,
|
|
115
|
+
formatStringExpressionCount: 0,
|
|
116
|
+
formattedStringLength: 0,
|
|
117
|
+
openUrlLength: 0,
|
|
24
118
|
renderNodeCount: 0,
|
|
119
|
+
validationCheckCount: 0,
|
|
120
|
+
validationOutputLength: 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function parseRenderPlanOptions(options) {
|
|
124
|
+
const parsed = parseOptionsObject(options, "A2UI native render plan options", [
|
|
125
|
+
"dataModel",
|
|
126
|
+
"locale",
|
|
127
|
+
]);
|
|
128
|
+
return {
|
|
129
|
+
...(parsed.dataModel === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: { dataModel: parseJsonObject(parsed.dataModel, "options.dataModel") }),
|
|
132
|
+
...(parsed.locale === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { locale: parseLocale(parsed.locale, "options.locale") }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function parseEventResolutionOptions(options) {
|
|
138
|
+
const parsed = parseOptionsObject(options, "A2UI native event resolution options", [
|
|
139
|
+
"instanceKey",
|
|
140
|
+
"locale",
|
|
141
|
+
]);
|
|
142
|
+
if (parsed.instanceKey !== undefined &&
|
|
143
|
+
(typeof parsed.instanceKey !== "string" || parsed.instanceKey.length === 0)) {
|
|
144
|
+
throw new A2uiParseError("Expected a non-empty A2UI native event instance key");
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...(parsed.instanceKey === undefined ? {} : { instanceKey: parsed.instanceKey }),
|
|
148
|
+
...(parsed.locale === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { locale: parseLocale(parsed.locale, "options.locale") }),
|
|
151
|
+
};
|
|
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") }),
|
|
25
167
|
};
|
|
26
|
-
return adaptComponent("root", "root", context);
|
|
27
168
|
}
|
|
28
|
-
function
|
|
169
|
+
function parseOptionsObject(value, label, allowedKeys) {
|
|
170
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
171
|
+
throw new A2uiParseError(`Expected ${label} to be an object`);
|
|
172
|
+
}
|
|
173
|
+
const prototype = Object.getPrototypeOf(value);
|
|
174
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
175
|
+
throw new A2uiParseError(`Expected plain ${label}`);
|
|
176
|
+
}
|
|
177
|
+
const parsed = value;
|
|
178
|
+
const unknownKey = Object.keys(parsed).find((key) => !allowedKeys.includes(key));
|
|
179
|
+
if (unknownKey !== undefined) {
|
|
180
|
+
throw new A2uiParseError(`Unexpected ${label.slice(0, -1)} ${JSON.stringify(unknownKey)}`);
|
|
181
|
+
}
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
function parseLocale(value, path) {
|
|
185
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128) {
|
|
186
|
+
throw new A2uiParseError(`Expected a non-empty BCP 47 locale at ${path}`);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const canonicalLocale = Intl.getCanonicalLocales(value)[0];
|
|
190
|
+
if (Intl.NumberFormat.supportedLocalesOf(canonicalLocale, { localeMatcher: "lookup" }).length ===
|
|
191
|
+
0) {
|
|
192
|
+
throw new A2uiParseError(`Unsupported BCP 47 locale ${JSON.stringify(value)} at ${path}`);
|
|
193
|
+
}
|
|
194
|
+
return canonicalLocale;
|
|
195
|
+
}
|
|
196
|
+
catch (cause) {
|
|
197
|
+
if (cause instanceof A2uiParseError) {
|
|
198
|
+
throw cause;
|
|
199
|
+
}
|
|
200
|
+
throw new A2uiParseError(`Invalid BCP 47 locale ${JSON.stringify(value)} at ${path}`, {
|
|
201
|
+
cause,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function adaptComponent(id, key, context, scope) {
|
|
29
206
|
context.renderNodeCount += 1;
|
|
30
207
|
if (context.renderNodeCount > A2UI_V1_NATIVE_MAX_RENDER_NODES) {
|
|
31
208
|
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum of ${A2UI_V1_NATIVE_MAX_RENDER_NODES} nodes`);
|
|
@@ -41,19 +218,19 @@ function adaptComponent(id, key, context) {
|
|
|
41
218
|
try {
|
|
42
219
|
switch (component.component) {
|
|
43
220
|
case "Row":
|
|
44
|
-
return adaptContainer(component, key, "row", undefined, context);
|
|
221
|
+
return adaptContainer(component, key, "row", undefined, context, scope);
|
|
45
222
|
case "Column":
|
|
46
|
-
return adaptContainer(component, key, "column", undefined, context);
|
|
223
|
+
return adaptContainer(component, key, "column", undefined, context, scope);
|
|
47
224
|
case "List":
|
|
48
|
-
return adaptList(component, key, context);
|
|
225
|
+
return adaptList(component, key, context, scope);
|
|
49
226
|
case "Card":
|
|
50
|
-
return adaptCard(component, key, context);
|
|
227
|
+
return adaptCard(component, key, context, scope);
|
|
51
228
|
case "Text":
|
|
52
|
-
return adaptText(component, key, context);
|
|
229
|
+
return adaptText(component, key, context, scope);
|
|
53
230
|
case "Button":
|
|
54
|
-
return adaptButton(component, key, context);
|
|
231
|
+
return adaptButton(component, key, context, scope);
|
|
55
232
|
case "TextField":
|
|
56
|
-
return adaptTextField(component, key, context);
|
|
233
|
+
return adaptTextField(component, key, context, scope);
|
|
57
234
|
default:
|
|
58
235
|
throw new A2uiParseError(`A2UI component ${JSON.stringify(id)} uses ${JSON.stringify(component.component)}, which the native adapter does not support`);
|
|
59
236
|
}
|
|
@@ -62,7 +239,7 @@ function adaptComponent(id, key, context) {
|
|
|
62
239
|
context.visiting.delete(id);
|
|
63
240
|
}
|
|
64
241
|
}
|
|
65
|
-
function adaptContainer(component, key, layout, variant, context) {
|
|
242
|
+
function adaptContainer(component, key, layout, variant, context, scope) {
|
|
66
243
|
if (!Array.isArray(component.children)) {
|
|
67
244
|
throw new A2uiParseError(`A2UI native adapter does not yet support dynamic children at components.${component.id}.children`);
|
|
68
245
|
}
|
|
@@ -70,168 +247,976 @@ function adaptContainer(component, key, layout, variant, context) {
|
|
|
70
247
|
if (variant !== undefined) {
|
|
71
248
|
props.variant = variant;
|
|
72
249
|
}
|
|
250
|
+
addContainerLayoutProps(component, props);
|
|
251
|
+
addCommonProps(component, props, context, scope);
|
|
252
|
+
return {
|
|
253
|
+
key,
|
|
254
|
+
component: "View",
|
|
255
|
+
props,
|
|
256
|
+
children: component.children.map((childId, index) => adaptComponent(childId, appendInstanceKey(key, childId, index), context, scope)),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function addContainerLayoutProps(component, props) {
|
|
73
260
|
if (component.justify !== undefined) {
|
|
74
|
-
|
|
261
|
+
const justify = expectString(component.justify, `components.${component.id}.justify`);
|
|
262
|
+
if (justify === "stretch") {
|
|
263
|
+
throw new A2uiParseError(`A2UI native adapter does not support main-axis stretch at components.${component.id}.justify`);
|
|
264
|
+
}
|
|
265
|
+
props.justify = justify;
|
|
75
266
|
}
|
|
76
267
|
if (component.align !== undefined) {
|
|
77
268
|
props.align = expectString(component.align, `components.${component.id}.align`);
|
|
78
269
|
}
|
|
79
|
-
|
|
270
|
+
}
|
|
271
|
+
function adaptList(component, key, context, scope) {
|
|
272
|
+
if (Array.isArray(component.children)) {
|
|
273
|
+
return adaptContainer(component, key, component.direction === "horizontal" ? "row" : "column", "list", context, scope);
|
|
274
|
+
}
|
|
275
|
+
const template = expectObject(component.children, `components.${component.id}.children`);
|
|
276
|
+
const pointer = expectAbsoluteBinding(template.path, `components.${component.id}.children.path`);
|
|
277
|
+
const componentId = expectString(template.componentId, `components.${component.id}.children.componentId`);
|
|
278
|
+
const value = parseJsonValue(resolveJsonPointer(context.dataModel, pointer, `components.${component.id}.children`), `components.${component.id}.children`);
|
|
279
|
+
if (!Array.isArray(value)) {
|
|
280
|
+
throw new A2uiParseError(`Expected an array at components.${component.id}.children path ${JSON.stringify(pointer)}`);
|
|
281
|
+
}
|
|
282
|
+
const props = {
|
|
283
|
+
layout: component.direction === "horizontal" ? "row" : "column",
|
|
284
|
+
variant: "list",
|
|
285
|
+
};
|
|
286
|
+
addContainerLayoutProps(component, props);
|
|
287
|
+
addCommonProps(component, props, context, scope);
|
|
80
288
|
return {
|
|
81
289
|
key,
|
|
82
290
|
component: "View",
|
|
83
291
|
props,
|
|
84
|
-
children:
|
|
292
|
+
children: value.map((item, index) => adaptComponent(componentId, appendInstanceKey(key, componentId, index), context, {
|
|
293
|
+
value: item,
|
|
294
|
+
pointer: appendPointerToken(pointer, String(index)),
|
|
295
|
+
index,
|
|
296
|
+
})),
|
|
85
297
|
};
|
|
86
298
|
}
|
|
87
|
-
function
|
|
88
|
-
return adaptContainer(component, key, component.direction === "horizontal" ? "row" : "column", "list", context);
|
|
89
|
-
}
|
|
90
|
-
function adaptCard(component, key, context) {
|
|
299
|
+
function adaptCard(component, key, context, scope) {
|
|
91
300
|
const childId = expectString(component.child, `components.${component.id}.child`);
|
|
92
301
|
const props = { layout: "column", variant: "card" };
|
|
93
|
-
addCommonProps(component, props, context);
|
|
302
|
+
addCommonProps(component, props, context, scope);
|
|
94
303
|
return {
|
|
95
304
|
key,
|
|
96
305
|
component: "View",
|
|
97
306
|
props,
|
|
98
|
-
children: [adaptComponent(childId,
|
|
307
|
+
children: [adaptComponent(childId, appendInstanceKey(key, childId, 0), context, scope)],
|
|
99
308
|
};
|
|
100
309
|
}
|
|
101
|
-
function adaptText(component, key, context) {
|
|
310
|
+
function adaptText(component, key, context, scope) {
|
|
102
311
|
const props = {
|
|
103
|
-
children: resolveDynamicString(component.text, `components.${component.id}.text`, context),
|
|
312
|
+
children: resolveDynamicString(component.text, `components.${component.id}.text`, context, scope),
|
|
104
313
|
};
|
|
105
314
|
if (component.variant !== undefined) {
|
|
106
315
|
props.variant = component.variant;
|
|
107
316
|
}
|
|
108
|
-
addCommonProps(component, props, context);
|
|
317
|
+
addCommonProps(component, props, context, scope);
|
|
109
318
|
return { key, component: "Text", props };
|
|
110
319
|
}
|
|
111
|
-
function adaptButton(component, key, context) {
|
|
112
|
-
rejectUnsupportedChecks(component);
|
|
320
|
+
function adaptButton(component, key, context, scope) {
|
|
113
321
|
const childId = expectString(component.child, `components.${component.id}.child`);
|
|
114
322
|
const child = context.surface.components.get(childId);
|
|
115
323
|
if (child?.component !== "Text") {
|
|
116
324
|
throw new A2uiParseError(`A2UI native Button ${JSON.stringify(component.id)} requires a Text child`);
|
|
117
325
|
}
|
|
118
326
|
const props = {
|
|
119
|
-
title: resolveDynamicString(child.text, `components.${child.id}.text`, context),
|
|
120
|
-
|
|
327
|
+
title: resolveDynamicString(child.text, `components.${child.id}.text`, context, scope),
|
|
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),
|
|
121
331
|
};
|
|
122
332
|
if (component.variant !== undefined) {
|
|
123
333
|
props.variant = component.variant;
|
|
124
334
|
}
|
|
125
|
-
addCommonProps(component, props, context);
|
|
335
|
+
addCommonProps(component, props, context, scope);
|
|
336
|
+
addValidationProps(component, props, context, scope, "button");
|
|
126
337
|
if (props.accessibilityLabel === undefined) {
|
|
127
338
|
props.accessibilityLabel = props.title;
|
|
128
339
|
}
|
|
129
340
|
return { key, component: "Button", props };
|
|
130
341
|
}
|
|
131
|
-
function adaptTextField(component, key, context) {
|
|
132
|
-
rejectUnsupportedChecks(component);
|
|
342
|
+
function adaptTextField(component, key, context, scope) {
|
|
133
343
|
const componentPath = `components.${component.id}`;
|
|
134
|
-
const label = resolveDynamicString(component.label, `${componentPath}.label`, context);
|
|
344
|
+
const label = resolveDynamicString(component.label, `${componentPath}.label`, context, scope);
|
|
135
345
|
const props = {
|
|
136
346
|
label,
|
|
137
347
|
placeholder: component.placeholder === undefined
|
|
138
348
|
? label
|
|
139
|
-
: resolveDynamicString(component.placeholder, `${componentPath}.placeholder`, context),
|
|
349
|
+
: resolveDynamicString(component.placeholder, `${componentPath}.placeholder`, context, scope),
|
|
140
350
|
};
|
|
141
351
|
if (component.value !== undefined) {
|
|
142
|
-
props.value = resolveDynamicString(component.value, `${componentPath}.value`, context);
|
|
352
|
+
props.value = resolveDynamicString(component.value, `${componentPath}.value`, context, scope);
|
|
143
353
|
if (isBinding(component.value)) {
|
|
144
|
-
props.binding =
|
|
354
|
+
props.binding = resolveBindingPointer(component.value.path, `${componentPath}.value.path`, scope);
|
|
145
355
|
}
|
|
146
356
|
}
|
|
147
357
|
if (component.variant !== undefined) {
|
|
148
358
|
props.variant = component.variant;
|
|
149
359
|
}
|
|
150
|
-
addCommonProps(component, props, context);
|
|
360
|
+
addCommonProps(component, props, context, scope);
|
|
361
|
+
addValidationProps(component, props, context, scope, "input");
|
|
151
362
|
if (props.accessibilityLabel === undefined) {
|
|
152
363
|
props.accessibilityLabel = label;
|
|
153
364
|
}
|
|
154
365
|
return { key, component: "TextInput", props };
|
|
155
366
|
}
|
|
156
|
-
function
|
|
157
|
-
if (component.checks
|
|
158
|
-
|
|
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;
|
|
159
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`);
|
|
474
|
+
}
|
|
475
|
+
return { min, max };
|
|
476
|
+
}
|
|
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`);
|
|
160
547
|
}
|
|
161
|
-
function
|
|
548
|
+
function resolveButtonAction(component, key, context, scope) {
|
|
162
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
|
+
}
|
|
163
586
|
if (!Object.hasOwn(action, "event")) {
|
|
164
|
-
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`);
|
|
165
588
|
}
|
|
166
589
|
const event = expectObject(action.event, `components.${component.id}.action.event`);
|
|
167
590
|
const eventName = expectString(event.name, `components.${component.id}.action.event.name`);
|
|
168
591
|
const eventContext = expectOptionalObject(event.context, `components.${component.id}.action.event.context`);
|
|
169
592
|
const resolvedContext = {};
|
|
170
593
|
for (const [name, value] of Object.entries(eventContext ?? {})) {
|
|
171
|
-
defineJsonProperty(resolvedContext, name, resolveDynamicValue(value, `components.${component.id}.action.event.context.${name}`, context));
|
|
594
|
+
defineJsonProperty(resolvedContext, name, resolveDynamicValue(value, `components.${component.id}.action.event.context.${name}`, context, scope));
|
|
172
595
|
}
|
|
173
596
|
const userMessage = event.userMessage === undefined
|
|
174
597
|
? undefined
|
|
175
|
-
: resolveDynamicString(event.userMessage, `components.${component.id}.action.event.userMessage`, context);
|
|
598
|
+
: resolveDynamicString(event.userMessage, `components.${component.id}.action.event.userMessage`, context, scope);
|
|
599
|
+
return {
|
|
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
|
+
},
|
|
608
|
+
};
|
|
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
|
+
}
|
|
176
668
|
return {
|
|
177
|
-
|
|
178
|
-
surfaceId:
|
|
179
|
-
sourceComponentId:
|
|
180
|
-
...(
|
|
181
|
-
context: parseJsonObject(resolvedContext, `components.${component.id}.action.event.context`),
|
|
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 }),
|
|
182
673
|
};
|
|
183
674
|
}
|
|
184
|
-
function
|
|
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
|
+
}
|
|
682
|
+
function addCommonProps(component, props, context, scope) {
|
|
185
683
|
if (component.weight !== undefined) {
|
|
186
|
-
|
|
684
|
+
const weight = expectFiniteNumber(component.weight, `components.${component.id}.weight`);
|
|
685
|
+
if (weight < 0) {
|
|
686
|
+
throw new A2uiParseError(`A2UI native adapter does not support negative weight at components.${component.id}.weight`);
|
|
687
|
+
}
|
|
688
|
+
props.weight = weight;
|
|
187
689
|
}
|
|
188
690
|
if (component.accessibility === undefined) {
|
|
189
691
|
return;
|
|
190
692
|
}
|
|
191
693
|
const accessibility = expectObject(component.accessibility, `components.${component.id}.accessibility`);
|
|
192
694
|
if (accessibility.label !== undefined) {
|
|
193
|
-
props.accessibilityLabel = resolveDynamicString(accessibility.label, `components.${component.id}.accessibility.label`, context);
|
|
695
|
+
props.accessibilityLabel = resolveDynamicString(accessibility.label, `components.${component.id}.accessibility.label`, context, scope);
|
|
194
696
|
}
|
|
195
697
|
if (accessibility.description !== undefined) {
|
|
196
|
-
props.accessibilityHint = resolveDynamicString(accessibility.description, `components.${component.id}.accessibility.description`, context);
|
|
698
|
+
props.accessibilityHint = resolveDynamicString(accessibility.description, `components.${component.id}.accessibility.description`, context, scope);
|
|
197
699
|
}
|
|
198
700
|
if (accessibility.live !== undefined) {
|
|
199
701
|
props.accessibilityLive = accessibility.live;
|
|
200
702
|
}
|
|
201
703
|
if (accessibility.hidden !== undefined) {
|
|
202
|
-
props.accessibilityHidden = resolveDynamicBoolean(accessibility.hidden, `components.${component.id}.accessibility.hidden`, context);
|
|
704
|
+
props.accessibilityHidden = resolveDynamicBoolean(accessibility.hidden, `components.${component.id}.accessibility.hidden`, context, scope);
|
|
203
705
|
}
|
|
204
706
|
}
|
|
205
|
-
function resolveDynamicString(value, path, context) {
|
|
206
|
-
const resolved = resolveDynamicValue(value, path, context);
|
|
707
|
+
function resolveDynamicString(value, path, context, scope) {
|
|
708
|
+
const resolved = resolveDynamicValue(value, path, context, scope);
|
|
207
709
|
return expectString(resolved, path);
|
|
208
710
|
}
|
|
209
|
-
function resolveDynamicBoolean(value, path, context) {
|
|
210
|
-
const resolved = resolveDynamicValue(value, path, context);
|
|
711
|
+
function resolveDynamicBoolean(value, path, context, scope) {
|
|
712
|
+
const resolved = resolveDynamicValue(value, path, context, scope);
|
|
211
713
|
if (typeof resolved !== "boolean") {
|
|
212
714
|
throw new A2uiParseError(`Expected a boolean at ${path}`);
|
|
213
715
|
}
|
|
214
716
|
return resolved;
|
|
215
717
|
}
|
|
216
|
-
function resolveDynamicValue(value, path, context) {
|
|
718
|
+
function resolveDynamicValue(value, path, context, scope) {
|
|
217
719
|
if (value === undefined) {
|
|
218
720
|
throw new A2uiParseError(`Missing dynamic value at ${path}`);
|
|
219
721
|
}
|
|
220
722
|
if (isFunctionCall(value)) {
|
|
723
|
+
if (value.call === "@index") {
|
|
724
|
+
if (scope === undefined) {
|
|
725
|
+
throw new A2uiParseError(`A2UI native adapter cannot evaluate @index outside a template at ${path}`);
|
|
726
|
+
}
|
|
727
|
+
const args = value.args === undefined ? undefined : expectObject(value.args, `${path}.args`);
|
|
728
|
+
const offset = args?.offset === undefined
|
|
729
|
+
? 0
|
|
730
|
+
: resolveDynamicNumber(args.offset, `${path}.args.offset`, context, scope);
|
|
731
|
+
return scope.index + offset;
|
|
732
|
+
}
|
|
733
|
+
if (VALIDATION_FUNCTION_NAMES.has(value.call)) {
|
|
734
|
+
return resolveValidationFunction(value, path, context, scope);
|
|
735
|
+
}
|
|
736
|
+
if (value.call === "formatNumber" || value.call === "formatCurrency") {
|
|
737
|
+
return resolveNumberFormat(value, path, context, scope);
|
|
738
|
+
}
|
|
739
|
+
if (value.call === "formatDate") {
|
|
740
|
+
return resolveDateFormat(value, path, context, scope);
|
|
741
|
+
}
|
|
742
|
+
if (value.call === "pluralize") {
|
|
743
|
+
return resolvePluralize(value, path, context, scope);
|
|
744
|
+
}
|
|
745
|
+
if (value.call === "and" || value.call === "or") {
|
|
746
|
+
return resolveBooleanList(value, path, context, scope);
|
|
747
|
+
}
|
|
748
|
+
if (value.call === "not") {
|
|
749
|
+
const args = expectObject(value.args, `${path}.args`);
|
|
750
|
+
return !resolveDynamicBoolean(args.value, `${path}.args.value`, context, scope);
|
|
751
|
+
}
|
|
752
|
+
if (value.call === "formatString") {
|
|
753
|
+
const args = expectObject(value.args, `${path}.args`);
|
|
754
|
+
const source = expectString(args.value, `${path}.args.value`);
|
|
755
|
+
const result = evaluateA2uiV1FormatString(source, (expression, index) => {
|
|
756
|
+
return resolveDynamicValue(expression, `${path}.args.value.interpolations[${index}]`, context, scope);
|
|
757
|
+
}, `${path}.args.value`, (expressionCount) => {
|
|
758
|
+
context.formatStringExpressionCount += expressionCount;
|
|
759
|
+
if (context.formatStringExpressionCount > JSON_MAX_VALUES) {
|
|
760
|
+
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum of ${JSON_MAX_VALUES} formatString expressions`);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
return recordFormattedString(result, path, context);
|
|
764
|
+
}
|
|
221
765
|
throw new A2uiParseError(`A2UI native adapter does not execute function ${JSON.stringify(value.call)} at ${path}`);
|
|
222
766
|
}
|
|
223
767
|
if (isBinding(value)) {
|
|
224
|
-
const pointer =
|
|
225
|
-
|
|
768
|
+
const pointer = expectString(value.path, `${path}.path`);
|
|
769
|
+
if (pointer.startsWith("/") || scope === undefined) {
|
|
770
|
+
const absolutePointer = expectAbsoluteBinding(pointer, `${path}.path`);
|
|
771
|
+
return parseJsonValue(resolveJsonPointer(context.dataModel, absolutePointer, path), path);
|
|
772
|
+
}
|
|
773
|
+
return parseJsonValue(resolveRelativePointer(scope.value, pointer, path), path);
|
|
226
774
|
}
|
|
227
775
|
return parseJsonValue(value, path);
|
|
228
776
|
}
|
|
777
|
+
function resolveDynamicNumber(value, path, context, scope) {
|
|
778
|
+
const resolved = resolveDynamicValue(value, path, context, scope);
|
|
779
|
+
if (typeof resolved !== "number" || !Number.isFinite(resolved)) {
|
|
780
|
+
throw new A2uiParseError(`Expected a finite number at ${path}`);
|
|
781
|
+
}
|
|
782
|
+
return resolved;
|
|
783
|
+
}
|
|
784
|
+
function resolveNumberFormat(call, path, context, scope) {
|
|
785
|
+
const args = expectObject(call.args, `${path}.args`);
|
|
786
|
+
const value = resolveDynamicNumber(args.value, `${path}.args.value`, context, scope);
|
|
787
|
+
const decimals = args.decimals === undefined
|
|
788
|
+
? undefined
|
|
789
|
+
: parseDecimalPlaces(resolveDynamicNumber(args.decimals, `${path}.args.decimals`, context, scope), `${path}.args.decimals`);
|
|
790
|
+
const grouping = args.grouping === undefined
|
|
791
|
+
? true
|
|
792
|
+
: resolveDynamicBoolean(args.grouping, `${path}.args.grouping`, context, scope);
|
|
793
|
+
const currency = call.call === "formatCurrency"
|
|
794
|
+
? parseCurrencyCode(resolveDynamicString(args.currency, `${path}.args.currency`, context, scope), `${path}.args.currency`)
|
|
795
|
+
: undefined;
|
|
796
|
+
const formatter = getNumberFormat(context, decimals, grouping, currency, path);
|
|
797
|
+
try {
|
|
798
|
+
return recordFormattedString(formatter.format(value), path, context);
|
|
799
|
+
}
|
|
800
|
+
catch (cause) {
|
|
801
|
+
throw new A2uiParseError(`A2UI native adapter could not execute ${JSON.stringify(call.call)} at ${path}`, { cause });
|
|
802
|
+
}
|
|
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
|
+
}
|
|
1106
|
+
const PLURAL_CATEGORIES = Object.freeze(["zero", "one", "two", "few", "many", "other"]);
|
|
1107
|
+
function resolvePluralize(call, path, context, scope) {
|
|
1108
|
+
const args = expectObject(call.args, `${path}.args`);
|
|
1109
|
+
const value = resolveDynamicNumber(args.value, `${path}.args.value`, context, scope);
|
|
1110
|
+
const forms = new Map();
|
|
1111
|
+
for (const category of PLURAL_CATEGORIES) {
|
|
1112
|
+
if (args[category] !== undefined) {
|
|
1113
|
+
forms.set(category, resolveDynamicString(args[category], `${path}.args.${category}`, context, scope));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
const other = forms.get("other");
|
|
1117
|
+
if (other === undefined) {
|
|
1118
|
+
throw new A2uiParseError(`Missing plural fallback at ${path}.args.other`);
|
|
1119
|
+
}
|
|
1120
|
+
const category = getPluralRules(context, path).select(value);
|
|
1121
|
+
return recordFormattedString(forms.get(category) ?? other, path, context);
|
|
1122
|
+
}
|
|
1123
|
+
function getPluralRules(context, path) {
|
|
1124
|
+
const key = context.locale ?? "";
|
|
1125
|
+
const cached = context.pluralRules.get(key);
|
|
1126
|
+
if (cached !== undefined) {
|
|
1127
|
+
return cached;
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
if (context.locale !== undefined &&
|
|
1131
|
+
Intl.PluralRules.supportedLocalesOf(context.locale, { localeMatcher: "lookup" }).length === 0) {
|
|
1132
|
+
throw new A2uiParseError(`Locale ${JSON.stringify(context.locale)} does not support plural rules at ${path}`);
|
|
1133
|
+
}
|
|
1134
|
+
const rules = new Intl.PluralRules(context.locale, { type: "cardinal" });
|
|
1135
|
+
context.pluralRules.set(key, rules);
|
|
1136
|
+
return rules;
|
|
1137
|
+
}
|
|
1138
|
+
catch (cause) {
|
|
1139
|
+
if (cause instanceof A2uiParseError) {
|
|
1140
|
+
throw cause;
|
|
1141
|
+
}
|
|
1142
|
+
throw new A2uiParseError(`A2UI native adapter could not construct plural rules at ${path}`, {
|
|
1143
|
+
cause,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function resolveBooleanList(call, path, context, scope) {
|
|
1148
|
+
const args = expectObject(call.args, `${path}.args`);
|
|
1149
|
+
if (!Array.isArray(args.values) || args.values.length < 2) {
|
|
1150
|
+
throw new A2uiParseError(`Expected at least two boolean values at ${path}.args.values`);
|
|
1151
|
+
}
|
|
1152
|
+
const values = args.values.map((value, index) => resolveDynamicBoolean(value, `${path}.args.values[${index}]`, context, scope));
|
|
1153
|
+
return call.call === "and" ? values.every(Boolean) : values.some(Boolean);
|
|
1154
|
+
}
|
|
1155
|
+
function parseDecimalPlaces(value, path) {
|
|
1156
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 100) {
|
|
1157
|
+
throw new A2uiParseError(`Expected decimal places from 0 through 100 at ${path}`);
|
|
1158
|
+
}
|
|
1159
|
+
return value;
|
|
1160
|
+
}
|
|
1161
|
+
function parseCurrencyCode(value, path) {
|
|
1162
|
+
const currency = value.toUpperCase();
|
|
1163
|
+
if (!/^[A-Z]{3}$/.test(currency) || !ISO_4217_CURRENCY_CODES.has(currency)) {
|
|
1164
|
+
throw new A2uiParseError(`Expected a current ISO 4217 currency code at ${path}`);
|
|
1165
|
+
}
|
|
1166
|
+
return currency;
|
|
1167
|
+
}
|
|
1168
|
+
function getNumberFormat(context, decimals, grouping, currency, path) {
|
|
1169
|
+
const key = JSON.stringify([
|
|
1170
|
+
context.locale ?? null,
|
|
1171
|
+
currency ?? null,
|
|
1172
|
+
decimals ?? null,
|
|
1173
|
+
grouping,
|
|
1174
|
+
]);
|
|
1175
|
+
const cached = context.numberFormats.get(key);
|
|
1176
|
+
if (cached !== undefined) {
|
|
1177
|
+
return cached;
|
|
1178
|
+
}
|
|
1179
|
+
const options = {
|
|
1180
|
+
useGrouping: grouping,
|
|
1181
|
+
...(currency === undefined ? {} : { style: "currency", currency }),
|
|
1182
|
+
...(decimals === undefined
|
|
1183
|
+
? {}
|
|
1184
|
+
: { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
|
|
1185
|
+
};
|
|
1186
|
+
try {
|
|
1187
|
+
const formatter = new Intl.NumberFormat(context.locale, options);
|
|
1188
|
+
context.numberFormats.set(key, formatter);
|
|
1189
|
+
return formatter;
|
|
1190
|
+
}
|
|
1191
|
+
catch (cause) {
|
|
1192
|
+
throw new A2uiParseError(`Invalid number-format options at ${path}`, { cause });
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function recordFormattedString(value, path, context) {
|
|
1196
|
+
if (value.length > JSON_MAX_STRING_LENGTH) {
|
|
1197
|
+
throw new A2uiParseError(`A2UI formatted output at ${path} exceeds maximum length of ${JSON_MAX_STRING_LENGTH}`);
|
|
1198
|
+
}
|
|
1199
|
+
context.formattedStringLength += value.length;
|
|
1200
|
+
if (context.formattedStringLength > A2UI_V1_MAX_SOURCE_LENGTH) {
|
|
1201
|
+
throw new A2uiParseError(`Expanded A2UI native plan exceeds maximum formatted-string length of ${A2UI_V1_MAX_SOURCE_LENGTH}`);
|
|
1202
|
+
}
|
|
1203
|
+
return value;
|
|
1204
|
+
}
|
|
229
1205
|
function resolveJsonPointer(document, pointer, path) {
|
|
230
1206
|
if (pointer === "") {
|
|
231
1207
|
return document;
|
|
232
1208
|
}
|
|
1209
|
+
return resolvePointerTokens(document, pointer.slice(1).split("/"), pointer, path);
|
|
1210
|
+
}
|
|
1211
|
+
function resolveRelativePointer(document, pointer, path) {
|
|
1212
|
+
if (pointer === "") {
|
|
1213
|
+
return document;
|
|
1214
|
+
}
|
|
1215
|
+
return resolvePointerTokens(document, pointer.split("/"), pointer, path);
|
|
1216
|
+
}
|
|
1217
|
+
function resolvePointerTokens(document, encodedTokens, pointer, path) {
|
|
233
1218
|
let cursor = document;
|
|
234
|
-
for (const encodedToken of
|
|
1219
|
+
for (const encodedToken of encodedTokens) {
|
|
235
1220
|
const token = decodePointerToken(encodedToken, pointer);
|
|
236
1221
|
if (Array.isArray(cursor)) {
|
|
237
1222
|
if (!/^(0|[1-9][0-9]*)$/.test(token)) {
|
|
@@ -251,6 +1236,25 @@ function resolveJsonPointer(document, pointer, path) {
|
|
|
251
1236
|
}
|
|
252
1237
|
return cursor;
|
|
253
1238
|
}
|
|
1239
|
+
function resolveBindingPointer(value, path, scope) {
|
|
1240
|
+
const pointer = expectString(value, path);
|
|
1241
|
+
if (pointer.startsWith("/") || scope === undefined) {
|
|
1242
|
+
return expectAbsoluteBinding(pointer, path);
|
|
1243
|
+
}
|
|
1244
|
+
return pointer === "" ? scope.pointer : `${scope.pointer}/${pointer}`;
|
|
1245
|
+
}
|
|
1246
|
+
function appendPointerToken(pointer, encodedToken) {
|
|
1247
|
+
return `${pointer}/${encodedToken}`;
|
|
1248
|
+
}
|
|
1249
|
+
function appendInstanceKey(parentKey, componentId, index) {
|
|
1250
|
+
// Component IDs are arbitrary strings. Escape every key delimiter, including the escape marker,
|
|
1251
|
+
// so distinct component paths cannot collapse to the same renderer-only dispatch identity.
|
|
1252
|
+
const encodedId = componentId
|
|
1253
|
+
.replaceAll("%", "%25")
|
|
1254
|
+
.replaceAll("/", "%2F")
|
|
1255
|
+
.replaceAll(":", "%3A");
|
|
1256
|
+
return `${parentKey}/${encodedId}:${index}`;
|
|
1257
|
+
}
|
|
254
1258
|
function expectAbsoluteBinding(value, path) {
|
|
255
1259
|
const pointer = expectString(value, path);
|
|
256
1260
|
if (pointer !== "" && !pointer.startsWith("/")) {
|