@promptctl/rich-js 0.17.0 → 0.18.1

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.
Files changed (147) hide show
  1. package/README.md +13 -26
  2. package/dist/core/anchor.d.ts +45 -0
  3. package/dist/core/anchor.js +39 -0
  4. package/dist/core/ansi.d.ts +65 -0
  5. package/dist/core/ansi.js +231 -0
  6. package/dist/core/box.d.ts +0 -1
  7. package/dist/core/box.js +6 -18
  8. package/dist/core/cells.d.ts +16 -5
  9. package/dist/core/cells.js +31 -10
  10. package/dist/core/color.d.ts +4 -8
  11. package/dist/core/color.js +28 -19
  12. package/dist/core/console.d.ts +13 -4
  13. package/dist/core/console.js +91 -36
  14. package/dist/core/export-html.d.ts +40 -5
  15. package/dist/core/export-html.js +111 -17
  16. package/dist/core/markup.d.ts +27 -5
  17. package/dist/core/markup.js +282 -170
  18. package/dist/core/measure.d.ts +1 -1
  19. package/dist/core/measure.js +2 -1
  20. package/dist/core/place.d.ts +30 -0
  21. package/dist/core/place.js +51 -0
  22. package/dist/core/pretty.d.ts +3 -0
  23. package/dist/core/pretty.js +5 -1
  24. package/dist/core/protocol.d.ts +136 -6
  25. package/dist/core/protocol.js +64 -2
  26. package/dist/core/render.d.ts +2 -2
  27. package/dist/core/render.js +2 -2
  28. package/dist/core/segment.d.ts +23 -4
  29. package/dist/core/segment.js +75 -16
  30. package/dist/core/spinnerData.d.ts +2 -0
  31. package/dist/core/spinnerData.js +3 -1
  32. package/dist/core/strip.d.ts +3 -7
  33. package/dist/core/strip.js +32 -35
  34. package/dist/core/style.d.ts +25 -0
  35. package/dist/core/style.js +61 -89
  36. package/dist/core/text.d.ts +16 -15
  37. package/dist/core/text.js +62 -76
  38. package/dist/host/app.d.ts +121 -0
  39. package/dist/host/app.js +255 -0
  40. package/dist/host/host-environment.d.ts +26 -0
  41. package/dist/host/host-environment.js +44 -0
  42. package/dist/host/index.d.ts +4 -2
  43. package/dist/host/index.js +2 -1
  44. package/dist/host/terminal-host.d.ts +43 -7
  45. package/dist/host/terminal-host.js +57 -3
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.js +4 -1
  48. package/dist/node/terminal-host.d.ts +31 -2
  49. package/dist/node/terminal-host.js +69 -2
  50. package/dist/node/traceback.d.ts +16 -6
  51. package/dist/node/traceback.js +30 -8
  52. package/dist/renderables/align.d.ts +2 -2
  53. package/dist/renderables/align.js +6 -19
  54. package/dist/renderables/columns.js +4 -20
  55. package/dist/renderables/embed.d.ts +50 -0
  56. package/dist/renderables/embed.js +71 -0
  57. package/dist/renderables/group.js +3 -1
  58. package/dist/renderables/layout.js +31 -34
  59. package/dist/renderables/live.d.ts +6 -3
  60. package/dist/renderables/live.js +89 -45
  61. package/dist/renderables/markdown.d.ts +1 -1
  62. package/dist/renderables/markdown.js +6 -4
  63. package/dist/renderables/padding.js +3 -2
  64. package/dist/renderables/panel.js +26 -41
  65. package/dist/renderables/progress.js +5 -8
  66. package/dist/renderables/progressBar.js +6 -5
  67. package/dist/renderables/rule.d.ts +3 -2
  68. package/dist/renderables/rule.js +9 -9
  69. package/dist/renderables/spinner.d.ts +5 -3
  70. package/dist/renderables/spinner.js +14 -8
  71. package/dist/renderables/syntax.d.ts +1 -1
  72. package/dist/renderables/syntax.js +2 -1
  73. package/dist/renderables/table.d.ts +26 -14
  74. package/dist/renderables/table.js +239 -141
  75. package/dist/renderables/traceback.js +8 -9
  76. package/dist/renderables/tree.d.ts +17 -3
  77. package/dist/renderables/tree.js +62 -71
  78. package/dist/renderables/viewport.d.ts +114 -0
  79. package/dist/renderables/viewport.js +198 -0
  80. package/dist/template-bindings/color-funcs.js +11 -13
  81. package/dist/template-bindings/index.js +4 -2
  82. package/dist/template-bindings/palette-funcs.js +6 -16
  83. package/dist/template-bindings/style-funcs.js +4 -0
  84. package/dist/themes/data/ansi-tables.d.ts +14 -0
  85. package/dist/themes/data/ansi-tables.js +49 -0
  86. package/dist/themes/data/atom-one-dark.js +19 -0
  87. package/dist/themes/data/atom-one-light.js +19 -0
  88. package/dist/themes/data/catppuccin-frappe.js +19 -0
  89. package/dist/themes/data/catppuccin-latte.js +19 -0
  90. package/dist/themes/data/catppuccin-macchiato.js +19 -0
  91. package/dist/themes/data/catppuccin-mocha.js +19 -0
  92. package/dist/themes/data/cyberpunk.js +2 -0
  93. package/dist/themes/data/default.js +2 -0
  94. package/dist/themes/data/dracula.js +19 -0
  95. package/dist/themes/data/flexoki.js +19 -0
  96. package/dist/themes/data/gruvbox.js +19 -0
  97. package/dist/themes/data/monokai.js +19 -0
  98. package/dist/themes/data/nord.js +19 -0
  99. package/dist/themes/data/rose-pine-dawn.js +19 -0
  100. package/dist/themes/data/rose-pine-moon.js +19 -0
  101. package/dist/themes/data/rose-pine.js +19 -0
  102. package/dist/themes/data/solarized-dark.js +19 -0
  103. package/dist/themes/data/solarized-light.js +19 -0
  104. package/dist/themes/data/svg-export.js +19 -0
  105. package/dist/themes/data/textual-ansi.js +2 -0
  106. package/dist/themes/data/textual-dark.js +2 -0
  107. package/dist/themes/data/textual-light.js +2 -0
  108. package/dist/themes/data/tokyo-night.js +19 -0
  109. package/dist/themes/data/types.d.ts +16 -0
  110. package/dist/themes/data/types.js +10 -1
  111. package/dist/themes/registry.d.ts +3 -2
  112. package/dist/themes/registry.js +13 -1
  113. package/dist/themes/terminalThemes.d.ts +2 -7
  114. package/dist/themes/terminalThemes.js +4 -9
  115. package/dist/widgets/button.d.ts +1 -1
  116. package/dist/widgets/button.js +4 -2
  117. package/dist/widgets/checkbox.d.ts +2 -2
  118. package/dist/widgets/checkbox.js +5 -3
  119. package/dist/widgets/dropdown.d.ts +2 -2
  120. package/dist/widgets/dropdown.js +13 -16
  121. package/dist/widgets/event-router.d.ts +21 -28
  122. package/dist/widgets/event-router.js +80 -73
  123. package/dist/widgets/focus-manager.d.ts +25 -6
  124. package/dist/widgets/focus-manager.js +63 -65
  125. package/dist/widgets/hit.d.ts +64 -0
  126. package/dist/widgets/hit.js +113 -0
  127. package/dist/widgets/index.d.ts +5 -7
  128. package/dist/widgets/index.js +3 -4
  129. package/dist/widgets/slider.d.ts +2 -2
  130. package/dist/widgets/slider.js +7 -7
  131. package/dist/widgets/static-item.d.ts +9 -15
  132. package/dist/widgets/static-item.js +9 -15
  133. package/dist/widgets/text-input.d.ts +2 -2
  134. package/dist/widgets/text-input.js +17 -14
  135. package/dist/widgets/toggle.d.ts +1 -1
  136. package/dist/widgets/toggle.js +3 -2
  137. package/dist/widgets/types.d.ts +14 -35
  138. package/dist/widgets/types.js +0 -1
  139. package/dist/widgets/widget-app.d.ts +63 -0
  140. package/dist/widgets/widget-app.js +199 -0
  141. package/dist/widgets/widget-base.d.ts +4 -6
  142. package/dist/widgets/widget-base.js +22 -30
  143. package/package.json +12 -4
  144. package/dist/host/host-stream.d.ts +0 -30
  145. package/dist/host/host-stream.js +0 -35
  146. package/dist/widgets/screen.d.ts +0 -98
  147. package/dist/widgets/screen.js +0 -530
@@ -5,8 +5,13 @@
5
5
  function hex2(byte) {
6
6
  return byte.toString(16).padStart(2, "0");
7
7
  }
8
+ // [LAW:one-source-of-truth] The one statement of a colour byte's range: the
9
+ // constructor asserts it and the string parser reports it, from this predicate.
10
+ function isByte(v) {
11
+ return Number.isInteger(v) && v >= 0 && v <= 255;
12
+ }
8
13
  function assertChannel(name, v) {
9
- if (!Number.isInteger(v) || v < 0 || v > 255) {
14
+ if (!isByte(v)) {
10
15
  throw new RangeError(`ColorRgba.${name} must be an integer in [0, 255]; got ${v}`);
11
16
  }
12
17
  }
@@ -501,7 +506,8 @@ export class ColorSpec {
501
506
  case ColorDepth.TRUECOLOR:
502
507
  return this.value;
503
508
  case ColorDepth.EIGHT_BIT:
504
- return EIGHT_BIT_TABLE.get(this.number);
509
+ // Slots 0–15 are the theme's own whatever depth names them, as `fixedValue` says.
510
+ return this.number < 16 ? (theme ?? INTERNAL_DEFAULT_THEME).ansiColors.get(this.number) : EIGHT_BIT_TABLE.get(this.number);
505
511
  case ColorDepth.STANDARD: {
506
512
  const t = theme ?? INTERNAL_DEFAULT_THEME;
507
513
  return t.ansiColors.get(this.number);
@@ -569,8 +575,8 @@ export class ColorSpec {
569
575
  // --- Parsing internals ---
570
576
  const HEX_RE = /^#([0-9a-f]{6})$/;
571
577
  const HEX_RGBA_RE = /^#([0-9a-f]{8})$/;
572
- const RGB_RE = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/;
573
- const COLOR_NUMBER_RE = /^color\((\d{1,3})\)$/;
578
+ const RGB_RE = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
579
+ const COLOR_NUMBER_RE = /^color\((\d+)\)$/;
574
580
  function parseSingle(key) {
575
581
  if (key === "default" || key === "") {
576
582
  return ColorSpec.default();
@@ -594,23 +600,30 @@ function parseSingle(key) {
594
600
  // rgb()
595
601
  const rgbMatch = RGB_RE.exec(key);
596
602
  if (rgbMatch) {
597
- const r = parseInt(rgbMatch[1], 10);
598
- const g = parseInt(rgbMatch[2], 10);
599
- const b = parseInt(rgbMatch[3], 10);
603
+ const r = parseByte(key, rgbMatch[1], "red");
604
+ const g = parseByte(key, rgbMatch[2], "green");
605
+ const b = parseByte(key, rgbMatch[3], "blue");
600
606
  return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, new ColorRgba(r, g, b));
601
607
  }
602
608
  // color(N)
603
609
  const numMatch = COLOR_NUMBER_RE.exec(key);
604
610
  if (numMatch) {
605
- const n = parseInt(numMatch[1], 10);
606
- if (n > 255) {
607
- throw new ColorParseError(`ColorSpec number ${n} is out of range (0-255)`);
608
- }
611
+ const n = parseByte(key, numMatch[1], "number");
609
612
  const type = n < 16 ? ColorDepth.STANDARD : ColorDepth.EIGHT_BIT;
610
613
  return new ColorSpec(key, type, n);
611
614
  }
612
615
  throw new ColorParseError(`Failed to parse color: "${key}"`);
613
616
  }
617
+ // [LAW:single-enforcer] The regexes admit any run of digits and leave the
618
+ // range to this check alone, so every out-of-range byte is a ColorParseError
619
+ // naming the field, never the generic parse failure or the constructor's RangeError.
620
+ function parseByte(key, digits, field) {
621
+ const n = parseInt(digits, 10);
622
+ if (!isByte(n)) {
623
+ throw new ColorParseError(`ColorSpec "${key}": ${field} ${digits} is out of range (0-255)`);
624
+ }
625
+ return n;
626
+ }
614
627
  // --- Utility functions ---
615
628
  export function parseRgbHex(hex) {
616
629
  const r = parseInt(hex.slice(0, 2), 16);
@@ -633,14 +646,10 @@ export function blendRgb(color1, color2, crossFade = 0.5) {
633
646
  * A terminal theme — surface/foreground baseline, the ANSI 16/256 LUT, and a
634
647
  * semantic palette.
635
648
  *
636
- * **`ansiColors` is the canonical ANSI table, not a per-theme override.** All
637
- * preset themes in this codebase use `STANDARD_TABLE` for `ansiColors`. Theme
638
- * identity lives entirely in `palette` (primary, accent, text-*, surface, ...);
639
- * `ColorSpec.parse("red")` is intentionally consistent across themes — same
640
- * design as Textual itself, which models theming at the truecolor/semantic
641
- * layer rather than overloading the ANSI 16. Custom callers building bespoke
642
- * themes that *do* want to retint ANSI 16 are free to pass their own
643
- * `ColorTable`; that path is supported but not how the built-in presets work.
649
+ * **`ansiColors` is the theme's own sixteen colours**, the shades a terminal
650
+ * showing this theme draws `red`, `blue` and the rest in. `ColorSpec.parse("red")`
651
+ * is ANSI colour 1 under every theme; which red that is, is the theme's to say,
652
+ * as it is in Rich, where each `TerminalTheme` carries its own table.
644
653
  */
645
654
  export class TerminalTheme {
646
655
  backgroundColor;
@@ -8,7 +8,7 @@ import type { TerminalTheme } from "./color.js";
8
8
  import { type JSONOptions } from "./json.js";
9
9
  import type { Highlighter } from "./highlighter.js";
10
10
  import { type RuleOptions } from "../renderables/rule.js";
11
- import type { RenderOptions, StyleErrorHandler } from "./protocol.js";
11
+ import type { Height, OverflowMethod, RenderOptions, StyleErrorHandler } from "./protocol.js";
12
12
  export interface ConsoleSink {
13
13
  write(chunk: string | Uint8Array): unknown;
14
14
  }
@@ -41,6 +41,13 @@ export interface ConsoleOptions {
41
41
  * TERM=dumb: false). `false` with `colorSystem: null` writes plain text.
42
42
  */
43
43
  hyperlinks?: boolean;
44
+ /**
45
+ * The terminal can draw only ASCII: boxes, rules, tree guides and widget
46
+ * marks draw with ASCII characters instead of line-drawing glyphs. A
47
+ * property of the output device, so it is set here once and reaches every
48
+ * renderable this console draws. Default false.
49
+ */
50
+ asciiOnly?: boolean;
44
51
  /**
45
52
  * Static width (cells). Ignored when `getSize` is provided. Falls back to
46
53
  * the `COLUMNS` env var / the bound stream's `columns` / 80.
@@ -94,7 +101,7 @@ export interface ConsoleOptions {
94
101
  export interface PrintOptions {
95
102
  style?: string | Style;
96
103
  justify?: "default" | "left" | "center" | "right" | "full";
97
- overflow?: "fold" | "crop" | "ellipsis" | "ignore";
104
+ overflow?: OverflowMethod;
98
105
  highlight?: boolean;
99
106
  markup?: boolean;
100
107
  softWrap?: boolean;
@@ -115,6 +122,7 @@ export declare class Console {
115
122
  private _highlight;
116
123
  private _theme;
117
124
  private _onStyleError;
125
+ private readonly _asciiOnly;
118
126
  private _highlighter;
119
127
  private _recorded;
120
128
  private _capture;
@@ -125,7 +133,6 @@ export declare class Console {
125
133
  };
126
134
  get width(): number;
127
135
  get height(): number;
128
- get encoding(): string;
129
136
  get isTerminal(): boolean;
130
137
  get isInteractive(): boolean;
131
138
  get colorSystem(): ColorDepth | null;
@@ -137,7 +144,9 @@ export declare class Console {
137
144
  get destination(): Destination;
138
145
  get file(): ConsoleSink;
139
146
  get theme(): Theme;
140
- get options(): RenderOptions;
147
+ get options(): RenderOptions & {
148
+ height: Height;
149
+ };
141
150
  print(...args: unknown[]): void;
142
151
  log(...args: unknown[]): void;
143
152
  rule(title?: string, options?: RuleOptions): void;
@@ -16,7 +16,45 @@ import { ReprHighlighter, NullHighlighter } from "./highlighter.js";
16
16
  // rather than being restated here; see `rule()`.
17
17
  import { Rule } from "../renderables/rule.js";
18
18
  import { segmentsToString } from "./render.js";
19
+ import { placeBlock } from "./place.js";
19
20
  import { isRenderable } from "./protocol.js";
21
+ // Three of the five justify methods place what a print draws as a block, the
22
+ // way Rich's `print` wraps each renderable in `Align`. The other two leave it
23
+ // where it was drawn: `"default"` asks for no placement, and `"full"` is a way
24
+ // of setting a paragraph's words, which only text can do.
25
+ const PLACED_BY = {
26
+ default: undefined,
27
+ full: undefined,
28
+ left: "left",
29
+ center: "center",
30
+ right: "right",
31
+ };
32
+ // A text run as one block: its items joined into one text, as the reference's
33
+ // `Text.join` joins them, drawn under the print's options and ended by the
34
+ // print's `end`. Measure and render both read that one text, so the width
35
+ // `justify` places the run at is the width it is drawn at. Each item's own
36
+ // `justify`, `overflow`, `noWrap` and `tabSize` stay behind, as they do in the
37
+ // reference: the print's options set the whole run.
38
+ class TextRun {
39
+ items;
40
+ end;
41
+ constructor(items, end) {
42
+ this.items = items;
43
+ this.end = end;
44
+ }
45
+ *render(options) {
46
+ yield* this.text(options).render(options);
47
+ yield this.end;
48
+ }
49
+ measure(options) {
50
+ return this.text(options).measure(options);
51
+ }
52
+ // A `Pretty` lays itself out for the width it is offered, so the run is
53
+ // joined per call rather than once.
54
+ text(options) {
55
+ return this.items.reduce((joined, item) => joined.append(item instanceof Pretty ? item.toText(options) : item), new RichText("", { end: "" }));
56
+ }
57
+ }
20
58
  // A host with an environment but no streams — what a browser looks like from
21
59
  // here. Frozen so the shared instance cannot be mutated into a fake terminal.
22
60
  const DETACHED_ENVIRONMENT = Object.freeze({
@@ -67,8 +105,9 @@ function terminalSize(environment, stream) {
67
105
  function defaultSink(stream) {
68
106
  if (stream === undefined) {
69
107
  throw new Error("Console: no `file` provided and the environment has no stream to " +
70
- "write to (e.g. running in a browser). Pass `file: hostStream(host)` " +
71
- "or any other ConsoleSink so output has somewhere to go.");
108
+ "write to (e.g. running in a browser). Pass " +
109
+ "`environment: hostEnvironment(host)` to write through a terminal " +
110
+ "host, or a `file` so output has somewhere to go.");
72
111
  }
73
112
  return stream;
74
113
  }
@@ -146,6 +185,7 @@ export class Console {
146
185
  _highlight;
147
186
  _theme;
148
187
  _onStyleError;
188
+ _asciiOnly;
149
189
  _highlighter;
150
190
  _recorded;
151
191
  // [LAW:types-are-the-program] The capture state is carried in the *type*
@@ -182,6 +222,7 @@ export class Console {
182
222
  this._highlight = options?.highlight !== false;
183
223
  this._highlighter = options?.highlighter ?? new ReprHighlighter();
184
224
  this._onStyleError = options?.onStyleError;
225
+ this._asciiOnly = options?.asciiOnly ?? false;
185
226
  this._recorded = [];
186
227
  this._capture = null;
187
228
  }
@@ -195,9 +236,6 @@ export class Console {
195
236
  get height() {
196
237
  return this._getSize().height;
197
238
  }
198
- get encoding() {
199
- return "utf-8";
200
- }
201
239
  get isTerminal() {
202
240
  return this._isTerminal;
203
241
  }
@@ -231,11 +269,16 @@ export class Console {
231
269
  return this._theme;
232
270
  }
233
271
  get options() {
272
+ // One read of the size, so a resize cannot pair one frame's width with
273
+ // another's height.
274
+ const { width, height } = this.size;
234
275
  return {
235
- maxWidth: this.width,
276
+ maxWidth: width,
277
+ // The terminal an inline print lands on: a ceiling, never a region —
278
+ // content keeps its natural height beneath it.
279
+ height: { rows: height, exact: false },
236
280
  isTerminal: this.isTerminal,
237
- encoding: this.encoding,
238
- asciiOnly: false,
281
+ asciiOnly: this._asciiOnly,
239
282
  theme: this._theme,
240
283
  onStyleError: this._onStyleError,
241
284
  colorSystem: this._destination.colorSystem,
@@ -335,53 +378,65 @@ export class Console {
335
378
  else
336
379
  blocks.push({ kind: "text", items: [text] });
337
380
  }
338
- // [LAW:parse-dont-validate] `"ignore"` is not a way of cutting a line but
339
- // the absence of an edge to cut at: no break, and nothing cut at the width.
340
- // That is what `noWrap` already means to `RichText`, so it crosses into the
341
- // render as `noWrap`, and `overflow` carries only the methods a renderable
342
- // applies. Soft wrap is the same request — the reference defaults it to
343
- // `"ignore"` — which is why the two arrive at one field.
381
+ // Soft wrap is the reference's pair of defaults, not a mode of its own: it
382
+ // stops wrapping, and when no overflow was named it asks for `"ignore"`, so
383
+ // the line leaves unbounded. A named overflow still cuts the unwrapped line
384
+ // at the width, as Rich's does. What `"ignore"` does to a line — no edge,
385
+ // no justify — is `RichText`'s to apply, so it crosses as it was asked for.
344
386
  const renderOpts = {
345
387
  ...this.options,
346
388
  justify: opts.justify === "default" ? undefined : opts.justify,
347
- overflow: opts.overflow === "ignore" ? undefined : opts.overflow,
348
- noWrap: softWrap || opts.overflow === "ignore",
389
+ overflow: opts.overflow ?? (softWrap ? "ignore" : undefined),
390
+ noWrap: softWrap,
349
391
  };
350
- // The print style, then the console's base style, over what each block drew
351
- // and not over the line ends added below: a styled break carries SGR codes
352
- // across the newline.
392
+ // The print style, then the console's base style, over each line a block
393
+ // drew and never over the break that ends it: a styled break carries SGR
394
+ // codes across the newline.
353
395
  // [LAW:dataflow-not-control-flow] no style to apply is an empty list, not
354
396
  // a skipped step.
355
397
  const styles = [printStyle, this._style].filter((style) => !style.isNull);
356
398
  const styleContent = (segments) => styles.reduce((styled, style) => Segment.applyStyle(styled, style), segments);
357
- // Every line end goes through the same writeSegments funnel as the text it
358
- // ends, so it survives recording — otherwise `exportText` and `exportHtml`
359
- // would join consecutive prints onto a single line. [LAW:single-enforcer]
360
399
  // The common default end is "\n" — reuse Segment's cached newline rather
361
400
  // than allocating one per print; only a non-default end needs a fresh one.
362
401
  const terminator = end === "\n" ? Segment.line() : new Segment(end);
402
+ const align = PLACED_BY[opts.justify ?? "default"];
403
+ // Each block as its lines, and whether its last line is closed. A placed
404
+ // block is closed line by line, as `Align` closes it. Unplaced, a text run
405
+ // is closed exactly where its content — its `end` included — breaks; any
406
+ // other block's lines are closed whether or not it closed them itself: a
407
+ // `Panel` ends in a line break and a bare `Spinner` or `ProgressBar` does
408
+ // not (they're line fragments, meant to be composed within a line —
409
+ // rich-flexstrip-5kf.4cq), and both leave the next print at the start of a
410
+ // line.
411
+ const drawBlock = (block) => {
412
+ const renderable = block.kind === "text" ? new TextRun(block.items, terminator) : block.renderable;
413
+ if (align)
414
+ return { lines: placeBlock(renderable, align, renderOpts), closed: true };
415
+ const drawn = [...renderable.render(renderOpts)];
416
+ // A text run's last segment is always its `end`, yielded even when
417
+ // empty, and `splitLines` drops the empty line after a final break only
418
+ // when nothing follows it — so the run is closed exactly when `end` is.
419
+ const closed = block.kind === "lines" || end.endsWith("\n");
420
+ return { lines: Segment.splitLines(drawn), closed };
421
+ };
422
+ // Every line end goes through the same writeSegments funnel as the text it
423
+ // ends, so it survives recording — otherwise `exportText` and `exportHtml`
424
+ // would join consecutive prints onto a single line. [LAW:single-enforcer]
363
425
  const output = [];
364
426
  for (const block of blocks) {
365
- if (block.kind === "text") {
366
- output.push(...styleContent(block.items.flatMap((item) => [...item.render(renderOpts)])), terminator);
367
- }
368
- else {
369
- // A block's lines are closed whether or not it closed them itself: a
370
- // `Panel` ends in a line break and a bare `Spinner` or `ProgressBar`
371
- // does not (they're line fragments, meant to be composed within a
372
- // line — rich-flexstrip-5kf.4cq), and both leave the next print at
373
- // the start of a line.
374
- for (const line of Segment.splitLines(block.renderable.render(renderOpts))) {
375
- output.push(...styleContent(line), Segment.line());
376
- }
377
- }
427
+ const { lines, closed } = drawBlock(block);
428
+ lines.forEach((line, index) => {
429
+ output.push(...styleContent(line));
430
+ if (closed || index < lines.length - 1)
431
+ output.push(Segment.line());
432
+ });
378
433
  }
379
434
  // Crop last, and crop the line-end with the rest: in the reference `end` is
380
435
  // the tail of the printed line, so a line cut at the edge loses it too.
381
436
  // Soft wrap turns cropping off whatever `crop` says, because a line it left
382
437
  // whole is meant to reach the terminal whole. [LAW:dataflow-not-control-flow]
383
438
  // Not cropping is an unbounded width rather than a skipped step — the same
384
- // spelling `RichText` uses for `noWrap`.
439
+ // spelling `RichText` uses for `"ignore"`.
385
440
  const cropWidth = !softWrap && (opts.crop ?? true) ? this.width : Infinity;
386
441
  this._writeSegments([...Segment.cropLines(output, cropWidth)]);
387
442
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * export-html — recorded segments drawn as a standalone HTML document.
2
+ * export-html — recorded segments drawn as HTML: a fragment that can sit in
3
+ * a page this library does not own, and a standalone document around it.
3
4
  *
4
5
  * An encoding of `export-lines` and nothing more. What a run looks like under
5
6
  * a theme is decided there; this module decides only how CSS says it. It never
@@ -14,11 +15,45 @@
14
15
  import type { TerminalTheme } from "./color.js";
15
16
  import type { Segment } from "./segment.js";
16
17
  /**
17
- * `segments` under `theme` as a complete HTML document.
18
+ * The CSS a page includes once, wherever fragments appear: what an inline
19
+ * style cannot say. A fragment that blinks draws steadily without it.
20
+ *
21
+ * The keyframes exist only for a reader who has not asked for reduced motion,
22
+ * so for one who has, the inline `animation` names nothing and the glyph holds
23
+ * still. One rule, so no second rule's order or survival in a host's CSS
24
+ * pipeline can bring the blink back.
25
+ */
26
+ export declare const HTML_FRAGMENT_CSS = "@media (prefers-reduced-motion:no-preference){@keyframes rich-blink{50%{color:transparent}}}";
27
+ /**
28
+ * `segments` under `theme` as one `pre` carrying its canvas, for embedding.
29
+ *
30
+ * [LAW:locality-or-seam] Every rule is inline on the `pre` or below it, so the
31
+ * fragment styles nothing outside itself; the host page keeps its own `body`,
32
+ * `pre` and `a` rules. The seam holds the other way too: `all:initial` stops
33
+ * the host's `pre` rules and inherited typography from reaching the rows, and
34
+ * the two properties `all` does not cover pin the rows left to right.
35
+ *
36
+ * The one way in is the `--rich-fragment-font` custom property, read as the
37
+ * `font` shorthand (`14px/1.3 "JetBrains Mono", monospace`). `all` resets no
38
+ * custom property, so a host sets it on any ancestor to draw the rows in its
39
+ * own code font; unset, the rows are the browser's default monospace at a
40
+ * fixed line height, as a terminal's rows are. A host's value wants one too: at
41
+ * `normal`, a row holding a glyph from a taller fallback font grows and shifts
42
+ * every row below it. It must be a whole shorthand, a size and a family at
43
+ * least: any other value is invalid when computed, and the rows then inherit
44
+ * the host's font.
18
45
  *
19
46
  * A browser draws no line for a newline at either edge of a `pre`: the parser
20
- * drops the one straight after `<pre>`, and the one before `</pre>` ends a
21
- * line without starting another. So the page opens with a newline of its own
22
- * and every row ends with one, and a blank first or last row is still drawn.
47
+ * drops the one straight after the open tag, and the one before `</pre>` ends a
48
+ * line without starting another. So the fragment opens with a newline of its
49
+ * own and every row ends with one, and a blank first or last row is still drawn.
50
+ */
51
+ export declare function encodeHtmlFragment(segments: Iterable<Segment>, theme?: TerminalTheme): string;
52
+ /**
53
+ * `segments` under `theme` as a complete HTML document.
54
+ *
55
+ * [LAW:one-source-of-truth] The document is the fragment in a shell, so the
56
+ * two cannot draw different pictures. The shell paints the page around the
57
+ * `pre` in the same canvas and includes the fragment CSS once.
23
58
  */
24
59
  export declare function encodeHtml(segments: Iterable<Segment>, theme?: TerminalTheme): string;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * export-html — recorded segments drawn as a standalone HTML document.
2
+ * export-html — recorded segments drawn as HTML: a fragment that can sit in
3
+ * a page this library does not own, and a standalone document around it.
3
4
  *
4
5
  * An encoding of `export-lines` and nothing more. What a run looks like under
5
6
  * a theme is decided there; this module decides only how CSS says it. It never
@@ -11,6 +12,7 @@
11
12
  * them: `white-space: pre`, and a scrollbar rather than a reflow when the page
12
13
  * is narrower than the terminal was.
13
14
  */
15
+ import { cellLen, graphemes } from "./cells.js";
14
16
  import { exportCanvas, exportLines } from "./export-lines.js";
15
17
  const ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
16
18
  const escapeText = (text) => text.replace(/[&<>]/g, (c) => ENTITIES[c]);
@@ -64,7 +66,53 @@ function glyphCss(look) {
64
66
  ...OUTLINE[look.outline],
65
67
  ];
66
68
  }
69
+ // `unset` hands the anchor the run's look and drops the host's `a` rules;
70
+ // `revert` gives back the browser's link cursor and focus ring.
71
+ const ANCHOR_CSS = "all:unset;cursor:revert;outline:revert";
67
72
  const span = (css, content) => `<span style="${escapeAttribute(css.join(";"))}">${content}</span>`;
73
+ /** Text of printable ASCII only: one cell a character, nothing to box. */
74
+ const ONE_CELL = /^[\x20-\x7e]*$/;
75
+ /**
76
+ * A box exactly `cells` of the monospace font wide (`ch` is one of its cells)
77
+ * for glyphs the browser would otherwise draw at a fallback font's width,
78
+ * which for a CJK character is rarely two cells: left to it, everything after
79
+ * them on the row shifts off its column. The box carries no paint, so the
80
+ * run's background runs through it at the run's own height; an atomic inline
81
+ * takes no decoration from its ancestors, so it inherits its parent's lines.
82
+ */
83
+ const cellBox = (cells) => ["display:inline-block", `width:${cells}ch`, "text-align:center", "text-decoration:inherit"];
84
+ /**
85
+ * `text` with each stretch of graphemes wider than one cell in one box as wide
86
+ * as their cells together, and `draw` applied to every stretch of escaped text
87
+ * inside and between the boxes. A stretch, not a glyph apiece, because a
88
+ * browser's find-in-page does not match across two boxes. Graphemes, not code
89
+ * points, because `cellLen` measures a joined emoji as one glyph; a zero-width
90
+ * one joins the stretch before it, so it can still combine.
91
+ */
92
+ function onGrid(text, draw) {
93
+ if (ONE_CELL.test(text))
94
+ return draw(escapeText(text));
95
+ const pieces = [];
96
+ let stretch = "";
97
+ let wideCells = 0;
98
+ const flush = () => {
99
+ if (stretch !== "")
100
+ pieces.push(wideCells === 0 ? draw(escapeText(stretch)) : span(cellBox(wideCells), draw(escapeText(stretch))));
101
+ stretch = "";
102
+ wideCells = 0;
103
+ };
104
+ for (const segment of graphemes(text)) {
105
+ const cells = cellLen(segment);
106
+ const wide = cells === 0 ? wideCells > 0 : cells > 1;
107
+ if (wide !== (wideCells > 0))
108
+ flush();
109
+ stretch += segment;
110
+ if (wide)
111
+ wideCells += cells;
112
+ }
113
+ flush();
114
+ return pieces.join("");
115
+ }
68
116
  /**
69
117
  * One run as markup.
70
118
  *
@@ -72,35 +120,81 @@ const span = (css, content) => `<span style="${escapeAttribute(css.join(";"))}">
72
120
  * span with a strike would double the strike too. It gets an outer span of its
73
121
  * own instead. That span carries the paint, because a descendant's background
74
122
  * may be painted over an ancestor's underline, and the blink, so the underline
75
- * blinks with the glyph.
123
+ * blinks with the glyph. A wide glyph's box sits between the two: it inherits
124
+ * the double underline, and the inner span inside it draws the other lines.
76
125
  */
77
126
  function runHtml({ text, look }) {
78
- const glyph = escapeText(text);
79
127
  const drawn = look.underline === "double"
80
- ? span([...paintCss(look), "text-decoration-line:underline", "text-decoration-style:double", ...BLINK[look.blink]], span([`color:${look.foreground.hex}`, ...glyphCss(look)], glyph))
81
- : span([...paintCss(look), ...glyphCss(look)], glyph);
82
- return look.href === null ? drawn : `<a href="${escapeAttribute(look.href)}">${drawn}</a>`;
128
+ ? span([...paintCss(look), "text-decoration-line:underline", "text-decoration-style:double", ...BLINK[look.blink]], onGrid(text, (glyph) => span([`color:${look.foreground.hex}`, ...glyphCss(look)], glyph)))
129
+ : span([...paintCss(look), ...glyphCss(look)], onGrid(text, (glyph) => glyph));
130
+ return look.href === null
131
+ ? drawn
132
+ : `<a href="${escapeAttribute(look.href)}" style="${ANCHOR_CSS}">${drawn}</a>`;
83
133
  }
84
134
  /**
85
- * `segments` under `theme` as a complete HTML document.
135
+ * The CSS a page includes once, wherever fragments appear: what an inline
136
+ * style cannot say. A fragment that blinks draws steadily without it.
137
+ *
138
+ * The keyframes exist only for a reader who has not asked for reduced motion,
139
+ * so for one who has, the inline `animation` names nothing and the glyph holds
140
+ * still. One rule, so no second rule's order or survival in a host's CSS
141
+ * pipeline can bring the blink back.
142
+ */
143
+ export const HTML_FRAGMENT_CSS = `@media (prefers-reduced-motion:no-preference){@keyframes ${BLINK_KEYFRAMES}{50%{color:transparent}}}`;
144
+ /**
145
+ * `segments` under `theme` as one `pre` carrying its canvas, for embedding.
146
+ *
147
+ * [LAW:locality-or-seam] Every rule is inline on the `pre` or below it, so the
148
+ * fragment styles nothing outside itself; the host page keeps its own `body`,
149
+ * `pre` and `a` rules. The seam holds the other way too: `all:initial` stops
150
+ * the host's `pre` rules and inherited typography from reaching the rows, and
151
+ * the two properties `all` does not cover pin the rows left to right.
152
+ *
153
+ * The one way in is the `--rich-fragment-font` custom property, read as the
154
+ * `font` shorthand (`14px/1.3 "JetBrains Mono", monospace`). `all` resets no
155
+ * custom property, so a host sets it on any ancestor to draw the rows in its
156
+ * own code font; unset, the rows are the browser's default monospace at a
157
+ * fixed line height, as a terminal's rows are. A host's value wants one too: at
158
+ * `normal`, a row holding a glyph from a taller fallback font grows and shifts
159
+ * every row below it. It must be a whole shorthand, a size and a family at
160
+ * least: any other value is invalid when computed, and the rows then inherit
161
+ * the host's font.
86
162
  *
87
163
  * A browser draws no line for a newline at either edge of a `pre`: the parser
88
- * drops the one straight after `<pre>`, and the one before `</pre>` ends a
89
- * line without starting another. So the page opens with a newline of its own
90
- * and every row ends with one, and a blank first or last row is still drawn.
164
+ * drops the one straight after the open tag, and the one before `</pre>` ends a
165
+ * line without starting another. So the fragment opens with a newline of its
166
+ * own and every row ends with one, and a blank first or last row is still drawn.
91
167
  */
92
- export function encodeHtml(segments, theme) {
168
+ export function encodeHtmlFragment(segments, theme) {
93
169
  const canvas = exportCanvas(theme);
94
170
  const rows = exportLines(segments, theme).map((row) => `${row.map(runHtml).join("")}\n`);
95
171
  const css = [
96
- `body{background:${canvas.background.hex};color:${canvas.foreground.hex};padding:1em}`,
97
- "pre{margin:0;font-family:monospace;white-space:pre;overflow-x:auto}",
98
- "a{color:inherit;text-decoration:inherit}",
99
- `@keyframes ${BLINK_KEYFRAMES}{50%{color:transparent}}`,
100
- ].join("\n");
172
+ "all:initial",
173
+ "direction:ltr",
174
+ "unicode-bidi:isolate",
175
+ "display:block",
176
+ `background:${canvas.background.hex}`,
177
+ `color:${canvas.foreground.hex}`,
178
+ "padding:1em",
179
+ "font:var(--rich-fragment-font,medium/1.2 monospace)",
180
+ "white-space:pre",
181
+ "overflow-x:auto",
182
+ ].join(";");
183
+ return `<pre style="${css}">\n${rows.join("")}</pre>`;
184
+ }
185
+ /**
186
+ * `segments` under `theme` as a complete HTML document.
187
+ *
188
+ * [LAW:one-source-of-truth] The document is the fragment in a shell, so the
189
+ * two cannot draw different pictures. The shell paints the page around the
190
+ * `pre` in the same canvas and includes the fragment CSS once.
191
+ */
192
+ export function encodeHtml(segments, theme) {
193
+ const canvas = exportCanvas(theme);
194
+ const css = `body{background:${canvas.background.hex};color:${canvas.foreground.hex};margin:0}\n${HTML_FRAGMENT_CSS}`;
101
195
  return [
102
196
  "<!DOCTYPE html>",
103
197
  `<html><head><meta charset="utf-8"><style>\n${css}\n</style></head>`,
104
- `<body><pre>\n${rows.join("")}</pre></body></html>`,
198
+ `<body>${encodeHtmlFragment(segments, theme)}</body></html>`,
105
199
  ].join("\n");
106
200
  }
@@ -62,12 +62,35 @@ export declare class MarkupSyntaxError extends MarkupError {
62
62
  }
63
63
  /**
64
64
  * Escapes markup characters so they render as literal text.
65
+ *
66
+ * [LAW:one-source-of-truth] The inverse of the two ways the parser reads a
67
+ * backslash run in front of a bracket, over the same `TAG_BODY`: before a tag
68
+ * the run is halved and an odd one left over escapes it, so the run is doubled
69
+ * and one added; before any other bracket one `\[` becomes `[`, so one
70
+ * backslash is added.
71
+ *
72
+ * No bracket in the output can open a tag, whatever it is joined to. A bracket
73
+ * whose tag the text ends before finishing — `[link=x` — is escaped as a tag,
74
+ * because the next fragment's `]` would finish it; when what follows is instead
75
+ * the caller's own tag, a backslash run in front of it renders doubled. A
76
+ * trailing run is doubled for the same reason: `escape("C:\\")` spliced before
77
+ * `[/bold]` would otherwise escape the closing tag, so at the very end of a
78
+ * string it renders doubled. The grammar cannot tell those cases apart, and
79
+ * both choices fail visibly rather than let input open or escape a tag.
80
+ *
81
+ * Rich's `escape` escapes only a finished tag and doubles only a trailing run
82
+ * of one, so input can drop a backslash, escape the closing tag after it, or
83
+ * open a tag across a join. All three are departures on purpose.
65
84
  */
66
85
  export declare function escape(text: string): string;
67
86
  export interface MarkupTagContext {
68
87
  /** Parsed `key=value` attributes from the opening tag. */
69
88
  attrs: Record<string, string>;
70
- /** Inner markup, already parsed (registry-aware) into a `RichText`. */
89
+ /**
90
+ * Inner markup, already parsed (registry-aware) into a `RichText`. It carries
91
+ * no `baseStyle`: that is painted once, under the whole output, so it sits
92
+ * beneath whatever the handler returns.
93
+ */
71
94
  children: RichText;
72
95
  /** Raw inner markup text (between the opening tag's `]` and the closing tag's `[`). */
73
96
  raw: string;
@@ -89,9 +112,8 @@ export interface RenderMarkupOptions {
89
112
  /**
90
113
  * Plugin-aware markup render. Always returns a `RichText`. Built-in style
91
114
  * tags become spans; registered plugin tags are resolved by their handler,
92
- * whose returned `RichText` is appended (text + spans) into the output. The
93
- * recursion is over markup *slices* — between top-level plugin tag pairs and
94
- * inside each pair — and every recursion node returns a `RichText`, so the
95
- * append path is uniform.
115
+ * whose returned `RichText` stands in the output where its pair stood, under
116
+ * any style tag that encloses the pair. The recursion is into the inner slice
117
+ * of each plugin pair, and every recursion node returns a `RichText`.
96
118
  */
97
119
  export declare function renderMarkup(markup: string, options?: RenderMarkupOptions): RichText;