flagstaff 0.2.1 → 0.3.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
@@ -13,7 +13,7 @@
13
13
 
14
14
  <p align="center">
15
15
  <a href="https://www.npmjs.com/package/flagstaff"><img src="https://img.shields.io/npm/v/flagstaff?style=flat-square&color=0a6b47" alt="npm version" /></a>
16
- <img src="https://img.shields.io/badge/dependencies-roundel-0a6b47?style=flat-square" alt="One dependency: roundel" />
16
+ <img src="https://img.shields.io/badge/dependencies-4%20in--family-0a6b47?style=flat-square" alt="Four dependencies, all in this repository: closeout, linegauge, paratext, roundel" />
17
17
  <img src="https://img.shields.io/badge/Node.js-24+-green.svg?style=flat-square" alt="Node.js 24+" />
18
18
  <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT" />
19
19
  </p>
@@ -243,11 +243,11 @@ screen, not the bytes.
243
243
  `logUpdateStderr`, with the row-level diffing intact: a five-row frame whose last row is a
244
244
  counter costs one row of output per tick, not five.
245
245
 
246
- log-update ships 113.4 KB across **sixteen** packages. This is 29.6 KB across **none** —
247
- the subpath reaches no package at all, not even roundel.
246
+ log-update ships 113.4 KB across **sixteen** packages. This subpath reaches two, both from
247
+ this repository: `linegauge/wrap` for the wrapper, and `closeout` for the cursor.
248
248
  It carries no port of `slice-ansi` — the wrapper already makes every row self-contained,
249
249
  so clipping a frame to the terminal's height is an array slice. `signal-exit`, 22.0 KB of
250
- those sixteen, is 1.4 KB here: `cursor.ts`, shared with the ora façade because both
250
+ those sixteen, is `closeout`'s to own, shared with the ora façade because both
251
251
  incumbents port the same `cli-cursor` → `restore-cursor` → `signal-exit` chain. Ctrl+C
252
252
  mid-frame puts your cursor back, and still terminates — unless your program installed its
253
253
  own `SIGINT` handler, in which case it is delivered once, to you, and this stays out of it.
@@ -332,11 +332,11 @@ Every subpath is a lock, not a convention, and the numbers below are asserted by
332
332
  `weight.test.ts` against `dist/`, not estimated: `flagstaff/loop` reaches 4.4 KB on disk and
333
333
  never the plugin registry; `flagstaff/plugin` 8.4 KB, of which 2.4 KB is the schema;
334
334
  `flagstaff/spinner` 9.4 KB; `flagstaff/ora` 46.5 KB — 55.9 KB with roundel counted, against
335
- ora's own 113.6 KB; `flagstaff/log-update` 29.6 KB, reaching **no package at all**, against
336
- log-update's own 113.4 KB across sixteen; `flagstaff/boxen` 33.7 KB — 43.0 KB with roundel
335
+ ora's own 113.6 KB; `flagstaff/log-update` 29.6 KB, against
336
+ log-update's own 113.4 KB across sixteen, reaching only `linegauge/wrap` and `closeout`; `flagstaff/boxen` 33.7 KB — 43.0 KB with roundel
337
337
  counted, against boxen's own 132.4 KB across nineteen; `flagstaff/cli-table3` 32.9 KB —
338
338
  42.3 KB with roundel, against cli-table3's own 106.0 KB across seven. The three façades share `wrap.js` and
339
- `width.js`, and the first two share `cursor.js`; none reaches another's port, and none
339
+ `width.js`, and the first two share `closeout`; none reaches another's port, and none
340
340
  reaches the core. `sideEffects: false` lets a
341
341
  bundler drop what a program does not use. ESM with a `default` condition, so
342
342
  `require('flagstaff/spinner')` works from CommonJS on Node ≥ 24.
@@ -370,3 +370,21 @@ Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on
370
370
  none requires the others.
371
371
 
372
372
  MIT © Ofri Peretz — see [LICENSE](./LICENSE).
373
+
374
+ ## Benchmarks
375
+
376
+ Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
377
+
378
+ Graded by the incumbent's own test suite:
379
+
380
+ | suite | passing |
381
+ | :-- | --: |
382
+ | `boxen` | 84 / 84 |
383
+ | `cli-table3` | 29 / 29 |
384
+ | `log-update` | 99 / 99 |
385
+ | `ora` | 99 / 99 |
386
+ ## Where it sits
387
+
388
+ Plugins register under the `tokens`, `glyphs`, `spinners`, `borders`, `components` keys, against the one schema the whole family shares.
389
+
390
+ Nothing in this family builds on it yet, and it builds on `closeout`, `linegauge`, `paratext`, `roundel`.
package/dist/box.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type Terminal } from './link.js';
1
2
  import { type BorderStyle, type Component } from './plugin.js';
2
3
  export interface BoxOptions {
3
4
  /** A registered border's name, or a style of your own. Default `round`. */
@@ -11,12 +12,27 @@ export interface BoxOptions {
11
12
  title?: string;
12
13
  /** Columns the whole box may occupy, borders included. Text wraps to fit. Default 80. */
13
14
  width?: number;
15
+ /**
16
+ * A url or a path the box's text points at — `file://…` for a path a terminal should be
17
+ * able to open, which is the case R12 is named after. The title is left alone: it is a
18
+ * label for the box, and a link around it would claim the border is clickable too.
19
+ */
20
+ href?: string;
21
+ /**
22
+ * The terminal the link is rendered for. Omitted, the real process is read through this
23
+ * package's seam. Supply one and the whole path is pure.
24
+ */
25
+ terminal?: Terminal;
14
26
  }
15
27
  /** Draw `text` in a box, as a string. The many callers who want only this want only this. */
16
28
  export declare function box(text: string, options?: BoxOptions): string;
17
29
  export interface BoxState {
18
30
  text: string;
19
31
  title?: string;
32
+ /** Per-state destination, overriding the one the component was built with. */
33
+ href?: string;
20
34
  }
21
35
  /** A box as a component: the text off a terminal, the drawing on one (R1). */
22
36
  export declare function boxComponent(options?: BoxOptions): Component<BoxState>;
37
+ /** The runtime shape `BoxOptions.terminal` takes, so a caller can name it (R12). */
38
+ export type { Terminal } from './link.js';
package/dist/box.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { width } from 'linegauge';
2
2
  import { wrap } from 'linegauge/wrap';
3
3
  import { muted } from 'roundel/tokens';
4
+ import { laid, painted, painter, STATIC } from './link.js';
4
5
  import { lookupBorder } from './plugin.js';
5
6
  const DEFAULT_WIDTH = 80;
6
7
  const DEFAULT_PAD_X = 1;
@@ -8,7 +9,7 @@ const DEFAULT_PAD_Y = 0;
8
9
  const BORDER_CELLS = 2;
9
10
  const ELLIPSIS = '…';
10
11
  const resolve = (border) => (typeof border === 'string' ? lookupBorder(border) : border);
11
- const padEnd = (line, cells) => line + ' '.repeat(Math.max(0, cells - width(line)));
12
+ const padEnd = (line, cells, measured = line) => line + ' '.repeat(Math.max(0, cells - width(measured)));
12
13
  function fitTitle(title, cells) {
13
14
  if (width(title) <= cells)
14
15
  return title;
@@ -42,18 +43,23 @@ export function box(text, options = {}) {
42
43
  const borderCells = style.left === '' ? 0 : BORDER_CELLS;
43
44
  const inner = Math.max(1, total - borderCells);
44
45
  const content = Math.max(1, inner - padX * BORDER_CELLS);
45
- const wrapped = wrap(text, content, { hard: true, trim: false }).split('\n');
46
+ const paint = painter(options.terminal);
47
+ const wrapped = wrap(laid(text, options.href, paint), content, { hard: true, trim: false }).split('\n');
46
48
  const blank = Array.from({ length: padY }, () => '');
47
49
  const pad = ' '.repeat(padX);
48
- const rows = [...blank, ...wrapped, ...blank].map((line) => `${style.left}${pad}${padEnd(line, content)}${pad}${style.right}`);
50
+ const rows = [...blank, ...wrapped, ...blank].map((line) => `${style.left}${pad}${padEnd(painted(line, options.href, paint), content, line)}${pad}${style.right}`);
49
51
  const top = topBorder(style, inner, options.title);
50
52
  const bottom = style.bottom === '' ? '' : style.bottomLeft + style.bottom.repeat(inner) + style.bottomRight;
51
53
  return [top, ...rows, bottom].filter((row) => row !== '').join('\n');
52
54
  }
53
55
  export function boxComponent(options = {}) {
56
+ const plain = painter(STATIC);
54
57
  return {
55
58
  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 })),
59
+ static: (state) => {
60
+ const body = laid(state.text, state.href ?? options.href, plain);
61
+ return state.title === undefined || state.title === '' ? body : `${state.title}: ${body}`;
62
+ },
63
+ frame: (_t, state) => muted(box(state.text, { ...options, ...(state.title === undefined ? {} : { title: state.title }), ...(state.href === undefined ? {} : { href: state.href }) })),
58
64
  };
59
65
  }
package/dist/boxen.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { width as stringWidth } from 'linegauge';
2
2
  import { wrap as wrapAnsi } from 'linegauge/wrap';
3
3
  import chalk from 'roundel/chalk';
4
+ import { processRuntime } from './runtime.js';
5
+ const rt = processRuntime();
4
6
  const NEWLINE = '\n';
5
7
  const PAD = ' ';
6
8
  const NONE = 'none';
@@ -20,7 +22,7 @@ const BOXES = {
20
22
  arrow: { topLeft: '↘', top: '↓', topRight: '↙', right: '←', bottomRight: '↖', bottom: '↑', bottomLeft: '↗', left: '→' },
21
23
  };
22
24
  function terminalColumns() {
23
- const { env, stdout, stderr } = process;
25
+ const { env, stdout, stderr } = rt;
24
26
  if (stdout?.columns)
25
27
  return stdout.columns;
26
28
  if (stderr?.columns)
@@ -173,8 +175,8 @@ function boxContent(content, contentWidth, options) {
173
175
  return result;
174
176
  }
175
177
  function sanitizeOptions(options) {
176
- if (options.fullscreen !== undefined && options.fullscreen !== false && process.stdout) {
177
- let dimensions = [process.stdout.columns, process.stdout.rows];
178
+ if (options.fullscreen !== undefined && options.fullscreen !== false && rt.stdout) {
179
+ let dimensions = [rt.stdout.columns, rt.stdout.rows];
178
180
  if (typeof options.fullscreen === 'function')
179
181
  dimensions = options.fullscreen(...dimensions);
180
182
  options.width ||= dimensions[0];
@@ -4,7 +4,18 @@ export declare function strlen(str: unknown): number;
4
4
  export declare function pad(str: string, len: number, padChar: string, dir?: string): string;
5
5
  export declare function truncate(str: string, desiredLength: number, truncateChar?: string): string;
6
6
  export declare function wordWrap(maxLength: number, input: string, wrapOnWordBoundary?: boolean): string[];
7
- /** OSC 8 — the terminal hyperlink escape. */
7
+ /**
8
+ * OSC 8 — the terminal hyperlink escape, and **not this package's to spell** (R12). The
9
+ * sequence is paratext's `link` capability rendered against {@link EMITTING}; this function
10
+ * contributes the argument order and upstream's `url || text`, and nothing else.
11
+ *
12
+ * Upstream's `hyperlink()` is an escape *builder*, not a policy decision: `utils-test.js`
13
+ * grades its exact bytes with no terminal anywhere in the call, and a cell given an `href`
14
+ * gets a sequence whatever `process.stdout` is. So this stays unconditional — the caller
15
+ * already decided — and the runtime handed to paratext says so out loud. `flagstaff/table`
16
+ * is the surface that *asks* whether the terminal can (rule 6); a façade that started asking
17
+ * would be reinterpreting its host, and `link.test.ts` pins these bytes against upstream's.
18
+ */
8
19
  export declare function hyperlink(url: string, text: string): string;
9
20
  export interface TableChars {
10
21
  [name: string]: string;
@@ -1,10 +1,14 @@
1
1
  import { measure } from 'linegauge';
2
+ import { linkFor } from 'paratext/link';
2
3
  import chalk from 'roundel/chalk';
3
4
  const ESC = '\u001B';
4
5
  const SGR = /\u001B\[(?:\d*;){0,5}\d*m/g;
5
6
  const SGR_CAPTURE = /\u001B\[((?:\d*;){0,5}\d*)m/g;
6
- const HYPERLINK_TAG = `${ESC}]8;;\u0007`;
7
7
  const HALF = 2;
8
+ const EMITTING = { env: { WT_SESSION: '1' }, isTTY: { stdout: true } };
9
+ const emitLink = linkFor(EMITTING);
10
+ const EMPTY_LINK = emitLink('', '');
11
+ const HYPERLINK_TAG = EMPTY_LINK.slice(0, EMPTY_LINK.length / HALF);
8
12
  export function strlen(str) {
9
13
  const stripped = String(str).replaceAll(SGR, '');
10
14
  return stripped.split('\n').reduce((memo, s) => Math.max(memo, measure(s)), 0);
@@ -209,9 +213,7 @@ function colorizeLines(input) {
209
213
  });
210
214
  }
211
215
  export function hyperlink(url, text) {
212
- const OSC = `${ESC}]`;
213
- const BEL = '\u0007';
214
- return [OSC, '8', ';', ';', url || text, BEL, text, OSC, '8', ';', ';', BEL].join('');
216
+ return emitLink(text, url || text);
215
217
  }
216
218
  const DEBUG_LEVEL = { WARN: 1, INFO: 2, DEBUG: 3 };
217
219
  let debugMessages = [];
package/dist/cli.js CHANGED
@@ -3,7 +3,9 @@ import { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { hoist, manualClock } from './loop.js';
5
5
  import { PluginError, register, registered } from './plugin.js';
6
+ import { processRuntime } from './runtime.js';
6
7
  import { spinner } from './spinner.js';
8
+ const rt = processRuntime();
7
9
  const MODES = ['tty', 'pipe', 'ci', 'json', 'accessible'];
8
10
  const ENV = { tty: {}, pipe: {}, ci: { CI: 'true' }, json: {}, accessible: { CLI_ACCESSIBLE: '1' } };
9
11
  const LABEL_WIDTH = 12;
@@ -118,11 +120,11 @@ async function main(argv, write) {
118
120
  write(`${name}: ok${before.has(name) ? ' (replaces an earlier registration)' : ''}\n`);
119
121
  return EXIT_OK;
120
122
  }
121
- const [, , command, ...rest] = process.argv;
123
+ const [, , command, ...rest] = rt.argv;
122
124
  const args = command === 'check' ? rest : [command, ...rest].filter((a) => a !== undefined);
123
- main(args, (s) => process.stdout.write(s)).then((code) => {
124
- process.exitCode = code;
125
+ main(args, (s) => rt.stdout.write(s)).then((code) => {
126
+ rt.exitCode = code;
125
127
  }, (e) => {
126
- process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
127
- process.exitCode = EXIT_RUNTIME;
128
+ rt.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
129
+ rt.exitCode = EXIT_RUNTIME;
128
130
  });
package/dist/link.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The one place this package reaches OSC 8, and it does not implement it (R12).
3
+ *
4
+ * **Why `paratext/link` and not `paratext`.** The root entry reaches 20,221 B across ten
5
+ * modules and calls `registerBuiltins()` at import — a side effect, in a package that declares
6
+ * `sideEffects: false` so a bundler may drop what a program does not use. `paratext/link` is
7
+ * **2,410 B across four modules and registers nothing**, and it carries the whole of what the
8
+ * built-ins need: the guess (`supportsLink`), the bytes (`linkFor`) and the capability record.
9
+ * The 17,811 B difference would land on `flagstaff/table`, whose entire budget is 4,000 B, so
10
+ * this was not a close call — but it was measured rather than assumed, which is the rule.
11
+ * What the narrow entry gives up is the registry: a host that re-registered `link` to correct
12
+ * paratext's guess does not change what a box or a table emits. A program that needs that
13
+ * imports `paratext` and calls `emit()` itself.
14
+ *
15
+ * **What this module is allowed to decide: nothing.** `supportsLink` is asked, never
16
+ * re-derived — `box.ts` and `table.ts` read `live` to choose a *layout* (a url that is about
17
+ * to be carried by an escape must not also be carried by the columns), and `paint` for the
18
+ * bytes. Neither file names `isTTY`, `TERM`, or an escape. PRINCIPLES rule 6 is therefore
19
+ * paratext's to keep, and `link.test.ts` checks it was kept.
20
+ */
21
+ import { linkFor } from 'paratext/link';
22
+ /**
23
+ * The slice of the world paratext needs in order to answer. Derived from `linkFor` rather
24
+ * than imported: `paratext/link` publishes the functions, not the type, and a second
25
+ * specifier for a type would put `paratext` in this package's graph for something
26
+ * `verbatimModuleSyntax` erases anyway.
27
+ */
28
+ export type Terminal = Parameters<typeof linkFor>[0];
29
+ /**
30
+ * The real process, narrowed to what paratext asks of it. Read through this package's own
31
+ * seam (Y9), so `runtime.ts` stays the one file here that names the process and a caller
32
+ * that passes its own `terminal` reaches no global at all.
33
+ */
34
+ export declare const terminalRuntime: () => Terminal;
35
+ /**
36
+ * The runtime a **static projection** is defined against: no terminal, so paratext renders
37
+ * the fallback. `static()` is the text a pipe, a log, an agent and a screen reader get (R1),
38
+ * which is the same question `linkFor` answers for a runtime that is not a terminal — so it
39
+ * is asked rather than answered here, and `text (url)` stays a string paratext owns.
40
+ */
41
+ export declare const STATIC: Terminal;
42
+ export interface Painter {
43
+ /**
44
+ * Whether the sequence is going to be emitted — paratext's answer, for a caller deciding
45
+ * *layout*. Off a terminal the url is content and belongs in the columns; on one it is
46
+ * carried by an escape that measures zero.
47
+ */
48
+ readonly live: boolean;
49
+ /** `(text, url) => string`. The bytes, whichever branch holds. */
50
+ readonly paint: (text: string, url: string) => string;
51
+ }
52
+ /** Bind both questions to one runtime, once per drawing. */
53
+ export declare function painter(terminal?: Terminal): Painter;
54
+ /**
55
+ * A cell or a body with a destination attached. `href` is a url or a path — `file://` for a
56
+ * path a terminal should be able to open, which is the case R12 is named after.
57
+ */
58
+ export interface Linked {
59
+ readonly text: string;
60
+ readonly href: string;
61
+ }
62
+ /** A table cell: text, or text with somewhere to go. A plain string stays a plain string. */
63
+ export type Cell = string | Linked;
64
+ export declare const cellText: (cell: Cell | undefined) => string;
65
+ export declare const cellHref: (cell: Cell | undefined) => string | undefined;
66
+ /**
67
+ * What the content reads as **before** the drawing measures it: the bare text where the
68
+ * sequence will carry the destination, paratext's fallback where it will not. This is the
69
+ * only consequence `live` has on layout, and it is the whole of it.
70
+ */
71
+ export declare function laid(text: string, href: string | undefined, painted: Painter): string;
72
+ /**
73
+ * The same content once it has been wrapped and is about to be padded. A line that survived
74
+ * wrapping gets the sequence; an empty one does not, because a link around nothing is an
75
+ * escape a terminal still has to parse and a screen reader still has to skip.
76
+ */
77
+ export declare function painted(line: string, href: string | undefined, painter_: Painter): string;
package/dist/link.js ADDED
@@ -0,0 +1,23 @@
1
+ import { linkFor, supportsLink } from 'paratext/link';
2
+ import { processRuntime } from './runtime.js';
3
+ export const terminalRuntime = () => {
4
+ const runtime = processRuntime();
5
+ return { env: runtime.env, isTTY: { stdout: runtime.stdout.isTTY === true } };
6
+ };
7
+ export const STATIC = { env: {}, isTTY: { stdout: false } };
8
+ export function painter(terminal) {
9
+ const runtime = terminal ?? terminalRuntime();
10
+ return { live: supportsLink(runtime), paint: linkFor(runtime) };
11
+ }
12
+ export const cellText = (cell) => (typeof cell === 'string' ? cell : (cell?.text ?? ''));
13
+ export const cellHref = (cell) => (typeof cell === 'string' || cell === undefined ? undefined : cell.href);
14
+ export function laid(text, href, painted) {
15
+ if (href === undefined || href === '')
16
+ return text;
17
+ return painted.live ? text : painted.paint(text, href);
18
+ }
19
+ export function painted(line, href, painter_) {
20
+ if (!painter_.live || href === undefined || href === '' || line === '')
21
+ return line;
22
+ return painter_.paint(line, href);
23
+ }
@@ -1,4 +1,4 @@
1
- /** What log-update writes to: `process.stdout` by default, anything stream-shaped in a test. */
1
+ /** What log-update writes to: the runtime's stdout by default, anything stream-shaped in a test. */
2
2
  export interface LogUpdateStream {
3
3
  write(chunk: string): unknown;
4
4
  columns?: number;
@@ -22,7 +22,7 @@ export interface LogUpdate {
22
22
  /** Replace the frame with text that stays, unclipped, and start fresh after it. */
23
23
  persist(...text: unknown[]): void;
24
24
  }
25
- /** A renderer bound to one stream. `logUpdate` is this over `process.stdout`. */
25
+ /** A renderer bound to one stream. `logUpdate` is this over the runtime's stdout. */
26
26
  export declare function createLogUpdate(stream: LogUpdateStream, { showCursor: keepCursor, defaultWidth, defaultHeight }?: LogUpdateOptions): LogUpdate;
27
27
  declare const logUpdate: LogUpdate;
28
28
  export declare const logUpdateStderr: LogUpdate;
@@ -1,6 +1,8 @@
1
- import process from 'node:process';
1
+ import { HIDE_CURSOR, SHOW_CURSOR } from 'closeout/cursor';
2
+ import restoreCursor from 'closeout/restore-cursor';
2
3
  import { wrap } from 'linegauge/wrap';
3
- import { HIDE_CURSOR, restoreCursorOnExit, SHOW_CURSOR } from './cursor.js';
4
+ import { processRuntime } from './runtime.js';
5
+ const rt = processRuntime();
4
6
  const CSI = '\u001B[';
5
7
  const SYNCHRONIZED_OUTPUT_ENABLE = `${CSI}?2026h`;
6
8
  const SYNCHRONIZED_OUTPUT_DISABLE = `${CSI}?2026l`;
@@ -18,15 +20,15 @@ const eraseLines = (count) => {
18
20
  return count > 0 ? sequence + CURSOR_LEFT : sequence;
19
21
  };
20
22
  function hideCursor() {
21
- if (process.stderr.isTTY !== true)
23
+ if (rt.stderr.isTTY !== true)
22
24
  return;
23
- restoreCursorOnExit();
24
- process.stderr.write(HIDE_CURSOR);
25
+ restoreCursor();
26
+ rt.stderr.write(HIDE_CURSOR);
25
27
  }
26
28
  function showCursor() {
27
- if (process.stderr.isTTY !== true)
29
+ if (rt.stderr.isTTY !== true)
28
30
  return;
29
- process.stderr.write(SHOW_CURSOR);
31
+ rt.stderr.write(SHOW_CURSOR);
30
32
  }
31
33
  const countLines = (text) => text.split('\n').length;
32
34
  function fitToHeight(wrapped, terminalHeight) {
@@ -168,6 +170,6 @@ export function createLogUpdate(stream, { showCursor: keepCursor = false, defaul
168
170
  };
169
171
  return render;
170
172
  }
171
- const logUpdate = createLogUpdate(process.stdout);
172
- export const logUpdateStderr = createLogUpdate(process.stderr);
173
+ const logUpdate = createLogUpdate(rt.stdout);
174
+ export const logUpdateStderr = createLogUpdate(rt.stderr);
173
175
  export default logUpdate;
package/dist/ora.d.ts CHANGED
@@ -5,7 +5,7 @@ export interface SpinnerDefinition {
5
5
  /** Every spinner cli-spinners ships, unedited — ora re-exports it and so do we. */
6
6
  export declare const spinners: Record<string, SpinnerDefinition>;
7
7
  export type Color = 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray';
8
- /** What ora writes to: `process.stderr` by default, anything stream-shaped in a test. */
8
+ /** What ora writes to: the runtime's stderr by default, anything stream-shaped in a test. */
9
9
  export interface OraStream {
10
10
  write(chunk: string, encoding?: unknown, callback?: unknown): boolean;
11
11
  isTTY?: boolean;
package/dist/ora.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import process from 'node:process';
2
+ import { HIDE_CURSOR, SHOW_CURSOR } from 'closeout/cursor';
3
+ import restoreCursor from 'closeout/restore-cursor';
3
4
  import { lineCount } from 'linegauge';
4
5
  import chalk from 'roundel/chalk';
5
- import { HIDE_CURSOR, restoreCursorOnExit, SHOW_CURSOR } from './cursor.js';
6
+ import { processRuntime } from './runtime.js';
6
7
  import spinnerCorpus from './spinners.json' with { type: 'json' };
8
+ const rt = processRuntime();
7
9
  export const spinners = spinnerCorpus;
8
10
  function isUnicodeSupported() {
9
- const { env } = process;
11
+ const { env } = rt;
10
12
  const { TERM, TERM_PROGRAM } = env;
11
- if (process.platform !== 'win32')
13
+ if (rt.platform !== 'win32')
12
14
  return TERM !== 'linux';
13
15
  return (Boolean(env['WT_SESSION']) ||
14
16
  Boolean(env['TERMINUS_SUBLIME']) ||
@@ -22,7 +24,7 @@ function isUnicodeSupported() {
22
24
  env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm');
23
25
  }
24
26
  function isInteractive(stream) {
25
- return Boolean(stream?.isTTY) && process.env['TERM'] !== 'dumb' && !('CI' in process.env);
27
+ return Boolean(stream?.isTTY) && rt.env['TERM'] !== 'dumb' && !('CI' in rt.env);
26
28
  }
27
29
  const UNICODE = isUnicodeSupported();
28
30
  const logSymbols = {
@@ -43,7 +45,7 @@ class StdinDiscarder {
43
45
  return;
44
46
  const code = typeof chunk === 'string' ? chunk.codePointAt(0) : chunk[0];
45
47
  if (code === ASCII_ETX_CODE)
46
- process.kill(process.pid, 'SIGINT');
48
+ rt.kill(rt.pid, 'SIGINT');
47
49
  };
48
50
  start() {
49
51
  this.#activeCount += 1;
@@ -58,8 +60,8 @@ class StdinDiscarder {
58
60
  this.#realStop();
59
61
  }
60
62
  #realStart() {
61
- const stdin = process.stdin;
62
- if (process.platform === 'win32' || stdin?.isTTY !== true || typeof stdin.setRawMode !== 'function') {
63
+ const stdin = rt.stdin;
64
+ if (rt.platform === 'win32' || stdin?.isTTY !== true || typeof stdin.setRawMode !== 'function') {
63
65
  this.#stdin = undefined;
64
66
  return;
65
67
  }
@@ -115,7 +117,7 @@ export class Ora {
115
117
  #color;
116
118
  constructor(options) {
117
119
  const given = typeof options === 'string' ? { text: options } : options;
118
- this.#options = { color: 'cyan', stream: process.stderr, discardStdin: true, hideCursor: true, isEnabled: false, isSilent: false, indent: 0, ...given };
120
+ this.#options = { color: 'cyan', stream: rt.stderr, discardStdin: true, hideCursor: true, isEnabled: false, isSilent: false, indent: 0, ...given };
119
121
  this.color = this.#options.color;
120
122
  this.#stream = this.#options.stream;
121
123
  if (typeof given?.isEnabled !== 'boolean')
@@ -132,7 +134,7 @@ export class Ora {
132
134
  this.prefixText = this.#options.prefixText;
133
135
  this.suffixText = this.#options.suffixText;
134
136
  this.indent = this.#options.indent;
135
- if (process.env['NODE_ENV'] === 'test')
137
+ if (rt.env['NODE_ENV'] === 'test')
136
138
  this.#exposeTestProperties();
137
139
  }
138
140
  #exposeTestProperties() {
@@ -319,7 +321,7 @@ export class Ora {
319
321
  return this;
320
322
  if (this.#options.hideCursor)
321
323
  this.#hideCursor();
322
- if (this.#options.discardStdin && process.stdin.isTTY) {
324
+ if (this.#options.discardStdin && rt.stdin.isTTY) {
323
325
  stdinDiscarder.start();
324
326
  this.#isDiscardingStdin = true;
325
327
  }
@@ -390,7 +392,7 @@ export class Ora {
390
392
  #hideCursor() {
391
393
  if (this.#stream.isTTY !== true)
392
394
  return;
393
- restoreCursorOnExit();
395
+ restoreCursor();
394
396
  this.#stream.write(HIDE_CURSOR);
395
397
  }
396
398
  #showCursor() {
@@ -434,7 +436,7 @@ export class Ora {
434
436
  #installHook() {
435
437
  if (!this.isEnabled || this.#hookedStreams.size > 0)
436
438
  return;
437
- for (const stream of new Set([this.#stream, process.stdout, process.stderr]))
439
+ for (const stream of new Set([this.#stream, rt.stdout, rt.stderr]))
438
440
  this.#hookStream(stream);
439
441
  }
440
442
  #hookStream(stream) {
package/dist/plugin.d.ts CHANGED
@@ -60,7 +60,7 @@ export interface Plugin {
60
60
  * the lock has to read the source to work across the family, and it does. A `const` array
61
61
  * would therefore buy nothing and cost ~195 B on `./spinner`, which has 82 B of headroom.
62
62
  */
63
- export type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_NO_STATIC_PROJECTION' | 'E_PLUGIN_CONTRACT' | 'E_UNKNOWN_SPINNER' | 'E_UNKNOWN_BORDER' | 'E_NO_CONTRIBUTION' | 'E_COMPONENT_THREW';
63
+ export type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_NO_STATIC_PROJECTION' | 'E_PLUGIN_CONTRACT' | 'E_UNKNOWN_SPINNER' | 'E_UNKNOWN_BORDER' | 'E_NO_CONTRIBUTION' | 'E_COMPONENT_THREW' | 'E_UNKNOWN_KIND';
64
64
  /** A refused plugin says what is wrong, where, and what to do about it. */
65
65
  export declare class PluginError extends Error {
66
66
  readonly code: PluginErrorCode;
@@ -1,11 +1,4 @@
1
- /**
2
- * The per-mode writers (R1). This is the only module in the package that emits a cursor
3
- * operation, and it does so only in `tty`; every other projection is text and a newline,
4
- * which is what R5 greps for. Streams and time come in as arguments — nothing here knows
5
- * `process` exists. The three sequences are written by hand rather than through
6
- * `node:readline`, whose helpers want a `Writable` where the loop only has a `Writer`.
7
- */
8
- import { HIDE_CURSOR, SHOW_CURSOR } from './cursor.js';
1
+ import { HIDE_CURSOR, SHOW_CURSOR } from 'closeout/cursor';
9
2
  import { type Component } from './plugin.js';
10
3
  export interface Writer {
11
4
  write(chunk: string): unknown;
@@ -1,4 +1,5 @@
1
- import { HIDE_CURSOR, restoreCursorOnExit, SHOW_CURSOR } from './cursor.js';
1
+ import { onExit } from 'closeout';
2
+ import { HIDE_CURSOR, SHOW_CURSOR } from 'closeout/cursor';
2
3
  export const DEFAULT_INTERVAL = 80;
3
4
  const CSI = '\u001B[';
4
5
  const NO_NET = () => undefined;
@@ -27,7 +28,7 @@ class TtyProjection {
27
28
  open(state) {
28
29
  this.#current = state;
29
30
  this.#out.write(HIDE_CURSOR);
30
- this.#dropCursorNet = restoreCursorOnExit((s) => void this.#out.write(s));
31
+ this.#dropCursorNet = onExit(() => void this.#out.write(SHOW_CURSOR), { phase: 'restore' });
31
32
  this.#paint();
32
33
  if (this.#component.frame !== undefined)
33
34
  this.#cancel = this.#clock.schedule(() => this.#repaint(), this.#interval);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Only the members this package actually reads. Anything not named here — `process.exit`,
3
+ * `cwd`, `chdir`, `emit` — is unreachable through the seam by construction, because the
4
+ * declared type is this and not `NodeJS.Process`.
5
+ */
6
+ export interface Runtime {
7
+ readonly env: NodeJS.ProcessEnv;
8
+ readonly argv: string[];
9
+ readonly platform: NodeJS.Platform;
10
+ readonly pid: number;
11
+ readonly stdin: NodeJS.ReadStream & {
12
+ fd: 0;
13
+ };
14
+ readonly stdout: NodeJS.WriteStream & {
15
+ fd: 1;
16
+ };
17
+ readonly stderr: NodeJS.WriteStream & {
18
+ fd: 2;
19
+ };
20
+ /** Set by `cli.ts` on the way out. Typed as node types it, string included. */
21
+ exitCode?: number | string | null | undefined;
22
+ kill(pid: number, signal: NodeJS.Signals): void;
23
+ }
24
+ /**
25
+ * The real process, narrowed. Nothing is read here: every access through the returned
26
+ * `Runtime` reaches the live process at the moment the caller makes it, which is what keeps
27
+ * `boxen`'s width lazy and lets ora's suite swap `process.kill` under a running spinner.
28
+ */
29
+ export declare const processRuntime: () => Runtime;
@@ -0,0 +1,2 @@
1
+ import process from 'node:process';
2
+ export const processRuntime = () => process;
package/dist/schema.json CHANGED
@@ -1 +1 @@
1
- {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json","title":"flagstaff plugin","description":"A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().","type":"object","required":["name"],"additionalProperties":true,"properties":{"name":{"type":"string","minLength":1,"description":"The plugin's name; also the prefix a host may use when two plugins contribute the same key."},"contract":{"type":"integer","minimum":1,"description":"The plugin contract this object follows. A host refuses a newer contract than it knows."},"tokens":{"type":"object","description":"A roundel theme: semantic token name to a hex colour, contrast-checked when flown.","additionalProperties":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}},"glyphs":{"type":"object","description":"Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.","additionalProperties":{"type":"string","minLength":1}},"spinners":{"type":"object","description":"Spinner styles by name, in cli-spinners' shape plus the static projection.","additionalProperties":{"$ref":"#/$defs/spinner"}},"borders":{"type":"object","description":"Border styles a box can be drawn with, by name.","additionalProperties":{"$ref":"#/$defs/border"}},"components":{"type":"object","description":"Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.","additionalProperties":{"$ref":"#/$defs/component"}}},"$defs":{"spinner":{"type":"object","required":["frames","interval","static"],"properties":{"frames":{"type":"array","items":{"type":"string"},"minItems":1},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between frames on a terminal."},"static":{"type":"string","description":"What a pipe prints instead of the animation."}}},"component":{"type":"object","required":["static"],"properties":{"static":{"description":"(state) => string. Required: the projection every non-terminal mode prints."},"frame":{"description":"(t, state) => string. Optional: the frame at t milliseconds since hoisting."},"sample":{"type":"object","required":["running","done"],"description":"Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.","properties":{"running":{"description":"The state to open with."},"done":{"description":"The state to close with."}}},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between repaints when `frame` is given; 80 when omitted."}}},"border":{"type":"object","required":["topLeft","top","topRight","left","right","bottomLeft","bottom","bottomRight"],"description":"cli-boxes' shape exactly, so that corpus imports unchanged.","properties":{"topLeft":{"type":"string"},"top":{"type":"string"},"topRight":{"type":"string"},"left":{"type":"string"},"right":{"type":"string"},"bottomLeft":{"type":"string"},"bottom":{"type":"string"},"bottomRight":{"type":"string"}}}}}
1
+ {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json","title":"flagstaff plugin","description":"A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().","type":"object","required":["name"],"additionalProperties":true,"properties":{"name":{"type":"string","minLength":1,"description":"The plugin's name; also the prefix a host may use when two plugins contribute the same key."},"contract":{"type":"integer","minimum":1,"description":"The plugin contract this object follows. A host refuses a newer contract than it knows."},"tokens":{"type":"object","description":"A roundel theme: semantic token name to a hex colour, contrast-checked when flown.","additionalProperties":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"propertyNames":{"enum":["error","warn","ok","hint","muted","command","flag","value","heading","ground"],"description":"roundel's ten semantic tokens, and nothing else — `roundel`'s own validate() refuses any other name."}},"glyphs":{"type":"object","description":"Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.","additionalProperties":{"type":"string","minLength":1}},"spinners":{"type":"object","description":"Spinner styles by name, in cli-spinners' shape plus the static projection.","additionalProperties":{"$ref":"#/$defs/spinner"}},"borders":{"type":"object","description":"Border styles a box can be drawn with, by name.","additionalProperties":{"$ref":"#/$defs/border"}},"components":{"type":"object","description":"Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.","additionalProperties":{"$ref":"#/$defs/component"}},"capabilities":{"$ref":"#/$defs/capabilities"}},"$defs":{"spinner":{"type":"object","required":["frames","interval","static"],"properties":{"frames":{"type":"array","items":{"type":"string"},"minItems":1},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between frames on a terminal."},"static":{"type":"string","description":"What a pipe prints instead of the animation."}}},"component":{"type":"object","required":["static"],"properties":{"static":{"description":"(state) => string. Required: the projection every non-terminal mode prints."},"frame":{"description":"(t, state) => string. Optional: the frame at t milliseconds since hoisting."},"sample":{"type":"object","required":["running","done"],"description":"Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.","properties":{"running":{"description":"The state to open with."},"done":{"description":"The state to close with."}}},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between repaints when `frame` is given; 80 when omitted."}}},"border":{"type":"object","required":["topLeft","top","topRight","left","right","bottomLeft","bottom","bottomRight"],"description":"cli-boxes' shape exactly, so that corpus imports unchanged.","properties":{"topLeft":{"type":"string"},"top":{"type":"string"},"topRight":{"type":"string"},"left":{"type":"string"},"right":{"type":"string"},"bottomLeft":{"type":"string"},"bottom":{"type":"string"},"bottomRight":{"type":"string"}}},"capability":{"type":"object","required":["name","osc","when","encode","fallback"],"additionalProperties":false,"description":"One paratext capability: what it says to the terminal, when the terminal is believed to understand it, and what prints when it does not. No functions, so it travels through JSON. `encode` and `fallback` are templates: `{field}` is the field's value, `{field|base64}` is it base64-encoded, and `[ … ]` is emitted only when every field inside it has a value.","properties":{"name":{"type":"string","minLength":1,"description":"How callers name it. Registering an existing name replaces it — how a caller corrects a guess we got wrong."},"osc":{"description":"The OSC code this speaks, or 'BEL' for the bell and for protocols that are not OSC at all, such as Kitty's.","oneOf":[{"type":"integer","minimum":0},{"const":"BEL"}]},"when":{"type":"object","description":"When the terminal is believed to understand it. Every clause must hold; `termProgram` and `envAny` are ORs within themselves. Guesses — no terminal answers 'do you do OSC 1337' — and therefore data a caller can replace.","additionalProperties":false,"properties":{"tty":{"type":"boolean","description":"Refuse a pipe. Almost always true: a file that receives OSC gets control bytes in it."},"termProgram":{"type":"array","items":{"type":"string"},"description":"Any one of these TERM_PROGRAM values."},"envAny":{"type":"array","items":{"type":"string"},"description":"Any one of these environment variables merely being set, as VTE announces itself."},"term":{"type":"string","description":"An exact TERM — Kitty is xterm-kitty."}}},"encode":{"type":"string","minLength":1,"description":"The bytes, as a template, for a terminal that does understand."},"fallback":{"type":"string","description":"What to print when it does not — PRINCIPLES rule 6. '' is a legitimate answer, a window title having nothing to say in a log; absence is not, which is why this is required and may be ''."}},"examples":[{"name":"kitty-image","osc":"BEL","when":{"tty":true,"term":"xterm-kitty"},"encode":"\u001b_Ga=T,f=100;{base64}\u001b\\","fallback":"{caption}"}]},"capabilities":{"type":"object","description":"paratext capabilities by name — the OSC section of a plugin, read the way flagstaff reads `spinners`.","additionalProperties":{"$ref":"#/$defs/capability"}},"capabilityDocument":{"description":"What paratext's `check()` accepts. Two shapes, and only one of them survives 1.0.","oneOf":[{"description":"The family shape: a plugin carrying its capabilities under `capabilities`.","type":"object","required":["name","capabilities"],"properties":{"name":{"type":"string","minLength":1},"contract":{"type":"integer","minimum":1},"capabilities":{"$ref":"#/$defs/capabilities"}}},{"deprecated":true,"description":"Deprecated: one capability as the whole document, the shape paratext's schema had before this one absorbed it. Accepted for one minor release, removed at 1.0 — `check()` validates it and says so.","$ref":"#/$defs/capability"}]}}}
package/dist/table.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { type Cell, type Terminal } from './link.js';
1
2
  import { type Component } from './plugin.js';
2
- export type Row = string[];
3
+ /** A row of cells. A plain `string[]` is still a row — a destination is opt-in per cell. */
4
+ export type Row = Cell[];
3
5
  export interface TableOptions {
4
6
  /** Column headers. Omitted, the table is drawn without a header row. */
5
7
  head?: string[];
@@ -7,6 +9,12 @@ export interface TableOptions {
7
9
  width?: number;
8
10
  /** Alignment per column; `left` for any column not named. */
9
11
  align?: ('left' | 'right')[];
12
+ /**
13
+ * The terminal a linked cell is rendered for. Omitted, the real process is read through
14
+ * this package's seam. Supply one and the whole path is pure — which is how `link.test.ts`
15
+ * grades both branches without a terminal in sight.
16
+ */
17
+ terminal?: Terminal;
10
18
  }
11
19
  /** Draw `rows` as a table, as a string. */
12
20
  export declare function table(rows: Row[], options?: TableOptions): string;
@@ -16,3 +24,5 @@ export interface TableState {
16
24
  }
17
25
  /** A table as a component: `header: value` pairs off a terminal, the grid on one (R1). */
18
26
  export declare function tableComponent(options?: TableOptions): Component<TableState>;
27
+ /** The cell shapes, re-exported so a caller of `flagstaff/table` can name them (R12). */
28
+ export type { Cell, Linked } from './link.js';
package/dist/table.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { width } from 'linegauge';
2
2
  import { wrap } from 'linegauge/wrap';
3
3
  import { heading, muted } from 'roundel/tokens';
4
+ import { cellHref, cellText, laid, painted, painter, STATIC } from './link.js';
4
5
  const DEFAULT_WIDTH = 80;
5
6
  const CHROME_PER_COLUMN = 3;
6
7
  const CHROME_FIXED = 1;
@@ -8,15 +9,15 @@ const MIN_COLUMN = 3;
8
9
  const V = '│';
9
10
  const H = '─';
10
11
  const CORNERS = { topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘', top: '┬', bottom: '┴', left: '├', right: '┤', cross: '┼' };
11
- const padTo = (line, cells, align) => {
12
- const gap = ' '.repeat(Math.max(0, cells - width(line)));
12
+ const padTo = (line, cells, align, measured = line) => {
13
+ const gap = ' '.repeat(Math.max(0, cells - width(measured)));
13
14
  return align === 'right' ? gap + line : line + gap;
14
15
  };
15
- function naturalWidths(rows, columns) {
16
+ function naturalWidths(rows, columns, paint) {
16
17
  const widths = Array.from({ length: columns }, () => 0);
17
18
  for (const row of rows) {
18
19
  for (let index = 0; index < columns; index += 1)
19
- widths[index] = Math.max(widths[index] ?? 0, width(row[index] ?? ''));
20
+ widths[index] = Math.max(widths[index] ?? 0, width(laid(cellText(row[index]), cellHref(row[index]), paint)));
20
21
  }
21
22
  return widths;
22
23
  }
@@ -35,10 +36,15 @@ function fitWidths(natural, available) {
35
36
  }
36
37
  return widths;
37
38
  }
38
- function layoutRow(row, widths, align) {
39
- const cells = widths.map((w, index) => wrap(row[index] ?? '', w, { hard: true, trim: false }).split('\n'));
39
+ function layoutRow(row, widths, align, paint) {
40
+ const cells = widths.map((w, index) => wrap(laid(cellText(row[index]), cellHref(row[index]), paint), w, { hard: true, trim: false }).split('\n'));
40
41
  const height = Math.max(1, ...cells.map((lines) => lines.length));
41
- return Array.from({ length: height }, (_, line) => `${V} ${widths.map((w, index) => padTo(cells[index]?.[line] ?? '', w, align[index] ?? 'left')).join(` ${V} `)} ${V}`);
42
+ return Array.from({ length: height }, (_, line) => `${V} ${widths
43
+ .map((w, index) => {
44
+ const plain = cells[index]?.[line] ?? '';
45
+ return padTo(painted(plain, cellHref(row[index]), paint), w, align[index] ?? 'left', plain);
46
+ })
47
+ .join(` ${V} `)} ${V}`);
42
48
  }
43
49
  const rule = (widths, left, mid, right) => left + widths.map((w) => H.repeat(w + 2)).join(mid) + right;
44
50
  export function table(rows, options = {}) {
@@ -46,23 +52,28 @@ export function table(rows, options = {}) {
46
52
  const total = options.width ?? DEFAULT_WIDTH;
47
53
  const align = options.align ?? [];
48
54
  const available = Math.max(columns * MIN_COLUMN, total - columns * CHROME_PER_COLUMN - CHROME_FIXED);
55
+ const paint = painter(options.terminal);
49
56
  const all = options.head === undefined ? rows : [options.head, ...rows];
50
- const widths = fitWidths(naturalWidths(all, columns), available);
57
+ const widths = fitWidths(naturalWidths(all, columns, paint), available);
51
58
  const out = [rule(widths, CORNERS.topLeft, CORNERS.top, CORNERS.topRight)];
52
59
  if (options.head !== undefined) {
53
- out.push(...layoutRow(options.head.map((cell) => heading(cell)), widths, align), rule(widths, CORNERS.left, CORNERS.cross, CORNERS.right));
60
+ out.push(...layoutRow(options.head.map((cell) => heading(cell)), widths, align, paint), rule(widths, CORNERS.left, CORNERS.cross, CORNERS.right));
54
61
  }
55
62
  for (const row of rows)
56
- out.push(...layoutRow(row, widths, align));
63
+ out.push(...layoutRow(row, widths, align, paint));
57
64
  out.push(rule(widths, CORNERS.bottomLeft, CORNERS.bottom, CORNERS.bottomRight));
58
65
  return out.join('\n');
59
66
  }
60
67
  export function tableComponent(options = {}) {
68
+ const plain = painter(STATIC);
61
69
  return {
62
70
  name: 'table',
63
71
  static: (state) => {
64
72
  const head = state.head ?? options.head;
65
- return state.rows.map((row) => (head === undefined ? row.join('\t') : row.map((cell, index) => `${head[index] ?? index}: ${cell}`).join(', '))).join('\n');
73
+ const show = (cell) => laid(cellText(cell), cellHref(cell), plain);
74
+ return state.rows
75
+ .map((row) => (head === undefined ? row.map((cell) => show(cell)).join('\t') : row.map((cell, index) => `${head[index] ?? index}: ${show(cell)}`).join(', ')))
76
+ .join('\n');
66
77
  },
67
78
  frame: (_t, state) => muted(table(state.rows, state.head === undefined ? options : { ...options, head: state.head })),
68
79
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagstaff",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The staff the flag flies from. A terminal frame loop with a static projection for agents and screen readers, and a plugin host for spinners, progress, boxes and tables. Drop-in paths for ora, log-update, boxen and cli-table3. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -115,10 +115,13 @@
115
115
  "plugins"
116
116
  ],
117
117
  "dependencies": {
118
- "linegauge": "^0.2.0",
119
- "roundel": "^0.3.0"
118
+ "closeout": "^0.2.0",
119
+ "linegauge": "^0.3.0",
120
+ "paratext": "^0.3.0",
121
+ "roundel": "^0.3.1"
120
122
  },
121
123
  "devDependencies": {
124
+ "fast-check": "^4.10.0",
122
125
  "vitest": "^5.0.0"
123
126
  }
124
127
  }
package/dist/cursor.d.ts DELETED
@@ -1,13 +0,0 @@
1
- export declare const HIDE_CURSOR = "\u001B[?25l";
2
- export declare const SHOW_CURSOR = "\u001B[?25h";
3
- /**
4
- * Installed once, the first time a cursor is hidden; puts it back however the process dies.
5
- *
6
- * `write` is how the caller's own surface reaches the terminal, and passing it is what makes
7
- * this usable from the core. `hoist()` already knows its stream — the Runtime gave it one and
8
- * the output mode was decided from it once (R1/U2) — so it must not be re-detected here: a
9
- * second detector is precisely what the policy exists to prevent. The façades pass nothing
10
- * and get the detection below, because that is their incumbents' contract — ora and
11
- * log-update restore *the process's* cursor whichever stream the caller handed them.
12
- */
13
- export declare function restoreCursorOnExit(write?: (s: string) => void): () => void;
package/dist/cursor.js DELETED
@@ -1,49 +0,0 @@
1
- import { constants } from 'node:os';
2
- import process from 'node:process';
3
- export const HIDE_CURSOR = '\u001B[?25l';
4
- export const SHOW_CURSOR = '\u001B[?25h';
5
- const TERMINATION_SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM', 'SIGBREAK'].filter((signal) => signal in constants.signals);
6
- function terminalStream() {
7
- if (process.stderr.isTTY)
8
- return process.stderr;
9
- if (process.stdout.isTTY)
10
- return process.stdout;
11
- return undefined;
12
- }
13
- let cursorRestoreInstalled = false;
14
- const NOTHING_TO_UNDO = () => undefined;
15
- export function restoreCursorOnExit(write) {
16
- if (cursorRestoreInstalled)
17
- return NOTHING_TO_UNDO;
18
- cursorRestoreInstalled = true;
19
- const terminal = write === undefined ? terminalStream() : { write };
20
- if (terminal === undefined)
21
- return NOTHING_TO_UNDO;
22
- let restored = false;
23
- const restore = () => {
24
- if (restored)
25
- return;
26
- restored = true;
27
- terminal.write(SHOW_CURSOR);
28
- };
29
- const installed = new Map();
30
- const uninstall = () => {
31
- for (const [name, fn] of installed)
32
- process.removeListener(name, fn);
33
- installed.clear();
34
- process.removeListener('exit', restore);
35
- cursorRestoreInstalled = false;
36
- };
37
- for (const signal of TERMINATION_SIGNALS) {
38
- const handler = () => {
39
- restore();
40
- uninstall();
41
- if (process.listenerCount(signal) === 0)
42
- process.kill(process.pid, signal);
43
- };
44
- process.on(signal, handler);
45
- installed.set(signal, handler);
46
- }
47
- process.once('exit', restore);
48
- return uninstall;
49
- }