flagstaff 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,25 +1,338 @@
1
1
  # flagstaff
2
2
 
3
- **Not yet released.** This version reserves the name; the first working release follows
4
- [`docs/intents/cli-render/`](https://github.com/ofri-peretz/burgee/tree/main/docs/intents/cli-render).
5
-
6
- A **flagstaff** is the staff a flag flies from. It is the simplest part of the whole
7
- apparatus and the only one that is always in view: a flag is hoisted on it, held there "at the
8
- dip" or "close up", changed, and lowered when it is done. That is a terminal render loop — the
9
- place frames are hoisted, held, changed and lowered, in view of whoever is reading.
10
-
11
- ## What it will be
12
-
13
- - **A frame loop with a static projection.** A spinner animates on a TTY, prints one line
14
- per state on a pipe, emits one event under `--json`, and is plain text in accessible mode.
15
- A component without a static projection is refused at registration.
16
- - **Plugins as data.** A third-party spinner, progress style or character is a plain object;
17
- the built-ins ship in the same shape. A plugin is a flag someone else made, flown from the
18
- same staff.
19
- - **Drop-in paths** for ora, log-update, boxen and cli-table3, graded by their own suites.
20
- - **No layout engine**, on purpose. Box, columns and a status line are the ceiling.
21
- - **Zero external dependencies.**
3
+ ora animates a spinner and, off a terminal, prints frames anyway — `\r` after `\r` into the
4
+ log an agent reads back. Ink fixes the terminal by shipping React and a layout engine.
5
+ **flagstaff** is the staff the flag flies from: a frame loop that hoists a component, holds
6
+ it, changes it and lowers it, and a **static projection** that is what every mode but the
7
+ terminal gets — one line per state on a pipe, one event per transition under `--json`,
8
+ plain text for a screen reader. Plugins are data. No layout engine. One dependency, and it
9
+ is [roundel](../roundel/README.md).
10
+
11
+ A **flagstaff** is the simplest part of the whole apparatus and the only one that is always
12
+ in view: a flag is hoisted on it, held there, changed, and lowered when it is done. That is a
13
+ terminal render loop — the place frames are hoisted, held, changed and lowered, in view of
14
+ whoever is reading.
15
+
16
+ ## Use
17
+
18
+ ```js
19
+ import { hoist } from 'flagstaff/loop';
20
+ import { spinner } from 'flagstaff/spinner';
21
+
22
+ const flag = hoist(spinner(), rt, { text: 'building' });
23
+ // ... work ...
24
+ flag.update({ text: 'linking' });
25
+ flag.lower({ text: 'built', status: 'ok' });
26
+ ```
27
+
28
+ `rt` is a runtime — `{ env, isTTY: { stdout }, stdout, stderr, clock }`; burgee's satisfies
29
+ it, so does a literal in a test. Which of the five modes runs is decided once by roundel's
30
+ output policy, never by this package:
31
+
32
+ | mode | what the same three calls write |
33
+ | :-- | :-- |
34
+ | `tty` | `⠋ building` repainted in place on the clock, then `✔ built` left on screen |
35
+ | `pipe`, `ci` | `… building` ⏎ `… linking` ⏎ `✔ built` — one line per state change, no `\r`, no escape |
36
+ | `json` | `{"event":"spinner","state":{"text":"building"}}` … one NDJSON event per transition, on stderr |
37
+ | `accessible` | the static text, never a redraw |
38
+
39
+ ## What is here
40
+
41
+ ### The loop
42
+
43
+ `hoist(component, rt, initial, { json })` returns `{ update(state), lower(state?), mode }`.
44
+ A component is `{ name, static(state), frame?(t, state), interval? }`. `static` is
45
+ required and is the artifact: what a pipe, an agent, a screen reader and the docs gallery
46
+ read. `frame` is optional and decorative. Nothing in the loop reads `process`; time comes
47
+ from `rt.clock`, so `manualClock()` makes a spinner's terminal output a fixed string a test
48
+ can assert byte for byte.
49
+
50
+ ### The plugin host
51
+
52
+ A plugin is one plain object, validated against [`schema.json`](./src/schema.json) — the
53
+ same file that ships in the tarball — and registered once:
54
+
55
+ ```js
56
+ // a third-party plugin, in full
57
+ export default {
58
+ name: 'nyan',
59
+ spinners: { nyan: { frames: ['≋', '≈', '~'], interval: 80, static: '…' } },
60
+ };
61
+ ```
62
+
63
+ ```js
64
+ import { register } from 'flagstaff/plugin';
65
+ register(nyan);
66
+ spinner('nyan');
67
+ ```
68
+
69
+ Keys: `spinners`, `borders`, `glyphs` (`ok`, `fail`, `warn`, `info`, `running` — change them
70
+ and every built-in that draws one changes), `tokens` (a roundel theme), `components`. A spinner
71
+ or component without a `static` is refused at `register()` with `E_NO_STATIC_PROJECTION`
72
+ and a fix. The built-in `dots` and `line` styles are a plugin of exactly this shape,
73
+ registered through the same door, so the built-ins cannot grow an API a plugin cannot reach.
74
+
75
+ `register()` is the **only** way in, and that is a property rather than a convention:
76
+ `registered()` hands back a copy — new maps over the frozen objects `register()` stored — so
77
+ `registered().spinners.set(…)` puts nothing in the registry and `.clear()` empties nothing.
78
+ A contribution that never met `validate()` cannot be reached by `spinner()`, `box()` or the
79
+ gallery, which is what makes "refused at the door" (U3) a fact about the code rather than
80
+ advice. Freezing also means the object you registered stays yours: edit it afterwards and
81
+ the registry does not change.
82
+
83
+ A component may declare `sample: { running, done }` — the two states `flagstaff check` and
84
+ the docs gallery *show* it with. The loop never reads it; a running program's state comes
85
+ from the program. Without one, both assume `{ phase: 'running' }` / `{ phase: 'done' }` and
86
+ say so in the output, rather than rendering an invented state as if it were yours.
87
+
88
+ ### The built-ins
89
+
90
+ Five components, each on its own subpath, each answering the static projection for itself:
91
+
92
+ ```js
93
+ import { progress } from 'flagstaff/progress';
94
+ import { tasks } from 'flagstaff/tasks';
95
+ import { box, boxComponent } from 'flagstaff/box';
96
+ import { table, tableComponent } from 'flagstaff/table';
97
+ ```
98
+
99
+ | component | on a terminal | everywhere else |
100
+ | :-- | :-- | :-- |
101
+ | `spinner` | `⠹ building` | `… building`, then `✔ built` |
102
+ | `progress` | a bar of blocks | `12/30 files · 40%` |
103
+ | `tasks` | every task, the running one animated | one line per task that has **settled** |
104
+ | `box` | the border, padding and title | `title: text` |
105
+ | `table` | the grid | one line per row of `header: value` pairs |
106
+
107
+ That table is the package's argument in one place. A bar of `█` in a log file tells an agent
108
+ nothing and tells a screen reader less; the count and the percentage tell both.
109
+
110
+ `box` and `table` are also plain string functions, because most callers want the string:
111
+
112
+ ```js
113
+ box('Ready on :3000', { title: 'dev', width: 40 });
114
+ table([['ora', '99'], ['log-update', '99']], { head: ['host', 'tests'] });
115
+ ```
116
+
117
+ No layout engine, and there will not be one: these measure with `width()`, wrap with
118
+ `wrap()`, and join strings.
119
+
120
+ ### The ora path
121
+
122
+ `flagstaff/ora` is ora 9's whole API, graded **99 / 99 by ora's own test suite** through
123
+ [`compat-oracle`](../compat-oracle/README.md). One import changes:
124
+
125
+ ```diff
126
+ -import ora from 'ora';
127
+ +import ora from 'flagstaff/ora';
128
+ ```
129
+
130
+ Everything else stays: `ora({ text, spinner, color, indent, prefixText, suffixText })`,
131
+ `.start() .stop() .succeed() .fail() .warn() .info() .stopAndPersist()`, `oraPromise()`,
132
+ the `spinners` corpus, the stream hooks that keep a `console.log` above the frame, the
133
+ synchronized-output sequences, the render deferral, the stdin discarder.
134
+
135
+ What changes is the bill. ora 9.4.1 ships 113,577 B of JavaScript across **seventeen
136
+ packages** — ora, chalk, cli-spinners, string-width, log-symbols, cli-cursor,
137
+ restore-cursor, onetime, mimic-function, signal-exit, is-interactive, is-unicode-supported,
138
+ stdin-discarder, yoctocolors, strip-ansi, ansi-regex, get-east-asian-width. `flagstaff/ora`
139
+ is 55,641 B across **two** — itself and roundel — **49% of ora's**, and 20,250 B of that is
140
+ the spinner corpus ora's API re-exports. Nothing in it reaches the frame loop, so a program
141
+ that migrates its spinner today can adopt `hoist()` a file at a time, or never.
142
+
143
+ Both sides are counted the same way, so the number reproduces: shipped code and data —
144
+ `.js`/`.mjs`/`.cjs` plus the `.json` a module imports, `package.json` never counted. Ours is
145
+ the import graph walked from `dist/` by `weight.test.ts`; ora's is every package that graph
146
+ touches in ora's own resolved tree, counted whole. Counting ora the stricter way — only the
147
+ 27 files its graph actually reaches — gives 101,809 B, and `flagstaff/ora` is still 55% of
148
+ that.
149
+
150
+ The cursor comes back the way ora's does. A spinner that hid the cursor restores it on a
151
+ clean exit **and** on `SIGINT`, `SIGTERM` and `SIGHUP` — node does not run `'exit'`
152
+ listeners for a signalled process, and Ctrl+C is how a spinner usually dies — then re-raises
153
+ the signal so the process still terminates, unless the program installed its own handler for
154
+ it. That is what ora buys with `restore-cursor` → `signal-exit`; here it is twenty lines and
155
+ no dependency. ora's own 99 never kill a process, so `ora.test.ts` grades it instead.
156
+
157
+ **The repo's accessible switch does not reach this surface.** `CLI_ACCESSIBLE=1` changes
158
+ what `roundel/policy` decides for every other entry point in the stack, but `flagstaff/ora`
159
+ imports `roundel/chalk` and never the policy, because a façade that reinterpreted its host
160
+ would fail the host's suite — so on a terminal `CLI_ACCESSIBLE=1` still animates and still
161
+ writes cursor escapes (measured: 4 escapes, cursor hidden), where `CI=true` disables the
162
+ spinner outright (0 escapes) because that is ora's own rule. Off a terminal it is moot. Use
163
+ `hoist()` when you want the switch to be honoured.
164
+
165
+ The static projection is the reason to move on eventually, not the reason to move:
166
+ `hoist()` is what gives a pipe one line per state instead of frames. `flagstaff/ora` is the
167
+ door, and it is deliberately ora's behaviour to the byte.
168
+
169
+ ### The boxen path
170
+
171
+ `flagstaff/boxen` is boxen 8's API, graded **84 / 84 by boxen's own test suite** — every one
172
+ of whose cases is a snapshot of the exact characters the box comes out as.
173
+
174
+ ```diff
175
+ -import boxen from 'boxen';
176
+ +import boxen from 'flagstaff/boxen';
177
+ ```
178
+
179
+ `borderStyle` (all eight of cli-boxes', a style object, or `none`), `borderColor`,
180
+ `backgroundColor`, `dimBorder`, `title` and `titleAlignment`, `textAlignment`, `padding`,
181
+ `margin`, `width`, `height`, `float`, `fullscreen`, and `_borderStyles`.
182
+
183
+ The drawing **is** the contract here, and matching it byte for byte is the compatibility
184
+ claim rather than a way of avoiding one: a user leaving boxen cares about one thing, whether
185
+ the box still looks the same. It carries cli-boxes' table itself rather than reading the
186
+ plugin registry — a façade whose drawing changed when somebody registered a plugin would be
187
+ reinterpreting its host. Named borders through the registry are `flagstaff/box`'s job.
188
+
189
+ ### The cli-table3 path
190
+
191
+ `flagstaff/cli-table3` is cli-table3 0.6.5's API, graded **29 / 29 by cli-table3's own test
192
+ suite** — the 38 of its cases that go through the public surface, less the nine that grade
193
+ `cli-table`, the *legacy* incumbent, and so pass whatever the target is. The other 197
194
+ `require('../src/…')` and test its four internal modules directly; those are reported beside
195
+ the number and never gate it, because passing them would mean copying cli-table3's file
196
+ layout rather than matching its behaviour.
197
+
198
+ ```diff
199
+ -const Table = require('cli-table3');
200
+ +import Table from 'flagstaff/cli-table3';
201
+ ```
202
+
203
+ `head`, `chars`, `style` (padding, `head`, `border`, `compact`), `colWidths`, `rowHeights`,
204
+ `colAligns`, `rowAligns`, `truncate`, `wordWrap`, `wrapOnWordBoundary`, per-cell `colSpan`,
205
+ `rowSpan`, `hAlign`, `vAlign`, `href`, and the `debug` channel with `table.messages` and
206
+ `Table.reset()`. It extends `Array`, because cli-table3 does and its callers push rows onto
207
+ it.
208
+
209
+ Four dependencies folded into one module rather than four: cli-table3's `table.js`,
210
+ `layout-manager.js`, `cell.js` and `utils.js` become one file, because the architecture is
211
+ not the contract — the drawing is.
212
+
213
+ ### The log-update path
214
+
215
+ `flagstaff/log-update` is log-update 8's API, graded **99 / 99 by log-update's own test
216
+ suite** — which renders every frame through a real terminal emulator and asserts the
217
+ screen, not the bytes.
218
+
219
+ ```diff
220
+ -import logUpdate from 'log-update';
221
+ +import logUpdate from 'flagstaff/log-update';
222
+ ```
223
+
224
+ `logUpdate()`, `.clear()`, `.done()`, `.persist()`, `createLogUpdate(stream, options)` and
225
+ `logUpdateStderr`, with the row-level diffing intact: a five-row frame whose last row is a
226
+ counter costs one row of output per tick, not five.
227
+
228
+ log-update ships 113.4 KB across **sixteen** packages. This is 29.6 KB across **none** —
229
+ the subpath reaches no package at all, not even roundel.
230
+ It carries no port of `slice-ansi` — the wrapper already makes every row self-contained,
231
+ so clipping a frame to the terminal's height is an array slice. `signal-exit`, 22.0 KB of
232
+ those sixteen, is 1.4 KB here: `cursor.ts`, shared with the ora façade because both
233
+ incumbents port the same `cli-cursor` → `restore-cursor` → `signal-exit` chain. Ctrl+C
234
+ mid-frame puts your cursor back, and still terminates — unless your program installed its
235
+ own `SIGINT` handler, in which case it is delivered once, to you, and this stays out of it.
236
+
237
+ **This is the one façade that lowers a layer guarantee, and it says so.** R5 — no cursor
238
+ escape off a terminal — cannot survive here: log-update's own suite asserts erase sequences
239
+ on a plain non-TTY stream, so a façade that suppressed them would fail the suite that is
240
+ the whole claim. What survives is the half the complaint behind R5 was actually about:
241
+ nothing this writes is ever a `\r`, **and never an absolute cursor-home** — every move is
242
+ a relative row move — on a terminal or off one, so a captured transcript stays parseable.
243
+ `log-update.test.ts` asserts both halves, and `hoist()` is what gives you the whole
244
+ guarantee.
245
+
246
+ ### Bringing a corpus with you
247
+
248
+ The ecosystem already has ~80 spinner styles and eight border sets, as plain JSON. Neither
249
+ is bundled here — the weight of a corpus nobody asked for is the thing this package exists
250
+ to avoid — so `flagstaff/import` turns the one you have into an ordinary plugin:
251
+
252
+ ```js
253
+ import cliSpinners from 'cli-spinners';
254
+ import { fromCliSpinners } from 'flagstaff/import';
255
+ import { register } from 'flagstaff/plugin';
256
+
257
+ register(fromCliSpinners(cliSpinners));
258
+ spinner('moon');
259
+ ```
260
+
261
+ `fromCliBoxes(cliBoxes)` does the same for borders, after which `box('…', { border:
262
+ 'arrow' })` draws with one. Both go through the same `register()` and the same schema, so
263
+ the gallery opens full and a third-party plugin starts as a copy of one of these. The
264
+ importer is 838 B and reaches nothing.
265
+
266
+ ### `flagstaff check`
267
+
268
+ ```bash
269
+ npx flagstaff check ./nyan.mjs
270
+ ```
271
+
272
+ Loads the file, validates it, and prints every contribution in all five modes side by side —
273
+ escapes made visible — so an author, or an agent that just wrote one, sees the static
274
+ projection next to the animation before anything ships. Exit 1 on a refusal, with the code
275
+ and the fix.
276
+
277
+ It opens with a census of what it found and closes with the verdict, so `ok` is never
278
+ printed before the rendering that would justify it:
279
+
280
+ ```text
281
+ nyan — 1 spinner, 0 borders, 0 components, 0 glyphs, 0 tokens
282
+ spinner nyan
283
+ tty ␛[?25l≋ working␛[1G␛[0J…
284
+ pipe ~nyan~ working⏎ ✔ done⏎
285
+ …
286
+ nyan: ok
287
+ ```
288
+
289
+ `0 spinners, 0 components` is how a misspelled key tells on itself. The schema is
290
+ `additionalProperties: true` on purpose — a key another package in the family reads belongs
291
+ in the same object — so a typo cannot be refused by the schema, and `check` is the surface
292
+ that has to notice:
293
+
294
+ ```text
295
+ typo — 0 spinners, 0 borders, 0 components, 0 glyphs, 0 tokens
296
+ unknown componets, spinner — flagstaff reads none of these; a key another package in the family reads is allowed here
297
+ E_NO_CONTRIBUTION: typo registers, but contributes nothing flagstaff can render
298
+ fix: flagstaff reads spinners, borders, components, glyphs and tokens; check those spellings. …
299
+ ```
300
+
301
+ Each component block names the state it was rendered with — the component's own `sample`
302
+ when it declares one, and otherwise the assumed `{ phase }` shape, said out loud. A `static`
303
+ that throws on the state it is handed is a refusal like any other, naming the modes it broke
304
+ in (`json` emits the state and never calls `static`, so it is usually the one that survives):
305
+
306
+ ```text
307
+ E_COMPONENT_THREW: g threw in tty, pipe, ci, accessible
308
+ fix: `static(state)` must return a string for the state it is rendered with; …
309
+ ```
310
+
311
+ ## Weight
312
+
313
+ Every subpath is a lock, not a convention, and the numbers below are asserted by
314
+ `weight.test.ts` against `dist/`, not estimated: `flagstaff/loop` reaches 4.4 KB on disk and
315
+ never the plugin registry; `flagstaff/plugin` 8.4 KB, of which 2.4 KB is the schema;
316
+ `flagstaff/spinner` 9.4 KB; `flagstaff/ora` 46.5 KB — 55.9 KB with roundel counted, against
317
+ ora's own 113.6 KB; `flagstaff/log-update` 29.6 KB, reaching **no package at all**, against
318
+ log-update's own 113.4 KB across sixteen; `flagstaff/boxen` 33.7 KB — 43.0 KB with roundel
319
+ counted, against boxen's own 151.4 KB across fourteen; `flagstaff/cli-table3` 32.9 KB —
320
+ 42.3 KB with roundel, against cli-table3's own 161.7 KB across seven. The three façades share `wrap.js` and
321
+ `width.js`, and the first two share `cursor.js`; none reaches another's port, and none
322
+ reaches the core. `sideEffects: false` lets a
323
+ bundler drop what a program does not use. ESM with a `default` condition, so
324
+ `require('flagstaff/spinner')` works from CommonJS on Node ≥ 24.
325
+
326
+ ## What is next
327
+
328
+ - **Drop-in paths** for boxen and cli-table3, graded by their own suites through
329
+ `compat-oracle` the way ora and log-update already are.
330
+
331
+ Every component, every registered plugin and every border is on the
332
+ [gallery](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/gallery.mdx),
333
+ which is generated by running them — the static projection beside the animation, in all
334
+ five modes.
22
335
 
23
336
  Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on burgee declares
24
- what it is, pennon gives it colours, flagstaff flies it, answering is how it replies. Each is
337
+ what it is, roundel carries its colours, flagstaff flies it, caique answers back. Each is
25
338
  an independent package; none requires the others.
package/THIRD-PARTY.md ADDED
@@ -0,0 +1,44 @@
1
+ # Third-party material shipped in this package
2
+
3
+ flagstaff has one runtime dependency (`roundel`) and writes its own code. Two things in
4
+ the tarball are not ours, and are here with their licence.
5
+
6
+ ## `dist/spinners.json` — cli-spinners
7
+
8
+ The spinner corpus reached through `flagstaff/ora` is `spinners.json` from
9
+ [cli-spinners](https://github.com/sindresorhus/cli-spinners) 3.2.0, unedited. It is data,
10
+ not code: `flagstaff/ora` re-exports it as `spinners` because ora does, and ora's own test
11
+ suite reads it. Nothing else in the package loads it — the core (`flagstaff`,
12
+ `flagstaff/loop`, `flagstaff/plugin`, `flagstaff/spinner`) ships two spinners of its own
13
+ and a lock that fails if any entry reaches this file.
14
+
15
+ > MIT License
16
+ >
17
+ > Copyright (c) Sindre Sorhus \<sindresorhus@gmail.com\> (https://sindresorhus.com)
18
+ >
19
+ > Permission is hereby granted, free of charge, to any person obtaining a copy of this
20
+ > software and associated documentation files (the "Software"), to deal in the Software
21
+ > without restriction, including without limitation the rights to use, copy, modify,
22
+ > merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
23
+ > permit persons to whom the Software is furnished to do so, subject to the following
24
+ > conditions:
25
+ >
26
+ > The above copyright notice and this permission notice shall be included in all copies or
27
+ > substantial portions of the Software.
28
+ >
29
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
30
+ > INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
31
+ > PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
32
+ > LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
33
+ > OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
34
+ > OTHER DEALINGS IN THE SOFTWARE.
35
+
36
+ ## The East Asian Width ranges in `dist/width.js`
37
+
38
+ The `WIDE` table is derived from the Unicode Character Database's `EastAsianWidth.txt`
39
+ (Unicode 17), the same derivation `get-east-asian-width` publishes. The Unicode data files
40
+ are distributed under the [Unicode licence](https://www.unicode.org/license.txt), which
41
+ permits redistribution of derived data with the notice above kept.
42
+
43
+ Neither package is a dependency: nothing is installed, and both are here as data compiled
44
+ into the build.
package/dist/box.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { type BorderStyle, type Component } from './plugin.js';
2
+ export interface BoxOptions {
3
+ /** A registered border's name, or a style of your own. Default `round`. */
4
+ border?: string | BorderStyle;
5
+ /** Cells of padding left and right of the text, and rows above and below. Default 1 / 0. */
6
+ padding?: {
7
+ x?: number;
8
+ y?: number;
9
+ };
10
+ /** A title written into the top border. Truncated with `…` if the box is too narrow. */
11
+ title?: string;
12
+ /** Columns the whole box may occupy, borders included. Text wraps to fit. Default 80. */
13
+ width?: number;
14
+ }
15
+ /** Draw `text` in a box, as a string. The many callers who want only this want only this. */
16
+ export declare function box(text: string, options?: BoxOptions): string;
17
+ export interface BoxState {
18
+ text: string;
19
+ title?: string;
20
+ }
21
+ /** A box as a component: the text off a terminal, the drawing on one (R1). */
22
+ export declare function boxComponent(options?: BoxOptions): Component<BoxState>;
package/dist/box.js ADDED
@@ -0,0 +1,59 @@
1
+ import { muted } from 'roundel/tokens';
2
+ import { lookupBorder } from './plugin.js';
3
+ import { width } from './width.js';
4
+ import { wrap } from './wrap.js';
5
+ const DEFAULT_WIDTH = 80;
6
+ const DEFAULT_PAD_X = 1;
7
+ const DEFAULT_PAD_Y = 0;
8
+ const BORDER_CELLS = 2;
9
+ const ELLIPSIS = '…';
10
+ const resolve = (border) => (typeof border === 'string' ? lookupBorder(border) : border);
11
+ const padEnd = (line, cells) => line + ' '.repeat(Math.max(0, cells - width(line)));
12
+ function fitTitle(title, cells) {
13
+ if (width(title) <= cells)
14
+ return title;
15
+ if (cells <= 1)
16
+ return '';
17
+ let out = '';
18
+ for (const character of title) {
19
+ if (width(out + character) > cells - 1)
20
+ break;
21
+ out += character;
22
+ }
23
+ return out + ELLIPSIS;
24
+ }
25
+ function topBorder(style, inner, title) {
26
+ if (style.top === '')
27
+ return '';
28
+ if (title === undefined || title === '')
29
+ return style.topLeft + style.top.repeat(inner) + style.topRight;
30
+ const fitted = fitTitle(title, Math.max(0, inner - BORDER_CELLS - BORDER_CELLS));
31
+ if (fitted === '')
32
+ return style.topLeft + style.top.repeat(inner) + style.topRight;
33
+ const label = ` ${fitted} `;
34
+ const rest = Math.max(0, inner - width(label) - 1);
35
+ return `${style.topLeft}${style.top}${label}${style.top.repeat(rest)}${style.topRight}`;
36
+ }
37
+ export function box(text, options = {}) {
38
+ const style = resolve(options.border ?? 'round');
39
+ const padX = options.padding?.x ?? DEFAULT_PAD_X;
40
+ const padY = options.padding?.y ?? DEFAULT_PAD_Y;
41
+ const total = options.width ?? DEFAULT_WIDTH;
42
+ const borderCells = style.left === '' ? 0 : BORDER_CELLS;
43
+ const inner = Math.max(1, total - borderCells);
44
+ const content = Math.max(1, inner - padX * BORDER_CELLS);
45
+ const wrapped = wrap(text, content, { hard: true, trim: false }).split('\n');
46
+ const blank = Array.from({ length: padY }, () => '');
47
+ const pad = ' '.repeat(padX);
48
+ const rows = [...blank, ...wrapped, ...blank].map((line) => `${style.left}${pad}${padEnd(line, content)}${pad}${style.right}`);
49
+ const top = topBorder(style, inner, options.title);
50
+ const bottom = style.bottom === '' ? '' : style.bottomLeft + style.bottom.repeat(inner) + style.bottomRight;
51
+ return [top, ...rows, bottom].filter((row) => row !== '').join('\n');
52
+ }
53
+ export function boxComponent(options = {}) {
54
+ return {
55
+ name: 'box',
56
+ static: (state) => (state.title === undefined || state.title === '' ? state.text : `${state.title}: ${state.text}`),
57
+ frame: (_t, state) => muted(box(state.text, state.title === undefined ? options : { ...options, title: state.title })),
58
+ };
59
+ }
@@ -0,0 +1,56 @@
1
+ export interface BoxenBorderStyle {
2
+ topLeft: string;
3
+ top: string;
4
+ topRight: string;
5
+ left: string;
6
+ right: string;
7
+ bottomLeft: string;
8
+ bottom: string;
9
+ bottomRight: string;
10
+ /** Retro-compatibility: sets `left` and `right` together. */
11
+ vertical?: string;
12
+ /** Retro-compatibility: sets `top` and `bottom` together. */
13
+ horizontal?: string;
14
+ }
15
+ export interface Spacing {
16
+ top?: number;
17
+ right?: number;
18
+ bottom?: number;
19
+ left?: number;
20
+ }
21
+ export interface BoxenOptions {
22
+ borderStyle?: string | BoxenBorderStyle;
23
+ borderColor?: string;
24
+ backgroundColor?: string;
25
+ dimBorder?: boolean;
26
+ title?: string;
27
+ titleAlignment?: 'left' | 'center' | 'right';
28
+ textAlignment?: 'left' | 'center' | 'right';
29
+ /** @deprecated boxen's own name for `textAlignment`, still honoured. */
30
+ align?: 'left' | 'center' | 'right';
31
+ padding?: number | Spacing;
32
+ margin?: number | Spacing;
33
+ width?: number;
34
+ height?: number;
35
+ float?: 'left' | 'center' | 'right';
36
+ fullscreen?: boolean | ((columns: number, rows: number) => [number, number]);
37
+ }
38
+ /**
39
+ * cli-boxes 4, as data. boxen re-exports this as `_borderStyles`, so it is part of the
40
+ * surface a caller can reach and not an implementation detail to be hidden.
41
+ */
42
+ declare const BOXES: Record<string, BoxenBorderStyle>;
43
+ /**
44
+ * Draw a box around `text`, exactly as boxen 8 draws it.
45
+ *
46
+ * An invalid `borderColor` or `backgroundColor` throws rather than drawing something
47
+ * plausible, because boxen throws: a colour name that is not one is a typo, and a box drawn
48
+ * in the wrong colour is a bug somebody ships.
49
+ */
50
+ /** boxen re-exports cli-boxes under this name, so it is surface a caller can reach. */
51
+ export { BOXES as _borderStyles };
52
+ /**
53
+ * `import boxen from 'boxen'` is the incumbent's surface. A named export here would break
54
+ * every migration this file exists to serve, so the house rule yields to the host.
55
+ */
56
+ export default function boxen(text: string, options?: BoxenOptions): string;