@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,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A character grid to paint a page into.
|
|
3
|
+
*
|
|
4
|
+
* Ink has no z-order and an Ink `Box` carries exactly one background colour, so
|
|
5
|
+
* a paywall's background layer drew *above* the page, a gradient behind copy
|
|
6
|
+
* flattened to one colour, and an image could never sit under text. All three
|
|
7
|
+
* are the same limit. Painting into a grid where **every cell owns its own
|
|
8
|
+
* colours** removes it: the background is painted first, everything after lands
|
|
9
|
+
* on top, and each row of a box can take its own colour.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure logic — no React, no Ink import. Ink stops being a layout
|
|
12
|
+
* engine and becomes a printer: `toRows()` hands it one row of like-styled spans
|
|
13
|
+
* per line.
|
|
14
|
+
*
|
|
15
|
+
* Two rules carry the whole thing:
|
|
16
|
+
*
|
|
17
|
+
* - `drawText` **keeps the background already in the cell** unless the style
|
|
18
|
+
* names one. That is precisely what puts text *over* a fill rather than
|
|
19
|
+
* punching a hole through it.
|
|
20
|
+
* - An image cell whose `fg` or `bg` is `""` means "leave what is underneath" —
|
|
21
|
+
* which is what `image.ts` emits for a pixel that is absent or fully
|
|
22
|
+
* transparent.
|
|
23
|
+
*
|
|
24
|
+
* Every write clips silently at every edge, including negative coordinates and
|
|
25
|
+
* rectangles that start off-grid: a painter that throws on an overhang would
|
|
26
|
+
* take the whole page render down with it.
|
|
27
|
+
*/
|
|
28
|
+
import { parseColor, toHex } from "./style.js";
|
|
29
|
+
/**
|
|
30
|
+
* The marker for the right-hand half of a wide glyph.
|
|
31
|
+
*
|
|
32
|
+
* A wide glyph (an emoji, a CJK ideograph) takes **two** columns, so it occupies
|
|
33
|
+
* two cells: the lead cell carries the glyph and the next one is a continuation
|
|
34
|
+
* that `toRows()` prints nothing for. That keeps the printed width of a row
|
|
35
|
+
* exactly equal to `cols`, which is what stops one `👋` from misaligning every
|
|
36
|
+
* row after it. The alternative — refusing to place a wide glyph at all — would
|
|
37
|
+
* drop the real Penguin fixture's `👋 This is a list` on the floor.
|
|
38
|
+
*/
|
|
39
|
+
const CONTINUATION = "";
|
|
40
|
+
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
41
|
+
/**
|
|
42
|
+
* East Asian Wide and Fullwidth ranges. The emoji test below covers the
|
|
43
|
+
* pictographic blocks, so this table only has to carry the CJK side; it is the
|
|
44
|
+
* conventional `wcwidth` set, trimmed to the ranges a paywall could plausibly
|
|
45
|
+
* contain.
|
|
46
|
+
*/
|
|
47
|
+
const WIDE_RANGES = [
|
|
48
|
+
[0x1100, 0x115f], // Hangul Jamo initial consonants
|
|
49
|
+
[0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
|
|
50
|
+
[0x3041, 0x33ff], // Hiragana through CJK compatibility
|
|
51
|
+
[0x3400, 0x4dbf], // CJK unified ideographs extension A
|
|
52
|
+
[0x4e00, 0x9fff], // CJK unified ideographs
|
|
53
|
+
[0xa000, 0xa4cf], // Yi
|
|
54
|
+
[0xa960, 0xa97f], // Hangul Jamo extended-A
|
|
55
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
56
|
+
[0xf900, 0xfaff], // CJK compatibility ideographs
|
|
57
|
+
[0xfe10, 0xfe19], // vertical forms
|
|
58
|
+
[0xfe30, 0xfe6f], // CJK compatibility forms, small form variants
|
|
59
|
+
[0xff00, 0xff60], // fullwidth forms
|
|
60
|
+
[0xffe0, 0xffe6], // fullwidth signs
|
|
61
|
+
[0x20000, 0x2fffd], // CJK extension B and beyond
|
|
62
|
+
[0x30000, 0x3fffd],
|
|
63
|
+
];
|
|
64
|
+
/** Text split into grapheme clusters — the unit a terminal cell actually holds. */
|
|
65
|
+
export function graphemes(text) {
|
|
66
|
+
return Array.from(GRAPHEMES.segment(text), (part) => part.segment);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How many columns one grapheme occupies: 0 for a control character, 2 for an
|
|
70
|
+
* emoji with emoji presentation or an East Asian wide character, 1 otherwise.
|
|
71
|
+
*
|
|
72
|
+
* A control character is worth zero rather than one, so a stray `\n` in authored
|
|
73
|
+
* copy does not silently eat a cell.
|
|
74
|
+
*/
|
|
75
|
+
export function cellWidth(grapheme) {
|
|
76
|
+
const first = grapheme.codePointAt(0);
|
|
77
|
+
if (first === undefined)
|
|
78
|
+
return 0;
|
|
79
|
+
if (first < 0x20 || (first >= 0x7f && first <= 0x9f))
|
|
80
|
+
return 0;
|
|
81
|
+
if (first === 0x200b)
|
|
82
|
+
return 0;
|
|
83
|
+
// U+FE0F asks for the emoji presentation of an otherwise text-default glyph.
|
|
84
|
+
if (grapheme.includes("️"))
|
|
85
|
+
return 2;
|
|
86
|
+
if (/\p{Emoji_Presentation}/u.test(grapheme))
|
|
87
|
+
return 2;
|
|
88
|
+
return WIDE_RANGES.some(([from, to]) => first >= from && first <= to) ? 2 : 1;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The columns a whole string occupies on screen.
|
|
92
|
+
*
|
|
93
|
+
* The one width in the renderer. It used to be three: `layout.ts` counted code
|
|
94
|
+
* points, `paint.ts` counted columns, and the two disagreed by a factor of two
|
|
95
|
+
* on CJK — `日本語` was measured as 3 and drawn as 6, so a Japanese paywall was
|
|
96
|
+
* laid out into half the room its text needed. Measurement and paint agreeing
|
|
97
|
+
* on where copy wraps is the rule the layout is built on; this is what makes
|
|
98
|
+
* them agree.
|
|
99
|
+
*/
|
|
100
|
+
export function textColumns(value) {
|
|
101
|
+
return graphemes(value).reduce((total, glyph) => total + cellWidth(glyph), 0);
|
|
102
|
+
}
|
|
103
|
+
export class Surface {
|
|
104
|
+
cols;
|
|
105
|
+
rows;
|
|
106
|
+
cells;
|
|
107
|
+
constructor(cols, rows) {
|
|
108
|
+
this.cols = size(cols);
|
|
109
|
+
this.rows = size(rows);
|
|
110
|
+
this.cells = Array.from({ length: this.cols * this.rows }, () => ({ ch: " " }));
|
|
111
|
+
}
|
|
112
|
+
/** A copy of one cell, or `undefined` when the coordinates are off the grid. */
|
|
113
|
+
cellAt(x, y) {
|
|
114
|
+
const cell = this.at(x, y);
|
|
115
|
+
return cell ? { ...cell } : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Copy another surface onto this one at `(x, y)`, **skipping cells it never
|
|
119
|
+
* painted**.
|
|
120
|
+
*
|
|
121
|
+
* This is what lets a footer sit over the page the way an absolutely
|
|
122
|
+
* positioned one does on the web: where the footer has a fill it covers what
|
|
123
|
+
* is beneath, and where it has none the page shows through. Replacing whole
|
|
124
|
+
* rows instead would punch a hole in the page wherever the footer is
|
|
125
|
+
* transparent.
|
|
126
|
+
*/
|
|
127
|
+
blit(other, x, y) {
|
|
128
|
+
for (let row = 0; row < other.rows; row += 1) {
|
|
129
|
+
for (let col = 0; col < other.cols; col += 1) {
|
|
130
|
+
const from = other.at(col, row);
|
|
131
|
+
if (!from || !painted(from))
|
|
132
|
+
continue;
|
|
133
|
+
const to = this.at(x + col, y + row);
|
|
134
|
+
if (!to)
|
|
135
|
+
continue;
|
|
136
|
+
Object.assign(to, from);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Empty a rectangle: no glyph, no colours, as if nothing had been painted.
|
|
142
|
+
*
|
|
143
|
+
* This exists for an image the terminal draws itself. Such an image is placed
|
|
144
|
+
* *below* cells that carry a background colour, so any fill left in its
|
|
145
|
+
* rectangle — the page's own, a section's — would hide it completely. Clearing
|
|
146
|
+
* is what lets the picture through, and it is why a button's fill painted
|
|
147
|
+
* afterwards still covers the picture, exactly as it should.
|
|
148
|
+
*/
|
|
149
|
+
clearRect(x, y, w, h) {
|
|
150
|
+
for (let row = 0; row < h; row += 1) {
|
|
151
|
+
for (let col = 0; col < w; col += 1) {
|
|
152
|
+
const cell = this.at(x + col, y + row);
|
|
153
|
+
if (cell)
|
|
154
|
+
for (const key of Object.keys(cell))
|
|
155
|
+
delete cell[key];
|
|
156
|
+
if (cell)
|
|
157
|
+
cell.ch = " ";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Paint a rectangle's background. Cells keep whatever glyph and foreground are
|
|
163
|
+
* already on them, which is how a fill drawn after its content still sits
|
|
164
|
+
* behind it.
|
|
165
|
+
*/
|
|
166
|
+
fillRect(x, y, w, h, bg) {
|
|
167
|
+
this.fillRectRows(x, y, w, h, () => bg);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Per-row colour, for a gradient: `colorAt(t)` decides each row's background
|
|
171
|
+
* with `t` running 0 at the top of the rect to 1 at the bottom. A rect one row
|
|
172
|
+
* tall is sampled at 0. Returning `null` leaves that row untouched, which is
|
|
173
|
+
* what an unresolvable fill does.
|
|
174
|
+
*
|
|
175
|
+
* `t` is measured against the **whole** rect, so a rect whose top is off-grid
|
|
176
|
+
* keeps its gradient undistorted.
|
|
177
|
+
*/
|
|
178
|
+
fillRectRows(x, y, w, h, colorAt) {
|
|
179
|
+
const width = size(w);
|
|
180
|
+
const height = size(h);
|
|
181
|
+
if (width === 0 || height === 0)
|
|
182
|
+
return;
|
|
183
|
+
for (let row = 0; row < height; row += 1) {
|
|
184
|
+
const gridY = y + row;
|
|
185
|
+
if (gridY < 0 || gridY >= this.rows)
|
|
186
|
+
continue;
|
|
187
|
+
const bg = colorAt(height === 1 ? 0 : row / (height - 1));
|
|
188
|
+
if (!bg)
|
|
189
|
+
continue;
|
|
190
|
+
for (let col = 0; col < width; col += 1) {
|
|
191
|
+
const cell = this.at(x + col, gridY);
|
|
192
|
+
if (cell)
|
|
193
|
+
cell.bg = bg;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Write text over whatever is beneath it, keeping each cell's existing
|
|
199
|
+
* background unless `style.bg` names one. `maxWidth` caps the columns written
|
|
200
|
+
* from `x`; a wide glyph counts as two of them and is never split in half.
|
|
201
|
+
*/
|
|
202
|
+
drawText(x, y, text, style = {}, maxWidth) {
|
|
203
|
+
if (y < 0 || y >= this.rows || this.cols === 0)
|
|
204
|
+
return;
|
|
205
|
+
const limit = typeof maxWidth === "number" && Number.isFinite(maxWidth) ? Math.max(0, Math.floor(maxWidth)) : null;
|
|
206
|
+
let used = 0;
|
|
207
|
+
let col = x;
|
|
208
|
+
for (const grapheme of graphemes(text)) {
|
|
209
|
+
const width = cellWidth(grapheme);
|
|
210
|
+
if (width === 0)
|
|
211
|
+
continue;
|
|
212
|
+
if (limit !== null && used + width > limit)
|
|
213
|
+
return;
|
|
214
|
+
if (col >= this.cols)
|
|
215
|
+
return;
|
|
216
|
+
// Never leave half a wide glyph against the right edge.
|
|
217
|
+
if (col + width > this.cols)
|
|
218
|
+
return;
|
|
219
|
+
if (col >= 0) {
|
|
220
|
+
this.breakWideAt(col, y);
|
|
221
|
+
if (width === 2)
|
|
222
|
+
this.breakWideAt(col + 1, y);
|
|
223
|
+
this.write(col, y, grapheme, style);
|
|
224
|
+
if (width === 2)
|
|
225
|
+
this.write(col + 1, y, CONTINUATION, style);
|
|
226
|
+
}
|
|
227
|
+
col += width;
|
|
228
|
+
used += width;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Half-block image cells, each carrying its own foreground and background. An
|
|
233
|
+
* empty `fg` or `bg` leaves what is already in the cell, and a cell that is a
|
|
234
|
+
* blank with neither is skipped entirely — that is `image.ts`'s way of saying
|
|
235
|
+
* the pixel was absent or fully transparent, so the page shows through.
|
|
236
|
+
*/
|
|
237
|
+
drawCells(x, y, cells) {
|
|
238
|
+
cells.forEach((row, rowIndex) => {
|
|
239
|
+
const gridY = y + rowIndex;
|
|
240
|
+
if (gridY < 0 || gridY >= this.rows)
|
|
241
|
+
return;
|
|
242
|
+
row.forEach((image, colIndex) => {
|
|
243
|
+
const gridX = x + colIndex;
|
|
244
|
+
const cell = this.at(gridX, gridY);
|
|
245
|
+
if (!cell)
|
|
246
|
+
return;
|
|
247
|
+
if (image.ch === " " && image.fg === "" && image.bg === "")
|
|
248
|
+
return;
|
|
249
|
+
this.breakWideAt(gridX, gridY);
|
|
250
|
+
const fg = parseColor(image.fg);
|
|
251
|
+
const bg = parseColor(image.bg);
|
|
252
|
+
cell.ch = image.ch;
|
|
253
|
+
if (fg)
|
|
254
|
+
cell.fg = fg;
|
|
255
|
+
if (bg)
|
|
256
|
+
cell.bg = bg;
|
|
257
|
+
// An image has no text attributes; anything beneath it loses them.
|
|
258
|
+
delete cell.bold;
|
|
259
|
+
delete cell.italic;
|
|
260
|
+
delete cell.underline;
|
|
261
|
+
delete cell.dim;
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* One row per line, each a run of like-styled spans — what Ink prints. Adjacent
|
|
267
|
+
* cells with identical styling merge, so a 44-column row is a handful of spans
|
|
268
|
+
* rather than 44. A wide glyph's continuation cell contributes nothing, because
|
|
269
|
+
* the glyph itself already prints two columns wide.
|
|
270
|
+
*/
|
|
271
|
+
toRows() {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (let y = 0; y < this.rows; y += 1) {
|
|
274
|
+
const spans = [];
|
|
275
|
+
let current = null;
|
|
276
|
+
let currentKey = "";
|
|
277
|
+
for (let x = 0; x < this.cols; x += 1) {
|
|
278
|
+
const cell = this.cells[y * this.cols + x];
|
|
279
|
+
if (cell.ch === CONTINUATION)
|
|
280
|
+
continue;
|
|
281
|
+
const key = styleKey(cell);
|
|
282
|
+
if (current && key === currentKey) {
|
|
283
|
+
current.text += cell.ch;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
current = { ...spanStyle(cell), text: cell.ch };
|
|
287
|
+
currentKey = key;
|
|
288
|
+
spans.push(current);
|
|
289
|
+
}
|
|
290
|
+
out.push(spans);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
at(x, y) {
|
|
295
|
+
if (!Number.isInteger(x) || !Number.isInteger(y))
|
|
296
|
+
return undefined;
|
|
297
|
+
if (x < 0 || y < 0 || x >= this.cols || y >= this.rows)
|
|
298
|
+
return undefined;
|
|
299
|
+
return this.cells[y * this.cols + x];
|
|
300
|
+
}
|
|
301
|
+
write(x, y, ch, style) {
|
|
302
|
+
const cell = this.at(x, y);
|
|
303
|
+
if (!cell)
|
|
304
|
+
return;
|
|
305
|
+
const bg = style.bg ?? cell.bg;
|
|
306
|
+
const next = { ch };
|
|
307
|
+
if (style.fg)
|
|
308
|
+
next.fg = style.fg;
|
|
309
|
+
if (bg)
|
|
310
|
+
next.bg = bg;
|
|
311
|
+
if (style.bold)
|
|
312
|
+
next.bold = true;
|
|
313
|
+
if (style.italic)
|
|
314
|
+
next.italic = true;
|
|
315
|
+
if (style.underline)
|
|
316
|
+
next.underline = true;
|
|
317
|
+
if (style.dim)
|
|
318
|
+
next.dim = true;
|
|
319
|
+
this.cells[y * this.cols + x] = next;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Clear the other half of any wide glyph that covers this cell, so a write
|
|
323
|
+
* never leaves an orphaned lead or continuation behind — either of which would
|
|
324
|
+
* change the row's printed width.
|
|
325
|
+
*/
|
|
326
|
+
breakWideAt(x, y) {
|
|
327
|
+
const cell = this.at(x, y);
|
|
328
|
+
if (!cell)
|
|
329
|
+
return;
|
|
330
|
+
if (cell.ch === CONTINUATION) {
|
|
331
|
+
const lead = this.at(x - 1, y);
|
|
332
|
+
if (lead)
|
|
333
|
+
lead.ch = " ";
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const next = this.at(x + 1, y);
|
|
337
|
+
if (next && next.ch === CONTINUATION)
|
|
338
|
+
next.ch = " ";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** A key that changes whenever anything about a cell's styling changes. */
|
|
342
|
+
function styleKey(cell) {
|
|
343
|
+
return [
|
|
344
|
+
cell.fg ? toHex(cell.fg) : "",
|
|
345
|
+
cell.bg ? toHex(cell.bg) : "",
|
|
346
|
+
cell.bold ? "b" : "",
|
|
347
|
+
cell.italic ? "i" : "",
|
|
348
|
+
cell.underline ? "u" : "",
|
|
349
|
+
cell.dim ? "d" : "",
|
|
350
|
+
].join("|");
|
|
351
|
+
}
|
|
352
|
+
/** A cell's styling as the span props Ink takes, with every absent key left off. */
|
|
353
|
+
function spanStyle(cell) {
|
|
354
|
+
const style = {};
|
|
355
|
+
if (cell.fg)
|
|
356
|
+
style.fg = toHex(cell.fg);
|
|
357
|
+
if (cell.bg)
|
|
358
|
+
style.bg = toHex(cell.bg);
|
|
359
|
+
if (cell.bold)
|
|
360
|
+
style.bold = true;
|
|
361
|
+
if (cell.italic)
|
|
362
|
+
style.italic = true;
|
|
363
|
+
if (cell.underline)
|
|
364
|
+
style.underline = true;
|
|
365
|
+
if (cell.dim)
|
|
366
|
+
style.dim = true;
|
|
367
|
+
return style;
|
|
368
|
+
}
|
|
369
|
+
function size(value) {
|
|
370
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
371
|
+
return 0;
|
|
372
|
+
return Math.floor(value);
|
|
373
|
+
}
|
|
374
|
+
/** A cell nothing has drawn on: a bare space with no colours of its own. */
|
|
375
|
+
function painted(cell) {
|
|
376
|
+
return cell.ch !== " " || cell.bg !== undefined || cell.fg !== undefined;
|
|
377
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { SKU_TEXT_REGEX, SMART_TEXT_PATTERN, VAR_REGEX, getSkuSmartTextValue, productDetail, } from "@namiml/sdk-core";
|
|
2
|
+
const BOLD = "**";
|
|
3
|
+
const ITALIC = "*";
|
|
4
|
+
/**
|
|
5
|
+
* Core's regexes are module-level and global, and core calls `.exec()` on them,
|
|
6
|
+
* which leaves `lastIndex` behind. Clone before every use.
|
|
7
|
+
*/
|
|
8
|
+
function clone(pattern) {
|
|
9
|
+
return new RegExp(pattern.source, pattern.flags);
|
|
10
|
+
}
|
|
11
|
+
/** Splits authored copy into styled segments. `**bold**`, `*italic*`, `[label](url)`. */
|
|
12
|
+
export function parseMarkdown(source) {
|
|
13
|
+
return merge(parseInline(source, {}));
|
|
14
|
+
}
|
|
15
|
+
/** Replaces the smart-text tokens a string carries, leaving any it cannot resolve in place. */
|
|
16
|
+
export function resolveSmartText(source, deps = {}) {
|
|
17
|
+
if (!source.includes(SMART_TEXT_PATTERN))
|
|
18
|
+
return source;
|
|
19
|
+
let values;
|
|
20
|
+
const skuValues = () => (values ??= buildSkuValues(deps));
|
|
21
|
+
return source.replace(clone(VAR_REGEX), (match, token) => {
|
|
22
|
+
const injected = asText(deps.resolve?.(token));
|
|
23
|
+
if (injected !== undefined)
|
|
24
|
+
return injected;
|
|
25
|
+
const name = skuTokenName(match);
|
|
26
|
+
if (name === undefined)
|
|
27
|
+
return match;
|
|
28
|
+
return asText(skuValues()[name]) ?? match;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Resolves the smart text, then parses the markdown around it. */
|
|
32
|
+
export function renderable(source, deps) {
|
|
33
|
+
return parseMarkdown(resolveSmartText(source, deps));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The `${sku.<name>}` variable name a match carries, or `undefined` when the
|
|
37
|
+
* token is not a sku token. A `${sku.<name>:<ref>}` token names another sku's
|
|
38
|
+
* value, which only core's per-sku resolution can answer, so it is reported as
|
|
39
|
+
* unresolvable and stays visible.
|
|
40
|
+
*/
|
|
41
|
+
function skuTokenName(match) {
|
|
42
|
+
const parsed = clone(SKU_TEXT_REGEX).exec(match);
|
|
43
|
+
if (!parsed || parsed[2])
|
|
44
|
+
return undefined;
|
|
45
|
+
return parsed[1];
|
|
46
|
+
}
|
|
47
|
+
function buildSkuValues(deps) {
|
|
48
|
+
const sku = deps.sku;
|
|
49
|
+
if (!sku)
|
|
50
|
+
return {};
|
|
51
|
+
const catalogue = Array.isArray(deps.productDetails) ? deps.productDetails : [];
|
|
52
|
+
const detail = productDetail(catalogue, sku.sku_ref_id ?? "");
|
|
53
|
+
// `getSkuSmartTextValue` writes resolved values back into `sku.variables`, so
|
|
54
|
+
// it gets a copy — a caller's sku must read the same on the second call.
|
|
55
|
+
const copy = { ...sku, variables: sku.variables ? { ...sku.variables } : undefined };
|
|
56
|
+
const skus = Array.isArray(deps.skus) ? deps.skus : undefined;
|
|
57
|
+
return getSkuSmartTextValue(detail, copy, skus);
|
|
58
|
+
}
|
|
59
|
+
/** A token value as display text, or `undefined` when there is nothing to show. */
|
|
60
|
+
function asText(value) {
|
|
61
|
+
if (value === undefined || value === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
|
64
|
+
return text === "" ? undefined : text;
|
|
65
|
+
}
|
|
66
|
+
function parseInline(source, style) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let literal = "";
|
|
69
|
+
let index = 0;
|
|
70
|
+
const flush = () => {
|
|
71
|
+
if (literal !== "") {
|
|
72
|
+
out.push(styled(literal, style));
|
|
73
|
+
literal = "";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
while (index < source.length) {
|
|
77
|
+
const link = matchLink(source, index);
|
|
78
|
+
if (link) {
|
|
79
|
+
flush();
|
|
80
|
+
out.push(...parseInline(link.label, { ...style, link: link.url }));
|
|
81
|
+
index = link.end;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const emphasis = matchEmphasis(source, index);
|
|
85
|
+
if (emphasis) {
|
|
86
|
+
flush();
|
|
87
|
+
out.push(...parseInline(emphasis.inner, { ...style, ...emphasis.style }));
|
|
88
|
+
index = emphasis.end;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
literal += source[index];
|
|
92
|
+
index += 1;
|
|
93
|
+
}
|
|
94
|
+
flush();
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function matchLink(source, index) {
|
|
98
|
+
if (source[index] !== "[")
|
|
99
|
+
return null;
|
|
100
|
+
const close = source.indexOf("]", index + 1);
|
|
101
|
+
if (close < 0 || source[close + 1] !== "(")
|
|
102
|
+
return null;
|
|
103
|
+
const end = source.indexOf(")", close + 2);
|
|
104
|
+
if (end < 0)
|
|
105
|
+
return null;
|
|
106
|
+
return { label: source.slice(index + 1, close), url: source.slice(close + 2, end), end: end + 1 };
|
|
107
|
+
}
|
|
108
|
+
function matchEmphasis(source, index) {
|
|
109
|
+
const marker = source.startsWith(BOLD, index) ? BOLD : source.startsWith(ITALIC, index) ? ITALIC : null;
|
|
110
|
+
if (!marker)
|
|
111
|
+
return null;
|
|
112
|
+
const from = index + marker.length;
|
|
113
|
+
const close = source.indexOf(marker, from);
|
|
114
|
+
// An unmatched — or empty — marker run is authored text, not emphasis.
|
|
115
|
+
if (close <= from)
|
|
116
|
+
return null;
|
|
117
|
+
return {
|
|
118
|
+
inner: source.slice(from, close),
|
|
119
|
+
style: marker === BOLD ? { bold: true } : { italic: true },
|
|
120
|
+
end: close + marker.length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function styled(text, style) {
|
|
124
|
+
const segment = { text };
|
|
125
|
+
if (style.bold)
|
|
126
|
+
segment.bold = true;
|
|
127
|
+
if (style.italic)
|
|
128
|
+
segment.italic = true;
|
|
129
|
+
if (style.link !== undefined)
|
|
130
|
+
segment.link = style.link;
|
|
131
|
+
return segment;
|
|
132
|
+
}
|
|
133
|
+
function sameStyle(a, b) {
|
|
134
|
+
return a.bold === b.bold && a.italic === b.italic && a.link === b.link;
|
|
135
|
+
}
|
|
136
|
+
function merge(segments) {
|
|
137
|
+
return segments.reduce((out, segment) => {
|
|
138
|
+
const previous = out[out.length - 1];
|
|
139
|
+
if (previous && sameStyle(previous, segment))
|
|
140
|
+
previous.text += segment.text;
|
|
141
|
+
else
|
|
142
|
+
out.push(segment);
|
|
143
|
+
return out;
|
|
144
|
+
}, []);
|
|
145
|
+
}
|
|
@@ -1,20 +1,38 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useInput } from "ink";
|
|
3
3
|
import { palette } from "../theme.js";
|
|
4
4
|
import { EventsPane } from "./EventsPane.js";
|
|
5
|
+
import { APPROXIMATION_NOTE, DEFAULT_VIEWPORT, NO_PRODUCTS_NOTE, PageRender, focusablePageNodes, frameCols, hasProductCards, } from "./PageRender.js";
|
|
5
6
|
/**
|
|
6
|
-
* A launched placement: the page
|
|
7
|
-
*
|
|
7
|
+
* A launched placement: the page on one side, what the SDK reported on the
|
|
8
|
+
* other.
|
|
9
|
+
*
|
|
10
|
+
* Two views of the same page share one pane and `t` swaps them. The **render**
|
|
11
|
+
* (NAM-4548) draws the page the way the paywall looks — authored colours,
|
|
12
|
+
* authored layout, real images — and is what the screen opens on. The
|
|
13
|
+
* **outline** (NAM-4542) is the developer's tree: every authored node in source
|
|
14
|
+
* order, scaffolding and hidden subtrees included, which is what you want when
|
|
15
|
+
* a paywall renders wrong. Neither replaces the other.
|
|
8
16
|
*
|
|
9
17
|
* Pure presentation plus input — it renders the props it is handed and turns
|
|
10
|
-
* keys into callbacks. The subscriptions, the bus
|
|
11
|
-
* `src/placements/launch.ts` and `Shell`, so this
|
|
12
|
-
* test with nothing configured.
|
|
18
|
+
* keys into callbacks. The subscriptions, the bus, the view, the focus index
|
|
19
|
+
* and the scroll offset live in `src/placements/launch.ts` and `Shell`, so this
|
|
20
|
+
* file can be rendered from a test with nothing configured.
|
|
13
21
|
*/
|
|
14
22
|
/** At or above this many columns the two panes sit side by side; below it they stack. */
|
|
15
23
|
export const SIDE_BY_SIDE_MIN_WIDTH = 100;
|
|
16
24
|
/** Columns between the two panes when they sit side by side. */
|
|
17
25
|
const GUTTER = 2;
|
|
26
|
+
/**
|
|
27
|
+
* Columns the Events pane keeps when the page asks for more.
|
|
28
|
+
*
|
|
29
|
+
* `EventRow` spends two on its marker and the rest on a timestamp, the event
|
|
30
|
+
* name and whatever of the payload fits, so below about this it stops being a
|
|
31
|
+
* readable log and becomes a column of ellipses.
|
|
32
|
+
*/
|
|
33
|
+
const EVENTS_MIN_WIDTH = 44;
|
|
34
|
+
/** Rows one PgUp / PgDn moves the frame. */
|
|
35
|
+
const SCROLL_STEP = 10;
|
|
18
36
|
/**
|
|
19
37
|
* What the arrows move between: a node the paywall gave an action to, that is
|
|
20
38
|
* actually on screen. The walker already drops `onTap` from a hidden subtree;
|
|
@@ -24,6 +42,20 @@ const GUTTER = 2;
|
|
|
24
42
|
export function focusableNodes(composite) {
|
|
25
43
|
return composite.sections.flatMap((section) => section.nodes).filter((node) => node.onTap && !node.hidden);
|
|
26
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The press the active view's focused node stands for.
|
|
47
|
+
*
|
|
48
|
+
* The render model and the outline are different node shapes, and `pressNode`
|
|
49
|
+
* speaks the outline's. A render node therefore presses as the `onTap` it
|
|
50
|
+
* carries — which is the only field `pressNode` reads — so both views report
|
|
51
|
+
* through exactly the same mapper.
|
|
52
|
+
*/
|
|
53
|
+
export function pressableFor(node) {
|
|
54
|
+
if (!node)
|
|
55
|
+
return undefined;
|
|
56
|
+
const onTap = "onTap" in node ? node.onTap : undefined;
|
|
57
|
+
return { depth: 0, kind: node.kind, onTap };
|
|
58
|
+
}
|
|
27
59
|
function clip(text, max) {
|
|
28
60
|
if (max <= 1 || text.length <= max)
|
|
29
61
|
return text;
|
|
@@ -41,23 +73,46 @@ function nodeLine(node) {
|
|
|
41
73
|
parts.push("· hidden");
|
|
42
74
|
return `${" ".repeat(node.depth)}${parts.join(" ")}`;
|
|
43
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Why the pane is empty, which both views need and neither may guess at: a flow
|
|
78
|
+
* has no one page, a paywall that resolved and served nothing is an authoring
|
|
79
|
+
* question, and a paywall that resolved at all is usually a device mismatch.
|
|
80
|
+
*/
|
|
81
|
+
function PaneNotice({ composite, placement, deviceName }) {
|
|
82
|
+
if (composite.sections.length > 0)
|
|
83
|
+
return null;
|
|
84
|
+
if (placement.kind === "flow") {
|
|
85
|
+
return _jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." });
|
|
86
|
+
}
|
|
87
|
+
if (composite.unresolved) {
|
|
88
|
+
return (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] }));
|
|
89
|
+
}
|
|
90
|
+
return _jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." });
|
|
91
|
+
}
|
|
44
92
|
function CompositePane({ composite, placement, focused, width, deviceName }) {
|
|
45
|
-
|
|
46
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), empty && placement.kind === "flow" && (_jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." })), empty && placement.kind !== "flow" && composite.unresolved && (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] })), empty && placement.kind !== "flow" && !composite.unresolved && (_jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." })), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
|
|
93
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), _jsx(PaneNotice, { composite: composite, placement: placement, deviceName: deviceName }), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
|
|
47
94
|
const isFocused = node === focused;
|
|
48
95
|
return (_jsxs(Text, { dimColor: node.hidden, children: [_jsx(Text, { color: palette.aqua, children: isFocused ? "›" : " " }), " ", _jsx(Text, { bold: isFocused, children: clip(nodeLine(node), Math.max(width - 2, 1)) })] }, `${section.name}-${index}`));
|
|
49
96
|
})] }, section.name))), composite.truncated > 0 && _jsxs(Text, { dimColor: true, children: ["+", composite.truncated, " more"] })] }));
|
|
50
97
|
}
|
|
51
|
-
export function LaunchScreen({ placement, composite, events, width, paused, expanded, selectedSeq, focusIndex, onPress, onMove, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
98
|
+
export function LaunchScreen({ placement, composite, page, caps, events, width, height, viewport, view, paused, expanded, selectedSeq, focusIndex, scrollTop, onPress, onMove, onScroll, onToggleView, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
|
|
99
|
+
const rendering = view === "render";
|
|
100
|
+
const outlineFocus = focusableNodes(composite)[focusIndex];
|
|
101
|
+
const renderFocus = focusablePageNodes(page)[focusIndex];
|
|
102
|
+
const pressable = rendering ? pressableFor(renderFocus) : outlineFocus;
|
|
54
103
|
useInput((input, key) => {
|
|
104
|
+
if (key.pageUp)
|
|
105
|
+
return onScroll(-SCROLL_STEP);
|
|
106
|
+
if (key.pageDown)
|
|
107
|
+
return onScroll(SCROLL_STEP);
|
|
55
108
|
if (key.upArrow || key.leftArrow)
|
|
56
109
|
return onMove(-1);
|
|
57
110
|
if (key.downArrow || key.rightArrow)
|
|
58
111
|
return onMove(1);
|
|
59
112
|
if (key.return)
|
|
60
|
-
return void (
|
|
113
|
+
return void (pressable && onPress(pressable));
|
|
114
|
+
if (input === "t")
|
|
115
|
+
return onToggleView();
|
|
61
116
|
if (input === "p")
|
|
62
117
|
return onTogglePause();
|
|
63
118
|
if (input === "c")
|
|
@@ -68,7 +123,23 @@ export function LaunchScreen({ placement, composite, events, width, paused, expa
|
|
|
68
123
|
return onBack();
|
|
69
124
|
});
|
|
70
125
|
const sideBySide = width >= SIDE_BY_SIDE_MIN_WIDTH;
|
|
71
|
-
const
|
|
126
|
+
const empty = composite.sections.length === 0;
|
|
127
|
+
// Only a drawn device frame gets to size the pane. The outline and the
|
|
128
|
+
// empty-state notice are text: they want the columns, and a phone frame's
|
|
129
|
+
// width would crop their sentences for no reason.
|
|
130
|
+
const showingFrame = rendering && !empty;
|
|
131
|
+
// For a frame, ask for exactly what it can use and no more — an even split
|
|
132
|
+
// starves it on a 120-column terminal and wastes columns on a 200-column one.
|
|
133
|
+
// The frame is usually limited by its rows rather than its columns, so the
|
|
134
|
+
// same height budget goes in here, plus two for its own border. The Events
|
|
135
|
+
// pane keeps its floor either way.
|
|
136
|
+
const pageRoom = Math.max(1, width - GUTTER - EVENTS_MIN_WIDTH);
|
|
137
|
+
const evenSplit = Math.floor(width / 2) - 1;
|
|
138
|
+
const paneWidth = !sideBySide
|
|
139
|
+
? width
|
|
140
|
+
: showingFrame
|
|
141
|
+
? Math.min(frameCols(viewport ?? DEFAULT_VIEWPORT, pageRoom - 2, Math.max(height - 4, 1)) + 2, pageRoom)
|
|
142
|
+
: evenSplit;
|
|
72
143
|
const eventsWidth = sideBySide ? width - paneWidth - GUTTER : width;
|
|
73
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [_jsx(CompositePane, { composite: composite, placement: placement, focused:
|
|
144
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [rendering ? (_jsx(Box, { flexDirection: "column", width: paneWidth, children: empty ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: page.pageName || "—" })] }), _jsx(PaneNotice, { composite: composite, placement: placement, deviceName: deviceName })] })) : (_jsx(PageRender, { page: page, caps: caps, width: paneWidth, height: height, viewport: viewport, focused: renderFocus, scrollTop: scrollTop })) })) : (_jsx(CompositePane, { composite: composite, placement: placement, focused: outlineFocus, width: paneWidth, deviceName: deviceName })), _jsx(Box, { flexDirection: "column", marginLeft: sideBySide ? GUTTER : 0, marginTop: sideBySide ? 0 : 1, children: _jsx(EventsPane, { events: events, selectedSeq: selectedSeq ?? null, paused: paused, expanded: expanded, width: eventsWidth }) })] }), notice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.coral, children: notice }) })), rendering && !empty && _jsx(Text, { dimColor: true, children: APPROXIMATION_NOTE }), rendering && !empty && page && hasProductCards(page.root) && _jsx(Text, { dimColor: true, children: NO_PRODUCTS_NOTE }), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 move \u00B7 enter press \u00B7 t ", rendering ? "outline" : "render", rendering ? " · PgUp/PgDn scroll" : "", " \u00B7 p pause \u00B7 c clear \u00B7 x expand \u00B7 q back"] })] }));
|
|
74
145
|
}
|