@promptctl/rich-js 0.18.0 → 0.19.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.
Files changed (244) hide show
  1. package/README.md +9 -7
  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 +145 -0
  7. package/dist/core/box.js +372 -0
  8. package/dist/core/cells.d.ts +192 -0
  9. package/dist/core/cells.js +334 -0
  10. package/dist/core/clock.d.ts +48 -0
  11. package/dist/core/clock.js +42 -0
  12. package/dist/core/color.d.ts +326 -0
  13. package/dist/core/color.js +1225 -0
  14. package/dist/core/console.d.ts +196 -0
  15. package/dist/core/console.js +596 -0
  16. package/dist/core/easing.d.ts +86 -0
  17. package/dist/core/easing.js +171 -0
  18. package/dist/core/emoji.d.ts +24 -0
  19. package/dist/core/emoji.js +391 -0
  20. package/dist/core/env.d.ts +16 -0
  21. package/dist/core/env.js +16 -0
  22. package/dist/core/export-html.d.ts +59 -0
  23. package/dist/core/export-html.js +197 -0
  24. package/dist/core/export-lines.d.ts +130 -0
  25. package/dist/core/export-lines.js +159 -0
  26. package/dist/core/export-svg.d.ts +49 -0
  27. package/dist/core/export-svg.js +213 -0
  28. package/dist/core/fnv1a.d.ts +10 -0
  29. package/dist/core/fnv1a.js +17 -0
  30. package/dist/core/highlighter.d.ts +33 -0
  31. package/dist/core/highlighter.js +190 -0
  32. package/dist/core/json.d.ts +27 -0
  33. package/dist/core/json.js +53 -0
  34. package/dist/core/markup.d.ts +140 -0
  35. package/dist/core/markup.js +654 -0
  36. package/dist/core/measure.d.ts +27 -0
  37. package/dist/core/measure.js +67 -0
  38. package/dist/core/memo.d.ts +19 -0
  39. package/dist/core/memo.js +34 -0
  40. package/dist/core/oklch.d.ts +161 -0
  41. package/dist/core/oklch.js +312 -0
  42. package/dist/core/osc8.d.ts +68 -0
  43. package/dist/core/osc8.js +78 -0
  44. package/dist/core/paint.d.ts +107 -0
  45. package/dist/core/paint.js +150 -0
  46. package/dist/core/place.d.ts +30 -0
  47. package/dist/core/place.js +49 -0
  48. package/dist/core/pretty.d.ts +247 -0
  49. package/dist/core/pretty.js +906 -0
  50. package/dist/core/protocol.d.ts +305 -0
  51. package/dist/core/protocol.js +183 -0
  52. package/dist/core/render.d.ts +87 -0
  53. package/dist/core/render.js +105 -0
  54. package/dist/core/segment.d.ts +162 -0
  55. package/dist/core/segment.js +488 -0
  56. package/dist/core/spinnerData.d.ts +12 -0
  57. package/dist/core/spinnerData.js +117 -0
  58. package/dist/core/strip.d.ts +152 -0
  59. package/dist/core/strip.js +326 -0
  60. package/dist/core/style.d.ts +189 -0
  61. package/dist/core/style.js +758 -0
  62. package/dist/core/subscription.d.ts +12 -0
  63. package/dist/core/subscription.js +12 -0
  64. package/dist/core/text.d.ts +283 -0
  65. package/dist/core/text.js +1097 -0
  66. package/dist/core/wrap.d.ts +46 -0
  67. package/dist/core/wrap.js +140 -0
  68. package/dist/host/app.d.ts +133 -0
  69. package/dist/host/app.js +285 -0
  70. package/dist/host/host-environment.d.ts +26 -0
  71. package/dist/host/host-environment.js +44 -0
  72. package/dist/host/index.d.ts +6 -0
  73. package/dist/host/index.js +3 -0
  74. package/dist/host/terminal-host.d.ts +180 -0
  75. package/dist/host/terminal-host.js +186 -0
  76. package/dist/index.d.ts +98 -0
  77. package/dist/index.js +114 -0
  78. package/dist/node/prompt.d.ts +34 -0
  79. package/dist/node/prompt.js +51 -0
  80. package/dist/node/save.d.ts +18 -0
  81. package/dist/node/save.js +24 -0
  82. package/dist/node/terminal-host.d.ts +114 -0
  83. package/dist/node/terminal-host.js +227 -0
  84. package/dist/node/traceback.d.ts +38 -0
  85. package/dist/node/traceback.js +98 -0
  86. package/dist/renderables/align.d.ts +16 -0
  87. package/dist/renderables/align.js +26 -0
  88. package/dist/renderables/columns.d.ts +77 -0
  89. package/dist/renderables/columns.js +191 -0
  90. package/dist/renderables/constrain.d.ts +15 -0
  91. package/dist/renderables/constrain.js +31 -0
  92. package/dist/renderables/effect.d.ts +98 -0
  93. package/dist/renderables/effect.js +160 -0
  94. package/dist/renderables/embed.d.ts +107 -0
  95. package/dist/renderables/embed.js +160 -0
  96. package/dist/renderables/flexStrip.d.ts +41 -0
  97. package/dist/renderables/flexStrip.js +200 -0
  98. package/dist/renderables/group.d.ts +14 -0
  99. package/dist/renderables/group.js +22 -0
  100. package/dist/renderables/layout.d.ts +75 -0
  101. package/dist/renderables/layout.js +223 -0
  102. package/dist/renderables/live.d.ts +74 -0
  103. package/dist/renderables/live.js +188 -0
  104. package/dist/renderables/markdown.d.ts +38 -0
  105. package/dist/renderables/markdown.js +587 -0
  106. package/dist/renderables/padding.d.ts +59 -0
  107. package/dist/renderables/padding.js +147 -0
  108. package/dist/renderables/panel.d.ts +114 -0
  109. package/dist/renderables/panel.js +303 -0
  110. package/dist/renderables/progress.d.ts +178 -0
  111. package/dist/renderables/progress.js +362 -0
  112. package/dist/renderables/progressBar.d.ts +31 -0
  113. package/dist/renderables/progressBar.js +64 -0
  114. package/dist/renderables/prompt.d.ts +71 -0
  115. package/dist/renderables/prompt.js +165 -0
  116. package/dist/renderables/ratio.d.ts +123 -0
  117. package/dist/renderables/ratio.js +213 -0
  118. package/dist/renderables/rule.d.ts +26 -0
  119. package/dist/renderables/rule.js +86 -0
  120. package/dist/renderables/spinner.d.ts +57 -0
  121. package/dist/renderables/spinner.js +103 -0
  122. package/dist/renderables/status.d.ts +29 -0
  123. package/dist/renderables/status.js +46 -0
  124. package/dist/renderables/syntax.d.ts +65 -0
  125. package/dist/renderables/syntax.js +166 -0
  126. package/dist/renderables/table.d.ts +232 -0
  127. package/dist/renderables/table.js +973 -0
  128. package/dist/renderables/traceback.d.ts +32 -0
  129. package/dist/renderables/traceback.js +279 -0
  130. package/dist/renderables/tree.d.ts +76 -0
  131. package/dist/renderables/tree.js +177 -0
  132. package/dist/renderables/viewport.d.ts +114 -0
  133. package/dist/renderables/viewport.js +196 -0
  134. package/dist/template-bindings/color-funcs.d.ts +109 -0
  135. package/dist/template-bindings/color-funcs.js +243 -0
  136. package/dist/template-bindings/helpers.d.ts +26 -0
  137. package/dist/template-bindings/helpers.js +33 -0
  138. package/dist/template-bindings/index.d.ts +119 -0
  139. package/dist/template-bindings/index.js +162 -0
  140. package/dist/template-bindings/palette-funcs.d.ts +96 -0
  141. package/dist/template-bindings/palette-funcs.js +145 -0
  142. package/dist/template-bindings/style-funcs.d.ts +28 -0
  143. package/dist/template-bindings/style-funcs.js +157 -0
  144. package/dist/themes/buildPalette.d.ts +57 -0
  145. package/dist/themes/buildPalette.js +100 -0
  146. package/dist/themes/colorMath.d.ts +94 -0
  147. package/dist/themes/colorMath.js +280 -0
  148. package/dist/themes/colorRef.d.ts +36 -0
  149. package/dist/themes/colorRef.js +78 -0
  150. package/dist/themes/data/ansi-tables.d.ts +14 -0
  151. package/dist/themes/data/ansi-tables.js +49 -0
  152. package/dist/themes/data/atom-one-dark.d.ts +3 -0
  153. package/dist/themes/data/atom-one-dark.js +177 -0
  154. package/dist/themes/data/atom-one-light.d.ts +3 -0
  155. package/dist/themes/data/atom-one-light.js +177 -0
  156. package/dist/themes/data/catppuccin-frappe.d.ts +3 -0
  157. package/dist/themes/data/catppuccin-frappe.js +177 -0
  158. package/dist/themes/data/catppuccin-latte.d.ts +3 -0
  159. package/dist/themes/data/catppuccin-latte.js +177 -0
  160. package/dist/themes/data/catppuccin-macchiato.d.ts +3 -0
  161. package/dist/themes/data/catppuccin-macchiato.js +177 -0
  162. package/dist/themes/data/catppuccin-mocha.d.ts +3 -0
  163. package/dist/themes/data/catppuccin-mocha.js +177 -0
  164. package/dist/themes/data/cyberpunk.d.ts +17 -0
  165. package/dist/themes/data/cyberpunk.js +193 -0
  166. package/dist/themes/data/default.d.ts +3 -0
  167. package/dist/themes/data/default.js +160 -0
  168. package/dist/themes/data/dracula.d.ts +3 -0
  169. package/dist/themes/data/dracula.js +177 -0
  170. package/dist/themes/data/flexoki.d.ts +3 -0
  171. package/dist/themes/data/flexoki.js +177 -0
  172. package/dist/themes/data/gruvbox.d.ts +3 -0
  173. package/dist/themes/data/gruvbox.js +177 -0
  174. package/dist/themes/data/index.d.ts +35 -0
  175. package/dist/themes/data/index.js +64 -0
  176. package/dist/themes/data/monokai.d.ts +3 -0
  177. package/dist/themes/data/monokai.js +177 -0
  178. package/dist/themes/data/nord.d.ts +3 -0
  179. package/dist/themes/data/nord.js +177 -0
  180. package/dist/themes/data/rose-pine-dawn.d.ts +3 -0
  181. package/dist/themes/data/rose-pine-dawn.js +177 -0
  182. package/dist/themes/data/rose-pine-moon.d.ts +3 -0
  183. package/dist/themes/data/rose-pine-moon.js +177 -0
  184. package/dist/themes/data/rose-pine.d.ts +3 -0
  185. package/dist/themes/data/rose-pine.js +177 -0
  186. package/dist/themes/data/solarized-dark.d.ts +3 -0
  187. package/dist/themes/data/solarized-dark.js +177 -0
  188. package/dist/themes/data/solarized-light.d.ts +3 -0
  189. package/dist/themes/data/solarized-light.js +177 -0
  190. package/dist/themes/data/svg-export.d.ts +3 -0
  191. package/dist/themes/data/svg-export.js +177 -0
  192. package/dist/themes/data/textual-ansi.d.ts +3 -0
  193. package/dist/themes/data/textual-ansi.js +160 -0
  194. package/dist/themes/data/textual-dark.d.ts +3 -0
  195. package/dist/themes/data/textual-dark.js +160 -0
  196. package/dist/themes/data/textual-light.d.ts +3 -0
  197. package/dist/themes/data/textual-light.js +160 -0
  198. package/dist/themes/data/tokyo-night.d.ts +3 -0
  199. package/dist/themes/data/tokyo-night.js +177 -0
  200. package/dist/themes/data/types.d.ts +31 -0
  201. package/dist/themes/data/types.js +10 -0
  202. package/dist/themes/palette.d.ts +45 -0
  203. package/dist/themes/palette.js +71 -0
  204. package/dist/themes/ramp.d.ts +70 -0
  205. package/dist/themes/ramp.js +103 -0
  206. package/dist/themes/registry.d.ts +44 -0
  207. package/dist/themes/registry.js +105 -0
  208. package/dist/themes/terminalThemes.d.ts +49 -0
  209. package/dist/themes/terminalThemes.js +63 -0
  210. package/dist/themes/transpose.d.ts +87 -0
  211. package/dist/themes/transpose.js +138 -0
  212. package/dist/widgets/button.d.ts +45 -0
  213. package/dist/widgets/button.js +177 -0
  214. package/dist/widgets/checkbox.d.ts +38 -0
  215. package/dist/widgets/checkbox.js +135 -0
  216. package/dist/widgets/dropdown.d.ts +64 -0
  217. package/dist/widgets/dropdown.js +416 -0
  218. package/dist/widgets/event-router.d.ts +71 -0
  219. package/dist/widgets/event-router.js +629 -0
  220. package/dist/widgets/focus-manager.d.ts +45 -0
  221. package/dist/widgets/focus-manager.js +191 -0
  222. package/dist/widgets/hit.d.ts +64 -0
  223. package/dist/widgets/hit.js +113 -0
  224. package/dist/widgets/index.d.ts +20 -0
  225. package/dist/widgets/index.js +14 -0
  226. package/dist/widgets/ink.d.ts +22 -0
  227. package/dist/widgets/ink.js +31 -0
  228. package/dist/widgets/slider.d.ts +54 -0
  229. package/dist/widgets/slider.js +247 -0
  230. package/dist/widgets/static-item.d.ts +34 -0
  231. package/dist/widgets/static-item.js +54 -0
  232. package/dist/widgets/text-input.d.ts +283 -0
  233. package/dist/widgets/text-input.js +1174 -0
  234. package/dist/widgets/themed-widget.d.ts +20 -0
  235. package/dist/widgets/themed-widget.js +89 -0
  236. package/dist/widgets/toggle.d.ts +41 -0
  237. package/dist/widgets/toggle.js +157 -0
  238. package/dist/widgets/types.d.ts +87 -0
  239. package/dist/widgets/types.js +30 -0
  240. package/dist/widgets/widget-app.d.ts +65 -0
  241. package/dist/widgets/widget-app.js +206 -0
  242. package/dist/widgets/widget-base.d.ts +42 -0
  243. package/dist/widgets/widget-base.js +172 -0
  244. package/package.json +16 -2
@@ -0,0 +1,213 @@
1
+ /**
2
+ * export-svg — recorded segments drawn as an SVG terminal window: rounded
3
+ * chrome with a title and three window buttons, the rows inside it, every
4
+ * glyph where its terminal cell was, and the text still selectable.
5
+ *
6
+ * An encoding of `export-lines` and nothing more, the same as `export-html`.
7
+ * What a run looks like under a theme is decided there; this module decides
8
+ * only where SVG puts it. It never sees a `Style`.
9
+ *
10
+ * The geometry is Rich's — a 20px character, a cell 0.61 of that wide, a row
11
+ * 1.22 of it tall, the same margin and padding — so a screenshot of the same
12
+ * program matches the library this ports. The frame's colour is not Rich's:
13
+ * Rich strokes it white at 35% whatever the theme, which vanishes around a
14
+ * light canvas. Here it is the theme's ink blended 35% into the canvas — over
15
+ * a black canvas the colour Rich draws — and opaque, so the half of the stroke
16
+ * lying outside the canvas does not take on the colour of the page around it.
17
+ *
18
+ * Nor is how a glyph lands on its cell. The exporter has no font engine and
19
+ * does not know which font the viewer will draw with, so no advance width can
20
+ * be trusted: a CJK character drawn from a fallback font is rarely two cells,
21
+ * and every glyph after it on the row drifts. So every chunk of a run is
22
+ * anchored at its own column and stretched to its own cells with
23
+ * `textLength`, and alignment is guaranteed by the numbers written here rather
24
+ * than by the font. Measured in headless Chromium on `"ab漢字cd😀ef é̂x"`:
25
+ * Rich's `textLength` (cell width × string length) was off by up to 12px, one
26
+ * `<text>` a run by 3.9px, this chunking by 0.
27
+ *
28
+ * Decorations are rectangles, not CSS `text-decoration`: SVG has no reliable
29
+ * double underline, and one `text-decoration` property cannot hold an
30
+ * underline and a strike with different styles.
31
+ */
32
+ import { cellLen, graphemes } from "./cells.js";
33
+ import { blendRgb } from "./color.js";
34
+ import { fnv1a } from "./fnv1a.js";
35
+ import { escapeAttribute, escapeText, exportCanvas, exportLines, } from "./export-lines.js";
36
+ const CHAR_HEIGHT = 20;
37
+ const CELL_WIDTH = CHAR_HEIGHT * 0.61;
38
+ const LINE_HEIGHT = CHAR_HEIGHT * 1.22;
39
+ const MARGIN = 1;
40
+ const PADDING = { top: 40, right: 8, bottom: 8, left: 8 };
41
+ /** A painted background sits 1.5px into its row and overlaps the next by a hair, so rows show no seam. */
42
+ const CELL_TOP = 1.5;
43
+ const CELL_HEIGHT = LINE_HEIGHT + 0.25;
44
+ const LINE_THICKNESS = 1.5;
45
+ const FONT_FAMILY = `"Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace`;
46
+ const FRAME_FADE = 0.35;
47
+ const WINDOW_BUTTONS = ["#ff5f57", "#febc2e", "#28c840"];
48
+ /** Coordinates to two decimals, so `3 × 12.2` is written `36.6` and not `36.599999999999994`. */
49
+ const n = (value) => String(Math.round(value * 100) / 100);
50
+ /**
51
+ * The blink keyframes every SVG defines identically, so one name serves them
52
+ * all: two inlined in one page define the same rule twice, and neither changes
53
+ * the other.
54
+ */
55
+ const BLINK_KEYFRAMES = "rich-svg-blink";
56
+ /**
57
+ * `text` cut into the pieces that are positioned one by one, starting at
58
+ * `column`: consecutive one-cell graphemes share a chunk, since the font's
59
+ * own advance is right for them to within what `textLength` corrects; every
60
+ * wider grapheme is a chunk of its own, since that advance is the one that
61
+ * drifts. A zero-cell grapheme joins the chunk before it so it can still
62
+ * combine, and only when a run opens with one does it stand as a chunk of no
63
+ * cells. Graphemes rather than code points, because `cellLen` measures a
64
+ * joined emoji as one glyph.
65
+ */
66
+ function chunk(text, column) {
67
+ const pieces = [];
68
+ for (const grapheme of graphemes(text)) {
69
+ const cells = cellLen(grapheme);
70
+ const last = pieces[pieces.length - 1];
71
+ if (last !== undefined && (cells === 0 || (cells === 1 && last.narrow))) {
72
+ last.text += grapheme;
73
+ last.cells += cells;
74
+ }
75
+ else {
76
+ pieces.push({ text: grapheme, cells, narrow: cells === 1 });
77
+ }
78
+ }
79
+ let at = column;
80
+ return pieces.map(({ text, cells }) => {
81
+ const placed = { text, column: at, cells };
82
+ at += cells;
83
+ return placed;
84
+ });
85
+ }
86
+ /** Every run of every row at the column the runs before it on its row end at. */
87
+ function place(rows) {
88
+ return rows.flatMap((runs, row) => {
89
+ let column = 0;
90
+ return runs.map(({ text, look }) => {
91
+ const chunks = chunk(text, column);
92
+ const cells = chunks.reduce((sum, c) => sum + c.cells, 0);
93
+ const placed = { row, column, cells, look, chunks };
94
+ column += cells;
95
+ return placed;
96
+ });
97
+ });
98
+ }
99
+ // [LAW:dataflow-not-control-flow] Each enum-valued field of a look is a table
100
+ // from its values to what it draws, so `Record` makes a new value a compile
101
+ // error until it has a row.
102
+ const UNDERLINE_BELOW_BASELINE = {
103
+ none: [],
104
+ single: [2],
105
+ double: [2, 5],
106
+ };
107
+ const BLINK_PERIOD = {
108
+ none: null,
109
+ slow: "1s",
110
+ fast: "0.5s",
111
+ };
112
+ const OUTLINE_RADIUS = {
113
+ none: null,
114
+ frame: 0,
115
+ encircle: CELL_WIDTH,
116
+ };
117
+ /** The CSS a look's class carries: what a glyph's `fill` and font say, and its blink. */
118
+ function lookCss(look) {
119
+ const period = BLINK_PERIOD[look.blink];
120
+ return [
121
+ `fill:${look.foreground.hex}`,
122
+ ...(look.bold ? ["font-weight:bold"] : []),
123
+ ...(look.italic ? ["font-style:italic"] : []),
124
+ ...(period === null ? [] : [`animation:${BLINK_KEYFRAMES} ${period} step-end infinite`]),
125
+ ].join(";");
126
+ }
127
+ const cellBox = (run) => `x="${n(run.column * CELL_WIDTH)}" y="${n(run.row * LINE_HEIGHT + CELL_TOP)}" ` +
128
+ `width="${n(run.cells * CELL_WIDTH)}" height="${n(CELL_HEIGHT)}"`;
129
+ /** A run's painted background; a run showing the canvas paints nothing. */
130
+ function backgroundSvg(run) {
131
+ return run.look.background === "canvas"
132
+ ? []
133
+ : [`<rect fill="${run.look.background.hex}" ${cellBox(run)} shape-rendering="crispEdges"/>`];
134
+ }
135
+ function textSvg({ text, column, cells }, baseline) {
136
+ // A chunk of no cells has no length to stretch to; it takes the font's.
137
+ const length = cells === 0 ? "" : ` textLength="${n(cells * CELL_WIDTH)}" lengthAdjust="spacingAndGlyphs"`;
138
+ return `<text x="${n(column * CELL_WIDTH)}" y="${n(baseline)}"${length} xml:space="preserve">${escapeText(text)}</text>`;
139
+ }
140
+ /**
141
+ * One run: its glyphs, then its lines and outline over them, in a group whose
142
+ * class carries the look — so the lines take the glyph's `fill` and blink
143
+ * with it. The outline is a stroke, so it names its colour itself.
144
+ */
145
+ function runSvg(run, className) {
146
+ const { look } = run;
147
+ const top = run.row * LINE_HEIGHT;
148
+ const baseline = top + CHAR_HEIGHT;
149
+ const x = n(run.column * CELL_WIDTH);
150
+ const width = n(run.cells * CELL_WIDTH);
151
+ const lineYs = [
152
+ ...UNDERLINE_BELOW_BASELINE[look.underline].map((offset) => baseline + offset),
153
+ ...(look.strike ? [baseline - 6] : []),
154
+ ...(look.overline ? [top + CELL_TOP] : []),
155
+ ];
156
+ const radius = OUTLINE_RADIUS[look.outline];
157
+ const drawn = [
158
+ ...run.chunks.map((c) => textSvg(c, baseline)),
159
+ ...lineYs.map((y) => `<rect x="${x}" y="${n(y)}" width="${width}" height="${LINE_THICKNESS}"/>`),
160
+ ...(radius === null
161
+ ? []
162
+ : [`<rect ${cellBox(run)} rx="${n(radius)}" fill="none" stroke="${look.foreground.hex}" stroke-width="1"/>`]),
163
+ ].join("");
164
+ const group = `<g class="${className}">${drawn}</g>`;
165
+ return look.href === null ? group : `<a href="${escapeAttribute(look.href)}">${group}</a>`;
166
+ }
167
+ /**
168
+ * `segments` under `theme` as a standalone SVG document.
169
+ *
170
+ * Every background is painted before any glyph, so no run's paint covers its
171
+ * neighbour's text. Whitespace is emitted as real spaces under
172
+ * `xml:space="preserve"`, never as `&#160;`, so a selection copies what the
173
+ * terminal showed.
174
+ */
175
+ export function encodeSvg(segments, { theme, title, width }) {
176
+ const canvas = exportCanvas(theme);
177
+ const rows = exportLines(segments, theme);
178
+ const runs = place(rows);
179
+ // The prefix every class name carries is a hash of the rules those classes
180
+ // define as well as of what is drawn, since a class is page-wide once the
181
+ // SVG is inlined: two exports that share a prefix define every rule alike, so
182
+ // the same recording under another theme cannot restyle this one.
183
+ const styled = runs.map((run) => ({ run, css: lookCss(run.look) }));
184
+ const rules = [...new Set(styled.map(({ css }) => css))];
185
+ const text = rows.map((row) => row.map((run) => run.text).join("")).join("\n");
186
+ const id = `terminal-${fnv1a(`${title.length}:${title}${text.length}:${text}${rules.join("\n")}`)}`;
187
+ const classOf = (css) => `${id}-r${rules.indexOf(css) + 1}`;
188
+ const drawnRuns = styled.map(({ run, css }) => runSvg(run, classOf(css)));
189
+ const columns = runs.reduce((widest, run) => Math.max(widest, run.column + run.cells), width);
190
+ const terminalWidth = Math.ceil(columns * CELL_WIDTH + PADDING.left + PADDING.right);
191
+ const terminalHeight = rows.length * LINE_HEIGHT + PADDING.top + PADDING.bottom;
192
+ const stylesheet = [
193
+ `.${id}-matrix{font-family:${FONT_FAMILY};font-size:${CHAR_HEIGHT}px}`,
194
+ `.${id}-title{font-size:18px;font-weight:bold;font-family:arial}`,
195
+ ...rules.map((css) => `.${classOf(css)}{${css}}`),
196
+ // Blink only for a reader who has not asked for reduced motion, as in HTML.
197
+ `@media (prefers-reduced-motion:no-preference){@keyframes ${BLINK_KEYFRAMES}{50%{fill:transparent}}}`,
198
+ ].join("\n");
199
+ return [
200
+ `<svg class="rich-terminal" viewBox="0 0 ${n(terminalWidth + 2 * MARGIN)} ${n(terminalHeight + 2 * MARGIN)}" xmlns="http://www.w3.org/2000/svg">`,
201
+ `<style>\n${stylesheet}\n</style>`,
202
+ `<rect fill="${canvas.background.hex}" stroke="${blendRgb(canvas.background, canvas.foreground, FRAME_FADE).hex}" stroke-width="1" ` +
203
+ `x="${MARGIN}" y="${MARGIN}" width="${n(terminalWidth)}" height="${n(terminalHeight)}" rx="8"/>`,
204
+ `<text class="${id}-title" fill="${canvas.foreground.hex}" text-anchor="middle" ` +
205
+ `x="${n(MARGIN + terminalWidth / 2)}" y="${MARGIN + CHAR_HEIGHT + 6}">${escapeText(title)}</text>`,
206
+ `<g transform="translate(26,22)">${WINDOW_BUTTONS.map((fill, i) => `<circle cx="${i * 22}" cy="0" r="7" fill="${fill}"/>`).join("")}</g>`,
207
+ `<g class="${id}-matrix" transform="translate(${MARGIN + PADDING.left},${MARGIN + PADDING.top})">`,
208
+ ...runs.flatMap(backgroundSvg),
209
+ ...drawnRuns,
210
+ `</g>`,
211
+ `</svg>`,
212
+ ].join("\n");
213
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * fnv1a — FNV-1a, 32-bit, over a string's UTF-8 bytes, as 8 lowercase hex
3
+ * digits: the short deterministic name this library derives wherever output
4
+ * needs an identifier that is a function of what it names — an OSC 8 link id,
5
+ * an SVG's class prefix — rather than of a counter or a clock.
6
+ *
7
+ * UTF-8 rather than UTF-16 code units, so any other runtime computing standard
8
+ * FNV-1a over the same text gets the same digits.
9
+ */
10
+ export declare function fnv1a(text: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * fnv1a — FNV-1a, 32-bit, over a string's UTF-8 bytes, as 8 lowercase hex
3
+ * digits: the short deterministic name this library derives wherever output
4
+ * needs an identifier that is a function of what it names — an OSC 8 link id,
5
+ * an SVG's class prefix — rather than of a counter or a clock.
6
+ *
7
+ * UTF-8 rather than UTF-16 code units, so any other runtime computing standard
8
+ * FNV-1a over the same text gets the same digits.
9
+ */
10
+ const utf8 = new TextEncoder();
11
+ export function fnv1a(text) {
12
+ let hash = 0x811c9dc5;
13
+ for (const byte of utf8.encode(text)) {
14
+ hash = Math.imul(hash ^ byte, 0x01000193);
15
+ }
16
+ return (hash >>> 0).toString(16).padStart(8, "0");
17
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Highlighters — apply styled spans to RichText based on pattern matching.
3
+ */
4
+ import { RichText } from "./text.js";
5
+ export declare abstract class Highlighter {
6
+ abstract highlight(text: RichText): void;
7
+ call(input: string): RichText;
8
+ }
9
+ export declare class NullHighlighter extends Highlighter {
10
+ highlight(_text: RichText): void;
11
+ }
12
+ export declare class RegexHighlighter extends Highlighter {
13
+ static highlights: (string | RegExp)[];
14
+ /** Style applied to matches. When ending with "." it acts as a namespace
15
+ * prefix: named groups become `baseStyle + groupName` (e.g. "repr." + "url"
16
+ * → "repr.url"). Otherwise it's used as a literal style for all matches
17
+ * (e.g. "bold red" applies "bold red" to every named group). */
18
+ static baseStyle: string;
19
+ highlight(text: RichText): void;
20
+ }
21
+ export declare class ReprHighlighter extends RegexHighlighter {
22
+ static baseStyle: string;
23
+ static highlights: RegExp[];
24
+ }
25
+ export declare class JSONHighlighter extends RegexHighlighter {
26
+ static baseStyle: string;
27
+ static highlights: RegExp[];
28
+ highlight(text: RichText): void;
29
+ }
30
+ export declare class ISO8601Highlighter extends RegexHighlighter {
31
+ static baseStyle: string;
32
+ static highlights: string[];
33
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Highlighters — apply styled spans to RichText based on pattern matching.
3
+ */
4
+ import { RichText } from "./text.js";
5
+ // --- Base ---
6
+ export class Highlighter {
7
+ call(input) {
8
+ const text = new RichText(input);
9
+ this.highlight(text);
10
+ return text;
11
+ }
12
+ }
13
+ // --- NullHighlighter ---
14
+ export class NullHighlighter extends Highlighter {
15
+ highlight(_text) {
16
+ // No-op
17
+ }
18
+ }
19
+ // --- RegexHighlighter ---
20
+ export class RegexHighlighter extends Highlighter {
21
+ static highlights = [];
22
+ /** Style applied to matches. When ending with "." it acts as a namespace
23
+ * prefix: named groups become `baseStyle + groupName` (e.g. "repr." + "url"
24
+ * → "repr.url"). Otherwise it's used as a literal style for all matches
25
+ * (e.g. "bold red" applies "bold red" to every named group). */
26
+ static baseStyle = "";
27
+ highlight(text) {
28
+ const ctor = this.constructor;
29
+ const baseStyle = ctor.baseStyle;
30
+ // Namespace mode: "repr." + groupName → "repr.url"
31
+ // Literal mode: "bold red" → applied directly to every match
32
+ const isNamespace = baseStyle.endsWith(".");
33
+ for (const pattern of ctor.highlights) {
34
+ // `d` reports where each group matched, as Python's `match.span(name)`
35
+ // does; searching the match for the group's text finds the first copy
36
+ // of it, which is not always the one the group captured.
37
+ const source = pattern instanceof RegExp ? pattern.source : pattern;
38
+ const flags = new Set([...(pattern instanceof RegExp ? pattern.flags : ""), "g", "d"]);
39
+ for (const match of text.plain.matchAll(new RegExp(source, [...flags].join("")))) {
40
+ if (match.indices === undefined)
41
+ throw new Error("a match of a `d`-flag pattern carries indices");
42
+ // Group definition order, as Rich's `groupdict()`: a later group's span
43
+ // lands on top of an earlier one's.
44
+ for (const [groupName, span] of Object.entries(match.indices.groups ?? {})) {
45
+ if (span === undefined || span[1] <= span[0])
46
+ continue;
47
+ const style = isNamespace ? `${baseStyle}${groupName}` : baseStyle || groupName;
48
+ text.stylize(style, span[0], span[1]);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ // --- Python's regex classes ---
55
+ // Rich compiles its patterns on a `str`, where Python's `\w`, `\b` and `\d` are
56
+ // Unicode-aware; JavaScript's are ASCII even under `u`, so `café=1` would lose
57
+ // its attribute name and `étrue` would gain a `true`. These spell Python's for
58
+ // the `u` flag for ReprHighlighter and JSONHighlighter. `.` is spelled `[^\n]`
59
+ // because Python's dot stops only at `\n`, JavaScript's at `\r`, U+2028 and
60
+ // U+2029 too.
61
+ const W = String.raw `\p{L}\p{N}_`;
62
+ const WB = String.raw `(?:(?<=[${W}])(?![${W}])|(?<![${W}])(?=[${W}]))`;
63
+ const NWB = String.raw `(?:(?<=[${W}])(?=[${W}])|(?<![${W}])(?![${W}]))`;
64
+ const D = String.raw `\p{Nd}`;
65
+ // Rich's `[0-9]+\.?[0-9]*`, the digits of a number with an optional fraction,
66
+ // spelled so a digit run has one way to split. Rich's spelling can hand any
67
+ // prefix of a run to `[0-9]+` and the rest to `[0-9]*`, and a run that fails
68
+ // the boundary after it is retried at every split: quadratic in its length.
69
+ // Both spellings match the same strings and try them in the same order.
70
+ const DECIMAL = String.raw `[0-9]+(?:\.[0-9]*)?`;
71
+ // --- ReprHighlighter ---
72
+ // Rich 15.0.0's patterns.
73
+ // [LAW:one-type-per-behavior] All repr patterns use the same RegexHighlighter mechanism
74
+ export class ReprHighlighter extends RegexHighlighter {
75
+ static baseStyle = "repr.";
76
+ static highlights = [
77
+ // `tag_contents` runs to the text's last `>`, so only the text's first `<`
78
+ // can open a tag; the lookbehind starts the match there and nowhere else,
79
+ // where Rich retries every later `<` and rescans to the end each time. The
80
+ // lookahead asks once whether any `>` follows; without it, a `<` before a
81
+ // long name with no `>` rescans to the end for every length of the name.
82
+ new RegExp(String.raw `(?<tag_start><)(?<=^[^<]*<)(?=[^]*>)(?<tag_name>[\-${W}.:|]*)(?<tag_contents>[^]*)(?<tag_end>>)`, "u"),
83
+ new RegExp(String.raw `(?<attrib_name>[${W}]{1,50})=(?<attrib_value>"?[${W}]+"?)?`, "u"),
84
+ new RegExp(String.raw `(?<brace>[\][{}()])`, "u"),
85
+ // [LAW:dataflow-not-control-flow] One alternation, scanned once, as Rich's
86
+ // `_combine_regex` joins it: where two could match, the earlier claims it.
87
+ new RegExp([
88
+ String.raw `(?<ipv4>[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})`,
89
+ String.raw `(?<ipv6>([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4})`,
90
+ String.raw `(?<eui64>(?:[0-9A-Fa-f]{1,2}-){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){7}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){3}[0-9A-Fa-f]{4})`,
91
+ String.raw `(?<eui48>(?:[0-9A-Fa-f]{1,2}-){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{1,2}:){5}[0-9A-Fa-f]{1,2}|(?:[0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4})`,
92
+ String.raw `(?<uuid>[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})`,
93
+ // Departs from Rich: a call name starts a word, so the run is scanned
94
+ // for its `(` once, not again from each of its characters. Rich also
95
+ // styles a name that starts where another match ended mid-word, the
96
+ // `foo` of `aa-bb-cc-dd-ee-fffoo(`; this leaves it to the patterns
97
+ // after this one, so it is plain or, as `true` in `-1.true(`, theirs.
98
+ String.raw `(?<call>(?<![${W}.])[${W}.]*?)\(`,
99
+ // JavaScript's `true`, `false`, `null` and `undefined` beside Python's
100
+ // names: `Pretty` draws JavaScript values through this highlighter.
101
+ String.raw `${WB}(?<bool_true>True|true)${WB}|${WB}(?<bool_false>False|false)${WB}|${WB}(?<none>None|null|undefined)${WB}`,
102
+ String.raw `(?<ellipsis>\.\.\.)`,
103
+ String.raw `(?<number_complex>(?<![${W}])(?:-?${DECIMAL}(?:e[\-+]?${D}+?)?)(?:[\-+](?:${DECIMAL}(?:e[\-+]?${D}+)?))?j)`,
104
+ String.raw `(?<number>(?<![${W}])-?${DECIMAL}(e[\-+]?${D}+?)?${WB}|0x[0-9a-fA-F]*)`,
105
+ String.raw `(?<path>${NWB}(\/[\-${W}._+]+)*\/)(?<filename>[\-${W}._+]*)?`,
106
+ String.raw `(?<![\\${W}])(?<str>b?'''[^\n]*?(?<!\\)'''|b?'[^\n]*?(?<!\\)'|b?"""[^\n]*?(?<!\\)"""|b?"[^\n]*?(?<!\\)")`,
107
+ String.raw `(?<url>(file|https|http|ws|wss):\/\/[\-0-9a-zA-Z$_+!\x60(),.?\/;:&=%#~@]*)`,
108
+ ].join("|"), "u"),
109
+ ];
110
+ }
111
+ // --- JSONHighlighter ---
112
+ // A JSON string as Rich 15.0.0 matches one, `b` bytes prefix included: an
113
+ // opening quote with no backslash or word character before it, through the
114
+ // first quote with no backslash before it, so an escaped quote stays inside.
115
+ const JSON_STR = String.raw `(?<![\\${W}])(?<str>b?"[^\n]*?(?<!\\)")`;
116
+ export class JSONHighlighter extends RegexHighlighter {
117
+ static baseStyle = "json.";
118
+ // [LAW:dataflow-not-control-flow] One alternation, scanned once, as Rich's
119
+ // `_combine_regex` joins it. A token claims its characters where the scan
120
+ // meets it, so the `true` and `12` inside `"true 12"` belong to the string.
121
+ static highlights = [
122
+ new RegExp([
123
+ String.raw `(?<brace>[\{\[\(\)\]\}])`,
124
+ String.raw `${WB}(?<bool_true>true)${WB}|${WB}(?<bool_false>false)${WB}|${WB}(?<null>null)${WB}`,
125
+ String.raw `(?<number>(?<![${W}])-?${DECIMAL}(e[\-+]?${D}+?)?${WB}|0x[0-9a-fA-F]*)`,
126
+ JSON_STR,
127
+ ].join("|"), "u"),
128
+ ];
129
+ // A string followed by a colon is also a key. Its `json.key` span comes after
130
+ // its `json.str` span, so the key style lands on top. Each string is found on
131
+ // its own and then checked for a colon. One `string(?=:)` pattern would get
132
+ // this wrong: its lazy body can stretch past a value's closing quote to the
133
+ // next key's, marking `"b", "c"` in `{"a": "b", "c": 1}` as one key.
134
+ highlight(text) {
135
+ super.highlight(text);
136
+ const colon = /[ \n\r\t]*:/y;
137
+ for (const { index, 0: str } of text.plain.matchAll(new RegExp(JSON_STR, "gu"))) {
138
+ const end = index + str.length;
139
+ colon.lastIndex = end;
140
+ if (colon.test(text.plain))
141
+ text.stylize("json.key", index, end);
142
+ }
143
+ }
144
+ }
145
+ // --- ISO8601Highlighter ---
146
+ // Rich 15.0.0's patterns, after the Regular Expressions Cookbook recipes it
147
+ // cites. Each matches a whole string that is one ISO 8601 value, never a value
148
+ // inside other text. Python's `$` also matches before a final `\n`, which
149
+ // JavaScript's does not; `END` is Python's.
150
+ const END = String.raw `(?=\n?$)`;
151
+ const YEAR = String.raw `(?<year>[0-9]{4})`;
152
+ const MONTH = String.raw `(?<month>1[0-2]|0[1-9])`;
153
+ const DAY = String.raw `(?<day>3[01]|0[1-9]|[12][0-9])`;
154
+ const HOUR = String.raw `(?<hour>2[0-3]|[01][0-9])`;
155
+ const MINUTE = String.raw `(?<minute>[0-5][0-9])`;
156
+ const SECOND = String.raw `(?<second>[0-5][0-9])`;
157
+ const WEEK = String.raw `(?<week>5[0-3]|[1-4][0-9]|0[1-9])`;
158
+ const TZ_HOUR = String.raw `[+-](?:2[0-3]|[01][0-9])`;
159
+ const XSD_DATE = String.raw `(?<date>(?<year>-?(?:[1-9][0-9]*)?[0-9]{4})-${MONTH}-${DAY})`;
160
+ const XSD_TZ = String.raw `(?<timezone>Z|${TZ_HOUR}:[0-5][0-9])?`;
161
+ // The cookbook's designator, whose minutes and their colon are both optional.
162
+ const TZ = String.raw `(?<timezone>Z|${TZ_HOUR}(?::?(?:[0-5][0-9]))?)`;
163
+ const COMPACT_TIME = `(?<time>${HOUR}${MINUTE}${SECOND})`;
164
+ export class ISO8601Highlighter extends RegexHighlighter {
165
+ static baseStyle = "iso8601.";
166
+ static highlights = [
167
+ // Dates
168
+ `^${YEAR}-${MONTH}${END}`,
169
+ `^(?<date>${YEAR}${MONTH}${DAY})${END}`,
170
+ String.raw `^(?<date>${YEAR}-?(?<day>36[0-6]|3[0-5][0-9]|[12][0-9]{2}|0[1-9][0-9]|00[1-9]))${END}`,
171
+ // Weeks
172
+ `^(?<date>${YEAR}-?W${WEEK})${END}`,
173
+ `^(?<date>${YEAR}-?W${WEEK}-?(?<day>[1-7]))${END}`,
174
+ // Times
175
+ `^(?<time>${HOUR}:?${MINUTE})${END}`,
176
+ `^${COMPACT_TIME}${END}`,
177
+ `^${TZ}${END}`,
178
+ `^${COMPACT_TIME}${TZ}${END}`,
179
+ // Date and time. Rich writes this as one pattern whose `-` and `:`
180
+ // separators are all present or all absent, keyed on a Python conditional
181
+ // group JavaScript lacks; it is the two spellings here, adjacent, so the
182
+ // one that matches lays its spans where Rich's single pattern would.
183
+ `^(?<date>${YEAR}(?<hyphen>-)${MONTH}-${DAY}) (?<time>${HOUR}:${MINUTE}:${SECOND})${END}`,
184
+ `^(?<date>${YEAR}${MONTH}${DAY}) ${COMPACT_TIME}${END}`,
185
+ // XML Schema date, time and dateTime
186
+ `^${XSD_DATE}${XSD_TZ}${END}`,
187
+ String.raw `^(?<time>${HOUR}:${MINUTE}:${SECOND}(?<frac>\.[0-9]+)?)${XSD_TZ}${END}`,
188
+ String.raw `^${XSD_DATE}T(?<time>${HOUR}:${MINUTE}:${SECOND}(?<ms>\.[0-9]+)?)${XSD_TZ}${END}`,
189
+ ];
190
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * JSON — renders JSON data with syntax highlighting and pretty-printing.
3
+ *
4
+ * Lives in `core/` for the reason `pretty.ts` does, argued in its header: it
5
+ * turns foreign input into `RichText`, composes no other renderable, and
6
+ * imports core primitives only. `Console.printJson` delegates here, so this is
7
+ * where the console can reach it without an upward edge. [LAW:one-way-deps]
8
+ */
9
+ import { Segment } from "./segment.js";
10
+ import { RichText } from "./text.js";
11
+ import type { Renderable, Measurable, RenderOptions } from "./protocol.js";
12
+ export interface JSONOptions {
13
+ indent?: number;
14
+ sortKeys?: boolean;
15
+ highlight?: boolean;
16
+ }
17
+ export declare class JSONRenderable implements Renderable, Measurable {
18
+ readonly text: RichText;
19
+ private constructor();
20
+ static fromString(jsonString: string, options?: JSONOptions): JSONRenderable;
21
+ static fromData(data: unknown, options?: JSONOptions): JSONRenderable;
22
+ render(options: RenderOptions): Iterable<Segment>;
23
+ measure(options: RenderOptions): {
24
+ minimum: number;
25
+ maximum: number;
26
+ };
27
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * JSON — renders JSON data with syntax highlighting and pretty-printing.
3
+ *
4
+ * Lives in `core/` for the reason `pretty.ts` does, argued in its header: it
5
+ * turns foreign input into `RichText`, composes no other renderable, and
6
+ * imports core primitives only. `Console.printJson` delegates here, so this is
7
+ * where the console can reach it without an upward edge. [LAW:one-way-deps]
8
+ */
9
+ import { RichText } from "./text.js";
10
+ import { JSONHighlighter } from "./highlighter.js";
11
+ const highlighter = new JSONHighlighter();
12
+ export class JSONRenderable {
13
+ text;
14
+ constructor(text) {
15
+ this.text = text;
16
+ }
17
+ static fromString(jsonString, options) {
18
+ const indent = options?.indent ?? 2;
19
+ const sortKeys = options?.sortKeys ?? false;
20
+ // Parse and re-serialize for consistent formatting
21
+ const data = JSON.parse(jsonString);
22
+ return JSONRenderable.fromData(data, { ...options, indent, sortKeys });
23
+ }
24
+ static fromData(data, options) {
25
+ const indent = options?.indent ?? 2;
26
+ const sortKeys = options?.sortKeys ?? false;
27
+ const doHighlight = options?.highlight !== false;
28
+ const replacer = sortKeys
29
+ ? (_key, value) => {
30
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
31
+ const sorted = {};
32
+ for (const k of Object.keys(value).sort()) {
33
+ sorted[k] = value[k];
34
+ }
35
+ return sorted;
36
+ }
37
+ return value;
38
+ }
39
+ : undefined;
40
+ const jsonStr = JSON.stringify(data, replacer, indent);
41
+ const text = new RichText(jsonStr);
42
+ if (doHighlight) {
43
+ highlighter.highlight(text);
44
+ }
45
+ return new JSONRenderable(text);
46
+ }
47
+ *render(options) {
48
+ yield* this.text.render(options);
49
+ }
50
+ measure(options) {
51
+ return this.text.measure(options);
52
+ }
53
+ }