@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610051802

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.
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Authored paywall styling → Ink props.
3
+ *
4
+ * Every value this module reads is authored in the Control Center and arrives
5
+ * verbatim in the paywall template: `fillColor`, `fontColor`, `borderColor`,
6
+ * `width`, `height`, `horizontalAlignment` / `verticalAlignment`. The shapes
7
+ * handled here are the shapes the two committed real staging templates in
8
+ * `tests/fixtures/` actually contain — notably the **space-separated**
9
+ * `rgba(1 25 50 / 0.05)` CSS form, which a comma-only parser gets wrong on real
10
+ * data, and `linear-gradient(…)` fills with two or three positioned stops.
11
+ *
12
+ * This file is pure logic: no React, no Ink import. It names the vocabularies
13
+ * Ink accepts (`Text` takes `color` / `backgroundColor` as hex strings; `Box`
14
+ * takes `width` / `height` as `number | string`, `justifyContent` of
15
+ * `flex-start|flex-end|center|space-between|space-around|space-evenly` and
16
+ * `alignItems` of `flex-start|center|flex-end|stretch`) so the screen layer can
17
+ * spread the result straight onto a component.
18
+ *
19
+ * Known limit, stated rather than papered over: a fill whose stops are liquid
20
+ * tokens (`${block.homeTeam.primaryColorLight}`, authored on the NFL templates)
21
+ * cannot be resolved here — those are resolved live against block data. Such a
22
+ * fill parses to `null`, which callers render as "no fill" rather than as a
23
+ * wrong colour.
24
+ */
25
+ /**
26
+ * The named colours worth carrying: `transparent` is authored in the real
27
+ * templates, and the CSS basic keywords cost nothing to accept. Anything else
28
+ * parses to `null` rather than being guessed at.
29
+ */
30
+ const NAMED_COLORS = {
31
+ transparent: [0, 0, 0, 0],
32
+ black: [0, 0, 0, 1],
33
+ silver: [192, 192, 192, 1],
34
+ gray: [128, 128, 128, 1],
35
+ grey: [128, 128, 128, 1],
36
+ white: [255, 255, 255, 1],
37
+ maroon: [128, 0, 0, 1],
38
+ red: [255, 0, 0, 1],
39
+ purple: [128, 0, 128, 1],
40
+ fuchsia: [255, 0, 255, 1],
41
+ green: [0, 128, 0, 1],
42
+ lime: [0, 255, 0, 1],
43
+ olive: [128, 128, 0, 1],
44
+ yellow: [255, 255, 0, 1],
45
+ navy: [0, 0, 128, 1],
46
+ blue: [0, 0, 255, 1],
47
+ teal: [0, 128, 128, 1],
48
+ aqua: [0, 255, 255, 1],
49
+ cyan: [0, 255, 255, 1],
50
+ orange: [255, 165, 0, 1],
51
+ };
52
+ /**
53
+ * Parse one authored colour.
54
+ *
55
+ * Accepts `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` / `rgba()` in both
56
+ * the comma form and the modern space form with a `/ alpha`, and the named
57
+ * colours above. Channels are clamped to 0..255 and alpha to 0..1, so a
58
+ * mis-authored value renders as something rather than as `NaN`.
59
+ *
60
+ * Returns `null` for anything else — including an unresolved `${…}` liquid
61
+ * token, which is a value we genuinely do not know yet, not a parse failure.
62
+ */
63
+ export function parseColor(value) {
64
+ if (typeof value !== "string")
65
+ return null;
66
+ const raw = value.trim();
67
+ if (raw === "")
68
+ return null;
69
+ const named = NAMED_COLORS[raw.toLowerCase()];
70
+ if (named)
71
+ return rgb(named[0], named[1], named[2], named[3]);
72
+ if (raw.startsWith("#"))
73
+ return parseHex(raw.slice(1));
74
+ const fn = /^rgba?\(([^)]*)\)$/i.exec(raw);
75
+ if (fn)
76
+ return parseRgbBody(fn[1]);
77
+ return null;
78
+ }
79
+ /**
80
+ * Parse an authored fill: a solid colour, or a `linear-gradient(…)`.
81
+ *
82
+ * A gradient's angle follows CSS — `180deg` runs top to bottom, `0deg` bottom
83
+ * to top — and defaults to `180deg` when the declaration omits it. Stops
84
+ * without a position are distributed evenly. If any stop's colour cannot be
85
+ * parsed the whole fill is `null`: half a gradient is worse than none.
86
+ */
87
+ export function parseFill(value) {
88
+ if (typeof value !== "string")
89
+ return null;
90
+ const raw = value.trim();
91
+ if (raw === "")
92
+ return null;
93
+ const gradient = /^linear-gradient\(([\s\S]*)\)$/i.exec(raw);
94
+ if (gradient)
95
+ return parseLinearGradient(gradient[1]);
96
+ const color = parseColor(raw);
97
+ return color ? { kind: "solid", color } : null;
98
+ }
99
+ /** The `#rrggbb` string Ink's `color` / `backgroundColor` props want. Alpha is dropped — a terminal cell has none. */
100
+ export function toHex(c) {
101
+ return `#${channelHex(c.r)}${channelHex(c.g)}${channelHex(c.b)}`;
102
+ }
103
+ /**
104
+ * Flatten `fg` over `bg` (source-over compositing), which is how a translucent
105
+ * authored fill becomes the single opaque colour a terminal cell can show.
106
+ */
107
+ export function blend(fg, bg) {
108
+ const fa = clamp(fg.a, 0, 1);
109
+ const ba = clamp(bg.a, 0, 1);
110
+ const a = fa + ba * (1 - fa);
111
+ if (a === 0)
112
+ return rgb(0, 0, 0, 0);
113
+ const mix = (f, b) => (f * fa + b * ba * (1 - fa)) / a;
114
+ return rgb(mix(fg.r, bg.r), mix(fg.g, bg.g), mix(fg.b, bg.b), a);
115
+ }
116
+ /**
117
+ * Sample a fill at `t`, 0 at the top of the box and 1 at the bottom.
118
+ *
119
+ * A solid fill answers with its colour at any `t`, so a caller can treat every
120
+ * fill the same way. A gradient is projected onto the vertical axis by its
121
+ * angle — `180deg` maps straight onto `t`, `0deg` reverses it, and a purely
122
+ * horizontal gradient collapses to its midpoint, because one terminal row has
123
+ * exactly one background colour.
124
+ */
125
+ export function sampleGradient(fill, t) {
126
+ if (!fill)
127
+ return null;
128
+ if (fill.kind === "solid")
129
+ return fill.color;
130
+ if (fill.stops.length === 0)
131
+ return null;
132
+ const along = clamp(project(clamp(t, 0, 1), fill.angle), 0, 1);
133
+ const stops = [...fill.stops].sort((a, b) => a.at - b.at);
134
+ const first = stops[0];
135
+ const last = stops[stops.length - 1];
136
+ if (along <= first.at)
137
+ return first.color;
138
+ if (along >= last.at)
139
+ return last.color;
140
+ for (let i = 1; i < stops.length; i += 1) {
141
+ const prev = stops[i - 1];
142
+ const next = stops[i];
143
+ if (along > next.at)
144
+ continue;
145
+ const span = next.at - prev.at;
146
+ const f = span === 0 ? 0 : (along - prev.at) / span;
147
+ return rgb(lerp(prev.color.r, next.color.r, f), lerp(prev.color.g, next.color.g, f), lerp(prev.color.b, next.color.b, f), lerp(prev.color.a, next.color.a, f));
148
+ }
149
+ return last.color;
150
+ }
151
+ /**
152
+ * A terminal cell is roughly twice as tall as it is wide, so a row is worth
153
+ * twice the device pixels of a column. That single constant is what makes a
154
+ * 393×852 phone at 44 columns come out about 48 rows — close enough to the
155
+ * real 2.17:1 aspect that a page reads as the right shape.
156
+ */
157
+ export const CELL_ASPECT = 2;
158
+ /**
159
+ * Build the scale for a device viewport rendered into `columns` terminal
160
+ * columns. `devicePx.height` is not needed for the scale itself — callers pass
161
+ * it through `toCells` to get the row count — but it belongs in the viewport
162
+ * the caller already holds, so the signature takes the whole thing.
163
+ */
164
+ export function cellScale(devicePx, columns) {
165
+ const cols = Number.isFinite(columns) && columns > 0 ? columns : 1;
166
+ const width = Number.isFinite(devicePx?.width) && devicePx.width > 0 ? devicePx.width : cols;
167
+ const pxPerCol = width / cols;
168
+ return { pxPerCol, pxPerRow: pxPerCol * CELL_ASPECT };
169
+ }
170
+ /**
171
+ * Device pixels → whole cells on one axis. A size the author meant to be
172
+ * visible never rounds away to nothing: anything above zero is at least one
173
+ * cell. Zero and below are zero.
174
+ */
175
+ export function toCells(px, axis, scale) {
176
+ if (!Number.isFinite(px) || px <= 0)
177
+ return 0;
178
+ const per = axis === "width" ? scale.pxPerCol : scale.pxPerRow;
179
+ if (!Number.isFinite(per) || per <= 0)
180
+ return 0;
181
+ return Math.max(1, Math.round(px / per));
182
+ }
183
+ /**
184
+ * Parse an authored `width` / `height`: `"100%"`, `"fitContent"`, or a number
185
+ * of device pixels. Anything else — a missing value, a form we do not know —
186
+ * is content sizing, which is what Ink does when the prop is absent.
187
+ */
188
+ export function parseSize(value, axis, scale) {
189
+ if (typeof value === "number") {
190
+ return Number.isFinite(value) ? { kind: "cells", value: toCells(value, axis, scale) } : { kind: "content" };
191
+ }
192
+ if (typeof value !== "string")
193
+ return { kind: "content" };
194
+ const raw = value.trim();
195
+ if (raw === "")
196
+ return { kind: "content" };
197
+ if (raw.toLowerCase() === "fitcontent")
198
+ return { kind: "content" };
199
+ const percent = /^(-?\d+(?:\.\d+)?)%$/.exec(raw);
200
+ if (percent)
201
+ return { kind: "percent", value: Math.max(0, Number(percent[1])) };
202
+ const pixels = /^(-?\d+(?:\.\d+)?)(?:px)?$/i.exec(raw);
203
+ if (pixels)
204
+ return { kind: "cells", value: toCells(Number(pixels[1]), axis, scale) };
205
+ return { kind: "content" };
206
+ }
207
+ /** A `Size` as Ink's `width` / `height` prop: a percent string, a cell count, or absent. */
208
+ export function toInkSize(size) {
209
+ switch (size.kind) {
210
+ case "percent":
211
+ return `${size.value}%`;
212
+ case "cells":
213
+ return size.value;
214
+ default:
215
+ return undefined;
216
+ }
217
+ }
218
+ /**
219
+ * Authored alignment words → the flex edge they mean. Every value here is one
220
+ * Ink accepts on both `justifyContent` and `alignItems`, which is why one table
221
+ * serves both axes.
222
+ */
223
+ const EDGE = {
224
+ left: "flex-start",
225
+ start: "flex-start",
226
+ top: "flex-start",
227
+ center: "center",
228
+ middle: "center",
229
+ right: "flex-end",
230
+ end: "flex-end",
231
+ bottom: "flex-end",
232
+ };
233
+ /**
234
+ * Authored `horizontalAlignment` / `verticalAlignment` → Ink's `justifyContent`
235
+ * and `alignItems`, which axis is which depending on the container's
236
+ * `flexDirection`. An axis the template did not author is left off entirely so
237
+ * Ink keeps its own default; a word we do not know is treated the same way.
238
+ */
239
+ export function alignmentProps(horizontal, vertical, direction) {
240
+ const main = edge(direction === "row" ? horizontal : vertical);
241
+ const cross = edge(direction === "row" ? vertical : horizontal);
242
+ const props = {};
243
+ if (main)
244
+ props.justifyContent = main;
245
+ if (cross)
246
+ props.alignItems = cross;
247
+ return props;
248
+ }
249
+ /**
250
+ * Where something narrower than its box sits in it: 0 at the near edge, 0.5
251
+ * centred, 1 at the far edge.
252
+ *
253
+ * Yoga positions a *node* from `alignmentProps`, but an image is smaller than
254
+ * the node it was given — it keeps its own shape — so the picture has to be
255
+ * placed inside that box by hand, and this is the same authored word read the
256
+ * same way. Anything unrecognised centres, which is what both renderers did
257
+ * unconditionally before: the NFL+ logo is authored `alignment: left` and sat
258
+ * in the middle of its box.
259
+ */
260
+ export function alignmentFraction(value) {
261
+ switch (edge(value)) {
262
+ case "flex-start":
263
+ return 0;
264
+ case "flex-end":
265
+ return 1;
266
+ default:
267
+ return 0.5;
268
+ }
269
+ }
270
+ function edge(value) {
271
+ if (typeof value !== "string")
272
+ return undefined;
273
+ return EDGE[value.trim().toLowerCase()];
274
+ }
275
+ function parseHex(digits) {
276
+ if (!/^[0-9a-f]+$/i.test(digits))
277
+ return null;
278
+ const expand = (pair) => parseInt(pair.length === 1 ? pair + pair : pair, 16);
279
+ switch (digits.length) {
280
+ case 3:
281
+ case 4: {
282
+ const parts = digits.split("");
283
+ return rgb(expand(parts[0]), expand(parts[1]), expand(parts[2]), parts[3] === undefined ? 1 : expand(parts[3]) / 255);
284
+ }
285
+ case 6:
286
+ case 8: {
287
+ const pairs = digits.match(/.{2}/g);
288
+ return rgb(expand(pairs[0]), expand(pairs[1]), expand(pairs[2]), pairs[3] === undefined ? 1 : expand(pairs[3]) / 255);
289
+ }
290
+ default:
291
+ return null;
292
+ }
293
+ }
294
+ /** The inside of an `rgb()` / `rgba()`, in either the comma form or the space + `/ alpha` form. */
295
+ function parseRgbBody(body) {
296
+ const [channelPart, alphaPart] = body.includes("/") ? splitOnce(body, "/") : [body, undefined];
297
+ const channels = channelPart.split(/[\s,]+/).filter((part) => part !== "");
298
+ const alphaTokens = alphaPart === undefined ? [] : alphaPart.split(/[\s,]+/).filter((part) => part !== "");
299
+ if (channels.length === 4 && alphaTokens.length === 0)
300
+ alphaTokens.push(channels.pop());
301
+ if (channels.length !== 3)
302
+ return null;
303
+ if (alphaTokens.length > 1)
304
+ return null;
305
+ const numbers = channels.map((part) => channelValue(part, 255));
306
+ if (numbers.some((value) => value === null))
307
+ return null;
308
+ const alpha = alphaTokens.length === 1 ? channelValue(alphaTokens[0], 1) : 1;
309
+ if (alpha === null)
310
+ return null;
311
+ return rgb(numbers[0], numbers[1], numbers[2], alpha);
312
+ }
313
+ /** One `rgb()` argument: a number, or a percentage of `full`. */
314
+ function channelValue(token, full) {
315
+ const match = /^(-?\d+(?:\.\d+)?)(%?)$/.exec(token);
316
+ if (!match)
317
+ return null;
318
+ const value = Number(match[1]);
319
+ if (!Number.isFinite(value))
320
+ return null;
321
+ return match[2] === "%" ? (value / 100) * full : value;
322
+ }
323
+ function parseLinearGradient(body) {
324
+ const parts = splitTopLevel(body);
325
+ if (parts.length === 0)
326
+ return null;
327
+ let angle = 180;
328
+ const first = parts[0].trim();
329
+ const degrees = /^(-?\d+(?:\.\d+)?)deg$/i.exec(first);
330
+ if (degrees) {
331
+ angle = Number(degrees[1]);
332
+ parts.shift();
333
+ }
334
+ else if (/^to\s+/i.test(first)) {
335
+ // `to bottom` and friends are valid CSS but are not authored by the Control
336
+ // Center, so they are not translated — refuse rather than guess.
337
+ return null;
338
+ }
339
+ if (parts.length < 2)
340
+ return null;
341
+ const raw = [];
342
+ for (const part of parts) {
343
+ const stop = parseStop(part);
344
+ if (!stop)
345
+ return null;
346
+ raw.push(stop);
347
+ }
348
+ const stops = raw.map((stop, index) => ({
349
+ color: stop.color,
350
+ at: stop.at ?? (raw.length === 1 ? 0 : index / (raw.length - 1)),
351
+ }));
352
+ return { kind: "gradient", angle, stops };
353
+ }
354
+ /** One gradient stop: a colour, optionally followed by a position. */
355
+ function parseStop(part) {
356
+ const raw = part.trim();
357
+ if (raw === "")
358
+ return null;
359
+ const positioned = /^([\s\S]*?)\s+(-?\d+(?:\.\d+)?)%$/.exec(raw);
360
+ const colorText = positioned ? positioned[1] : raw;
361
+ const color = parseColor(colorText);
362
+ if (!color)
363
+ return null;
364
+ return { color, at: positioned ? clamp(Number(positioned[2]) / 100, 0, 1) : null };
365
+ }
366
+ /** Split on commas that are not inside parentheses, so `rgb(1, 25, 50)` survives. */
367
+ function splitTopLevel(body) {
368
+ const parts = [];
369
+ let depth = 0;
370
+ let current = "";
371
+ for (const char of body) {
372
+ if (char === "(")
373
+ depth += 1;
374
+ if (char === ")")
375
+ depth -= 1;
376
+ if (char === "," && depth === 0) {
377
+ parts.push(current);
378
+ current = "";
379
+ continue;
380
+ }
381
+ current += char;
382
+ }
383
+ parts.push(current);
384
+ return parts.map((part) => part.trim()).filter((part) => part !== "");
385
+ }
386
+ function splitOnce(value, separator) {
387
+ const index = value.indexOf(separator);
388
+ return [value.slice(0, index), value.slice(index + separator.length)];
389
+ }
390
+ /**
391
+ * How much of a gradient's run shows along the vertical axis, given its CSS
392
+ * angle. `180deg` (top to bottom) is 1, `0deg` is -1, and horizontal is 0 —
393
+ * snapped exactly, since `Math.cos` of a right angle is not quite zero.
394
+ */
395
+ function project(t, angle) {
396
+ const vertical = -Math.cos((angle * Math.PI) / 180);
397
+ const snapped = Math.abs(vertical) < 1e-9 ? 0 : vertical;
398
+ return 0.5 + (t - 0.5) * snapped;
399
+ }
400
+ function lerp(from, to, f) {
401
+ return from + (to - from) * f;
402
+ }
403
+ function channelHex(value) {
404
+ return Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0");
405
+ }
406
+ function clamp(value, min, max) {
407
+ if (!Number.isFinite(value))
408
+ return min;
409
+ return Math.min(max, Math.max(min, value));
410
+ }
411
+ function rgb(r, g, b, a) {
412
+ return { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255), a: clamp(a, 0, 1) };
413
+ }