@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,331 @@
|
|
|
1
|
+
// The typed view model an Aria response widget becomes, once the envelope
|
|
2
|
+
// envelope scanner has lifted it out of the assistant's prose.
|
|
3
|
+
//
|
|
4
|
+
// The load-bearing observation, read off the reference catalogue: EIGHT of the
|
|
5
|
+
// fourteen kinds share one skeleton — `text` + `options[]` + a primary and a
|
|
6
|
+
// secondary button. So this is one normalized model with variations, not
|
|
7
|
+
// fourteen parsers, and the per-kind work in `aria-widget-kinds.ts` stays small.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is PURE. No network, no worker state, no globals — which is
|
|
10
|
+
// what lets the whole catalogue be tested without booting a bundle.
|
|
11
|
+
|
|
12
|
+
/** The fourteen documented kinds, plus the arm that keeps the screen alive when
|
|
13
|
+
* the service ships a fifteenth.
|
|
14
|
+
*
|
|
15
|
+
* `UNKNOWN` is not a failure mode, it is the forward-compatibility contract:
|
|
16
|
+
* the service can add a widget type any day, and an owner must see the
|
|
17
|
+
* assistant's prose rather than a broken screen. The generic card upgrades
|
|
18
|
+
* this arm
|
|
19
|
+
* to a generic card; the contract here is only that it never throws and never
|
|
20
|
+
* blanks. */
|
|
21
|
+
export type WidgetKind =
|
|
22
|
+
| "SINGLE_SELECT"
|
|
23
|
+
| "MULTI_SELECT"
|
|
24
|
+
| "SINGLE_SELECT_BUTTON"
|
|
25
|
+
| "RADIO_BUTTON"
|
|
26
|
+
| "PLANS"
|
|
27
|
+
| "PREMIUM_UPSELL"
|
|
28
|
+
| "VISUAL_DISPLAY"
|
|
29
|
+
| "AUTOCOMPLETE"
|
|
30
|
+
| "ITEMS_SELECTION"
|
|
31
|
+
| "QUESTIONNAIRE"
|
|
32
|
+
| "CHART"
|
|
33
|
+
| "TODO_PLAN"
|
|
34
|
+
| "ASYNC_TOOL"
|
|
35
|
+
| "OAUTH_REQUIRED"
|
|
36
|
+
| "UNKNOWN";
|
|
37
|
+
|
|
38
|
+
/** Every kind above, in declaration order. Exported because two other things
|
|
39
|
+
* must agree with this union exactly and cannot read a TypeScript type at
|
|
40
|
+
* runtime: the `FragmentSlot` case ids in the descriptor, and the
|
|
41
|
+
* test that pins them to it. */
|
|
42
|
+
export const WIDGET_KINDS: readonly WidgetKind[] = [
|
|
43
|
+
"SINGLE_SELECT",
|
|
44
|
+
"MULTI_SELECT",
|
|
45
|
+
"SINGLE_SELECT_BUTTON",
|
|
46
|
+
"RADIO_BUTTON",
|
|
47
|
+
"PLANS",
|
|
48
|
+
"PREMIUM_UPSELL",
|
|
49
|
+
"VISUAL_DISPLAY",
|
|
50
|
+
"AUTOCOMPLETE",
|
|
51
|
+
"ITEMS_SELECTION",
|
|
52
|
+
"QUESTIONNAIRE",
|
|
53
|
+
"CHART",
|
|
54
|
+
"TODO_PLAN",
|
|
55
|
+
"ASYNC_TOOL",
|
|
56
|
+
"OAUTH_REQUIRED",
|
|
57
|
+
"UNKNOWN",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** One selectable row.
|
|
61
|
+
*
|
|
62
|
+
* Every field is present and scalar, always — never optional, never null. That
|
|
63
|
+
* is not tidiness, it is a hard requirement of the renderer: `$item.`
|
|
64
|
+
* substitutes WHOLE prop values only, a missing field fails substitution for
|
|
65
|
+
* the entire row, and a non-scalar stringifies to `""`. The existing transcript
|
|
66
|
+
* bubbles already carry fixed `link1..3Label/Url/Hidden` slots for exactly this
|
|
67
|
+
* reason (`aria-chat.ts`). A row that omits `subtitle` renders nothing at all
|
|
68
|
+
* rather than rendering without a subtitle. */
|
|
69
|
+
export interface WidgetOption {
|
|
70
|
+
title: string;
|
|
71
|
+
subtitle: string;
|
|
72
|
+
text: string;
|
|
73
|
+
value: string;
|
|
74
|
+
pictureUrl: string;
|
|
75
|
+
buttonLabel: string;
|
|
76
|
+
/** The per-option button's `respondText`, before selection substitution. */
|
|
77
|
+
respondText: string;
|
|
78
|
+
preselected: boolean;
|
|
79
|
+
/** The option button's own destination, when it has one instead of a
|
|
80
|
+
* `respondText`. `mobileAction.link` / `.externalLink`. Raw — disclosure is
|
|
81
|
+
* the caller's job, through the one existing helper. */
|
|
82
|
+
actionUrl: string;
|
|
83
|
+
/** `mobileAction.screen.id` — an in-app destination rather than a url. */
|
|
84
|
+
actionScreenId: string;
|
|
85
|
+
/** What to say when `actionScreenId` names a screen this app does not have.
|
|
86
|
+
* The catalogue ships exactly that case, and dropping it left an option that
|
|
87
|
+
* navigates nowhere and explains nothing. */
|
|
88
|
+
notSupportedText: string;
|
|
89
|
+
/** PLANS writes each plan's benefits as `list: [{text, icon}]`. Joined to one
|
|
90
|
+
* scalar because an option row is `$item.`-templated and a nested array
|
|
91
|
+
* substitutes as `""`; the structured form rides in `extra` for the kinds
|
|
92
|
+
* that want icons. Empty for every other kind. */
|
|
93
|
+
listText: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A footer button. Same all-fields-present rule as `WidgetOption`. */
|
|
97
|
+
export interface WidgetButton {
|
|
98
|
+
label: string;
|
|
99
|
+
/** What to send back when tapped, before `<SELECTED>` substitution. */
|
|
100
|
+
respondText: string;
|
|
101
|
+
/** `primaryButton.disableIfNothingSelected` — the multi-select gate. */
|
|
102
|
+
disableIfEmpty: boolean;
|
|
103
|
+
/** `mobileAction.externalLink` / `mobileAction.link`. Raw and UNCHECKED here:
|
|
104
|
+
* disclosure is the view's job and reuses the existing `hostOf`/`isWixHost`/
|
|
105
|
+
* `disclosedLabel` machinery in `aria-chat.ts`, which already survives the
|
|
106
|
+
* two defeats that check was hardened against. Normalizing is not vetting,
|
|
107
|
+
* and this
|
|
108
|
+
* model must not grow a second URL check. */
|
|
109
|
+
externalUrl: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The normalized widget. One shape for all fifteen arms.
|
|
113
|
+
*
|
|
114
|
+
* `extra` carries the per-kind fields that do not fit the shared skeleton —
|
|
115
|
+
* chart slices, todo items, questionnaire questions, the async job id. Kept as
|
|
116
|
+
* an untyped bag on purpose: the kinds that need it each have their own issue
|
|
117
|
+
* and will type their own slice of it there, rather than this
|
|
118
|
+
* file growing fourteen optional members that fourteen renderers must ignore. */
|
|
119
|
+
export interface NormalizedWidget {
|
|
120
|
+
kind: WidgetKind;
|
|
121
|
+
text: string;
|
|
122
|
+
bottomText: string;
|
|
123
|
+
badge: string;
|
|
124
|
+
options: WidgetOption[];
|
|
125
|
+
primary: WidgetButton | null;
|
|
126
|
+
secondary: WidgetButton | null;
|
|
127
|
+
extra: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Reading an untrusted payload
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
//
|
|
134
|
+
// Widget payloads are model-influenced content off the wire. Every reader below
|
|
135
|
+
// answers with a usable value for ANY input — including `null`, an array where
|
|
136
|
+
// an object was expected, and a number where a string was. Nothing here throws,
|
|
137
|
+
// because the one thing worse than a widget that renders wrong is a chat screen
|
|
138
|
+
// that stops rendering.
|
|
139
|
+
|
|
140
|
+
/** May this url be handed to the system opener?
|
|
141
|
+
*
|
|
142
|
+
* `http(s)` only. A widget button's destination is MODEL OUTPUT and is
|
|
143
|
+
* reachable by prompt injection through content this owner does not control
|
|
144
|
+
* — a review, an enquiry, a product description. The transcript's own links
|
|
145
|
+
* are already restricted to `http(s)` by the extractor that finds them, but
|
|
146
|
+
* a widget url arrives straight off the payload, so before this it reached
|
|
147
|
+
* the opener after nothing but a `trim()`.
|
|
148
|
+
*
|
|
149
|
+
* That matters more than it looks: the opener takes ANY scheme, so a payload
|
|
150
|
+
* could name `tel:`, `sms:`, a `wix://` deep link, or a scheme some other
|
|
151
|
+
* app on the device registered. Disclosure changes the LABEL beside the
|
|
152
|
+
* button; it does not stop the navigation.
|
|
153
|
+
*
|
|
154
|
+
* Exported so it can be tested directly: through the worker the guard is
|
|
155
|
+
* INVISIBLE — a refused url and a url whose opener throws both leave the
|
|
156
|
+
* state untouched, so a test driving the action cannot tell them apart.
|
|
157
|
+
*
|
|
158
|
+
* Anchored and case-insensitive: `HTTPS:` is the same scheme, and
|
|
159
|
+
* `javascript:void(0)//https://x` must not pass on a substring. */
|
|
160
|
+
export function isWebUrl(url: string): boolean {
|
|
161
|
+
return /^https?:\/\//i.test(url.trim());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A plain object, or `null` for anything else — arrays included. `typeof []`
|
|
165
|
+
* is `"object"`, so the array test is doing real work here. */
|
|
166
|
+
export function objectAt(v: unknown): Record<string, unknown> | null {
|
|
167
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A display string, whether the wire wrote it bare or wrapped.
|
|
171
|
+
*
|
|
172
|
+
* QUESTIONNAIRE writes every label as `{ text: "…" }` — question titles,
|
|
173
|
+
* subtitles, each option's title and subtitle. Every other kind writes them
|
|
174
|
+
* bare. Reading only the bare form turned a whole questionnaire into a shape
|
|
175
|
+
* with the right question COUNT and no prompts or choices in it: a renderer
|
|
176
|
+
* would have shown an empty form, and the count assertion would still pass.
|
|
177
|
+
*
|
|
178
|
+
* Accepts either, at any depth of one. */
|
|
179
|
+
export function textOf(v: unknown): string {
|
|
180
|
+
if (typeof v === "string") return v;
|
|
181
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
182
|
+
const o = objectAt(v);
|
|
183
|
+
return o && typeof o.text === "string" ? o.text : "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `textOf` for a key on a container. */
|
|
187
|
+
export function textAt(o: Record<string, unknown> | null, key: string): string {
|
|
188
|
+
return o ? textOf(o[key]) : "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A string, or `""`. Numbers and booleans are stringified — the service does
|
|
192
|
+
* send a bare number for a value now and then, and dropping it would lose the
|
|
193
|
+
* option's identity rather than merely its label. */
|
|
194
|
+
export function stringAt(o: Record<string, unknown> | null, key: string): string {
|
|
195
|
+
if (!o) return "";
|
|
196
|
+
const v = o[key];
|
|
197
|
+
if (typeof v === "string") return v;
|
|
198
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A boolean, or `false`. Only a real `true` counts: a truthy string would make
|
|
203
|
+
* `"false"` mean true, which is the wrong way for a preselection to fail. */
|
|
204
|
+
export function boolAt(o: Record<string, unknown> | null, key: string): boolean {
|
|
205
|
+
return !!o && o[key] === true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** An array of plain objects, or `[]`. Non-object entries are dropped rather
|
|
209
|
+
* than coerced: a string in an options array has no value to select by, and a
|
|
210
|
+
* row that selects nothing is worse than a row that is not there. */
|
|
211
|
+
export function objectsAt(o: Record<string, unknown> | null, key: string): Record<string, unknown>[] {
|
|
212
|
+
if (!o) return [];
|
|
213
|
+
const v = o[key];
|
|
214
|
+
if (!Array.isArray(v)) return [];
|
|
215
|
+
const out: Record<string, unknown>[] = [];
|
|
216
|
+
for (const e of v) {
|
|
217
|
+
const obj = objectAt(e);
|
|
218
|
+
if (obj) out.push(obj);
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A badge's display text. The service sends `{text, type, skin}`; only the
|
|
224
|
+
* text reaches the model, because `type` and `skin` are Wix-design-system
|
|
225
|
+
* tokens with no counterpart in Himi's vocabulary. */
|
|
226
|
+
export function badgeTextOf(o: Record<string, unknown> | null): string {
|
|
227
|
+
return stringAt(objectAt(o?.badge), "text");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One button, or `null` when the key is absent.
|
|
231
|
+
*
|
|
232
|
+
* `null` rather than an empty button is the whole point: the view hides the
|
|
233
|
+
* footer
|
|
234
|
+
* row on `null`, and an all-empty button would paint a tappable control with no
|
|
235
|
+
* label that sends an empty string. */
|
|
236
|
+
export function buttonAt(o: Record<string, unknown> | null, key: string): WidgetButton | null {
|
|
237
|
+
const b = objectAt(o?.[key]);
|
|
238
|
+
if (!b) return null;
|
|
239
|
+
const action = objectAt(b.mobileAction);
|
|
240
|
+
return {
|
|
241
|
+
label: stringAt(b, "label"),
|
|
242
|
+
respondText: stringAt(b, "respondText"),
|
|
243
|
+
disableIfEmpty: boolAt(b, "disableIfNothingSelected"),
|
|
244
|
+
// Two spellings for one concept, and both are live in the catalogue:
|
|
245
|
+
// PREMIUM_UPSELL sends `externalLink`, CHART sends `link`.
|
|
246
|
+
externalUrl: stringAt(action, "externalLink") || stringAt(action, "link"),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The shared option skeleton — the eight-kind commonality this model is built
|
|
251
|
+
* around. Per-kind normalizers reshape what comes out; none of them re-reads
|
|
252
|
+
* the wire format. */
|
|
253
|
+
export function optionsOf(o: Record<string, unknown> | null): WidgetOption[] {
|
|
254
|
+
return objectsAt(o, "options").map((opt) => {
|
|
255
|
+
// `button` is the usual spelling; VISUAL_DISPLAY's variant card uses
|
|
256
|
+
// `primaryButton`, the same name the widget-level footer uses.
|
|
257
|
+
const button = objectAt(opt.button) ?? objectAt(opt.primaryButton);
|
|
258
|
+
const action = objectAt(button?.mobileAction);
|
|
259
|
+
// A variant card carries its label under `sections`, not as a flat title.
|
|
260
|
+
const sections = objectsAt(opt, "sections");
|
|
261
|
+
const first = sections[0] ?? null;
|
|
262
|
+
return {
|
|
263
|
+
title: textAt(opt, "title") || textAt(first, "title"),
|
|
264
|
+
subtitle: textAt(opt, "subtitle") || textAt(first, "subtitle"),
|
|
265
|
+
text: textAt(opt, "text"),
|
|
266
|
+
value: stringAt(opt, "value"),
|
|
267
|
+
pictureUrl: stringAt(objectAt(opt.picture), "url"),
|
|
268
|
+
buttonLabel: stringAt(button, "label"),
|
|
269
|
+
respondText: stringAt(button, "respondText"),
|
|
270
|
+
preselected: boolAt(opt, "isPreselected"),
|
|
271
|
+
actionUrl: stringAt(action, "externalLink") || stringAt(action, "link"),
|
|
272
|
+
actionScreenId: stringAt(objectAt(action?.screen), "id"),
|
|
273
|
+
notSupportedText: textAt(objectAt(action?.notSupported), "description"),
|
|
274
|
+
listText: objectsAt(opt, "list").map((x) => textOf(x)).filter(Boolean).join("\n"),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An empty widget of the given kind. The base every normalizer starts from, and
|
|
280
|
+
* the answer for a payload that could not be read at all. */
|
|
281
|
+
export function emptyWidget(kind: WidgetKind, text = ""): NormalizedWidget {
|
|
282
|
+
return { kind, text, bottomText: "", badge: "", options: [], primary: null, secondary: null, extra: {} };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// `<SELECTED>` substitution
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** The tokens the service writes into a `respondText` where the owner's choice
|
|
290
|
+
* belongs: `"Use <SELECTED>"` becomes `"Use Sunny Deals"`.
|
|
291
|
+
*
|
|
292
|
+
* TWO spellings, both live in the reference catalogue — `<SELECTED>` five
|
|
293
|
+
* times, `<VALUES>` three (RADIO_BUTTON's primary button, and each PLANS
|
|
294
|
+
* option's). Handling only the first sent the owner's own words back with
|
|
295
|
+
* `<VALUES>` still in them, which is the failure mode this substitution
|
|
296
|
+
* exists to prevent, just wearing the other name. */
|
|
297
|
+
export const SELECTED_TOKEN = "<SELECTED>";
|
|
298
|
+
export const VALUES_TOKEN = "<VALUES>";
|
|
299
|
+
/** Matches either. A regex for `split` only — never for `replace`, whose `$`
|
|
300
|
+
* semantics are the reason this is a split in the first place. */
|
|
301
|
+
const SELECTION_TOKENS = /<SELECTED>|<VALUES>/g;
|
|
302
|
+
|
|
303
|
+
/** Put the selection into a `respondText`.
|
|
304
|
+
*
|
|
305
|
+
* Lives here, as a pure string function, rather than in the worker — so the
|
|
306
|
+
* cases that actually bite can be tested exhaustively without booting a bundle.
|
|
307
|
+
*
|
|
308
|
+
* Three decisions worth stating, because each is a case the naive version gets
|
|
309
|
+
* wrong:
|
|
310
|
+
*
|
|
311
|
+
* - **`split`/`join`, not `replaceAll`.** The replacement is a selected VALUE:
|
|
312
|
+
* model-influenced text off the wire. `String.replaceAll` reads `$` in its
|
|
313
|
+
* replacement as a SUBSTITUTION PATTERN, so a value containing `$&` splices
|
|
314
|
+
* the matched `<SELECTED>` back in, `` $` `` splices the text before it, and
|
|
315
|
+
* `$$` collapses to one `$`. A price like `"$5 $& up"` would come back
|
|
316
|
+
* mangled. `split`/`join` has no such syntax and inserts the value literally.
|
|
317
|
+
* (It is NOT about re-scanning: `replaceAll` with a string replacement does
|
|
318
|
+
* not re-read its own output, so a value merely containing the literal
|
|
319
|
+
* `<SELECTED>` is safe either way. An earlier draft of this comment claimed
|
|
320
|
+
* otherwise and the mutation test proved it wrong.)
|
|
321
|
+
* - **No token means no substitution.** The service sends plenty of
|
|
322
|
+
* `respondText` values with no placeholder at all; they are sent verbatim.
|
|
323
|
+
* - **An empty selection collapses the token** rather than leaving it visible.
|
|
324
|
+
* Sending the owner's own words back with `<SELECTED>` still in them is the
|
|
325
|
+
* one outcome that reads as a bug to the person who sees it. */
|
|
326
|
+
export function substituteSelection(respondText: string, selected: string[]): string {
|
|
327
|
+
if (!respondText) return "";
|
|
328
|
+
const parts = respondText.split(SELECTION_TOKENS);
|
|
329
|
+
if (parts.length === 1) return respondText;
|
|
330
|
+
return parts.join(selected.join(", "));
|
|
331
|
+
}
|