synthesisui 0.16.80 → 0.16.82

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,584 @@
1
+ /**
2
+ * THE VARIANT STYLES, READ OUT OF THE SOURCE - deterministically, no model.
3
+ *
4
+ * Every imported component arrived with `variants[axis][option] = {}`: the axes came
5
+ * from the TYPE, which says an axis EXISTS and nothing about what it does. So a real
6
+ * `Button` with nine variants and six sizes previewed as one look, and you were seeing
7
+ * a third of the component (dono, 01/08).
8
+ *
9
+ * The styling was sitting in the file the whole time, structured:
10
+ *
11
+ * const buttonVariants = cva("inline-flex rounded-[var(--radius-md)] …", {
12
+ * variants: {
13
+ * variant: { ocean: "bg-ocean-950 text-white hover:bg-ocean-900 dark:…" },
14
+ * size: { sm: "h-10 px-3 [font-size:var(--text-body-s)]" },
15
+ * },
16
+ * defaultVariants: { variant: "vibrant-purple", size: "md" },
17
+ * })
18
+ *
19
+ * Nine real colours, six real heights, and their own hover per variant. An object
20
+ * literal, so reading it needs no model and no guess - which is the whole rule this
21
+ * codebase runs on: deterministic first, a model only for what genuinely has no form.
22
+ *
23
+ * WHAT IT READS
24
+ * cva(base, { variants, defaultVariants, compoundVariants })
25
+ * tv({ base, variants, defaultVariants }) - tailwind-variants
26
+ * cn("always", cond && "sometimes") - where the condition names a
27
+ * prop and a value
28
+ *
29
+ * SOURCE ORDER IS PRESERVED, and that is not tidiness. Two classes touching one
30
+ * property resolve in their app by position in the string; a recipe resolves by
31
+ * position in `layers`. They agree only if the reader inserts in the order it read.
32
+ */
33
+ import { parseClass, transcribe } from "./transcribe.js";
34
+ /**
35
+ * A Tailwind modifier, mapped to the condition it IS.
36
+ *
37
+ * `group-hover:` is the whole reason `within` exists: it means "when the thing around
38
+ * me is hovered", which is a different claim from `hover:` and had no expression at
39
+ * all before. `peer-*` is the sibling version of the same idea and reads the same way
40
+ * from a recipe's point of view.
41
+ */
42
+ const MODIFIER_STATE = {
43
+ hover: "hover",
44
+ focus: "focus",
45
+ "focus-visible": "focusVisible",
46
+ "focus-within": "focus",
47
+ active: "active",
48
+ disabled: "disabled",
49
+ checked: "checked",
50
+ indeterminate: "checked",
51
+ selected: "selected",
52
+ open: "open",
53
+ invalid: "invalid",
54
+ required: "required",
55
+ "read-only": "readOnly",
56
+ placeholder: "placeholder",
57
+ even: "even",
58
+ odd: "odd",
59
+ first: "first",
60
+ last: "last",
61
+ empty: "empty",
62
+ "aria-checked": "checked",
63
+ "aria-selected": "selected",
64
+ "aria-expanded": "open",
65
+ "aria-invalid": "invalid",
66
+ "aria-busy": "loading",
67
+ };
68
+ /** `group-hover`, `group-focus`, `peer-checked` - the state of something ELSE. */
69
+ const RELATIONAL = /^(?:group|peer)-(.+)$/;
70
+ /** `data-[state=open]:`, `data-[checked]:` - how @base-ui and Radix spell a state. */
71
+ const DATA_MODIFIER = /^data-\[(?:state=)?([a-z-]+)\]$/;
72
+ /** A breakpoint is a bare name we cannot read as a state. Their scale names them, so
73
+ * the caller decides whether the name exists rather than this guessing a width. */
74
+ const BREAKPOINT = /^(?:xs|sm|md|lg|xl|2xl|3xl|tablet|desktop|wide|mobile)$/;
75
+ /** What a modifier chain means, as a condition. Order inside the chain does not
76
+ * matter to CSS, so this folds rather than sequences. */
77
+ export function conditionOf(modifiers) {
78
+ const out = {};
79
+ for (const raw of modifiers) {
80
+ if (raw === "dark") {
81
+ // The dark half has its own home in the transcription and no slot inside a
82
+ // layer. Saying so beats writing the light value into a dark rule.
83
+ out.lost = true;
84
+ continue;
85
+ }
86
+ const relational = RELATIONAL.exec(raw);
87
+ if (relational) {
88
+ const state = MODIFIER_STATE[relational[1]];
89
+ if (state)
90
+ out.within = state;
91
+ else
92
+ out.lost = true;
93
+ continue;
94
+ }
95
+ const data = DATA_MODIFIER.exec(raw);
96
+ if (data) {
97
+ const state = MODIFIER_STATE[data[1]];
98
+ if (state)
99
+ out.state = state;
100
+ else
101
+ out.lost = true;
102
+ continue;
103
+ }
104
+ const state = MODIFIER_STATE[raw];
105
+ if (state) {
106
+ out.state = state;
107
+ continue;
108
+ }
109
+ if (BREAKPOINT.test(raw)) {
110
+ out.at = raw;
111
+ continue;
112
+ }
113
+ out.lost = true;
114
+ }
115
+ return out;
116
+ }
117
+ /** A stable key for a condition, so classes sharing one land in one layer. */
118
+ function keyOf(c, variant) {
119
+ const v = Object.entries(variant ?? {})
120
+ .sort(([a], [b]) => a.localeCompare(b))
121
+ .map(([k, o]) => `${k}=${o}`)
122
+ .join("&");
123
+ return `${v}|${c.state ?? ""}|${c.within ?? ""}|${c.at ?? ""}`;
124
+ }
125
+ /**
126
+ * Split a class list into the base and the conditional layers it contains.
127
+ *
128
+ * `variant` travels through untouched, so the ghost button's own hover comes out as
129
+ * `{ variant: { variant: "ghost" }, state: "hover" }` - one selector, which is exactly
130
+ * what one hover per element could never say.
131
+ */
132
+ export function splitByCondition(classes, variant) {
133
+ const base = [];
134
+ const unslotted = [];
135
+ // A Map keeps insertion order, which IS the cascade order we promise to preserve.
136
+ const layers = new Map();
137
+ for (const cls of classes) {
138
+ const { modifiers } = parseClass(cls);
139
+ if (modifiers.length === 0) {
140
+ if (variant) {
141
+ const key = keyOf({}, variant);
142
+ const at = layers.get(key) ?? { when: { variant }, classes: [] };
143
+ at.classes.push(cls);
144
+ layers.set(key, at);
145
+ }
146
+ else {
147
+ base.push(cls);
148
+ }
149
+ continue;
150
+ }
151
+ const cond = conditionOf(modifiers);
152
+ if (cond.lost) {
153
+ unslotted.push(cls);
154
+ continue;
155
+ }
156
+ const key = keyOf(cond, variant);
157
+ const layer = layers.get(key) ??
158
+ {
159
+ ...(variant || cond.state || cond.within
160
+ ? {
161
+ when: {
162
+ ...(variant ? { variant } : {}),
163
+ ...(cond.state ? { state: cond.state } : {}),
164
+ ...(cond.within ? { within: cond.within } : {}),
165
+ },
166
+ }
167
+ : {}),
168
+ ...(cond.at ? { at: cond.at } : {}),
169
+ classes: [],
170
+ };
171
+ layer.classes.push(cls);
172
+ layers.set(key, layer);
173
+ }
174
+ return { base, layers: [...layers.values()], unslotted };
175
+ }
176
+ // ---------------------------------------------------------------------------
177
+ // Reading the literal
178
+ // ---------------------------------------------------------------------------
179
+ /** From an opening delimiter, the index just past its match. Depth-counted, and
180
+ * string-aware so a brace inside a class list cannot end the object early. */
181
+ function endOf(source, open) {
182
+ const pairs = { "(": ")", "{": "}", "[": "]" };
183
+ const close = pairs[source[open]];
184
+ if (!close)
185
+ return -1;
186
+ let depth = 0;
187
+ let quote = null;
188
+ for (let i = open; i < source.length; i++) {
189
+ const ch = source[i];
190
+ if (quote) {
191
+ if (ch === "\\")
192
+ i += 1;
193
+ else if (ch === quote)
194
+ quote = null;
195
+ continue;
196
+ }
197
+ if (ch === '"' || ch === "'" || ch === "`") {
198
+ quote = ch;
199
+ continue;
200
+ }
201
+ if (ch === source[open])
202
+ depth += 1;
203
+ else if (ch === close) {
204
+ depth -= 1;
205
+ if (depth === 0)
206
+ return i + 1;
207
+ }
208
+ }
209
+ return -1;
210
+ }
211
+ /**
212
+ * Every string literal in a fragment, concatenated.
213
+ *
214
+ * A real `cva` base is two strings joined by `+` across a line break, and a template
215
+ * literal with no interpolation is the same thing spelled differently. Anything with
216
+ * a `${}` in it is a runtime value and is left alone rather than half-read.
217
+ */
218
+ export function literalClasses(fragment) {
219
+ const out = [];
220
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|`([^`]*)`/g;
221
+ for (const m of fragment.matchAll(re)) {
222
+ const raw = m[1] ?? m[2] ?? m[3] ?? "";
223
+ if (m[3] != null && raw.includes("${"))
224
+ continue;
225
+ out.push(...raw.split(/\s+/).filter(Boolean));
226
+ }
227
+ return out;
228
+ }
229
+ /**
230
+ * Line and block comments removed, quotes respected.
231
+ *
232
+ * A real `cva` annotates its sizes - `sm: "h-10 px-3", // 32px` - and without this the
233
+ * comment became part of the NEXT key: the axis came back with an option literally
234
+ * called `"// 24px\n sm"` (probe, 01/08).
235
+ */
236
+ export function stripComments(body) {
237
+ let out = "";
238
+ let quote = null;
239
+ for (let i = 0; i < body.length; i++) {
240
+ const ch = body[i];
241
+ if (quote) {
242
+ out += ch;
243
+ if (ch === "\\") {
244
+ out += body[i + 1] ?? "";
245
+ i += 1;
246
+ }
247
+ else if (ch === quote) {
248
+ quote = null;
249
+ }
250
+ continue;
251
+ }
252
+ if (ch === '"' || ch === "'" || ch === "`") {
253
+ quote = ch;
254
+ out += ch;
255
+ continue;
256
+ }
257
+ if (ch === "/" && body[i + 1] === "/") {
258
+ const nl = body.indexOf("\n", i);
259
+ if (nl === -1)
260
+ break;
261
+ i = nl - 1;
262
+ continue;
263
+ }
264
+ if (ch === "/" && body[i + 1] === "*") {
265
+ const close = body.indexOf("*/", i + 2);
266
+ if (close === -1)
267
+ break;
268
+ i = close + 1;
269
+ continue;
270
+ }
271
+ out += ch;
272
+ }
273
+ return out;
274
+ }
275
+ /** `key: <value>` at the top level of an object body, value returned verbatim. */
276
+ function topLevelEntries(raw) {
277
+ const body = stripComments(raw);
278
+ const out = [];
279
+ let i = 0;
280
+ let quote = null;
281
+ let depth = 0;
282
+ let keyStart = 0;
283
+ let key = null;
284
+ let valueStart = 0;
285
+ for (; i < body.length; i++) {
286
+ const ch = body[i];
287
+ if (quote) {
288
+ if (ch === "\\")
289
+ i += 1;
290
+ else if (ch === quote)
291
+ quote = null;
292
+ continue;
293
+ }
294
+ if (ch === '"' || ch === "'" || ch === "`") {
295
+ quote = ch;
296
+ continue;
297
+ }
298
+ if ("([{".includes(ch)) {
299
+ depth += 1;
300
+ continue;
301
+ }
302
+ if (")]}".includes(ch)) {
303
+ depth -= 1;
304
+ continue;
305
+ }
306
+ if (depth !== 0)
307
+ continue;
308
+ if (ch === ":" && key === null) {
309
+ key = body
310
+ .slice(keyStart, i)
311
+ .trim()
312
+ .replace(/^["']|["']$/g, "");
313
+ valueStart = i + 1;
314
+ continue;
315
+ }
316
+ if (ch === "," && key !== null) {
317
+ out.push({ key, value: body.slice(valueStart, i) });
318
+ key = null;
319
+ keyStart = i + 1;
320
+ }
321
+ }
322
+ if (key !== null)
323
+ out.push({ key, value: body.slice(valueStart) });
324
+ return out;
325
+ }
326
+ /** The first `{` at the top level of an argument list - the options object. */
327
+ function topLevelBrace(args) {
328
+ let quote = null;
329
+ let depth = 0;
330
+ for (let i = 0; i < args.length; i++) {
331
+ const ch = args[i];
332
+ if (quote) {
333
+ if (ch === "\\")
334
+ i += 1;
335
+ else if (ch === quote)
336
+ quote = null;
337
+ continue;
338
+ }
339
+ if (ch === '"' || ch === "'" || ch === "`") {
340
+ quote = ch;
341
+ continue;
342
+ }
343
+ if (ch === "{" && depth === 0)
344
+ return i;
345
+ if ("([".includes(ch))
346
+ depth += 1;
347
+ else if (")]".includes(ch))
348
+ depth -= 1;
349
+ }
350
+ return -1;
351
+ }
352
+ /** The raw value of `name:` in an object body, or null. */
353
+ function valueAt(body, name) {
354
+ for (const entry of topLevelEntries(body)) {
355
+ if (entry.key === name)
356
+ return entry.value;
357
+ }
358
+ return null;
359
+ }
360
+ /** The body of `name: { … }` inside an object body, or null. */
361
+ function objectAt(body, name) {
362
+ for (const entry of topLevelEntries(body)) {
363
+ if (entry.key !== name)
364
+ continue;
365
+ const open = entry.value.indexOf("{");
366
+ if (open === -1)
367
+ return null;
368
+ const end = endOf(entry.value, open);
369
+ return end === -1 ? null : entry.value.slice(open + 1, end - 1);
370
+ }
371
+ return null;
372
+ }
373
+ /** `const NAME = cva(` / `= tv(` - the assignment, so a call site can find it. */
374
+ const VARIANT_CALL = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(cva|tv|cn)\s*\(/g;
375
+ /**
376
+ * Every variant definition in a file, in source order.
377
+ *
378
+ * A file usually has one; a compound component has one per part, which is why the
379
+ * symbol travels - `cardVariants` and `cardHeaderVariants` are two different things
380
+ * and only their names say which is which.
381
+ */
382
+ export function readVariants(source) {
383
+ const out = [];
384
+ VARIANT_CALL.lastIndex = 0;
385
+ for (const m of source.matchAll(VARIANT_CALL)) {
386
+ const symbol = m[1];
387
+ const kind = m[2];
388
+ const open = (m.index ?? 0) + m[0].length - 1;
389
+ const end = endOf(source, open);
390
+ if (end === -1)
391
+ continue;
392
+ const args = source.slice(open + 1, end - 1);
393
+ /**
394
+ * TWO LEVELS, AND CONFLATING THEM READ NOTHING.
395
+ *
396
+ * `cva("base classes", { variants: … })` is an argument list whose SECOND item is
397
+ * the options object. Reading `variants` off the argument list finds nothing,
398
+ * because at that level `variants:` sits one brace deep - the first version came
399
+ * back with zero axes from a file declaring fifteen (probe, 01/08).
400
+ */
401
+ const optionsOpen = topLevelBrace(args);
402
+ const optionsEnd = optionsOpen === -1 ? -1 : endOf(args, optionsOpen);
403
+ const options = optionsOpen === -1 || optionsEnd === -1
404
+ ? ""
405
+ : args.slice(optionsOpen + 1, optionsEnd - 1);
406
+ // `tv({ base: … })` names its base; `cva(base, { … })` puts it first.
407
+ const leadingBase = kind === "tv" || optionsOpen === -1 ? "" : args.slice(0, optionsOpen);
408
+ const namedBase = valueAt(options, "base");
409
+ const read = {
410
+ symbol,
411
+ base: literalClasses(leadingBase || namedBase || ""),
412
+ variants: {},
413
+ defaults: {},
414
+ layers: [],
415
+ unslotted: [],
416
+ };
417
+ // The base's own modifiers are layers too: a `cva` base carries
418
+ // `disabled:opacity-50` and `focus-visible:ring-2` before any variant exists.
419
+ const split = splitByCondition(read.base);
420
+ read.base = split.base;
421
+ read.layers.push(...split.layers);
422
+ read.unslotted.push(...split.unslotted);
423
+ const variantsBody = objectAt(options, "variants");
424
+ if (variantsBody) {
425
+ for (const axis of topLevelEntries(variantsBody)) {
426
+ const openBrace = axis.value.indexOf("{");
427
+ if (openBrace === -1)
428
+ continue;
429
+ const axisEnd = endOf(axis.value, openBrace);
430
+ if (axisEnd === -1)
431
+ continue;
432
+ const axisBody = axis.value.slice(openBrace + 1, axisEnd - 1);
433
+ for (const option of topLevelEntries(axisBody)) {
434
+ const classes = literalClasses(option.value);
435
+ if (classes.length === 0)
436
+ continue;
437
+ read.variants[axis.key] ??= {};
438
+ read.variants[axis.key][option.key] = classes;
439
+ const per = splitByCondition(classes, { [axis.key]: option.key });
440
+ read.layers.push(...per.layers);
441
+ read.unslotted.push(...per.unslotted);
442
+ }
443
+ }
444
+ }
445
+ const defaultsBody = objectAt(options, "defaultVariants");
446
+ if (defaultsBody) {
447
+ for (const entry of topLevelEntries(defaultsBody)) {
448
+ const value = literalClasses(entry.value)[0];
449
+ if (value)
450
+ read.defaults[entry.key] = value;
451
+ }
452
+ }
453
+ /**
454
+ * COMPOUND VARIANTS ARE THE CASE `layers` WAS SHAPED FOR.
455
+ *
456
+ * `{ variant: "ghost", size: "sm", class: "px-2" }` is two axes holding at once,
457
+ * which a flat `variants` map cannot express at all - it has one key per axis. It
458
+ * comes out as one layer with two conditions and one selector.
459
+ */
460
+ const compoundIdx = options.search(/compoundVariants\s*:\s*\[/);
461
+ if (compoundIdx !== -1) {
462
+ const arrOpen = options.indexOf("[", compoundIdx);
463
+ const arrEnd = endOf(options, arrOpen);
464
+ if (arrEnd !== -1) {
465
+ const arr = options.slice(arrOpen + 1, arrEnd - 1);
466
+ let cursor = 0;
467
+ while (cursor < arr.length) {
468
+ const objOpen = arr.indexOf("{", cursor);
469
+ if (objOpen === -1)
470
+ break;
471
+ const objEnd = endOf(arr, objOpen);
472
+ if (objEnd === -1)
473
+ break;
474
+ const body = arr.slice(objOpen + 1, objEnd - 1);
475
+ const variant = {};
476
+ let classes = [];
477
+ for (const entry of topLevelEntries(body)) {
478
+ if (entry.key === "class" || entry.key === "className") {
479
+ classes = literalClasses(entry.value);
480
+ continue;
481
+ }
482
+ const value = literalClasses(entry.value)[0];
483
+ if (value)
484
+ variant[entry.key] = value;
485
+ }
486
+ if (classes.length > 0 && Object.keys(variant).length > 0) {
487
+ const per = splitByCondition(classes, variant);
488
+ read.layers.push(...per.layers);
489
+ read.unslotted.push(...per.unslotted);
490
+ }
491
+ cursor = objEnd;
492
+ }
493
+ }
494
+ }
495
+ out.push(read);
496
+ }
497
+ return out;
498
+ }
499
+ /**
500
+ * A CONDITIONAL CLASS EXPRESSION - the other half of how real components style.
501
+ *
502
+ * `cn("base", variant === "solid" && "bg-primary", disabled && "opacity-50")`. The
503
+ * literal is right there beside the condition, and when the condition names a prop
504
+ * and a value it names a variant. When it names only a prop (`disabled && …`) it reads
505
+ * as that prop being truthy, which for a boolean IS the variant.
506
+ *
507
+ * Read separately from `cva` because a file can have both, and because this shape is
508
+ * what a codebase that never adopted a variant library uses everywhere.
509
+ */
510
+ const INLINE_COND = /([A-Za-z_$][\w$.]*)\s*(?:===|==)\s*["']([^"']+)["']\s*&&\s*(["'`])((?:(?!\3).)*)\3|([A-Za-z_$][\w$]*)\s*&&\s*(["'`])((?:(?!\6).)*)\6/g;
511
+ export function readInlineConditions(source) {
512
+ const layers = [];
513
+ INLINE_COND.lastIndex = 0;
514
+ for (const m of source.matchAll(INLINE_COND)) {
515
+ const prop = (m[1] ?? m[5] ?? "").split(".").pop() ?? "";
516
+ const option = m[2] ?? "true";
517
+ const raw = m[4] ?? m[7] ?? "";
518
+ if (!prop || raw.includes("${"))
519
+ continue;
520
+ const classes = raw.split(/\s+/).filter(Boolean);
521
+ if (classes.length === 0)
522
+ continue;
523
+ const per = splitByCondition(classes, { [prop]: option });
524
+ layers.push(...per.layers);
525
+ }
526
+ return layers;
527
+ }
528
+ /**
529
+ * The whole reading of one file's variant styling, transcribed - ready for a recipe.
530
+ *
531
+ * `declared` is their own custom properties, so `bg-ocean-950` comes out as a ref to
532
+ * the token they declared rather than as a hex we looked up.
533
+ */
534
+ export function transcribeVariants(source, declared) {
535
+ const reads = readVariants(source);
536
+ const inline = readInlineConditions(source);
537
+ const axes = {};
538
+ const defaults = {};
539
+ const allLayers = [];
540
+ const baseClasses = [];
541
+ const unslotted = [];
542
+ for (const read of reads) {
543
+ baseClasses.push(...read.base);
544
+ allLayers.push(...read.layers);
545
+ unslotted.push(...read.unslotted);
546
+ Object.assign(defaults, read.defaults);
547
+ for (const [axis, options] of Object.entries(read.variants)) {
548
+ const names = Object.keys(options);
549
+ // Two options is what makes a closed set somebody chose from - the same rule
550
+ // the contract writer and the doctor already follow.
551
+ if (names.length >= 2)
552
+ axes[axis] = names;
553
+ }
554
+ }
555
+ allLayers.push(...inline);
556
+ const layers = allLayers
557
+ .map((layer) => {
558
+ /**
559
+ * THE MODIFIER COMES OFF BEFORE THE TRANSCRIPTION.
560
+ *
561
+ * The condition is already carried by the layer, so `hover:bg-ocean-900` has to
562
+ * arrive as `bg-ocean-900`. Left on, `transcribe` correctly filed it under
563
+ * `states.hover` - and this reads `base`, so every hover in a nine-variant
564
+ * button vanished (probe, 01/08).
565
+ */
566
+ const bare = layer.classes.map((cls) => parseClass(cls).utility);
567
+ const t = transcribe(bare, declared);
568
+ const style = { ...t.base, ...t.dark };
569
+ return {
570
+ ...(layer.when ? { when: layer.when } : {}),
571
+ ...(layer.at ? { at: layer.at } : {}),
572
+ style,
573
+ };
574
+ })
575
+ .filter((layer) => Object.keys(layer.style).length > 0);
576
+ return {
577
+ reads,
578
+ axes,
579
+ defaults,
580
+ layers,
581
+ base: transcribe(baseClasses, declared),
582
+ unslotted,
583
+ };
584
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WHAT IS HAPPENING RIGHT NOW, and how much is left.
3
+ *
4
+ * "While the design system import is running it would be good to have an animated
5
+ * progress and underneath the current component and a (10 of 1235)" (dono, 01/08).
6
+ *
7
+ * A census over a real monorepo reads 2797 files and the terminal said nothing for
8
+ * most of it. Twenty silent minutes is indistinguishable from twenty broken ones, and
9
+ * that is the difference this makes: not speed, but the ability to wait.
10
+ *
11
+ * TWO AUDIENCES, ONE API. A person at a terminal gets a bar that redraws in place; the
12
+ * skill runs the CLI with no TTY and pipes the output, so it gets a plain milestone
13
+ * line every so often. Neither has to know about the other, and the non-TTY path never
14
+ * writes a carriage return into a transcript.
15
+ */
16
+ const BAR_WIDTH = 24;
17
+ /** Every this-many items, the non-TTY path prints one line. Enough to see movement in
18
+ * a piped log without turning it into the log. */
19
+ const MILESTONE_EVERY = 25;
20
+ /** A bar that reads as a bar at any width. Two glyphs, no colour: this line is
21
+ * overwritten dozens of times a second and anything fancier flickers. */
22
+ function bar(done, total) {
23
+ const ratio = total > 0 ? Math.min(1, done / total) : 0;
24
+ const filled = Math.round(ratio * BAR_WIDTH);
25
+ return `${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}`;
26
+ }
27
+ /** Truncated from the LEFT, because the end of a path is the part that identifies it. */
28
+ function tail(label, width) {
29
+ if (label.length <= width)
30
+ return label;
31
+ return `…${label.slice(-(width - 1))}`;
32
+ }
33
+ export function startProgress(title, opts) {
34
+ const write = opts?.write ?? ((s) => process.stdout.write(s));
35
+ const tty = opts?.isTty ?? Boolean(process.stdout.isTTY);
36
+ let total = opts?.total ?? 0;
37
+ let done = 0;
38
+ let last = "";
39
+ const paint = () => {
40
+ const count = total > 0 ? `${done} of ${total}` : String(done);
41
+ const line = ` ${bar(done, total)} ${count} ${tail(last, 40)}`;
42
+ // Pad to the previous width so a shorter label cannot leave the tail of a
43
+ // longer one behind on the line.
44
+ write(`\r${line.padEnd(80)}`);
45
+ };
46
+ return {
47
+ step(label) {
48
+ done += 1;
49
+ last = label;
50
+ if (tty) {
51
+ paint();
52
+ return;
53
+ }
54
+ // NO CARRIAGE RETURNS IN A TRANSCRIPT. A milestone line every so often, plus
55
+ // the first one so the reader knows the phase started at all.
56
+ if (done === 1 || done % MILESTONE_EVERY === 0) {
57
+ const count = total > 0 ? `${done} of ${total}` : `${done}`;
58
+ write(` ${title}: ${count} - ${label}\n`);
59
+ }
60
+ },
61
+ total(n) {
62
+ total = n;
63
+ },
64
+ done(summary) {
65
+ if (tty)
66
+ write(`\r${" ".repeat(80)}\r`);
67
+ if (summary)
68
+ write(` ${summary}\n`);
69
+ },
70
+ };
71
+ }