@mcp-native/react-native 0.3.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/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 events = findNativeEvents(adaptComponent("root", "root", context, undefined), sourceComponentId, parsedOptions.instanceKey);
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
- function findNativeEvents(element, sourceComponentId, instanceKey) {
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 createAdapterContext(surface, policy, dataModel, locale) {
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
- if (Intl.NumberFormat.supportedLocalesOf(value, { localeMatcher: "lookup" }).length === 0) {
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 new Intl.NumberFormat(value).resolvedOptions().locale;
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
- event: resolveButtonEvent(component, key, 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),
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 rejectUnsupportedChecks(component) {
294
- if (component.checks !== undefined) {
295
- throw new A2uiParseError(`A2UI native adapter does not yet support renderer-side checks at components.${component.id}.checks`);
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 resolveButtonEvent(component, key, context, scope) {
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)} does not support local function actions`);
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
- name: eventName,
315
- surfaceId: context.surface.surfaceId,
316
- sourceComponentId: component.id,
317
- instanceKey: key,
318
- ...(userMessage === undefined ? {} : { userMessage }),
319
- context: parseJsonObject(resolvedContext, `components.${component.id}.action.event.context`),
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`);