@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610060208
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/placements/page.js +34 -0
- package/dist/render/capabilities.js +63 -0
- package/dist/render/graphics.js +203 -0
- package/dist/render/image.js +362 -0
- package/dist/render/layout.js +544 -0
- package/dist/render/page-model.js +610 -0
- package/dist/render/paint.js +625 -0
- package/dist/render/style.js +413 -0
- package/dist/render/surface.js +377 -0
- package/dist/render/text.js +145 -0
- package/dist/screens/LaunchScreen.js +85 -14
- package/dist/screens/PageRender.js +273 -0
- package/dist/screens/Shell.js +24 -5
- package/dist/screens/useTerminalImages.js +129 -0
- package/dist/utils/terminal.js +17 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One page of a paywall template → the model a realistic render draws from.
|
|
3
|
+
*
|
|
4
|
+
* This is the counterpart of `src/render/composite.ts`, not a replacement for
|
|
5
|
+
* it. The composite is a **developer's outline**: every authored node in source
|
|
6
|
+
* order, including the scaffolding and the hidden subtrees, because that is
|
|
7
|
+
* what you need when a paywall renders wrong. This model is **what the paywall
|
|
8
|
+
* shows**: the scaffolding folds into layout, a button is one node with its
|
|
9
|
+
* label on it, a condition has already picked its branch, a product container
|
|
10
|
+
* has already become cards, and a hidden subtree is gone. The two views differ
|
|
11
|
+
* on purpose and the screen keeps both (`t` toggles them).
|
|
12
|
+
*
|
|
13
|
+
* Five rules it exists to enforce, each of which was a defect in what NAM-4542
|
|
14
|
+
* shipped — measured on the real staging paywall `penguin`, where nine of
|
|
15
|
+
* thirty printed lines were scaffolding:
|
|
16
|
+
*
|
|
17
|
+
* 1. `container`, `spacer` and `condition` never print their own name.
|
|
18
|
+
* 2. A `button`'s label lifts out of its child `text` node, so `button` +
|
|
19
|
+
* `text Close` on two lines becomes one button labelled `Close`.
|
|
20
|
+
* 3. A `condition` resolves to its live branch instead of rendering as a node.
|
|
21
|
+
* 4. A `productContainer` becomes one `product` card per sku row.
|
|
22
|
+
* 5. A `hidden: true` subtree is dropped entirely.
|
|
23
|
+
*
|
|
24
|
+
* **Styling is carried through raw.** Every value on a node's `style` is the
|
|
25
|
+
* authored string or number exactly as the template holds it —
|
|
26
|
+
* `"linear-gradient(340deg, rgba(0 79 255 / 1) 0%, …)"`, `"100%"`,
|
|
27
|
+
* `"fitContent"`, `16`. Parsing them is `src/render/style.ts`'s job and
|
|
28
|
+
* applying them is the screen's; doing any of it twice is how the two drift.
|
|
29
|
+
*
|
|
30
|
+
* Text is the same split: this module decides *which* strings are on the page
|
|
31
|
+
* and in *what* context (which sku a card is for), and `src/render/text.ts`
|
|
32
|
+
* turns a string into `Segment`s. Its `resolve` hook is where the two meet —
|
|
33
|
+
* page state and launch context resolve here, `${sku.*}` resolves there,
|
|
34
|
+
* through core's own value map.
|
|
35
|
+
*/
|
|
36
|
+
import { renderable } from "./text.js";
|
|
37
|
+
/** Page slots, in source order, paired with the section they become. */
|
|
38
|
+
const SECTIONS = [
|
|
39
|
+
{ name: "header", key: "header" },
|
|
40
|
+
{ name: "content", key: "contentContainer" },
|
|
41
|
+
{ name: "footer", key: "footer" },
|
|
42
|
+
{ name: "background", key: "backgroundContainer" },
|
|
43
|
+
];
|
|
44
|
+
/** Child arrays, in the order the SDKs render them. */
|
|
45
|
+
const CHILD_LIST_KEYS = [
|
|
46
|
+
"components",
|
|
47
|
+
"pausedComponents",
|
|
48
|
+
"playingComponents",
|
|
49
|
+
"mutedComponents",
|
|
50
|
+
"volumeComponents",
|
|
51
|
+
];
|
|
52
|
+
/** Single-child slots. */
|
|
53
|
+
const CHILD_NODE_KEYS = ["collapseHeader", "groupHeaderTemplate"];
|
|
54
|
+
/** Components that are one pressable unit with a lifted label. */
|
|
55
|
+
const BUTTONS = new Set(["button", "playPauseButton", "volumeButton", "toggleButton"]);
|
|
56
|
+
/** Leaves with no children and nothing to print — drawn from their style alone. */
|
|
57
|
+
const STYLE_ONLY = new Set(["radio", "toggleSwitch", "progressIndicator", "progressBar", "qrCode", "textInput"]);
|
|
58
|
+
/** How many passes a nested token (`${a.${b}}`) may take before we give up. */
|
|
59
|
+
const MAX_TOKEN_PASSES = 5;
|
|
60
|
+
/** A string still carrying an unresolved token. */
|
|
61
|
+
const UNRESOLVED = /\$\{[^}]*\}/;
|
|
62
|
+
/**
|
|
63
|
+
* Build the render model for one page.
|
|
64
|
+
*
|
|
65
|
+
* `opts.state` is the live paywall state: it is merged over the template's
|
|
66
|
+
* `initialState`, so `${launch.customAttributes.intent}` and
|
|
67
|
+
* `${state.currentGroupId}` resolve the way they do on a device. Leave it out
|
|
68
|
+
* and the page renders exactly as the template's own initial state says.
|
|
69
|
+
*/
|
|
70
|
+
export function pageModel(paywall, pageName, opts) {
|
|
71
|
+
const page = pagesOf(paywall).find((candidate) => candidate.name === pageName);
|
|
72
|
+
if (!page)
|
|
73
|
+
return { pageName, root: { kind: "box", style: {}, children: [] }, unresolved: true };
|
|
74
|
+
const ctx = rootContext(paywall, opts?.state);
|
|
75
|
+
const children = [];
|
|
76
|
+
for (const { name, key } of SECTIONS) {
|
|
77
|
+
const rendered = rootsOf(page[key]).flatMap((root) => render(root, ctx));
|
|
78
|
+
if (rendered.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
children.push(sectionNode(name, rendered));
|
|
81
|
+
}
|
|
82
|
+
return { pageName: text(page.name) ?? pageName, root: { kind: "box", style: {}, children } };
|
|
83
|
+
}
|
|
84
|
+
/** One slot's nodes under a box that names the slot. */
|
|
85
|
+
function sectionNode(section, children) {
|
|
86
|
+
const only = children.length === 1 ? children[0] : undefined;
|
|
87
|
+
if (only && only.kind === "box" && only.section === undefined)
|
|
88
|
+
return { ...only, section };
|
|
89
|
+
return { kind: "box", style: {}, children, section };
|
|
90
|
+
}
|
|
91
|
+
/** The lookup scope and the selected sku the page starts from. */
|
|
92
|
+
function rootContext(paywall, live) {
|
|
93
|
+
const initial = asNode(paywall?.template?.initialState) ?? {};
|
|
94
|
+
const scope = { ...initial, ...(asNode(live) ?? {}) };
|
|
95
|
+
scope.state = scope;
|
|
96
|
+
const groupId = text(scope.currentGroupId);
|
|
97
|
+
const menus = menusOf(paywall);
|
|
98
|
+
const menu = (groupId ? menus.find((entry) => entry.id === groupId || entry.ref === groupId) : undefined) ?? menus[0];
|
|
99
|
+
const selectedProducts = asNode(scope.selectedProducts) ?? {};
|
|
100
|
+
const selectedId = groupId ? text(selectedProducts[groupId]) : undefined;
|
|
101
|
+
const skus = menu?.products ?? [];
|
|
102
|
+
const sku = skus.find((candidate) => candidate.id === selectedId);
|
|
103
|
+
return { scope, sku, skus, menus };
|
|
104
|
+
}
|
|
105
|
+
function menusOf(paywall) {
|
|
106
|
+
const menus = Array.isArray(paywall?.sku_menus) ? paywall.sku_menus : [];
|
|
107
|
+
return menus.map((menu) => ({
|
|
108
|
+
id: text(menu.id),
|
|
109
|
+
ref: text(menu.ref),
|
|
110
|
+
products: Array.isArray(menu.products) ? menu.products : [],
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One authored node → the nodes it renders as: none when it is hidden or its
|
|
115
|
+
* condition is dead, several when it is a condition or a product container.
|
|
116
|
+
*/
|
|
117
|
+
function render(raw, ctx) {
|
|
118
|
+
if (raw.hidden === true)
|
|
119
|
+
return [];
|
|
120
|
+
const node = withOverrides(raw, ctx);
|
|
121
|
+
const component = text(node.component) ?? "";
|
|
122
|
+
switch (component) {
|
|
123
|
+
case "condition":
|
|
124
|
+
return conditionHolds(node, ctx) ? children(node, ctx) : [];
|
|
125
|
+
case "spacer":
|
|
126
|
+
return [{ kind: "spacer", size: sizeOf(node) }];
|
|
127
|
+
case "text":
|
|
128
|
+
case "countdownTimerText":
|
|
129
|
+
return [textNode(text(node.text) ?? "", node, ctx)];
|
|
130
|
+
case "text-list":
|
|
131
|
+
return [{ kind: "box", style: boxStyle(node), children: listItems(node, ctx) }];
|
|
132
|
+
case "symbol":
|
|
133
|
+
return [textNode(symbolLabel(node), node, ctx)];
|
|
134
|
+
case "image":
|
|
135
|
+
case "svgImage":
|
|
136
|
+
return [imageNode(text(node.url) ?? "", node, ctx)];
|
|
137
|
+
case "videoUrl":
|
|
138
|
+
return [videoNode(node, ctx)];
|
|
139
|
+
case "productContainer":
|
|
140
|
+
case "flexProductContainer":
|
|
141
|
+
return [{ kind: "box", style: boxStyle(node), children: productCards(node, ctx) }];
|
|
142
|
+
default:
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (BUTTONS.has(component) && !ctx.inProductCard)
|
|
146
|
+
return [buttonNode(node, ctx)];
|
|
147
|
+
if (STYLE_ONLY.has(component))
|
|
148
|
+
return [{ kind: "box", style: boxStyle(node), children: [] }];
|
|
149
|
+
// Everything else is a container of some kind — `container`, `stack`,
|
|
150
|
+
// `carouselContainer`, `responsiveGrid`, `repeatingGrid`, `collapseContainer`,
|
|
151
|
+
// a segment picker, or a button inside a product card. It shapes layout and
|
|
152
|
+
// never prints its own name. A repeating list's rows are not expanded: the
|
|
153
|
+
// CLI has no block data to loop over, so its template renders once.
|
|
154
|
+
const box = { kind: "box", style: boxStyle(node), children: children(node, ctx) };
|
|
155
|
+
const onTap = userAction(node.onTap);
|
|
156
|
+
if (onTap)
|
|
157
|
+
return [{ ...box, onTap, focusable: true }];
|
|
158
|
+
return [box];
|
|
159
|
+
}
|
|
160
|
+
function children(node, ctx) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const key of CHILD_LIST_KEYS) {
|
|
163
|
+
for (const child of rootsOf(node[key]))
|
|
164
|
+
out.push(...render(child, ctx));
|
|
165
|
+
}
|
|
166
|
+
for (const key of CHILD_NODE_KEYS) {
|
|
167
|
+
const child = asNode(node[key]);
|
|
168
|
+
if (child)
|
|
169
|
+
out.push(...render(child, ctx));
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function textNode(source, node, ctx) {
|
|
174
|
+
return { kind: "text", segments: segments(source, ctx), style: textStyle(node) };
|
|
175
|
+
}
|
|
176
|
+
function listItems(node, ctx) {
|
|
177
|
+
const texts = Array.isArray(node.texts) ? node.texts : [];
|
|
178
|
+
return texts
|
|
179
|
+
.map(text)
|
|
180
|
+
.filter((item) => item !== undefined)
|
|
181
|
+
.map((item) => textNode(item, node, ctx));
|
|
182
|
+
}
|
|
183
|
+
function imageNode(url, node, ctx, alt) {
|
|
184
|
+
const image = {
|
|
185
|
+
kind: "image",
|
|
186
|
+
url: resolveText(url, ctx),
|
|
187
|
+
style: boxStyle(node),
|
|
188
|
+
};
|
|
189
|
+
const cropping = text(node.imageCropping);
|
|
190
|
+
if (cropping)
|
|
191
|
+
image.cropping = cropping;
|
|
192
|
+
const label = alt ?? text(node.alt);
|
|
193
|
+
if (label)
|
|
194
|
+
image.alt = label;
|
|
195
|
+
return image;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A video renders as its authored fallback image — the terminal has no video —
|
|
199
|
+
* and says so in the alt text. With no fallback there is nothing to draw, so it
|
|
200
|
+
* names the media instead, the way the outline does.
|
|
201
|
+
*/
|
|
202
|
+
function videoNode(node, ctx) {
|
|
203
|
+
const fallback = text(node.fallbackImage);
|
|
204
|
+
const name = mediaName(text(node.url));
|
|
205
|
+
if (fallback)
|
|
206
|
+
return imageNode(fallback, node, ctx, `video: ${name}`);
|
|
207
|
+
return { kind: "text", segments: [{ text: `[video: ${name}]` }], style: textStyle(node) };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A button is one node. Its label lifts out of the `text` descendants the
|
|
211
|
+
* template puts inside it — NAM-4542's walker confirmed labels live there and
|
|
212
|
+
* never on the button itself. A button with no text at all (a close button
|
|
213
|
+
* whose only child is a `symbol`) falls back to its screenreader label and then
|
|
214
|
+
* to the symbol, so it is never blank.
|
|
215
|
+
*/
|
|
216
|
+
function buttonNode(node, ctx) {
|
|
217
|
+
const inner = children(node, { ...ctx, inProductCard: false });
|
|
218
|
+
const label = joinLines(textLines(inner));
|
|
219
|
+
const spoken = text(node.screenreaderText);
|
|
220
|
+
const button = {
|
|
221
|
+
kind: "button",
|
|
222
|
+
label: label.length > 0 ? label : spoken ? segments(spoken, ctx) : [],
|
|
223
|
+
style: boxStyle(node),
|
|
224
|
+
focusable: true,
|
|
225
|
+
};
|
|
226
|
+
const onTap = userAction(node.onTap);
|
|
227
|
+
if (onTap)
|
|
228
|
+
button.onTap = onTap;
|
|
229
|
+
return button;
|
|
230
|
+
}
|
|
231
|
+
/** `[icon: xmark]` — the outline's own way of naming a symbol, kept in step. */
|
|
232
|
+
function symbolLabel(node) {
|
|
233
|
+
return `[icon: ${text(node.name) ?? "symbol"}]`;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* A product container becomes one card per sku in its menu, each rendered with
|
|
237
|
+
* that sku in context — which is what makes a `${sku.featured}` condition pick
|
|
238
|
+
* a different branch on the featured card than on its siblings.
|
|
239
|
+
*/
|
|
240
|
+
function productCards(node, ctx) {
|
|
241
|
+
const menu = skusFor(node, ctx);
|
|
242
|
+
return menu.map((sku) => productCard(node, sku, { ...ctx, sku, skus: menu, inProductCard: true }));
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The sku rows a container shows: the group in context, or the menu a
|
|
246
|
+
* `products: "subset"` container names in `subsetGroup`.
|
|
247
|
+
*/
|
|
248
|
+
function skusFor(node, ctx) {
|
|
249
|
+
const subset = text(node.products) === "subset" ? text(node.subsetGroup) : undefined;
|
|
250
|
+
if (!subset)
|
|
251
|
+
return ctx.skus ?? [];
|
|
252
|
+
const named = (ctx.menus ?? []).find((menu) => menu.id === subset || menu.ref === subset);
|
|
253
|
+
return named?.products ?? ctx.skus ?? [];
|
|
254
|
+
}
|
|
255
|
+
function productCard(container, sku, ctx) {
|
|
256
|
+
const featured = sku.featured === true;
|
|
257
|
+
const template = (featured ? container.productFeaturedComponents : container.productBaseComponents) ?? container.components;
|
|
258
|
+
const rendered = rootsOf(template).flatMap((child) => render(child, ctx));
|
|
259
|
+
const card = {
|
|
260
|
+
kind: "product",
|
|
261
|
+
lines: textLines(rendered),
|
|
262
|
+
focusable: true,
|
|
263
|
+
};
|
|
264
|
+
const onTap = firstAction(rendered) ?? userAction(container.onTap);
|
|
265
|
+
if (onTap)
|
|
266
|
+
card.onTap = onTap;
|
|
267
|
+
const only = rendered.length === 1 ? rendered[0] : undefined;
|
|
268
|
+
if (only && only.kind === "box")
|
|
269
|
+
card.style = only.style;
|
|
270
|
+
// A card's outer box is usually a bare layout container; the fill that makes
|
|
271
|
+
// it look like a control is authored on the CTA nested inside it. On the real
|
|
272
|
+
// Penguin page that is the `namiBuySKU` button, `fillColor #ffffff` with a
|
|
273
|
+
// 2px border and an 8 radius — dropping it left the card as an empty outline.
|
|
274
|
+
const skin = cardSkin(rendered);
|
|
275
|
+
if (skin)
|
|
276
|
+
card.style = { ...(card.style ?? {}), ...skin };
|
|
277
|
+
return card;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The fill and border of the first styled control inside a card, which is what
|
|
281
|
+
* the card should wear. Layout values (width, spacing, direction) stay with the
|
|
282
|
+
* container; only the skin is lifted.
|
|
283
|
+
*/
|
|
284
|
+
function cardSkin(nodes) {
|
|
285
|
+
let found;
|
|
286
|
+
const visit = (node) => {
|
|
287
|
+
if (found)
|
|
288
|
+
return;
|
|
289
|
+
const style = "style" in node ? node.style : undefined;
|
|
290
|
+
if (style?.fill && (node.kind === "button" || node.kind === "box")) {
|
|
291
|
+
found = { fill: style.fill, ...(style.border ? { border: style.border } : {}) };
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (node.kind === "box")
|
|
295
|
+
node.children.forEach(visit);
|
|
296
|
+
};
|
|
297
|
+
nodes.forEach(visit);
|
|
298
|
+
return found;
|
|
299
|
+
}
|
|
300
|
+
/** Every printed line under these nodes, in source order. */
|
|
301
|
+
function textLines(nodes) {
|
|
302
|
+
const lines = [];
|
|
303
|
+
const visit = (node) => {
|
|
304
|
+
if (node.kind === "text")
|
|
305
|
+
lines.push(node.segments);
|
|
306
|
+
else if (node.kind === "button")
|
|
307
|
+
lines.push(node.label);
|
|
308
|
+
else if (node.kind === "product")
|
|
309
|
+
lines.push(...node.lines);
|
|
310
|
+
else if (node.kind === "box")
|
|
311
|
+
node.children.forEach(visit);
|
|
312
|
+
};
|
|
313
|
+
nodes.forEach(visit);
|
|
314
|
+
return lines.filter((line) => line.length > 0);
|
|
315
|
+
}
|
|
316
|
+
function firstAction(nodes) {
|
|
317
|
+
for (const node of nodes) {
|
|
318
|
+
if (node.kind === "box" || node.kind === "button" || node.kind === "product") {
|
|
319
|
+
if (node.onTap)
|
|
320
|
+
return node.onTap;
|
|
321
|
+
}
|
|
322
|
+
if (node.kind === "box") {
|
|
323
|
+
const nested = firstAction(node.children);
|
|
324
|
+
if (nested)
|
|
325
|
+
return nested;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
/** Several text nodes inside one button read as one label, spaced apart. */
|
|
331
|
+
function joinLines(lines) {
|
|
332
|
+
return lines.flatMap((line, index) => (index === 0 ? line : [{ text: " " }, ...line]));
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Conditions
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
/**
|
|
338
|
+
* Whether a `condition` (or one `conditionAttributes` entry) holds, following
|
|
339
|
+
* `sdk/web`'s `conditionComponentMatches`: `assertions` all have to hold,
|
|
340
|
+
* `orAssertions` any one of them (an empty list holds), and a condition with
|
|
341
|
+
* neither never renders.
|
|
342
|
+
*/
|
|
343
|
+
function conditionHolds(node, ctx) {
|
|
344
|
+
const all = node.assertions;
|
|
345
|
+
if (Array.isArray(all))
|
|
346
|
+
return all.every((assertion) => assertionHolds(assertion, ctx));
|
|
347
|
+
const any = node.orAssertions;
|
|
348
|
+
if (Array.isArray(any))
|
|
349
|
+
return any.length === 0 || any.some((assertion) => assertionHolds(assertion, ctx));
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
/** The operators, matching `sdk/web`'s `testObjectMatches`. */
|
|
353
|
+
function assertionHolds(assertion, ctx) {
|
|
354
|
+
const test = asNode(assertion);
|
|
355
|
+
if (!test)
|
|
356
|
+
return false;
|
|
357
|
+
const left = resolveValue(test.value, ctx);
|
|
358
|
+
const right = resolveValue(test.expected, ctx);
|
|
359
|
+
switch (text(test.operator)) {
|
|
360
|
+
case "equals":
|
|
361
|
+
return left === right;
|
|
362
|
+
case "notEquals":
|
|
363
|
+
return left !== right;
|
|
364
|
+
case "contains":
|
|
365
|
+
return contains(left, right);
|
|
366
|
+
case "notContains":
|
|
367
|
+
return !contains(left, right);
|
|
368
|
+
case "set":
|
|
369
|
+
return typeof right === "boolean" ? isSet(left) === right : isSet(left);
|
|
370
|
+
case "notSet":
|
|
371
|
+
return !isSet(left);
|
|
372
|
+
case "gt":
|
|
373
|
+
return compare(left, right) > 0;
|
|
374
|
+
case "gte":
|
|
375
|
+
return compare(left, right) >= 0;
|
|
376
|
+
case "lt":
|
|
377
|
+
return compare(left, right) < 0;
|
|
378
|
+
case "lte":
|
|
379
|
+
return compare(left, right) <= 0;
|
|
380
|
+
default:
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function contains(left, right) {
|
|
385
|
+
if (Array.isArray(left))
|
|
386
|
+
return left.some((item) => item === right);
|
|
387
|
+
return String(left ?? "").includes(String(right ?? ""));
|
|
388
|
+
}
|
|
389
|
+
/** A value counts as set when it is there and is not a token we could not resolve. */
|
|
390
|
+
function isSet(value) {
|
|
391
|
+
if (value === undefined || value === null || value === "")
|
|
392
|
+
return false;
|
|
393
|
+
return !UNRESOLVED.test(String(value));
|
|
394
|
+
}
|
|
395
|
+
function compare(left, right) {
|
|
396
|
+
const a = Number(left);
|
|
397
|
+
const b = Number(right);
|
|
398
|
+
if (!Number.isNaN(a) && !Number.isNaN(b))
|
|
399
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
400
|
+
const x = String(left ?? "");
|
|
401
|
+
const y = String(right ?? "");
|
|
402
|
+
return x === y ? 0 : x < y ? -1 : 1;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* `conditionAttributes` are the same rule applied to one node's attributes
|
|
406
|
+
* rather than to a subtree: every entry whose assertions hold merges its
|
|
407
|
+
* `attributes` over the node. It is how a purchase button reads
|
|
408
|
+
* "Start 7-Day Free Trial" for a trial-eligible sku, and how the selected
|
|
409
|
+
* product's radio is the filled one.
|
|
410
|
+
*/
|
|
411
|
+
function withOverrides(node, ctx) {
|
|
412
|
+
const entries = node.conditionAttributes;
|
|
413
|
+
if (!Array.isArray(entries) || entries.length === 0)
|
|
414
|
+
return node;
|
|
415
|
+
let out = node;
|
|
416
|
+
for (const entry of entries) {
|
|
417
|
+
const rule = asNode(entry);
|
|
418
|
+
if (!rule || !conditionHolds(rule, ctx))
|
|
419
|
+
continue;
|
|
420
|
+
const attributes = asNode(rule.attributes);
|
|
421
|
+
if (attributes)
|
|
422
|
+
out = { ...out, ...attributes };
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Tokens
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
/**
|
|
430
|
+
* A `${…}` token's value from the page's own state.
|
|
431
|
+
*
|
|
432
|
+
* A whole-string token keeps its type, so `${sku.featured}` compares as the
|
|
433
|
+
* boolean it is rather than as `"true"`. Innermost tokens resolve first, which
|
|
434
|
+
* is what makes the real `${state.selectedProducts.${state.currentGroupId}}`
|
|
435
|
+
* work. **A token we cannot answer stays as itself** — never a blank — so an
|
|
436
|
+
* authoring mistake stays visible and a dead condition stays dead.
|
|
437
|
+
*/
|
|
438
|
+
function resolveValue(value, ctx) {
|
|
439
|
+
if (typeof value !== "string" || !value.includes("${"))
|
|
440
|
+
return value;
|
|
441
|
+
let current = value;
|
|
442
|
+
for (let pass = 0; pass < MAX_TOKEN_PASSES; pass++) {
|
|
443
|
+
const whole = /^\$\{([^${}]*)\}$/.exec(current);
|
|
444
|
+
if (whole) {
|
|
445
|
+
const found = lookup(whole[1], ctx);
|
|
446
|
+
if (found === undefined || found === null)
|
|
447
|
+
return current;
|
|
448
|
+
if (typeof found !== "string" || !found.includes("${"))
|
|
449
|
+
return found;
|
|
450
|
+
current = found;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const next = current.replace(/\$\{([^${}]*)\}/g, (match, path) => {
|
|
454
|
+
const found = lookup(path, ctx);
|
|
455
|
+
return found === undefined || found === null ? match : String(found);
|
|
456
|
+
});
|
|
457
|
+
if (next === current)
|
|
458
|
+
return current;
|
|
459
|
+
current = next;
|
|
460
|
+
}
|
|
461
|
+
return current;
|
|
462
|
+
}
|
|
463
|
+
function resolveText(value, ctx) {
|
|
464
|
+
const resolved = resolveValue(value, ctx);
|
|
465
|
+
return typeof resolved === "string" ? resolved : String(resolved);
|
|
466
|
+
}
|
|
467
|
+
/** A dotted path against the page state, or against the sku in context. */
|
|
468
|
+
function lookup(path, ctx) {
|
|
469
|
+
const parts = path.split(".").filter((part) => part !== "");
|
|
470
|
+
if (parts.length === 0)
|
|
471
|
+
return undefined;
|
|
472
|
+
if (parts[0] === "sku") {
|
|
473
|
+
if (!ctx.sku)
|
|
474
|
+
return undefined;
|
|
475
|
+
const variables = asNode(ctx.sku.variables);
|
|
476
|
+
const name = parts.slice(1).join(".");
|
|
477
|
+
if (variables && variables[name] !== undefined)
|
|
478
|
+
return variables[name];
|
|
479
|
+
return walk(ctx.sku, parts.slice(1));
|
|
480
|
+
}
|
|
481
|
+
return walk(ctx.scope, parts);
|
|
482
|
+
}
|
|
483
|
+
function walk(root, parts) {
|
|
484
|
+
let current = root;
|
|
485
|
+
for (const part of parts) {
|
|
486
|
+
const node = asNode(current);
|
|
487
|
+
if (!node)
|
|
488
|
+
return undefined;
|
|
489
|
+
current = node[part];
|
|
490
|
+
}
|
|
491
|
+
return current;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* A string → the styled segments that draw it.
|
|
495
|
+
*
|
|
496
|
+
* `src/render/text.ts` owns markdown and the `${sku.*}` value map (core's own
|
|
497
|
+
* `getSkuSmartTextValue`); this module owns the page state, so it hands that
|
|
498
|
+
* over as `resolve` and leaves the sku tokens to core.
|
|
499
|
+
*/
|
|
500
|
+
function segments(source, ctx) {
|
|
501
|
+
if (source === "")
|
|
502
|
+
return [];
|
|
503
|
+
return renderable(source, {
|
|
504
|
+
sku: ctx.sku,
|
|
505
|
+
skus: ctx.skus,
|
|
506
|
+
resolve: (token) => {
|
|
507
|
+
if (token.startsWith("sku."))
|
|
508
|
+
return undefined;
|
|
509
|
+
const found = lookup(token, ctx);
|
|
510
|
+
if (found === undefined || found === null || typeof found === "object")
|
|
511
|
+
return undefined;
|
|
512
|
+
return String(found);
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Style
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
function boxStyle(node) {
|
|
520
|
+
const style = {};
|
|
521
|
+
put(style, "fill", text(node.fillColor));
|
|
522
|
+
put(style, "width", size(node.width));
|
|
523
|
+
put(style, "height", size(node.height));
|
|
524
|
+
put(style, "padding", edges(node, "Padding"));
|
|
525
|
+
put(style, "margin", edges(node, "Margin"));
|
|
526
|
+
put(style, "spacing", number(node.spacing));
|
|
527
|
+
put(style, "direction", text(node.direction));
|
|
528
|
+
put(style, "alignment", text(node.alignment));
|
|
529
|
+
put(style, "horizontalAlignment", text(node.horizontalAlignment));
|
|
530
|
+
put(style, "verticalAlignment", text(node.verticalAlignment));
|
|
531
|
+
put(style, "border", border(node));
|
|
532
|
+
return style;
|
|
533
|
+
}
|
|
534
|
+
function textStyle(node) {
|
|
535
|
+
const style = boxStyle(node);
|
|
536
|
+
put(style, "fontColor", text(node.fontColor));
|
|
537
|
+
put(style, "fontSize", number(node.fontSize));
|
|
538
|
+
put(style, "fontName", text(node.fontName));
|
|
539
|
+
put(style, "textType", text(node.textType));
|
|
540
|
+
put(style, "linkColor", text(node.linkColor));
|
|
541
|
+
put(style, "capitalize", bool(node.capitalize));
|
|
542
|
+
put(style, "strikethrough", bool(node.strikethrough));
|
|
543
|
+
return style;
|
|
544
|
+
}
|
|
545
|
+
function edges(node, suffix) {
|
|
546
|
+
const edge = {};
|
|
547
|
+
put(edge, "top", size(node[`top${suffix}`]));
|
|
548
|
+
put(edge, "right", size(node[`right${suffix}`]));
|
|
549
|
+
put(edge, "bottom", size(node[`bottom${suffix}`]));
|
|
550
|
+
put(edge, "left", size(node[`left${suffix}`]));
|
|
551
|
+
return Object.keys(edge).length > 0 ? edge : undefined;
|
|
552
|
+
}
|
|
553
|
+
function border(node) {
|
|
554
|
+
const value = {};
|
|
555
|
+
put(value, "color", text(node.borderColor));
|
|
556
|
+
put(value, "width", number(node.borderWidth));
|
|
557
|
+
put(value, "radius", number(node.borderRadius));
|
|
558
|
+
return Object.keys(value).length > 0 ? value : undefined;
|
|
559
|
+
}
|
|
560
|
+
/** A spacer's authored extent; one cell when it is a percentage we cannot size here. */
|
|
561
|
+
function sizeOf(node) {
|
|
562
|
+
return number(node.width) ?? number(node.height) ?? 1;
|
|
563
|
+
}
|
|
564
|
+
function put(target, key, value) {
|
|
565
|
+
if (value !== undefined)
|
|
566
|
+
target[key] = value;
|
|
567
|
+
}
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Template reading
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
function pagesOf(paywall) {
|
|
572
|
+
const pages = paywall?.template?.pages;
|
|
573
|
+
return Array.isArray(pages) ? pages.filter((page) => asNode(page) !== undefined) : [];
|
|
574
|
+
}
|
|
575
|
+
/** A slot's roots: an array of components, a single container, or nothing. */
|
|
576
|
+
function rootsOf(value) {
|
|
577
|
+
if (Array.isArray(value))
|
|
578
|
+
return value.map(asNode).filter((node) => node !== undefined);
|
|
579
|
+
const node = asNode(value);
|
|
580
|
+
return node ? [node] : [];
|
|
581
|
+
}
|
|
582
|
+
function userAction(value) {
|
|
583
|
+
const node = asNode(value);
|
|
584
|
+
if (!node)
|
|
585
|
+
return undefined;
|
|
586
|
+
return typeof node.function === "string" ? node : undefined;
|
|
587
|
+
}
|
|
588
|
+
/** The media's own name: the last segment of its url. */
|
|
589
|
+
function mediaName(url) {
|
|
590
|
+
if (!url)
|
|
591
|
+
return "media";
|
|
592
|
+
const path = url.split(/[?#]/)[0];
|
|
593
|
+
return path.split("/").filter(Boolean).pop() ?? url;
|
|
594
|
+
}
|
|
595
|
+
function asNode(value) {
|
|
596
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
597
|
+
}
|
|
598
|
+
function text(value) {
|
|
599
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
600
|
+
}
|
|
601
|
+
function number(value) {
|
|
602
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
603
|
+
}
|
|
604
|
+
function bool(value) {
|
|
605
|
+
return typeof value === "boolean" ? value : undefined;
|
|
606
|
+
}
|
|
607
|
+
/** A width or height, raw: `100`, `"100%"`, `"fitContent"`. */
|
|
608
|
+
function size(value) {
|
|
609
|
+
return number(value) ?? text(value);
|
|
610
|
+
}
|