@wix/himalaya-cli 0.960.0 → 0.962.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/cli.mjs +1 -0
- package/package.json +2 -2
- package/templates/beauty-salon-owner/config.ts +1 -1
- package/templates/beauty-salon-owner/dev/index.ts +3075 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/community-club-owner/config.ts +1 -1
- package/templates/community-club-owner/dev/index.ts +3075 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/community-club-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/events-meetups-owner/config.ts +1 -1
- package/templates/events-meetups-owner/dev/index.ts +3075 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/events-meetups-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/gym-fitness-owner/config.ts +1 -1
- package/templates/gym-fitness-owner/dev/index.ts +3075 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/nonprofit-cause-owner/config.ts +1 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +3075 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/owner-lite/config.ts +1 -1
- package/templates/owner-lite/dev/index.ts +3075 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/owner-lite/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/podcast-creator-owner/config.ts +1 -1
- package/templates/podcast-creator-owner/dev/index.ts +3075 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/portfolio-creative-owner/config.ts +1 -1
- package/templates/portfolio-creative-owner/dev/index.ts +3075 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/restaurant-owner/config.ts +1 -1
- package/templates/restaurant-owner/dev/index.ts +3075 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/restaurant-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/retail-store-owner/config.ts +1 -1
- package/templates/retail-store-owner/dev/index.ts +3075 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/retail-store-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/yoga-studio-owner/config.ts +1 -1
- package/templates/yoga-studio-owner/dev/index.ts +3075 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// A widget type that did not exist when this shipped, rendered anyway.
|
|
2
|
+
//
|
|
3
|
+
// The assistant service can ship a new widget kind any day. Left alone, an
|
|
4
|
+
// unrecognised kind degrades to plain prose — safe, but it throws away the
|
|
5
|
+
// options and buttons the payload carried. Eight of the fourteen known kinds
|
|
6
|
+
// share one skeleton (`text` + `options[]` + buttons), so a GENERIC renderer
|
|
7
|
+
// can present a future kind usefully without knowing what it is.
|
|
8
|
+
//
|
|
9
|
+
// This is the one place `WorkerSubtree` is the right tool. The rule elsewhere
|
|
10
|
+
// is that a variant you can enumerate belongs in a `FragmentSlot` case, and
|
|
11
|
+
// every known kind has one. A kind nobody could enumerate when the screen was
|
|
12
|
+
// authored is the exception: a `FragmentSlot` cannot hold a case that did not
|
|
13
|
+
// exist yet.
|
|
14
|
+
//
|
|
15
|
+
// FAIL-CLOSED IS THE WHOLE DESIGN. The host validates the entire tree before
|
|
16
|
+
// decoding it, and ANY violation — an unlisted type, a budget overrun, a bad
|
|
17
|
+
// id, an action outside the allowlist — rejects the whole value and renders
|
|
18
|
+
// the declared fallback. There is no partial render, so this file returns ""
|
|
19
|
+
// for "I could not compose something I am sure of" and lets the prose card
|
|
20
|
+
// take over. Returning a tree that is nearly right is the one outcome worse
|
|
21
|
+
// than returning none.
|
|
22
|
+
//
|
|
23
|
+
// Follows `himi-owner-lite-ask/blocks.ts`, which solved this shape first: the
|
|
24
|
+
// worker decides the layout, copy is sanitized, and nothing off the wire ever
|
|
25
|
+
// becomes STRUCTURE.
|
|
26
|
+
|
|
27
|
+
import type { NormalizedWidget, WidgetOption } from "./aria-widget-model.js";
|
|
28
|
+
|
|
29
|
+
/** Minimal structural type. A worker bundle cannot import the screen-authoring
|
|
30
|
+
* helpers — those run where screens are built, not in the worker — so it
|
|
31
|
+
* hand-rolls its nodes. */
|
|
32
|
+
interface Node {
|
|
33
|
+
id: string;
|
|
34
|
+
body: { package: "himalaya"; type: string; props: Record<string, unknown> };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The safe types this composer emits. A SUBSET of the platform's 19, and it
|
|
38
|
+
* must stay a subset of the descriptor's `allowedTypes` — the two are checked
|
|
39
|
+
* against each other in the tests, because a perfectly good tree is rejected
|
|
40
|
+
* wholesale when they drift. */
|
|
41
|
+
export const GENERIC_TYPES = ["Card", "VStack", "HStack", "Text", "Badge", "Image", "Spacer", "Button", "Divider"] as const;
|
|
42
|
+
|
|
43
|
+
/** What a future kind needing a chart or text entry gets instead.
|
|
44
|
+
*
|
|
45
|
+
* `Chart`, `DonutChart`, `TextField`, `Form`, `LazyList`, `ListItem`,
|
|
46
|
+
* `Timeline`, `Grid` and `SearchField` are ALL absent from the platform-safe
|
|
47
|
+
* list, so such a kind cannot be rendered generically at all. It falls back to
|
|
48
|
+
* prose until it is given a real case — and the copy says so rather than
|
|
49
|
+
* pretending the owner has seen everything. */
|
|
50
|
+
export const GENERIC_UNSUPPORTED_NOTE = "This needs the latest app version to show in full.";
|
|
51
|
+
|
|
52
|
+
/** The slot the Aria widget region mounts the generic card in.
|
|
53
|
+
*
|
|
54
|
+
* Exported because BOTH sides must agree: the descriptor names it as the
|
|
55
|
+
* `WorkerSubtree` node's id, and the worker prefixes every node id with
|
|
56
|
+
* `wsub.<slotId>.`. If the two ever disagree the entire tree is rejected with
|
|
57
|
+
* `id_violation` and every unknown widget silently becomes prose — which
|
|
58
|
+
* looks exactly like the feature working as designed. One constant, so they
|
|
59
|
+
* cannot drift. */
|
|
60
|
+
export const WIDGET_SUBTREE_SLOT = "ariaWidget";
|
|
61
|
+
|
|
62
|
+
/** Longest literal copy rendered from a payload. Same ceiling as `blocks.ts`. */
|
|
63
|
+
const MAX_COPY = 240;
|
|
64
|
+
|
|
65
|
+
/** Sanitize LITERAL copy off the wire.
|
|
66
|
+
*
|
|
67
|
+
* The host validates a worker-built tree's shape, not its prop VALUES, so a
|
|
68
|
+
* structurally perfect payload can still carry machine noise into the card.
|
|
69
|
+
* A model that degenerates mid-answer leaks its own reasoning as one long
|
|
70
|
+
* underscore-joined run, and two cheap signals catch that: absurd length, and
|
|
71
|
+
* long runs with no whitespace. Rejected copy returns "" so the caller DROPS
|
|
72
|
+
* that node rather than rendering it. */
|
|
73
|
+
export function cleanCopy(raw: unknown): string {
|
|
74
|
+
if (typeof raw !== "string") return "";
|
|
75
|
+
const text = raw.trim();
|
|
76
|
+
if (!text || text.length > MAX_COPY) return "";
|
|
77
|
+
let longest = 0;
|
|
78
|
+
for (const word of text.split(/\s+/)) longest = Math.max(longest, word.length);
|
|
79
|
+
if (longest > 40) return "";
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GenericCardOptions {
|
|
84
|
+
/** The `WorkerSubtree` slot. Every id must be prefixed `wsub.<slotId>.` or
|
|
85
|
+
* the whole tree is rejected with `id_violation` — that is the RFC's
|
|
86
|
+
* provenance mechanism, so a worker-authored action cannot be mistaken for
|
|
87
|
+
* an ordinary UI tap. */
|
|
88
|
+
slotId: string;
|
|
89
|
+
/** The worker service name, for `tier5.<service>.<action>` ids. Passed in
|
|
90
|
+
* rather than hardcoded, because more than one app mounts this chat and
|
|
91
|
+
* each names its service differently. */
|
|
92
|
+
service: string;
|
|
93
|
+
/** Whether an option's picture may be loaded. Supplied by the caller so the
|
|
94
|
+
* host check stays in one place — a decorative image cannot carry its own
|
|
95
|
+
* provenance, and a silently-loaded remote asset is a tracking pixel with
|
|
96
|
+
* extra steps. */
|
|
97
|
+
allowPicture?: (url: string) => boolean;
|
|
98
|
+
/** Node ceiling. Defaults below the platform's 200 so the composer refuses
|
|
99
|
+
* before the validator has to. */
|
|
100
|
+
maxNodes?: number;
|
|
101
|
+
/** Byte ceiling, likewise below the platform's 65 536. */
|
|
102
|
+
maxBytes?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const DEFAULT_MAX_NODES = 180;
|
|
106
|
+
const DEFAULT_MAX_BYTES = 60_000;
|
|
107
|
+
|
|
108
|
+
/** Compose a generic card for a widget kind we do not recognise.
|
|
109
|
+
*
|
|
110
|
+
* Returns the tree as a JSON string, or "" when it cannot compose one it is
|
|
111
|
+
* sure of — no options AND no prompt, or a payload that would breach a
|
|
112
|
+
* budget. "" means "render the prose fallback", which is a good outcome, not
|
|
113
|
+
* an error.
|
|
114
|
+
*
|
|
115
|
+
* Budgets are checked against the COMPOSED tree rather than guessed from the
|
|
116
|
+
* option count, and breaching one returns "" rather than dropping rows. A
|
|
117
|
+
* truncated picker is indistinguishable from a complete one, so an owner
|
|
118
|
+
* choosing from it cannot tell that the option they wanted was removed. */
|
|
119
|
+
export function composeGenericCard(w: NormalizedWidget, opts: GenericCardOptions): string {
|
|
120
|
+
const { slotId, service } = opts;
|
|
121
|
+
const maxNodes = opts.maxNodes ?? DEFAULT_MAX_NODES;
|
|
122
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
123
|
+
const allowPicture = opts.allowPicture ?? (() => false);
|
|
124
|
+
|
|
125
|
+
const id = (local: string) => `wsub.${slotId}.${local}`;
|
|
126
|
+
const node = (local: string, type: string, props: Record<string, unknown>): Node =>
|
|
127
|
+
({ id: id(local), body: { package: "himalaya", type, props } });
|
|
128
|
+
// Every action is the SAME worker action with different params. The id is
|
|
129
|
+
// what the policy allowlists, so keeping it to one keeps the allowlist to one
|
|
130
|
+
// entry — the smallest surface a future payload can reach.
|
|
131
|
+
const respond = (value: string) => ({ actionId: `tier5.${service}.widgetRespond`, params: { value } });
|
|
132
|
+
|
|
133
|
+
const prompt = cleanCopy(w.text);
|
|
134
|
+
const badge = cleanCopy(w.badge);
|
|
135
|
+
const bottom = cleanCopy(w.bottomText);
|
|
136
|
+
|
|
137
|
+
const rows: Node[] = [];
|
|
138
|
+
w.options.forEach((o: WidgetOption, i: number) => {
|
|
139
|
+
const title = cleanCopy(o.title) || cleanCopy(o.text);
|
|
140
|
+
// Same alternation the known kinds use: the service writes `subtitle` or
|
|
141
|
+
// `text`, never both, so the second line is whichever survived the title.
|
|
142
|
+
const sub = cleanCopy(o.title) ? (cleanCopy(o.subtitle) || cleanCopy(o.text)) : cleanCopy(o.subtitle);
|
|
143
|
+
if (!title && !sub) return; // an empty row is worse than a missing one
|
|
144
|
+
const value = typeof o.value === "string" ? o.value : "";
|
|
145
|
+
if (!value) return; // nothing to send back; the row would be inert
|
|
146
|
+
|
|
147
|
+
const line: Node[] = [];
|
|
148
|
+
const pic = typeof o.pictureUrl === "string" ? o.pictureUrl : "";
|
|
149
|
+
if (pic && allowPicture(pic)) {
|
|
150
|
+
line.push(node(`pic.${i}`, "Image", {
|
|
151
|
+
url: pic, width: 40, height: 40, shape: "rounded", contentMode: "fill",
|
|
152
|
+
contentDescription: title || sub,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const stack: Node[] = [node(`t.${i}`, "Text", { text: title || sub, style: "body", maxLines: 2 })];
|
|
156
|
+
if (title && sub) stack.push(node(`s.${i}`, "Text", { text: sub, style: "footnote", color: "textSecondary", maxLines: 2 }));
|
|
157
|
+
line.push(node(`rt.${i}`, "VStack", { spacing: 2, children: stack }));
|
|
158
|
+
line.push(node(`sp.${i}`, "Spacer", {}));
|
|
159
|
+
|
|
160
|
+
const label = cleanCopy(o.buttonLabel);
|
|
161
|
+
// A row with no button of its own is still tappable through a button we
|
|
162
|
+
// mint: an option the owner cannot choose is decoration. `Chip` and
|
|
163
|
+
// `SelectionControl` are on the safe list too, but a button reads the same
|
|
164
|
+
// on every one of iOS, Android, macOS, Web and React Native, and this card
|
|
165
|
+
// exists precisely because nobody has designed for this kind yet.
|
|
166
|
+
line.push(node(`pick.${i}`, "Button", {
|
|
167
|
+
title: label || "Choose", style: "secondary", size: "sm", action: respond(value),
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
rows.push(node(`row.${i}`, "HStack", { spacing: 10, children: line }));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Nothing worth showing. The prose card already says what the reply said.
|
|
174
|
+
if (!prompt && rows.length === 0) return "";
|
|
175
|
+
|
|
176
|
+
const body: Node[] = [];
|
|
177
|
+
if (badge) body.push(node("badge", "Badge", { text: badge }));
|
|
178
|
+
if (prompt) body.push(node("prompt", "Text", { text: prompt, style: "body" }));
|
|
179
|
+
if (rows.length) {
|
|
180
|
+
body.push(node("rule", "Divider", {}));
|
|
181
|
+
body.push(...rows);
|
|
182
|
+
}
|
|
183
|
+
if (bottom) body.push(node("bottom", "Text", { text: bottom, style: "footnote", color: "textSecondary", maxLines: 3 }));
|
|
184
|
+
|
|
185
|
+
const footer: Node[] = [];
|
|
186
|
+
const primaryLabel = cleanCopy(w.primary?.label);
|
|
187
|
+
if (primaryLabel) {
|
|
188
|
+
footer.push(node("primary", "Button", { title: primaryLabel, style: "primary", action: respond("primary") }));
|
|
189
|
+
}
|
|
190
|
+
const secondaryLabel = cleanCopy(w.secondary?.label);
|
|
191
|
+
if (secondaryLabel) {
|
|
192
|
+
footer.push(node("secondary", "Button", { title: secondaryLabel, style: "secondary", action: respond("secondary") }));
|
|
193
|
+
}
|
|
194
|
+
if (footer.length) {
|
|
195
|
+
footer.push(node("fsp", "Spacer", {}));
|
|
196
|
+
body.push(node("footer", "HStack", { spacing: 8, children: footer }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Said out loud, because an owner on an older build should know the card is
|
|
200
|
+
// a stand-in rather than the whole thing.
|
|
201
|
+
body.push(node("note", "Text", { text: GENERIC_UNSUPPORTED_NOTE, style: "caption", color: "textSecondary", maxLines: 2 }));
|
|
202
|
+
|
|
203
|
+
const tree = node("card", "Card", {
|
|
204
|
+
children: [node("stack", "VStack", { spacing: 8, children: body })],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (countNodes(tree) > maxNodes) return "";
|
|
208
|
+
const json = JSON.stringify(tree);
|
|
209
|
+
if (utf8Length(json) > maxBytes) return "";
|
|
210
|
+
return json;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** UTF-8 byte length, counted by hand.
|
|
214
|
+
*
|
|
215
|
+
* NOT `new TextEncoder()`: it does not exist on JavaScriptCore, which is the
|
|
216
|
+
* engine this worker runs on for iOS and macOS, and the bundler does not
|
|
217
|
+
* polyfill it — so the reference throws before any work is done. The budget
|
|
218
|
+
* check would have taken the whole card down on the two platforms hardest to
|
|
219
|
+
* notice it on.
|
|
220
|
+
*
|
|
221
|
+
* The host measures the same string with a real encoder, so this has to agree
|
|
222
|
+
* with it exactly rather than approximate: 1 byte below U+0080, 2 below
|
|
223
|
+
* U+0800, 4 for a surrogate PAIR (one astral character, consuming both code
|
|
224
|
+
* units), 3 otherwise. */
|
|
225
|
+
export function utf8Length(s: string): number {
|
|
226
|
+
let bytes = 0;
|
|
227
|
+
for (let i = 0; i < s.length; i++) {
|
|
228
|
+
const code = s.charCodeAt(i);
|
|
229
|
+
if (code < 0x80) bytes += 1;
|
|
230
|
+
else if (code < 0x800) bytes += 2;
|
|
231
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
|
|
232
|
+
const next = s.charCodeAt(i + 1);
|
|
233
|
+
// A well-formed pair is one astral character: four bytes, two units.
|
|
234
|
+
if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; }
|
|
235
|
+
// A LONE high surrogate is not a character. `JSON.stringify` escapes it
|
|
236
|
+
// to `\udXXX`, so it cannot reach here from this composer — but this
|
|
237
|
+
// function is exported, and answering 3 keeps it in step with what an
|
|
238
|
+
// encoder does with the replacement character it would emit.
|
|
239
|
+
else bytes += 3;
|
|
240
|
+
} else bytes += 3;
|
|
241
|
+
}
|
|
242
|
+
return bytes;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every node in the tree, counted the way the validator counts them. */
|
|
246
|
+
function countNodes(n: Node): number {
|
|
247
|
+
let total = 1;
|
|
248
|
+
const children = (n.body.props as { children?: unknown }).children;
|
|
249
|
+
if (Array.isArray(children)) for (const c of children) total += countNodes(c as Node);
|
|
250
|
+
return total;
|
|
251
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Turning a scanned envelope into a `NormalizedWidget`.
|
|
2
|
+
//
|
|
3
|
+
// Eight of the fourteen kinds are the shared skeleton and need no code of their
|
|
4
|
+
// own; the rest each reshape one or two fields. The whole file is pure and
|
|
5
|
+
// total: `normalizeWidget` answers for ANY input, including a payload that is
|
|
6
|
+
// not JSON, a `data` that is an array, and a widget name nobody has shipped yet.
|
|
7
|
+
|
|
8
|
+
import type { WidgetPayload } from "./aria-widgets.js";
|
|
9
|
+
import type { NormalizedWidget, WidgetKind, WidgetOption } from "./aria-widget-model.js";
|
|
10
|
+
import {
|
|
11
|
+
badgeTextOf,
|
|
12
|
+
boolAt,
|
|
13
|
+
buttonAt,
|
|
14
|
+
emptyWidget,
|
|
15
|
+
objectAt,
|
|
16
|
+
objectsAt,
|
|
17
|
+
optionsOf,
|
|
18
|
+
stringAt,
|
|
19
|
+
textAt,
|
|
20
|
+
textOf,
|
|
21
|
+
} from "./aria-widget-model.js";
|
|
22
|
+
|
|
23
|
+
/** Wire name → kind. Thirteen of the fourteen carry a `_WIDGET` suffix and
|
|
24
|
+
* `OAUTH_REQUIRED` does not, so this is a table rather than a string trim — and
|
|
25
|
+
* a table is what we want anyway: an unrecognised name must fall to `UNKNOWN`,
|
|
26
|
+
* and trimming a suffix would silently invent a kind out of whatever the
|
|
27
|
+
* service sent next. */
|
|
28
|
+
//
|
|
29
|
+
// `Object.create(null)` rather than a plain literal: a plain object inherits
|
|
30
|
+
// from `Object.prototype`, so `KIND_BY_NAME["toString"]` is a FUNCTION and
|
|
31
|
+
// `?? "UNKNOWN"` never fires. A service that ships a widget called `toString`,
|
|
32
|
+
// `constructor` or `valueOf` — or an attacker who can influence the name — got
|
|
33
|
+
// back an inherited member as the "kind" instead of the forward-compatible
|
|
34
|
+
// fallback. A null-prototype table has nothing to inherit.
|
|
35
|
+
const KIND_BY_NAME: Record<string, WidgetKind> = Object.assign(Object.create(null) as Record<string, WidgetKind>, {
|
|
36
|
+
SINGLE_SELECT_WIDGET: "SINGLE_SELECT",
|
|
37
|
+
MULTI_SELECT_WIDGET: "MULTI_SELECT",
|
|
38
|
+
SINGLE_SELECT_BUTTON_WIDGET: "SINGLE_SELECT_BUTTON",
|
|
39
|
+
RADIO_BUTTON_WIDGET: "RADIO_BUTTON",
|
|
40
|
+
PLANS_WIDGET: "PLANS",
|
|
41
|
+
PREMIUM_UPSELL_WIDGET: "PREMIUM_UPSELL",
|
|
42
|
+
VISUAL_DISPLAY_WIDGET: "VISUAL_DISPLAY",
|
|
43
|
+
AUTOCOMPLETE_WIDGET: "AUTOCOMPLETE",
|
|
44
|
+
ITEMS_SELECTION_WIDGET: "ITEMS_SELECTION",
|
|
45
|
+
QUESTIONNAIRE_WIDGET: "QUESTIONNAIRE",
|
|
46
|
+
CHART_WIDGET: "CHART",
|
|
47
|
+
TODO_PLAN_WIDGET: "TODO_PLAN",
|
|
48
|
+
ASYNC_TOOL_WIDGET: "ASYNC_TOOL",
|
|
49
|
+
OAUTH_REQUIRED: "OAUTH_REQUIRED",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** The kind for a wire name, or `"UNKNOWN"`. Exported for the descriptor work
|
|
53
|
+
* by the screen builder, which needs the same mapping without re-parsing a
|
|
54
|
+
* payload. */
|
|
55
|
+
export function kindOfName(name: string): WidgetKind {
|
|
56
|
+
return KIND_BY_NAME[name] ?? "UNKNOWN";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The eight-kind shared skeleton: text, bottom text, badge, options, buttons. */
|
|
60
|
+
function skeleton(kind: WidgetKind, d: Record<string, unknown> | null): NormalizedWidget {
|
|
61
|
+
return {
|
|
62
|
+
kind,
|
|
63
|
+
text: stringAt(d, "text"),
|
|
64
|
+
bottomText: stringAt(d, "bottomText"),
|
|
65
|
+
badge: badgeTextOf(d),
|
|
66
|
+
options: optionsOf(d),
|
|
67
|
+
primary: buttonAt(d, "primaryButton"),
|
|
68
|
+
secondary: buttonAt(d, "secondaryButton"),
|
|
69
|
+
extra: {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One `sections[{title,subtitle}]` row of a VISUAL_DISPLAY.
|
|
74
|
+
*
|
|
75
|
+
* Sections are NOT options, and the distinction is load-bearing: the reference
|
|
76
|
+
* catalogue's visual-display entry carries both at once — three `sections`
|
|
77
|
+
* describing the product, and one `options` entry that is a selectable variant
|
|
78
|
+
* card with its own nested sections and button. An earlier draft of this file
|
|
79
|
+
* folded sections into `options` when `options` was empty, which read fine
|
|
80
|
+
* against a simpler fixture and silently DROPPED all three sections against
|
|
81
|
+
* this one. They are different axes, so they get different fields.
|
|
82
|
+
*
|
|
83
|
+
* `textButton` is read but only its label and respondText: the section button
|
|
84
|
+
* is the send-back loop's, and sections render as static rows. */
|
|
85
|
+
interface WidgetSection {
|
|
86
|
+
title: string;
|
|
87
|
+
subtitle: string;
|
|
88
|
+
buttonLabel: string;
|
|
89
|
+
respondText: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sectionsOf(d: Record<string, unknown> | null): WidgetSection[] {
|
|
93
|
+
return objectsAt(d, "sections").map((s) => {
|
|
94
|
+
const b = objectAt(s.textButton);
|
|
95
|
+
return {
|
|
96
|
+
title: stringAt(s, "title"),
|
|
97
|
+
subtitle: stringAt(s, "subtitle"),
|
|
98
|
+
buttonLabel: stringAt(b, "label"),
|
|
99
|
+
respondText: stringAt(b, "respondText"),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One widget, normalized. Never throws.
|
|
105
|
+
*
|
|
106
|
+
* Three ways in, and all three end somewhere renderable:
|
|
107
|
+
*
|
|
108
|
+
* - a known kind with readable `data` → its normalizer;
|
|
109
|
+
* - an unknown NAME → `UNKNOWN` carrying whatever `text` the payload had, so
|
|
110
|
+
* the owner still reads the assistant's sentence;
|
|
111
|
+
* - `data` that is absent, not JSON, or not an object → `UNKNOWN` with empty
|
|
112
|
+
* text. There is nothing to show, and the region collapses rather than
|
|
113
|
+
* painting a blank card. */
|
|
114
|
+
export function normalizeWidget(p: WidgetPayload): NormalizedWidget {
|
|
115
|
+
const kind = kindOfName(p.name);
|
|
116
|
+
|
|
117
|
+
// Read `data` defensively. A payload whose JSON is corrupt is not an error
|
|
118
|
+
// path worth surfacing — the envelope already survived a scanner built to
|
|
119
|
+
// tolerate exactly this, and the screen's job is to keep rendering.
|
|
120
|
+
let d: Record<string, unknown> | null = null;
|
|
121
|
+
if (p.dataJson) {
|
|
122
|
+
try {
|
|
123
|
+
d = objectAt(JSON.parse(p.dataJson));
|
|
124
|
+
} catch {
|
|
125
|
+
d = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// An unreadable payload cannot even yield prose. Report UNKNOWN and let the
|
|
130
|
+
// region collapse — note this also catches a KNOWN name whose data is broken,
|
|
131
|
+
// which is deliberate: a SINGLE_SELECT with no options is not a widget, it is
|
|
132
|
+
// a card with nothing to tap.
|
|
133
|
+
if (!d) return emptyWidget("UNKNOWN");
|
|
134
|
+
|
|
135
|
+
const base = skeleton(kind, d);
|
|
136
|
+
|
|
137
|
+
switch (kind) {
|
|
138
|
+
// The eight that are the skeleton, unchanged.
|
|
139
|
+
case "SINGLE_SELECT":
|
|
140
|
+
case "SINGLE_SELECT_BUTTON":
|
|
141
|
+
case "RADIO_BUTTON":
|
|
142
|
+
return base;
|
|
143
|
+
|
|
144
|
+
case "PLANS":
|
|
145
|
+
// Each plan's benefit list, with its icons. `optionsOf` already joined
|
|
146
|
+
// the labels into `listText` for the templated row; this keeps the
|
|
147
|
+
// structured form for whoever renders the icons.
|
|
148
|
+
return {
|
|
149
|
+
...base,
|
|
150
|
+
extra: {
|
|
151
|
+
lists: objectsAt(d, "options").map((opt) =>
|
|
152
|
+
objectsAt(opt, "list").map((item) => ({
|
|
153
|
+
text: textOf(item),
|
|
154
|
+
icon: stringAt(objectAt(item.icon), "name"),
|
|
155
|
+
iconColor: stringAt(objectAt(item.icon), "color"),
|
|
156
|
+
})),
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
case "AUTOCOMPLETE":
|
|
162
|
+
// `single` vs `multi`. Without it the host cannot tell a one-choice
|
|
163
|
+
// autocomplete from a many-choice one, and picks the wrong submit
|
|
164
|
+
// behaviour for half of them.
|
|
165
|
+
return { ...base, extra: { selectionType: stringAt(d, "selectionType") } };
|
|
166
|
+
|
|
167
|
+
case "MULTI_SELECT":
|
|
168
|
+
// `optionLayout` decides thumbnail vs list rows; `footerButtonsAsColumn`
|
|
169
|
+
// stacks the footer. Both are layout hints the picker reads, so they ride in
|
|
170
|
+
// `extra` rather than widening the shared shape.
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
extra: {
|
|
174
|
+
optionLayout: stringAt(d, "optionLayout"),
|
|
175
|
+
footerButtonsAsColumn: boolAt(d, "footerButtonsAsColumn"),
|
|
176
|
+
// A selection CEILING. The reference catalogue only shows it on
|
|
177
|
+
// ITEMS_SELECTION, but it is a generic multi-select constraint and
|
|
178
|
+
// nothing in the wire format restricts it to one kind — reading it
|
|
179
|
+
// here costs a line and not reading it would silently ignore a limit
|
|
180
|
+
// the service had set. Enforced in the worker: no component carries
|
|
181
|
+
// a selection-count prop, so it cannot be declared.
|
|
182
|
+
maxSelected: numberAt(d, "maxSelected"),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
case "PREMIUM_UPSELL":
|
|
187
|
+
// The only kind whose headline is `title` rather than `text`. Both are
|
|
188
|
+
// kept: `title` leads, `text` is the body, and flattening them would lose
|
|
189
|
+
// the upsell's own heading.
|
|
190
|
+
return { ...base, extra: { title: stringAt(d, "title") } };
|
|
191
|
+
|
|
192
|
+
case "VISUAL_DISPLAY":
|
|
193
|
+
// `sections` and `options` are independent and can both be present — see
|
|
194
|
+
// `sectionsOf`. Options stay options; sections ride in `extra`.
|
|
195
|
+
return {
|
|
196
|
+
...base,
|
|
197
|
+
extra: {
|
|
198
|
+
// Two spellings, both live: the catalogue entry is named "…legacy
|
|
199
|
+
// image", which is the older bare-string `image` beside the newer
|
|
200
|
+
// `{url}` object. Trying the object first means a payload carrying
|
|
201
|
+
// both takes the structured one.
|
|
202
|
+
imageUrl: stringAt(objectAt(d.image), "url") || stringAt(d, "image"),
|
|
203
|
+
sectionsLayout: stringAt(d, "sectionsLayout"),
|
|
204
|
+
sections: sectionsOf(d),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
case "ITEMS_SELECTION":
|
|
209
|
+
// Carries no options of its own — the rows are fetched later from
|
|
210
|
+
// `providerKey`. All this can do is record the handle.
|
|
211
|
+
return {
|
|
212
|
+
...base,
|
|
213
|
+
extra: { providerKey: stringAt(d, "providerKey"), maxSelected: numberAt(d, "maxSelected") },
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
case "QUESTIONNAIRE":
|
|
217
|
+
// A questionnaire with no questions is in the catalogue on purpose. It
|
|
218
|
+
// normalizes to an empty list rather than UNKNOWN: the kind is known and
|
|
219
|
+
// its buttons may still be meaningful, and the picker decides what an empty
|
|
220
|
+
// one looks like.
|
|
221
|
+
return {
|
|
222
|
+
...base,
|
|
223
|
+
extra: {
|
|
224
|
+
// Every label here is `{ text: … }` rather than a bare string — this
|
|
225
|
+
// kind is alone in that, and reading it with the scalar reader gave a
|
|
226
|
+
// questionnaire with the right number of questions and nothing
|
|
227
|
+
// written on any of them.
|
|
228
|
+
questions: objectsAt(d, "questions").map((q) => ({
|
|
229
|
+
type: stringAt(q, "type"),
|
|
230
|
+
required: boolAt(q, "required"),
|
|
231
|
+
title: textAt(q, "title"),
|
|
232
|
+
subtitle: textAt(q, "subtitle"),
|
|
233
|
+
options: optionsOf(q),
|
|
234
|
+
// The "or type your own" affordance, and it is PER QUESTION rather
|
|
235
|
+
// than per widget — only the multi-select one carries it in the
|
|
236
|
+
// catalogue. Its label is nested twice (`placeholder.text`).
|
|
237
|
+
// Dropping it removed the only answer some questions accept.
|
|
238
|
+
freeFormatType: stringAt(objectAt(q.freeFormatOption), "type"),
|
|
239
|
+
freeFormatPlaceholder: textAt(objectAt(q.freeFormatOption), "placeholder"),
|
|
240
|
+
})),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
case "CHART":
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
extra: {
|
|
248
|
+
chartType: stringAt(d, "chartType"),
|
|
249
|
+
description: stringAt(d, "description"),
|
|
250
|
+
slices: objectsAt(d, "pieSlices").map((s) => ({
|
|
251
|
+
label: stringAt(s, "label"),
|
|
252
|
+
value: numberAt(s, "value"),
|
|
253
|
+
})),
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
case "TODO_PLAN":
|
|
258
|
+
return {
|
|
259
|
+
...base,
|
|
260
|
+
extra: {
|
|
261
|
+
title: stringAt(d, "title"),
|
|
262
|
+
items: objectsAt(d, "items").map((i) => ({
|
|
263
|
+
label: stringAt(i, "label"),
|
|
264
|
+
status: stringAt(i, "status"),
|
|
265
|
+
})),
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
case "ASYNC_TOOL":
|
|
270
|
+
// The catalogue ships one WITHOUT a job id. It stays ASYNC_TOOL with an
|
|
271
|
+
// empty id rather than degrading to UNKNOWN: the status messages are the
|
|
272
|
+
// useful part, and they are shown while declining to poll for a job with
|
|
273
|
+
// cannot name.
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
extra: {
|
|
277
|
+
asyncJobId: stringAt(d, "asyncJobId"),
|
|
278
|
+
statusMessages: statusMessagesOf(objectAt(d.statusMessages)),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
case "OAUTH_REQUIRED":
|
|
283
|
+
// Both are URLs, and both stay RAW here. They are disclosed through the
|
|
284
|
+
// existing helper; normalizing is not vetting.
|
|
285
|
+
return {
|
|
286
|
+
...base,
|
|
287
|
+
extra: {
|
|
288
|
+
authorizationUrl: stringAt(d, "authorizationUrl"),
|
|
289
|
+
serverUrl: stringAt(d, "serverUrl"),
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
case "UNKNOWN":
|
|
294
|
+
default:
|
|
295
|
+
// A name we do not know, but a payload we could read.
|
|
296
|
+
//
|
|
297
|
+
// Read as the SHARED SKELETON rather than text alone. Eight of the
|
|
298
|
+
// fourteen known kinds are that skeleton, so it is the best available
|
|
299
|
+
// guess at what an unknown fifteenth carries — and it is only a guess,
|
|
300
|
+
// which is why the generic composer sanitizes every field and drops
|
|
301
|
+
// whatever it cannot use.
|
|
302
|
+
//
|
|
303
|
+
// Reading more is safe because every reader is TOTAL: a payload whose
|
|
304
|
+
// `options` is a string, a number or absent yields an empty array, not
|
|
305
|
+
// a throw. The cost of guessing wrong is a card with fewer rows; the
|
|
306
|
+
// cost of not guessing is the prose-only degradation this replaces.
|
|
307
|
+
return skeleton("UNKNOWN", d);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A finite number, or `0`. `NaN` and the infinities are excluded because they
|
|
312
|
+
* reach a chart axis and a selection limit, and both misbehave quietly there. */
|
|
313
|
+
function numberAt(o: Record<string, unknown> | null, key: string): number {
|
|
314
|
+
if (!o) return 0;
|
|
315
|
+
const v = o[key];
|
|
316
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
317
|
+
if (typeof v === "string") {
|
|
318
|
+
const n = Number(v);
|
|
319
|
+
if (Number.isFinite(n) && v.trim() !== "") return n;
|
|
320
|
+
}
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The four async-tool status lines, each defaulting to `""`. */
|
|
325
|
+
function statusMessagesOf(o: Record<string, unknown> | null): Record<string, string> {
|
|
326
|
+
return {
|
|
327
|
+
initialized: stringAt(o, "initialized"),
|
|
328
|
+
processing: stringAt(o, "processing"),
|
|
329
|
+
finished: stringAt(o, "finished"),
|
|
330
|
+
failed: stringAt(o, "failed"),
|
|
331
|
+
};
|
|
332
|
+
}
|