@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.
- package/README.md +13 -26
- package/dist/core/anchor.d.ts +45 -0
- package/dist/core/anchor.js +39 -0
- package/dist/core/ansi.d.ts +65 -0
- package/dist/core/ansi.js +231 -0
- package/dist/core/box.d.ts +0 -1
- package/dist/core/box.js +6 -18
- package/dist/core/cells.d.ts +16 -5
- package/dist/core/cells.js +31 -10
- package/dist/core/color.d.ts +4 -8
- package/dist/core/color.js +28 -19
- package/dist/core/console.d.ts +13 -4
- package/dist/core/console.js +91 -36
- package/dist/core/export-html.d.ts +40 -5
- package/dist/core/export-html.js +111 -17
- package/dist/core/markup.d.ts +27 -5
- package/dist/core/markup.js +282 -170
- package/dist/core/measure.d.ts +1 -1
- package/dist/core/measure.js +2 -1
- package/dist/core/place.d.ts +30 -0
- package/dist/core/place.js +51 -0
- package/dist/core/pretty.d.ts +3 -0
- package/dist/core/pretty.js +5 -1
- package/dist/core/protocol.d.ts +136 -6
- package/dist/core/protocol.js +64 -2
- package/dist/core/render.d.ts +2 -2
- package/dist/core/render.js +2 -2
- package/dist/core/segment.d.ts +23 -4
- package/dist/core/segment.js +75 -16
- package/dist/core/spinnerData.d.ts +2 -0
- package/dist/core/spinnerData.js +3 -1
- package/dist/core/strip.d.ts +3 -7
- package/dist/core/strip.js +32 -35
- package/dist/core/style.d.ts +25 -0
- package/dist/core/style.js +61 -89
- package/dist/core/text.d.ts +16 -15
- package/dist/core/text.js +62 -76
- package/dist/host/app.d.ts +121 -0
- package/dist/host/app.js +255 -0
- package/dist/host/host-environment.d.ts +26 -0
- package/dist/host/host-environment.js +44 -0
- package/dist/host/index.d.ts +4 -2
- package/dist/host/index.js +2 -1
- package/dist/host/terminal-host.d.ts +43 -7
- package/dist/host/terminal-host.js +57 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/node/terminal-host.d.ts +31 -2
- package/dist/node/terminal-host.js +69 -2
- package/dist/node/traceback.d.ts +16 -6
- package/dist/node/traceback.js +30 -8
- package/dist/renderables/align.d.ts +2 -2
- package/dist/renderables/align.js +6 -19
- package/dist/renderables/columns.js +4 -20
- package/dist/renderables/embed.d.ts +50 -0
- package/dist/renderables/embed.js +71 -0
- package/dist/renderables/group.js +3 -1
- package/dist/renderables/layout.js +31 -34
- package/dist/renderables/live.d.ts +6 -3
- package/dist/renderables/live.js +89 -45
- package/dist/renderables/markdown.d.ts +1 -1
- package/dist/renderables/markdown.js +6 -4
- package/dist/renderables/padding.js +3 -2
- package/dist/renderables/panel.js +26 -41
- package/dist/renderables/progress.js +5 -8
- package/dist/renderables/progressBar.js +6 -5
- package/dist/renderables/rule.d.ts +3 -2
- package/dist/renderables/rule.js +9 -9
- package/dist/renderables/spinner.d.ts +5 -3
- package/dist/renderables/spinner.js +14 -8
- package/dist/renderables/syntax.d.ts +1 -1
- package/dist/renderables/syntax.js +2 -1
- package/dist/renderables/table.d.ts +26 -14
- package/dist/renderables/table.js +239 -141
- package/dist/renderables/traceback.js +8 -9
- package/dist/renderables/tree.d.ts +17 -3
- package/dist/renderables/tree.js +62 -71
- package/dist/renderables/viewport.d.ts +114 -0
- package/dist/renderables/viewport.js +198 -0
- package/dist/template-bindings/color-funcs.js +11 -13
- package/dist/template-bindings/index.js +4 -2
- package/dist/template-bindings/palette-funcs.js +6 -16
- package/dist/template-bindings/style-funcs.js +4 -0
- package/dist/themes/data/ansi-tables.d.ts +14 -0
- package/dist/themes/data/ansi-tables.js +49 -0
- package/dist/themes/data/atom-one-dark.js +19 -0
- package/dist/themes/data/atom-one-light.js +19 -0
- package/dist/themes/data/catppuccin-frappe.js +19 -0
- package/dist/themes/data/catppuccin-latte.js +19 -0
- package/dist/themes/data/catppuccin-macchiato.js +19 -0
- package/dist/themes/data/catppuccin-mocha.js +19 -0
- package/dist/themes/data/cyberpunk.js +2 -0
- package/dist/themes/data/default.js +2 -0
- package/dist/themes/data/dracula.js +19 -0
- package/dist/themes/data/flexoki.js +19 -0
- package/dist/themes/data/gruvbox.js +19 -0
- package/dist/themes/data/monokai.js +19 -0
- package/dist/themes/data/nord.js +19 -0
- package/dist/themes/data/rose-pine-dawn.js +19 -0
- package/dist/themes/data/rose-pine-moon.js +19 -0
- package/dist/themes/data/rose-pine.js +19 -0
- package/dist/themes/data/solarized-dark.js +19 -0
- package/dist/themes/data/solarized-light.js +19 -0
- package/dist/themes/data/svg-export.js +19 -0
- package/dist/themes/data/textual-ansi.js +2 -0
- package/dist/themes/data/textual-dark.js +2 -0
- package/dist/themes/data/textual-light.js +2 -0
- package/dist/themes/data/tokyo-night.js +19 -0
- package/dist/themes/data/types.d.ts +16 -0
- package/dist/themes/data/types.js +10 -1
- package/dist/themes/registry.d.ts +3 -2
- package/dist/themes/registry.js +13 -1
- package/dist/themes/terminalThemes.d.ts +2 -7
- package/dist/themes/terminalThemes.js +4 -9
- package/dist/widgets/button.d.ts +1 -1
- package/dist/widgets/button.js +4 -2
- package/dist/widgets/checkbox.d.ts +2 -2
- package/dist/widgets/checkbox.js +5 -3
- package/dist/widgets/dropdown.d.ts +2 -2
- package/dist/widgets/dropdown.js +13 -16
- package/dist/widgets/event-router.d.ts +21 -28
- package/dist/widgets/event-router.js +80 -73
- package/dist/widgets/focus-manager.d.ts +25 -6
- package/dist/widgets/focus-manager.js +63 -65
- package/dist/widgets/hit.d.ts +64 -0
- package/dist/widgets/hit.js +113 -0
- package/dist/widgets/index.d.ts +5 -7
- package/dist/widgets/index.js +3 -4
- package/dist/widgets/slider.d.ts +2 -2
- package/dist/widgets/slider.js +7 -7
- package/dist/widgets/static-item.d.ts +9 -15
- package/dist/widgets/static-item.js +9 -15
- package/dist/widgets/text-input.d.ts +2 -2
- package/dist/widgets/text-input.js +17 -14
- package/dist/widgets/toggle.d.ts +1 -1
- package/dist/widgets/toggle.js +3 -2
- package/dist/widgets/types.d.ts +14 -35
- package/dist/widgets/types.js +0 -1
- package/dist/widgets/widget-app.d.ts +63 -0
- package/dist/widgets/widget-app.js +199 -0
- package/dist/widgets/widget-base.d.ts +4 -6
- package/dist/widgets/widget-base.js +22 -30
- package/package.json +12 -4
- package/dist/host/host-stream.d.ts +0 -30
- package/dist/host/host-stream.js +0 -35
- package/dist/widgets/screen.d.ts +0 -98
- package/dist/widgets/screen.js +0 -530
package/dist/core/markup.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
234
|
+
tokens.push({
|
|
235
|
+
kind: "tag",
|
|
187
236
|
fullMatch: captured,
|
|
188
237
|
isClosing,
|
|
189
238
|
isImplicitClose,
|
|
190
239
|
styleName,
|
|
191
240
|
parameters,
|
|
192
|
-
start
|
|
193
|
-
end
|
|
241
|
+
start,
|
|
242
|
+
end,
|
|
194
243
|
});
|
|
195
244
|
}
|
|
196
|
-
|
|
245
|
+
if (position < markup.length)
|
|
246
|
+
tokens.push({ kind: "text", text: markup.slice(position), start: position });
|
|
247
|
+
return tokens;
|
|
197
248
|
}
|
|
198
|
-
function
|
|
199
|
-
return
|
|
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
|
|
205
|
-
*
|
|
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(
|
|
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,
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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`
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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,
|
|
582
|
+
return renderSlice(tokenize(markup), { source: markup, enclosing: [] }, options);
|
|
493
583
|
}
|
|
494
|
-
function renderSlice(
|
|
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(
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
606
|
+
parts.push({ kind: "rendered", text: handler({ attrs: open.attrs, children: innerRichText, raw: innerRaw }) });
|
|
526
607
|
cursor = close.end;
|
|
527
608
|
}
|
|
528
|
-
|
|
529
|
-
|
|
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 `
|
|
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 (
|
|
549
|
-
|
|
550
|
-
if (t.isImplicitClose)
|
|
654
|
+
if (isOpener(t)) {
|
|
655
|
+
stack.push(i);
|
|
551
656
|
continue;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
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,
|
|
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
|
}
|
package/dist/core/measure.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Measurement — min/max cell width calculation for renderables.
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
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;
|
package/dist/core/measure.js
CHANGED
|
@@ -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[][];
|