@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
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { cellLen } from "./cells.js";
20
20
  import { Style, StyleSyntaxError } from "./style.js";
21
- import { RichText, Span } from "./text.js";
21
+ import { RichText, Span, stripControlChars } from "./text.js";
22
22
  import { emojiReplace } from "./emoji.js";
23
23
  // --- Tag ---
24
24
  export class Tag {
@@ -137,13 +137,32 @@ function excerpt(text, index) {
137
137
  // --- escape ---
138
138
  /**
139
139
  * Escapes markup characters so they render as literal text.
140
+ *
141
+ * [LAW:one-source-of-truth] The inverse of the two ways the parser reads a
142
+ * backslash run in front of a bracket, over the same `TAG_BODY`: before a tag
143
+ * the run is halved and an odd one left over escapes it, so the run is doubled
144
+ * and one added; before any other bracket one `\[` becomes `[`, so one
145
+ * backslash is added.
146
+ *
147
+ * No bracket in the output can open a tag, whatever it is joined to. A bracket
148
+ * whose tag the text ends before finishing — `[link=x` — is escaped as a tag,
149
+ * because the next fragment's `]` would finish it; when what follows is instead
150
+ * the caller's own tag, a backslash run in front of it renders doubled. A
151
+ * trailing run is doubled for the same reason: `escape("C:\\")` spliced before
152
+ * `[/bold]` would otherwise escape the closing tag, so at the very end of a
153
+ * string it renders doubled. The grammar cannot tell those cases apart, and
154
+ * both choices fail visibly rather than let input open or escape a tag.
155
+ *
156
+ * Rich's `escape` escapes only a finished tag and doubles only a trailing run
157
+ * of one, so input can drop a backslash, escape the closing tag after it, or
158
+ * open a tag across a join. All three are departures on purpose.
140
159
  */
141
160
  export function escape(text) {
142
- return text.replace(/\[/g, "\\[");
161
+ return text
162
+ .replace(ESCAPE_RE, (_, run, live) => live === undefined ? `${run}\\[` : `${run}${run}\\[`)
163
+ .replace(TRAILING_RUN_RE, "$&$&");
143
164
  }
144
165
  // --- render ---
145
- // Fast path: if no `[` in text, skip parsing entirely
146
- const HAS_TAG_RE = /\[/;
147
166
  // [LAW:one-source-of-truth] The authority for this grammar is `RE_TAGS` in
148
167
  // `rich/markup.py`, and this is deliberately its expression rather than a
149
168
  // re-derivation, so the two can be read side by side; `[^[]*?` is lazy against
@@ -155,19 +174,48 @@ const HAS_TAG_RE = /\[/;
155
174
  // every `Console.print` with no error, and it omitted `@` and the punctuation
156
175
  // the reference admits, so `[a+b]` stayed literal (rich-markup-sec).
157
176
  // `markup-grammar.golden.txt` pins both directions.
158
- // The leading alternative handles an escaped bracket: \[
159
- const TAG_RE = /(?:\\\[)|(\[[a-z#/@][^[]*?\])/g;
160
- function parseTags(markup) {
161
- const tags = [];
162
- const re = new RegExp(TAG_RE.source, TAG_RE.flags);
163
- let match;
164
- while ((match = re.exec(markup)) !== null) {
165
- // Escaped bracket
166
- if (match[0] === "\\[")
167
- continue;
168
- const captured = match[1];
169
- if (!captured)
177
+ //
178
+ // The leading group is the run of backslashes in front of the bracket, which
179
+ // `tokenize` halves as `_parse` does: each pair is one literal backslash, and a
180
+ // leftover odd one escapes the tag. An alternative that consumed any `\[` as an
181
+ // escape could not tell "escaped tag" from "literal backslash, then a live tag",
182
+ // so `\\[red]hi[/red]` lost its opening tag and died on the close
183
+ // (rich-markup-nm1).
184
+ //
185
+ // A run is only ever matched from its first backslash — the lookbehind — which
186
+ // is where a leftmost match starts anyway. Without it, a long run that no
187
+ // bracket follows is re-scanned from every backslash in it: quadratic in the
188
+ // run, on exactly the untrusted text `escape` is for.
189
+ const TAG_BODY = String.raw `[a-z#/@][^[]*?\]`;
190
+ const RUN = String.raw `(?<!\\)(\\*)`;
191
+ const TAG_RE = new RegExp(String.raw `${RUN}(\[${TAG_BODY})`, "g");
192
+ /**
193
+ * Every bracket and the run in front of it; the empty second group is set when
194
+ * a tag starts there or could, once more text is joined after the end. It is an
195
+ * alternation rather than `(…)?` because a quantified group may not match empty.
196
+ */
197
+ const ESCAPE_RE = new RegExp(String.raw `${RUN}\[(?:(?=${TAG_BODY}|(?:[a-z#/@][^[\]]*)?$)()|)`, "g");
198
+ const TRAILING_RUN_RE = /(?<!\\)\\+$/;
199
+ function tokenize(markup) {
200
+ const tokens = [];
201
+ let position = 0;
202
+ for (const match of markup.matchAll(TAG_RE)) {
203
+ const run = match[1];
204
+ const captured = match[2];
205
+ let start = match.index;
206
+ const end = start + match[0].length;
207
+ if (start > position)
208
+ tokens.push({ kind: "text", text: markup.slice(position, start), start: position });
209
+ position = end;
210
+ const literal = Math.floor(run.length / 2);
211
+ if (literal > 0) {
212
+ tokens.push({ kind: "text", text: "\\".repeat(literal), start });
213
+ start += literal * 2;
214
+ }
215
+ if (run.length % 2 === 1) {
216
+ tokens.push({ kind: "text", text: captured, start });
170
217
  continue;
218
+ }
171
219
  const inner = captured.slice(1, -1); // Remove [ ]
172
220
  const isClose = inner.startsWith("/");
173
221
  const stylePart = isClose ? inner.slice(1) : inner;
@@ -183,26 +231,49 @@ function parseTags(markup) {
183
231
  // it was an explicit close of the empty style rather than an implicit one.
184
232
  const isImplicitClose = isClose && styleName === "";
185
233
  const isClosing = isClose && !isImplicitClose;
186
- tags.push({
234
+ tokens.push({
235
+ kind: "tag",
187
236
  fullMatch: captured,
188
237
  isClosing,
189
238
  isImplicitClose,
190
239
  styleName,
191
240
  parameters,
192
- start: match.index,
193
- end: match.index + match[0].length,
241
+ start,
242
+ end,
194
243
  });
195
244
  }
196
- return tags;
245
+ if (position < markup.length)
246
+ tokens.push({ kind: "text", text: markup.slice(position), start: position });
247
+ return tokens;
197
248
  }
198
- function wholeString(markup) {
199
- return { source: markup, base: 0, enclosing: [] };
249
+ function isTag(token) {
250
+ return token.kind === "tag";
251
+ }
252
+ /**
253
+ * The tokens that begin in `[from, to)` of the source. `tokenize` emits them in
254
+ * source order, so this is two binary searches and a slice: a string of many
255
+ * plugin pairs costs what it holds, not pairs × tokens.
256
+ */
257
+ function within(tokens, from, to) {
258
+ return tokens.slice(firstAtOrAfter(tokens, from), firstAtOrAfter(tokens, to));
259
+ }
260
+ function firstAtOrAfter(tokens, offset) {
261
+ let low = 0;
262
+ let high = tokens.length;
263
+ while (low < high) {
264
+ const mid = (low + high) >>> 1;
265
+ if (tokens[mid].start < offset)
266
+ low = mid + 1;
267
+ else
268
+ high = mid;
269
+ }
270
+ return low;
200
271
  }
201
272
  /**
202
273
  * Parses the built-in style dialect — `[bold red]text[/bold red]` — into a
203
274
  * `RichText` with styled spans. Module-private on purpose: `renderMarkup` is
204
- * this module's one crossing, and it delegates straight here the moment a
205
- * string carries no paired plugin tag.
275
+ * this module's one crossing, and it hands every slice here once its plugin
276
+ * pairs are resolved.
206
277
  *
207
278
  * [LAW:single-enforcer] Exporting this is what let a caller bind to the inner
208
279
  * layer, and two of them did — `console.ts` and `prompt.ts` imported it as
@@ -219,69 +290,69 @@ function wholeString(markup) {
219
290
  * function names; an empty registry puts it in the data, where the one
220
291
  * boundary admits both.
221
292
  */
222
- function render(markup, origin, baseStyle, options) {
223
- // Fast path: no brackets at all
224
- if (!HAS_TAG_RE.test(markup)) {
225
- let text = markup;
226
- if (options?.emoji !== false) {
227
- text = emojiReplace(text);
228
- }
229
- const result = new RichText(text);
230
- if (baseStyle)
231
- result.stylize(baseStyle);
232
- return result;
233
- }
234
- const tags = parseTags(markup);
293
+ function render(parts, origin, baseStyle, options) {
235
294
  const doEmoji = options?.emoji !== false;
236
295
  // Build plain text and track spans
237
296
  let plainText = "";
238
297
  const spans = [];
298
+ const spliced = [];
239
299
  const openStack = [];
240
- const unparsable = (reason, tag) => new MarkupSyntaxError(reason, origin.source, origin.base + tag.start, [
300
+ const unparsable = (reason, tag) => new MarkupSyntaxError(reason, origin.source, tag.start, [
241
301
  ...origin.enclosing,
242
302
  ...openStack.map((opened) => opened.tag),
243
303
  ]);
244
- let lastEnd = 0;
245
- for (const tag of tags) {
246
- // Add text before this tag
247
- const textBefore = markup.slice(lastEnd, tag.start);
248
- const processed = unescapeBrackets(doEmoji ? emojiReplace(textBefore) : textBefore);
249
- plainText += processed;
250
- if (tag.isImplicitClose) {
251
- // [/] — close the most recent open tag
252
- if (openStack.length === 0) {
253
- // [/] never closes a plugin tag, so an enclosing plugin tag can be open
254
- // here while no style tag is.
255
- throw unparsable(`Closing tag ${tag.fullMatch} has no open style tag to close`, tag);
256
- }
257
- const opened = openStack.pop();
258
- spans.push(new Span(opened.textStart, plainText.length, openTagStyle(opened)));
304
+ // [LAW:dataflow-not-control-flow] One stack across every part, so a style
305
+ // tag opened before a plugin pair is still open after it and its span covers
306
+ // the handler's output like any other run of text (rich-markup-cg6).
307
+ for (const part of parts) {
308
+ if (part.kind === "rendered") {
309
+ spliced.push({ at: plainText.length, text: part.text });
310
+ plainText += part.text.plain;
311
+ continue;
259
312
  }
260
- else if (tag.isClosing) {
261
- // [/style] — find and close matching open tag
262
- const idx = findLastOpen(openStack, tag.styleName);
263
- if (idx === -1) {
264
- throw unparsable(`Closing tag ${tag.fullMatch} doesn't match any open tag`, tag);
313
+ for (const token of part.tokens) {
314
+ if (!isTag(token)) {
315
+ // A `\[` in front of a bracket no tag starts at is unescaped here, per
316
+ // text token, as the reference does; a tag's own escape was settled by
317
+ // `tokenize` against its backslash run.
318
+ const text = token.text.replace(/\\\[/g, "[");
319
+ // Text reaches `plainText` as `RichText` will hold it, so every offset
320
+ // counted along the way — a span, a splice point — indexes the text it
321
+ // lands on. Stripped only by the constructor, a control character
322
+ // shifted every style after it one cell right.
323
+ plainText += stripControlChars(doEmoji ? emojiReplace(text) : text);
324
+ continue;
325
+ }
326
+ const tag = token;
327
+ if (tag.isImplicitClose) {
328
+ // [/] — close the most recent open tag
329
+ if (openStack.length === 0) {
330
+ throw unparsable(`Closing tag ${tag.fullMatch} has no open tag to close`, tag);
331
+ }
332
+ const opened = openStack.pop();
333
+ spans.push(new Span(opened.textStart, plainText.length, openTagStyle(opened)));
334
+ }
335
+ else if (tag.isClosing) {
336
+ // [/style] — find and close matching open tag
337
+ const idx = findLastOpen(openStack, tag.styleName);
338
+ if (idx === -1) {
339
+ throw unparsable(`Closing tag ${tag.fullMatch} doesn't match any open tag`, tag);
340
+ }
341
+ const opened = openStack[idx];
342
+ spans.push(new Span(opened.textStart, plainText.length, openTagStyle(opened)));
343
+ openStack.splice(idx, 1);
344
+ }
345
+ else {
346
+ // Opening tag
347
+ openStack.push({
348
+ tag: tag.fullMatch,
349
+ styleName: tag.styleName,
350
+ parameters: tag.parameters,
351
+ textStart: plainText.length,
352
+ });
265
353
  }
266
- const opened = openStack[idx];
267
- spans.push(new Span(opened.textStart, plainText.length, openTagStyle(opened)));
268
- openStack.splice(idx, 1);
269
- }
270
- else {
271
- // Opening tag
272
- openStack.push({
273
- tag: tag.fullMatch,
274
- styleName: tag.styleName,
275
- parameters: tag.parameters,
276
- textStart: plainText.length,
277
- });
278
354
  }
279
- lastEnd = tag.end;
280
355
  }
281
- // Add remaining text after last tag
282
- const trailing = markup.slice(lastEnd);
283
- const processedTrailing = unescapeBrackets(doEmoji ? emojiReplace(trailing) : trailing);
284
- plainText += processedTrailing;
285
356
  // Auto-close what is still open, innermost first. The reference pops its
286
357
  // stack here (`while style_stack: start, tag = style_stack.pop()`), and
287
358
  // popping is what keeps every span in this list in the order its tag closed
@@ -322,6 +393,16 @@ function render(markup, origin, baseStyle, options) {
322
393
  result.stylize(style, span.start, span.end);
323
394
  }
324
395
  }
396
+ // A handler's own styles go down last — its base style, then its spans.
397
+ // Every style span that reaches into a handler's output encloses all of it —
398
+ // no tag of this walk sits inside a plugin pair — so each of these is the
399
+ // inner span, and inner repaints outer exactly as the sort above has it for
400
+ // tags.
401
+ for (const { at, text } of spliced) {
402
+ result.stylize(text.style, at, at + text.length);
403
+ for (const span of text.spans)
404
+ result.stylize(span.style, at + span.start, at + span.end);
405
+ }
325
406
  return result;
326
407
  }
327
408
  /**
@@ -339,19 +420,29 @@ function openTagStyle(opened) {
339
420
  ? `${opened.styleName} ${opened.parameters}`
340
421
  : opened.styleName;
341
422
  }
342
- function findLastOpen(stack, name) {
423
+ /**
424
+ * Whether `[/name]` closes `opened`.
425
+ *
426
+ * [LAW:one-source-of-truth] Two walks ask this: the built-in parser closing a
427
+ * style, and the plugin pass keeping the same stack so it knows what `[/]`
428
+ * refers to. If they disagreed on which open tag `[/red on blue]` takes off
429
+ * the stack, they would disagree about what the next `[/]` closes.
430
+ */
431
+ function closesByName(opened, name) {
343
432
  // Handle "on" in style names for closing: [/red on blue] should match [red on blue]
344
433
  const normalized = name.trim();
345
- for (let i = stack.length - 1; i >= 0; i--) {
346
- const entry = stack[i];
347
- if (openTagStyle(entry) === normalized || entry.styleName === normalized)
434
+ return openTagStyle(opened) === normalized || opened.styleName === normalized;
435
+ }
436
+ function findLastOpen(stack, name) {
437
+ return findLastIndex(stack, (entry) => closesByName(entry, name));
438
+ }
439
+ function findLastIndex(items, match) {
440
+ for (let i = items.length - 1; i >= 0; i--) {
441
+ if (match(items[i]))
348
442
  return i;
349
443
  }
350
444
  return -1;
351
445
  }
352
- function unescapeBrackets(text) {
353
- return text.replace(/\\\[/g, "[");
354
- }
355
446
  // [LAW:one-source-of-truth] The one answer to "what is a legal plugin tag
356
447
  // name?". Both sites that need it are derived from this fragment rather than
357
448
  // each carrying its own scanner: `register` anchors it to reject a name markup
@@ -483,124 +574,145 @@ function parsePluginAttrs(after) {
483
574
  /**
484
575
  * Plugin-aware markup render. Always returns a `RichText`. Built-in style
485
576
  * tags become spans; registered plugin tags are resolved by their handler,
486
- * whose returned `RichText` is appended (text + spans) into the output. The
487
- * recursion is over markup *slices* — between top-level plugin tag pairs and
488
- * inside each pair — and every recursion node returns a `RichText`, so the
489
- * append path is uniform.
577
+ * whose returned `RichText` stands in the output where its pair stood, under
578
+ * any style tag that encloses the pair. The recursion is into the inner slice
579
+ * of each plugin pair, and every recursion node returns a `RichText`.
490
580
  */
491
581
  export function renderMarkup(markup, options) {
492
- return renderSlice(markup, wholeString(markup), options);
582
+ return renderSlice(tokenize(markup), { source: markup, enclosing: [] }, options);
493
583
  }
494
- function renderSlice(markup, origin, options) {
584
+ function renderSlice(tokens, origin, options) {
495
585
  const registry = options?.registry ?? globalMarkupRegistry;
496
- const baseStyle = options?.baseStyle;
497
- const doEmoji = options?.emoji !== false;
498
- // Fast path: no `[` at all → no possible tags.
499
- if (!HAS_TAG_RE.test(markup)) {
500
- return render(markup, origin, baseStyle, { emoji: doEmoji });
501
- }
502
- const tags = parseTags(markup);
503
586
  // Pair each opening plugin tag with its matching closer up-front, so the
504
587
  // splice walk can just iterate top-level pairs in source order with no
505
588
  // nested-state book-keeping.
506
- const { annotated, topLevel: tagPairs } = pairPluginTags(tags, registry, origin);
507
- if (tagPairs.size === 0) {
508
- return render(markup, origin, baseStyle, { emoji: doEmoji });
509
- }
510
- // [LAW:one-type-per-behavior] Always assemble a single RichText. Fragments
511
- // between plugin pairs are parsed by the built-in `render`, plugin pairs
512
- // hand their inner slice (recursively) to a handler; both produce RichText,
513
- // both get appended into one accumulator.
514
- const out = new RichText("");
589
+ const { annotated, topLevel: tagPairs } = pairPluginTags(tokens.filter(isTag), registry, origin);
590
+ // [LAW:one-type-per-behavior] Each top-level plugin pair is resolved by its
591
+ // handler, and what it returns takes the pair's place in one built-in walk
592
+ // over the whole slice. Rendering the text between pairs as separate parses
593
+ // is what closed every style tag at a plugin boundary (rich-markup-cg6).
594
+ const parts = [];
515
595
  let cursor = 0;
516
596
  for (const [openIdx, closeIdx] of tagPairs) {
517
597
  const open = annotated[openIdx];
518
598
  const close = annotated[closeIdx];
519
- if (open.start > cursor) {
520
- out.append(render(markup.slice(cursor, open.start), offsetBy(origin, cursor), baseStyle, { emoji: doEmoji }));
521
- }
522
- const innerRaw = markup.slice(open.end, close.start);
523
- const innerRichText = renderSlice(innerRaw, { ...offsetBy(origin, open.end), enclosing: [...origin.enclosing, open.fullMatch] }, options);
599
+ parts.push({ kind: "markup", tokens: within(tokens, cursor, open.start) });
600
+ const innerRaw = origin.source.slice(open.end, close.start);
601
+ // The base style is painted once, under the whole output, by the top of
602
+ // this recursion. Painted into `children` as well, it came back spliced in
603
+ // over every tag enclosing the pair.
604
+ const innerRichText = renderSlice(within(tokens, open.end, close.start), { ...origin, enclosing: [...origin.enclosing, open.fullMatch] }, { ...options, baseStyle: undefined });
524
605
  const handler = registry.get(open.pluginName);
525
- out.append(handler({ attrs: open.attrs, children: innerRichText, raw: innerRaw }));
606
+ parts.push({ kind: "rendered", text: handler({ attrs: open.attrs, children: innerRichText, raw: innerRaw }) });
526
607
  cursor = close.end;
527
608
  }
528
- if (cursor < markup.length) {
529
- out.append(render(markup.slice(cursor), offsetBy(origin, cursor), baseStyle, { emoji: doEmoji }));
530
- }
531
- if (baseStyle)
532
- out.stylize(baseStyle);
533
- return out;
534
- }
535
- /** The origin of a sub-slice starting `offset` into the slice `origin` describes. */
536
- function offsetBy(origin, offset) {
537
- return { ...origin, base: origin.base + offset };
609
+ parts.push({ kind: "markup", tokens: within(tokens, cursor, Infinity) });
610
+ return render(parts, origin, options?.baseStyle, { emoji: options?.emoji !== false });
538
611
  }
539
612
  function pairPluginTags(tags, registry, origin) {
540
613
  // Annotate tags with plugin info, then pair openers with closers. Only
541
614
  // top-level pairs are returned; inner pairs will be re-discovered by the
542
- // recursive `renderMarkup` call on the inner slice.
615
+ // recursive `renderSlice` call on the inner slice.
543
616
  const annotated = tags.map((t) => annotatePluginTag(t, registry));
617
+ // [LAW:one-source-of-truth] `[/]` closes the most recent open tag of *any*
618
+ // kind, so this walk keeps the one stack the built-in dialect keeps, style
619
+ // tags on it too; an implicit close pairs a plugin tag exactly when it is on
620
+ // top. A stack of plugin tags alone cannot say what `[/]` refers to, which is
621
+ // why `[shout]one[/]` used to leave its handler unfired (rich-markup-gfr).
622
+ //
623
+ // A pair's end closes everything opened inside it, because that is where the
624
+ // recursion's slice ends and the built-in walk closes what is left open. The
625
+ // stack drops them there too, so a later `[/]` means here what it means to
626
+ // the walk that renders it.
544
627
  const stack = [];
545
628
  const pairs = new Map();
629
+ // Each opener's closing tag, whichever tag closed it — its own or a pair's end.
630
+ const closedAt = new Map();
631
+ // Each tag a pair's end closed, with that pair's opening tag.
632
+ const closedByPair = new Map();
633
+ // Style tags closed inside a plugin tag they opened outside of, keyed by that
634
+ // plugin tag — a crossing only once the plugin tag turns out to pair.
635
+ const crossedInto = new Map();
636
+ const isOpener = (t) => !t.isClosing && !t.isImplicitClose;
637
+ const isPlugin = (idx) => annotated[idx].pluginName !== undefined;
638
+ const closes = (close) => (openIdx) => {
639
+ const open = annotated[openIdx];
640
+ if (close.isImplicitClose)
641
+ return true;
642
+ return close.pluginName !== undefined
643
+ ? open.pluginName === close.pluginName
644
+ : open.pluginName === undefined && closesByName(open, close.styleName);
645
+ };
646
+ // The tags open at a tag's position, as the built-in walk would report them.
647
+ const openAt = (caret) => annotated.flatMap((t, k) => (k < caret && isOpener(t) && (closedAt.get(k) ?? Infinity) >= caret ? [t.fullMatch] : []));
648
+ const crossing = (styleOpen, close, pairOpen) => new MarkupSyntaxError(`Closing tag ${annotated[close].fullMatch} closes ${annotated[styleOpen].fullMatch} ` +
649
+ `across the boundary of plugin tag ${annotated[pairOpen].fullMatch}: ` +
650
+ `a style tag must open and close on the same side of a plugin pair, ` +
651
+ `because the handler replaces the text inside it.`, origin.source, annotated[close].start, [...origin.enclosing, ...openAt(close)]);
546
652
  for (let i = 0; i < annotated.length; i++) {
547
653
  const t = annotated[i];
548
- if (!t.pluginName)
549
- continue;
550
- if (t.isImplicitClose)
654
+ if (isOpener(t)) {
655
+ stack.push(i);
551
656
  continue;
552
- if (t.isClosing) {
553
- // Find matching open in stack.
554
- for (let j = stack.length - 1; j >= 0; j--) {
555
- const openIdx = stack[j];
556
- if (annotated[openIdx].pluginName === t.pluginName) {
557
- pairs.set(openIdx, i);
558
- stack.splice(j, 1);
559
- break;
560
- }
657
+ }
658
+ const j = findLastIndex(stack, closes(t));
659
+ if (j === -1) {
660
+ // [LAW:no-silent-failure] A closer that matches nothing reaches for the
661
+ // last tag before it that it could name, `[/]` and `[/name]` alike. If a
662
+ // pair's end closed that tag, the closer reaches across the pair, and
663
+ // saying so beats the built-in parser's "doesn't match any open tag";
664
+ // otherwise the built-in parser rejects it, with the location it reports.
665
+ let target = i - 1;
666
+ while (target >= 0 && !(isOpener(annotated[target]) && closes(t)(target)))
667
+ target--;
668
+ const pairOpen = closedByPair.get(target);
669
+ if (pairOpen === undefined)
670
+ continue;
671
+ if (t.pluginName === undefined)
672
+ throw crossing(target, i, pairOpen);
673
+ // Overlap is unrepresentable rather than unimplemented: a handler receives
674
+ // `children` as one contiguous slice, so a region straddling another
675
+ // pair's closing boundary has nothing to hand it. A style span may cross
676
+ // another style span because it annotates; a plugin pair may not because
677
+ // it replaces. The caret goes where nesting broke: the outer pair's end.
678
+ const inner = annotated[target];
679
+ const outer = annotated[pairOpen];
680
+ const caret = closedAt.get(pairOpen);
681
+ throw new MarkupSyntaxError(`Plugin tag [${inner.pluginName}] overlaps [${outer.pluginName}]: plugin tags must nest, ` +
682
+ `because a handler receives one contiguous slice. ` +
683
+ `Close [/${inner.pluginName}] before [/${outer.pluginName}].`, origin.source, annotated[caret].start, [...origin.enclosing, ...openAt(caret)]);
684
+ }
685
+ const openIdx = stack[j];
686
+ if (!isPlugin(openIdx)) {
687
+ for (const k of stack.slice(j + 1)) {
688
+ if (isPlugin(k) && !crossedInto.has(k))
689
+ crossedInto.set(k, { styleOpen: openIdx, close: i });
561
690
  }
691
+ stack.splice(j, 1);
692
+ closedAt.set(openIdx, i);
693
+ continue;
562
694
  }
563
- else {
564
- stack.push(i);
695
+ const crossed = crossedInto.get(openIdx);
696
+ if (crossed !== undefined)
697
+ throw crossing(crossed.styleOpen, crossed.close, openIdx);
698
+ for (const k of stack.splice(j)) {
699
+ closedAt.set(k, i);
700
+ if (k !== openIdx)
701
+ closedByPair.set(k, openIdx);
565
702
  }
703
+ pairs.set(openIdx, i);
566
704
  }
567
- // Filter pairs to top-level only. A pair that opens inside the current one is
568
- // either contained — re-discovered when the recursion renders the outer
569
- // pair's inner slice — or overlapping, and only its *closing* position tells
570
- // the two apart. Testing the open position alone conflated them and dropped
571
- // the overlapping pair, which orphaned its closing tag into the trailing
572
- // slice, where the built-in parser blamed the wrong tag for the wrong reason.
705
+ // Pairs nest, so one is top-level exactly when it opens after the last
706
+ // top-level pair closed. Decided after the walk and not at each close: a
707
+ // plugin tag still on the stack there may never pair, and then encloses
708
+ // nothing.
573
709
  const topLevel = new Map();
574
- let outerEnd = -1;
575
- let outerOpenIdx = -1;
576
- const sortedOpens = [...pairs.keys()].sort((a, b) => a - b);
577
- for (const openIdx of sortedOpens) {
578
- const closeIdx = pairs.get(openIdx);
579
- if (annotated[openIdx].start < outerEnd) {
580
- // [LAW:no-silent-failure] Overlap is unrepresentable here rather than
581
- // unimplemented: a handler receives `children` as one contiguous slice,
582
- // so a region straddling another pair's closing boundary has nothing to
583
- // hand it. A style span may overlap because it annotates; a plugin pair
584
- // may not because it replaces.
585
- if (annotated[closeIdx].end > outerEnd) {
586
- const inner = annotated[openIdx];
587
- const outer = annotated[outerOpenIdx];
588
- // The caret goes under the outer pair's closing tag, the point where
589
- // nesting breaks. This pass sees plugin tags only; of those, a pair is
590
- // open there when it opens before that tag and closes at or after it.
591
- const caret = annotated[pairs.get(outerOpenIdx)].start;
592
- const openAtCaret = sortedOpens
593
- .filter((o) => annotated[o].start < caret && annotated[pairs.get(o)].start >= caret)
594
- .map((o) => annotated[o].fullMatch);
595
- throw new MarkupSyntaxError(`Plugin tag [${inner.pluginName}] overlaps [${outer.pluginName}]: plugin tags must nest, ` +
596
- `because a handler receives one contiguous slice. ` +
597
- `Close [/${inner.pluginName}] before [/${outer.pluginName}].`, origin.source, origin.base + caret, [...origin.enclosing, ...openAtCaret]);
598
- }
710
+ let outerClose = -1;
711
+ for (const openIdx of [...pairs.keys()].sort((a, b) => a - b)) {
712
+ if (openIdx < outerClose)
599
713
  continue;
600
- }
601
- topLevel.set(openIdx, closeIdx);
602
- outerOpenIdx = openIdx;
603
- outerEnd = annotated[closeIdx].end;
714
+ outerClose = pairs.get(openIdx);
715
+ topLevel.set(openIdx, outerClose);
604
716
  }
605
717
  return { annotated, topLevel };
606
718
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Measurement — min/max cell width calculation for renderables.
3
3
  */
4
- import type { Measurable, RenderOptions } from "./protocol.js";
4
+ import { type Measurable, type RenderOptions } from "./protocol.js";
5
5
  export declare class Measurement {
6
6
  readonly minimum: number;
7
7
  readonly maximum: number;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Measurement — min/max cell width calculation for renderables.
3
3
  */
4
+ import { measuring } from "./protocol.js";
4
5
  export class Measurement {
5
6
  minimum;
6
7
  maximum;
@@ -43,7 +44,7 @@ export class Measurement {
43
44
  static get(options, measurable) {
44
45
  if (options.maxWidth < 1)
45
46
  return new Measurement(0, 0);
46
- const { minimum, maximum } = measurable.measure(options);
47
+ const { minimum, maximum } = measurable.measure(measuring(options));
47
48
  return new Measurement(minimum, Math.min(maximum, options.maxWidth)).normalize();
48
49
  }
49
50
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * place — a renderable's output set down as one block inside a wider width.
3
+ *
4
+ * The block is placed, not its lines. The renderable is measured, drawn at the
5
+ * width it asked for and at its own height — a region's height is dropped, as
6
+ * the reference's `Align` drops it — and every line is padded to the widest, so the block is a
7
+ * rectangle; only then is the rectangle padded to the offered width. A
8
+ * multi-line paragraph keeps its own shape under `"center"` — `hi` over `hello`
9
+ * stays a 5-cell block, centred as one — where padding each line to the full
10
+ * width on its own would centre `hi` against the whole terminal. That is
11
+ * Rich's `Align`, and `test/core/console.test.ts` pins it against the
12
+ * reference's bytes.
13
+ *
14
+ * It lives in `core/` because `Console.print` places with it: Rich's `print`
15
+ * wraps what it prints in `Align` for the three alignments, and `console.ts`
16
+ * reaching for `renderables/align.ts` would be a third upward edge out of this
17
+ * layer. [LAW:one-way-deps] `Align` is this function behind the `Renderable`
18
+ * interface. [LAW:one-source-of-truth]
19
+ */
20
+ import { Segment } from "./segment.js";
21
+ import { type Renderable, type RenderOptions } from "./protocol.js";
22
+ export type Alignment = "left" | "center" | "right";
23
+ /**
24
+ * `renderable`'s lines, the block they form placed by `align` in
25
+ * `options.maxWidth`. `options.maxWidth` must be finite: the spare cells are
26
+ * written out as spaces. A block wider than the offer — a line with nowhere to
27
+ * break, drawn by a renderable that does not cut its own — is left unpadded at
28
+ * its full width, for whatever holds it to crop or keep.
29
+ */
30
+ export declare function placeBlock(renderable: Renderable, align: Alignment, options: RenderOptions): Segment[][];