feat(macos): add Canvas A2UI renderer

This commit is contained in:
Peter Steinberger
2025-12-17 11:35:06 +01:00
parent 1cdebb68a0
commit cdb5ddb2da
408 changed files with 73598 additions and 32 deletions

View File

@@ -0,0 +1,236 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { BooleanValue, NumberValue, StringValue } from "../types/primitives";
import {
AnyComponentNode,
ComponentArrayReference,
ResolvedAudioPlayer,
ResolvedButton,
ResolvedCard,
ResolvedCheckbox,
ResolvedColumn,
ResolvedDateTimeInput,
ResolvedDivider,
ResolvedIcon,
ResolvedImage,
ResolvedList,
ResolvedModal,
ResolvedMultipleChoice,
ResolvedRow,
ResolvedSlider,
ResolvedTabItem,
ResolvedTabs,
ResolvedText,
ResolvedTextField,
ResolvedVideo,
ValueMap,
} from "../types/types";
export function isValueMap(value: unknown): value is ValueMap {
return isObject(value) && "key" in value;
}
export function isPath(key: string, value: unknown): value is string {
return key === "path" && typeof value === "string";
}
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isComponentArrayReference(
value: unknown
): value is ComponentArrayReference {
if (!isObject(value)) return false;
return "explicitList" in value || "template" in value;
}
function isStringValue(value: unknown): value is StringValue {
return (
isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "string") ||
"literalString" in value)
);
}
function isNumberValue(value: unknown): value is NumberValue {
return (
isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "number") ||
"literalNumber" in value)
);
}
function isBooleanValue(value: unknown): value is BooleanValue {
return (
isObject(value) &&
("path" in value ||
("literal" in value && typeof value.literal === "boolean") ||
"literalBoolean" in value)
);
}
function isAnyComponentNode(value: unknown): value is AnyComponentNode {
if (!isObject(value)) return false;
const hasBaseKeys = "id" in value && "type" in value && "properties" in value;
if (!hasBaseKeys) return false;
return true;
}
export function isResolvedAudioPlayer(
props: unknown
): props is ResolvedAudioPlayer {
return isObject(props) && "url" in props && isStringValue(props.url);
}
export function isResolvedButton(props: unknown): props is ResolvedButton {
return (
isObject(props) &&
"child" in props &&
isAnyComponentNode(props.child) &&
"action" in props
);
}
export function isResolvedCard(props: unknown): props is ResolvedCard {
if (!isObject(props)) return false;
if (!("child" in props)) {
if (!("children" in props)) {
return false;
} else {
return (
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode)
);
}
}
return isAnyComponentNode(props.child);
}
export function isResolvedCheckbox(props: unknown): props is ResolvedCheckbox {
return (
isObject(props) &&
"label" in props &&
isStringValue(props.label) &&
"value" in props &&
isBooleanValue(props.value)
);
}
export function isResolvedColumn(props: unknown): props is ResolvedColumn {
return (
isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode)
);
}
export function isResolvedDateTimeInput(
props: unknown
): props is ResolvedDateTimeInput {
return isObject(props) && "value" in props && isStringValue(props.value);
}
export function isResolvedDivider(props: unknown): props is ResolvedDivider {
// Dividers can have all optional properties, so just checking if
// it's an object is enough.
return isObject(props);
}
export function isResolvedImage(props: unknown): props is ResolvedImage {
return isObject(props) && "url" in props && isStringValue(props.url);
}
export function isResolvedIcon(props: unknown): props is ResolvedIcon {
return isObject(props) && "name" in props && isStringValue(props.name);
}
export function isResolvedList(props: unknown): props is ResolvedList {
return (
isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode)
);
}
export function isResolvedModal(props: unknown): props is ResolvedModal {
return (
isObject(props) &&
"entryPointChild" in props &&
isAnyComponentNode(props.entryPointChild) &&
"contentChild" in props &&
isAnyComponentNode(props.contentChild)
);
}
export function isResolvedMultipleChoice(
props: unknown
): props is ResolvedMultipleChoice {
return isObject(props) && "selections" in props;
}
export function isResolvedRow(props: unknown): props is ResolvedRow {
return (
isObject(props) &&
"children" in props &&
Array.isArray(props.children) &&
props.children.every(isAnyComponentNode)
);
}
export function isResolvedSlider(props: unknown): props is ResolvedSlider {
return isObject(props) && "value" in props && isNumberValue(props.value);
}
function isResolvedTabItem(item: unknown): item is ResolvedTabItem {
return (
isObject(item) &&
"title" in item &&
isStringValue(item.title) &&
"child" in item &&
isAnyComponentNode(item.child)
);
}
export function isResolvedTabs(props: unknown): props is ResolvedTabs {
return (
isObject(props) &&
"tabItems" in props &&
Array.isArray(props.tabItems) &&
props.tabItems.every(isResolvedTabItem)
);
}
export function isResolvedText(props: unknown): props is ResolvedText {
return isObject(props) && "text" in props && isStringValue(props.text);
}
export function isResolvedTextField(
props: unknown
): props is ResolvedTextField {
return isObject(props) && "label" in props && isStringValue(props.label);
}
export function isResolvedVideo(props: unknown): props is ResolvedVideo {
return isObject(props) && "url" in props && isStringValue(props.url);
}

View File

@@ -0,0 +1,855 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ServerToClientMessage,
AnyComponentNode,
BeginRenderingMessage,
DataArray,
DataMap,
DataModelUpdate,
DataValue,
DeleteSurfaceMessage,
ResolvedMap,
ResolvedValue,
Surface,
SurfaceID,
SurfaceUpdateMessage,
MessageProcessor,
ValueMap,
DataObject,
} from "../types/types";
import {
isComponentArrayReference,
isObject,
isPath,
isResolvedAudioPlayer,
isResolvedButton,
isResolvedCard,
isResolvedCheckbox,
isResolvedColumn,
isResolvedDateTimeInput,
isResolvedDivider,
isResolvedIcon,
isResolvedImage,
isResolvedList,
isResolvedModal,
isResolvedMultipleChoice,
isResolvedRow,
isResolvedSlider,
isResolvedTabs,
isResolvedText,
isResolvedTextField,
isResolvedVideo,
isValueMap,
} from "./guards.js";
/**
* Processes and consolidates A2UIProtocolMessage objects into a structured,
* hierarchical model of UI surfaces.
*/
export class A2uiMessageProcessor implements MessageProcessor {
static readonly DEFAULT_SURFACE_ID = "@default";
#mapCtor: MapConstructor = Map;
#arrayCtor: ArrayConstructor = Array;
#setCtor: SetConstructor = Set;
#objCtor: ObjectConstructor = Object;
#surfaces: Map<SurfaceID, Surface>;
constructor(
readonly opts: {
mapCtor: MapConstructor;
arrayCtor: ArrayConstructor;
setCtor: SetConstructor;
objCtor: ObjectConstructor;
} = { mapCtor: Map, arrayCtor: Array, setCtor: Set, objCtor: Object }
) {
this.#arrayCtor = opts.arrayCtor;
this.#mapCtor = opts.mapCtor;
this.#setCtor = opts.setCtor;
this.#objCtor = opts.objCtor;
this.#surfaces = new opts.mapCtor();
}
getSurfaces(): ReadonlyMap<string, Surface> {
return this.#surfaces;
}
clearSurfaces() {
this.#surfaces.clear();
}
processMessages(messages: ServerToClientMessage[]): void {
for (const message of messages) {
if (message.beginRendering) {
this.#handleBeginRendering(
message.beginRendering,
message.beginRendering.surfaceId
);
}
if (message.surfaceUpdate) {
this.#handleSurfaceUpdate(
message.surfaceUpdate,
message.surfaceUpdate.surfaceId
);
}
if (message.dataModelUpdate) {
this.#handleDataModelUpdate(
message.dataModelUpdate,
message.dataModelUpdate.surfaceId
);
}
if (message.deleteSurface) {
this.#handleDeleteSurface(message.deleteSurface);
}
}
}
/**
* Retrieves the data for a given component node and a relative path string.
* This correctly handles the special `.` path, which refers to the node's
* own data context.
*/
getData(
node: AnyComponentNode,
relativePath: string,
surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID
): DataValue | null {
const surface = this.#getOrCreateSurface(surfaceId);
if (!surface) return null;
let finalPath: string;
// The special `.` path means the final path is the node's data context
// path and so we return the dataContextPath as-is.
if (relativePath === "." || relativePath === "") {
finalPath = node.dataContextPath ?? "/";
} else {
// For all other paths, resolve them against the node's context.
finalPath = this.resolvePath(relativePath, node.dataContextPath);
}
return this.#getDataByPath(surface.dataModel, finalPath);
}
setData(
node: AnyComponentNode | null,
relativePath: string,
value: DataValue,
surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID
): void {
if (!node) {
console.warn("No component node set");
return;
}
const surface = this.#getOrCreateSurface(surfaceId);
if (!surface) return;
let finalPath: string;
// The special `.` path means the final path is the node's data context
// path and so we return the dataContextPath as-is.
if (relativePath === "." || relativePath === "") {
finalPath = node.dataContextPath ?? "/";
} else {
// For all other paths, resolve them against the node's context.
finalPath = this.resolvePath(relativePath, node.dataContextPath);
}
this.#setDataByPath(surface.dataModel, finalPath, value);
}
resolvePath(path: string, dataContextPath?: string): string {
// If the path is absolute, it overrides any context.
if (path.startsWith("/")) {
return path;
}
if (dataContextPath && dataContextPath !== "/") {
// Ensure there's exactly one slash between the context and the path.
return dataContextPath.endsWith("/")
? `${dataContextPath}${path}`
: `${dataContextPath}/${path}`;
}
// Fallback for no context or root context: make it an absolute path.
return `/${path}`;
}
#parseIfJsonString(value: DataValue): DataValue {
if (typeof value !== "string") {
return value;
}
const trimmedValue = value.trim();
if (
(trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) ||
(trimmedValue.startsWith("[") && trimmedValue.endsWith("]"))
) {
try {
// It looks like JSON, attempt to parse it.
return JSON.parse(value);
} catch (e) {
// It looked like JSON but wasn't. Keep the original string.
console.warn(
`Failed to parse potential JSON string: "${value.substring(
0,
50
)}..."`,
e
);
return value; // Return original string
}
}
// It's a string, but not JSON-like.
return value;
}
/**
* Converts a specific array format [{key: "...", value_string: "..."}, ...]
* into a standard Map. It also attempts to parse any string values that
* appear to be stringified JSON.
*/
#convertKeyValueArrayToMap(arr: DataArray): DataMap {
const map = new this.#mapCtor<string, DataValue>();
for (const item of arr) {
if (!isObject(item) || !("key" in item)) continue;
const key = item.key as string;
// Find the value, which is in a property prefixed with "value".
const valueKey = this.#findValueKey(item);
if (!valueKey) continue;
let value: DataValue = item[valueKey];
// It's a valueMap. We must recursively convert it.
if (valueKey === "valueMap" && Array.isArray(value)) {
value = this.#convertKeyValueArrayToMap(value);
} else if (typeof value === "string") {
value = this.#parseIfJsonString(value);
}
this.#setDataByPath(map, key, value);
}
return map;
}
#setDataByPath(root: DataMap, path: string, value: DataValue): void {
// Check if the incoming value is the special key-value array format.
if (
Array.isArray(value) &&
(value.length === 0 || (isObject(value[0]) && "key" in value[0]))
) {
// Check for "set primitive at path" convention:
// path: "/messages/123", contents: [{ key: ".", valueString: "hi" }]
if (value.length === 1 && isObject(value[0]) && value[0].key === ".") {
const item = value[0];
const valueKey = this.#findValueKey(item);
if (valueKey) {
// Extract the primitive value
value = item[valueKey];
// We must still process this value in case it's a valueMap or
// a JSON string.
if (valueKey === "valueMap" && Array.isArray(value)) {
value = this.#convertKeyValueArrayToMap(value);
} else if (typeof value === "string") {
value = this.#parseIfJsonString(value);
}
// Now, `value` is the primitive (e.g., "hi"), and we continue
// the function.
} else {
// Malformed, but fall back to existing behavior.
value = this.#convertKeyValueArrayToMap(value);
}
} else {
value = this.#convertKeyValueArrayToMap(value);
}
}
const segments = this.#normalizePath(path)
.split("/")
.filter((s) => s);
if (segments.length === 0) {
// Root data can either be a Map or an Object. If we receive an Object,
// however, we will normalize it to a proper Map.
if (value instanceof Map || isObject(value)) {
// Normalize an Object to a Map.
if (!(value instanceof Map) && isObject(value)) {
value = new this.#mapCtor(Object.entries(value));
}
root.clear();
for (const [key, v] of value.entries()) {
root.set(key, v);
}
} else {
console.error("Cannot set root of DataModel to a non-Map value.");
}
return;
}
let current: DataMap | DataArray = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
let target: DataValue | undefined;
if (current instanceof Map) {
target = current.get(segment);
} else if (Array.isArray(current) && /^\d+$/.test(segment)) {
target = current[parseInt(segment, 10)];
}
if (
target === undefined ||
typeof target !== "object" ||
target === null
) {
target = new this.#mapCtor();
if (current instanceof this.#mapCtor) {
current.set(segment, target);
} else if (Array.isArray(current)) {
current[parseInt(segment, 10)] = target;
}
}
current = target as DataMap | DataArray;
}
const finalSegment = segments[segments.length - 1];
const storedValue = value;
if (current instanceof this.#mapCtor) {
current.set(finalSegment, storedValue);
} else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) {
current[parseInt(finalSegment, 10)] = storedValue;
}
}
/**
* Normalizes a path string into a consistent, slash-delimited format.
* Converts bracket notation and dot notation in a two-pass.
* e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title"
* e.g., "book.0.title" -> "/book/0/title"
*/
#normalizePath(path: string): string {
// 1. Replace all bracket accessors `[index]` with dot accessors `.index`
const dotPath = path.replace(/\[(\d+)\]/g, ".$1");
// 2. Split by dots
const segments = dotPath.split(".");
// 3. Join with slashes and ensure it starts with a slash
return "/" + segments.filter((s) => s.length > 0).join("/");
}
#getDataByPath(root: DataMap, path: string): DataValue | null {
const segments = this.#normalizePath(path)
.split("/")
.filter((s) => s);
let current: DataValue = root;
for (const segment of segments) {
if (current === undefined || current === null) return null;
if (current instanceof Map) {
current = current.get(segment) as DataMap;
} else if (Array.isArray(current) && /^\d+$/.test(segment)) {
current = current[parseInt(segment, 10)];
} else if (isObject(current)) {
current = current[segment];
} else {
// If we need to traverse deeper but `current` is a primitive, the path is invalid.
return null;
}
}
return current;
}
#getOrCreateSurface(surfaceId: string): Surface {
let surface: Surface | undefined = this.#surfaces.get(surfaceId);
if (!surface) {
surface = new this.#objCtor({
rootComponentId: null,
componentTree: null,
dataModel: new this.#mapCtor(),
components: new this.#mapCtor(),
styles: new this.#objCtor(),
}) as Surface;
this.#surfaces.set(surfaceId, surface);
}
return surface;
}
#handleBeginRendering(
message: BeginRenderingMessage,
surfaceId: SurfaceID
): void {
const surface = this.#getOrCreateSurface(surfaceId);
surface.rootComponentId = message.root;
surface.styles = message.styles ?? {};
this.#rebuildComponentTree(surface);
}
#handleSurfaceUpdate(
message: SurfaceUpdateMessage,
surfaceId: SurfaceID
): void {
const surface = this.#getOrCreateSurface(surfaceId);
for (const component of message.components) {
surface.components.set(component.id, component);
}
this.#rebuildComponentTree(surface);
}
#handleDataModelUpdate(message: DataModelUpdate, surfaceId: SurfaceID): void {
const surface = this.#getOrCreateSurface(surfaceId);
const path = message.path ?? "/";
this.#setDataByPath(
surface.dataModel,
path,
message.contents
);
this.#rebuildComponentTree(surface);
}
#handleDeleteSurface(message: DeleteSurfaceMessage): void {
this.#surfaces.delete(message.surfaceId);
}
/**
* Starts at the root component of the surface and builds out the tree
* recursively. This process involves resolving all properties of the child
* components, and expanding on any explicit children lists or templates
* found in the structure.
*
* @param surface The surface to be built.
*/
#rebuildComponentTree(surface: Surface): void {
if (!surface.rootComponentId) {
surface.componentTree = null;
return;
}
// Track visited nodes to avoid circular references.
const visited = new this.#setCtor<string>();
surface.componentTree = this.#buildNodeRecursive(
surface.rootComponentId,
surface,
visited,
"/",
"" // Initial idSuffix.
);
}
/** Finds a value key in a map. */
#findValueKey(value: Record<string, unknown>): string | undefined {
return Object.keys(value).find((k) => k.startsWith("value"));
}
/**
* Builds out the nodes recursively.
*/
#buildNodeRecursive(
baseComponentId: string,
surface: Surface,
visited: Set<string>,
dataContextPath: string,
idSuffix = ""
): AnyComponentNode | null {
const fullId = `${baseComponentId}${idSuffix}`; // Construct the full ID
const { components } = surface;
if (!components.has(baseComponentId)) {
return null;
}
if (visited.has(fullId)) {
throw new Error(`Circular dependency for component "${fullId}".`);
}
visited.add(fullId);
const componentData = components.get(baseComponentId)!;
const componentProps = componentData.component ?? {};
const componentType = Object.keys(componentProps)[0];
const unresolvedProperties =
componentProps[componentType as keyof typeof componentProps];
// Manually build the resolvedProperties object by resolving each value in
// the component's properties.
const resolvedProperties: ResolvedMap = new this.#objCtor() as ResolvedMap;
if (isObject(unresolvedProperties)) {
for (const [key, value] of Object.entries(unresolvedProperties)) {
resolvedProperties[key] = this.#resolvePropertyValue(
value,
surface,
visited,
dataContextPath,
idSuffix
);
}
}
visited.delete(fullId);
// Now that we have the resolved properties in place we can go ahead and
// ensure that they meet expectations in terms of types and so forth,
// casting them into the specific shape for usage.
const baseNode = {
id: fullId,
dataContextPath,
weight: componentData.weight ?? "initial",
};
switch (componentType) {
case "Text":
if (!isResolvedText(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Text",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Image":
if (!isResolvedImage(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Image",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Icon":
if (!isResolvedIcon(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Icon",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Video":
if (!isResolvedVideo(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Video",
properties: resolvedProperties,
}) as AnyComponentNode;
case "AudioPlayer":
if (!isResolvedAudioPlayer(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "AudioPlayer",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Row":
if (!isResolvedRow(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Row",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Column":
if (!isResolvedColumn(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Column",
properties: resolvedProperties,
}) as AnyComponentNode;
case "List":
if (!isResolvedList(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "List",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Card":
if (!isResolvedCard(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Card",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Tabs":
if (!isResolvedTabs(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Tabs",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Divider":
if (!isResolvedDivider(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Divider",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Modal":
if (!isResolvedModal(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Modal",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Button":
if (!isResolvedButton(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Button",
properties: resolvedProperties,
}) as AnyComponentNode;
case "CheckBox":
if (!isResolvedCheckbox(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "CheckBox",
properties: resolvedProperties,
}) as AnyComponentNode;
case "TextField":
if (!isResolvedTextField(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "TextField",
properties: resolvedProperties,
}) as AnyComponentNode;
case "DateTimeInput":
if (!isResolvedDateTimeInput(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "DateTimeInput",
properties: resolvedProperties,
}) as AnyComponentNode;
case "MultipleChoice":
if (!isResolvedMultipleChoice(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "MultipleChoice",
properties: resolvedProperties,
}) as AnyComponentNode;
case "Slider":
if (!isResolvedSlider(resolvedProperties)) {
throw new Error(`Invalid data; expected ${componentType}`);
}
return new this.#objCtor({
...baseNode,
type: "Slider",
properties: resolvedProperties,
}) as AnyComponentNode;
default:
// Catch-all for other custom component types.
return new this.#objCtor({
...baseNode,
type: componentType,
properties: resolvedProperties,
}) as AnyComponentNode;
}
}
/**
* Recursively resolves an individual property value. If a property indicates
* a child node (a string that matches a component ID), an explicitList of
* children, or a template, these will be built out here.
*/
#resolvePropertyValue(
value: unknown,
surface: Surface,
visited: Set<string>,
dataContextPath: string,
idSuffix = ""
): ResolvedValue {
// 1. If it's a string that matches a component ID, build that node.
if (typeof value === "string" && surface.components.has(value)) {
return this.#buildNodeRecursive(
value,
surface,
visited,
dataContextPath,
idSuffix
);
}
// 2. If it's a ComponentArrayReference (e.g., a `children` property),
// resolve the list and return an array of nodes.
if (isComponentArrayReference(value)) {
if (value.explicitList) {
return value.explicitList.map((id) =>
this.#buildNodeRecursive(
id,
surface,
visited,
dataContextPath,
idSuffix
)
);
}
if (value.template) {
const fullDataPath = this.resolvePath(
value.template.dataBinding,
dataContextPath
);
const data = this.#getDataByPath(surface.dataModel, fullDataPath);
const template = value.template;
// Handle Array data.
if (Array.isArray(data)) {
return data.map((_, index) => {
// Create a synthetic ID based on the template ID and the
// full index path of the data (e.g., template-id:0:1)
const parentIndices = dataContextPath
.split("/")
.filter((segment) => /^\d+$/.test(segment));
const newIndices = [...parentIndices, index];
const newSuffix = `:${newIndices.join(":")}`;
const childDataContextPath = `${fullDataPath}/${index}`;
return this.#buildNodeRecursive(
template.componentId, // baseId
surface,
visited,
childDataContextPath,
newSuffix // new suffix
);
});
}
// Handle Map data.
const mapCtor = this.#mapCtor;
if (data instanceof mapCtor) {
return Array.from(data.keys(), (key) => {
const newSuffix = `:${key}`;
const childDataContextPath = `${fullDataPath}/${key}`;
return this.#buildNodeRecursive(
template.componentId, // baseId
surface,
visited,
childDataContextPath,
newSuffix // new suffix
);
});
}
// Return empty array if the data is not ready yet.
return new this.#arrayCtor();
}
}
// 3. If it's a plain array, resolve each of its items.
if (Array.isArray(value)) {
return value.map((item) =>
this.#resolvePropertyValue(
item,
surface,
visited,
dataContextPath,
idSuffix
)
);
}
// 4. If it's a plain object, resolve each of its properties.
if (isObject(value)) {
const newObj: ResolvedMap = new this.#objCtor() as ResolvedMap;
for (const [key, propValue] of Object.entries(value)) {
// Special case for paths. Here we might get /item/ or ./ on the front
// of the path which isn't what we want. In this case we check the
// dataContextPath and if 1) it's not the default and 2) we also see the
// path beginning with /item/ or ./we trim it.
let propertyValue = propValue;
if (isPath(key, propValue) && dataContextPath !== "/") {
propertyValue = propValue
.replace(/^\.?\/item/, "")
.replace(/^\.?\/text/, "")
.replace(/^\.?\/label/, "")
.replace(/^\.?\//, "");
newObj[key] = propertyValue as ResolvedValue;
continue;
}
newObj[key] = this.#resolvePropertyValue(
propertyValue,
surface,
visited,
dataContextPath,
idSuffix
);
}
return newObj;
}
// 5. Otherwise, it's a primitive value.
return value as ResolvedValue;
}
}

View File

@@ -0,0 +1,31 @@
/*
Copyright 2025 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { A2uiMessageProcessor } from "./model-processor.js";
import { SignalArray } from "signal-utils/array";
import { SignalMap } from "signal-utils/map";
import { SignalObject } from "signal-utils/object";
import { SignalSet } from "signal-utils/set";
export function create() {
return new A2uiMessageProcessor({
arrayCtor: SignalArray as unknown as ArrayConstructor,
mapCtor: SignalMap as unknown as MapConstructor,
objCtor: SignalObject as unknown as ObjectConstructor,
setCtor: SignalSet as unknown as SetConstructor,
});
}