@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.
- package/README.md +9 -7
- 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 +145 -0
- package/dist/core/box.js +372 -0
- package/dist/core/cells.d.ts +192 -0
- package/dist/core/cells.js +334 -0
- package/dist/core/clock.d.ts +48 -0
- package/dist/core/clock.js +42 -0
- package/dist/core/color.d.ts +326 -0
- package/dist/core/color.js +1225 -0
- package/dist/core/console.d.ts +196 -0
- package/dist/core/console.js +596 -0
- package/dist/core/easing.d.ts +86 -0
- package/dist/core/easing.js +171 -0
- package/dist/core/emoji.d.ts +24 -0
- package/dist/core/emoji.js +391 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +16 -0
- package/dist/core/export-html.d.ts +59 -0
- package/dist/core/export-html.js +197 -0
- package/dist/core/export-lines.d.ts +130 -0
- package/dist/core/export-lines.js +159 -0
- package/dist/core/export-svg.d.ts +49 -0
- package/dist/core/export-svg.js +213 -0
- package/dist/core/fnv1a.d.ts +10 -0
- package/dist/core/fnv1a.js +17 -0
- package/dist/core/highlighter.d.ts +33 -0
- package/dist/core/highlighter.js +190 -0
- package/dist/core/json.d.ts +27 -0
- package/dist/core/json.js +53 -0
- package/dist/core/markup.d.ts +140 -0
- package/dist/core/markup.js +654 -0
- package/dist/core/measure.d.ts +27 -0
- package/dist/core/measure.js +67 -0
- package/dist/core/memo.d.ts +19 -0
- package/dist/core/memo.js +34 -0
- package/dist/core/oklch.d.ts +161 -0
- package/dist/core/oklch.js +312 -0
- package/dist/core/osc8.d.ts +68 -0
- package/dist/core/osc8.js +78 -0
- package/dist/core/paint.d.ts +107 -0
- package/dist/core/paint.js +150 -0
- package/dist/core/place.d.ts +30 -0
- package/dist/core/place.js +49 -0
- package/dist/core/pretty.d.ts +247 -0
- package/dist/core/pretty.js +906 -0
- package/dist/core/protocol.d.ts +305 -0
- package/dist/core/protocol.js +183 -0
- package/dist/core/render.d.ts +87 -0
- package/dist/core/render.js +105 -0
- package/dist/core/segment.d.ts +162 -0
- package/dist/core/segment.js +488 -0
- package/dist/core/spinnerData.d.ts +12 -0
- package/dist/core/spinnerData.js +117 -0
- package/dist/core/strip.d.ts +152 -0
- package/dist/core/strip.js +326 -0
- package/dist/core/style.d.ts +189 -0
- package/dist/core/style.js +758 -0
- package/dist/core/subscription.d.ts +12 -0
- package/dist/core/subscription.js +12 -0
- package/dist/core/text.d.ts +283 -0
- package/dist/core/text.js +1097 -0
- package/dist/core/wrap.d.ts +46 -0
- package/dist/core/wrap.js +140 -0
- package/dist/host/app.d.ts +133 -0
- package/dist/host/app.js +285 -0
- package/dist/host/host-environment.d.ts +26 -0
- package/dist/host/host-environment.js +44 -0
- package/dist/host/index.d.ts +6 -0
- package/dist/host/index.js +3 -0
- package/dist/host/terminal-host.d.ts +180 -0
- package/dist/host/terminal-host.js +186 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +114 -0
- package/dist/node/prompt.d.ts +34 -0
- package/dist/node/prompt.js +51 -0
- package/dist/node/save.d.ts +18 -0
- package/dist/node/save.js +24 -0
- package/dist/node/terminal-host.d.ts +114 -0
- package/dist/node/terminal-host.js +227 -0
- package/dist/node/traceback.d.ts +38 -0
- package/dist/node/traceback.js +98 -0
- package/dist/renderables/align.d.ts +16 -0
- package/dist/renderables/align.js +26 -0
- package/dist/renderables/columns.d.ts +77 -0
- package/dist/renderables/columns.js +191 -0
- package/dist/renderables/constrain.d.ts +15 -0
- package/dist/renderables/constrain.js +31 -0
- package/dist/renderables/effect.d.ts +98 -0
- package/dist/renderables/effect.js +160 -0
- package/dist/renderables/embed.d.ts +107 -0
- package/dist/renderables/embed.js +160 -0
- package/dist/renderables/flexStrip.d.ts +41 -0
- package/dist/renderables/flexStrip.js +200 -0
- package/dist/renderables/group.d.ts +14 -0
- package/dist/renderables/group.js +22 -0
- package/dist/renderables/layout.d.ts +75 -0
- package/dist/renderables/layout.js +223 -0
- package/dist/renderables/live.d.ts +74 -0
- package/dist/renderables/live.js +188 -0
- package/dist/renderables/markdown.d.ts +38 -0
- package/dist/renderables/markdown.js +587 -0
- package/dist/renderables/padding.d.ts +59 -0
- package/dist/renderables/padding.js +147 -0
- package/dist/renderables/panel.d.ts +114 -0
- package/dist/renderables/panel.js +303 -0
- package/dist/renderables/progress.d.ts +178 -0
- package/dist/renderables/progress.js +362 -0
- package/dist/renderables/progressBar.d.ts +31 -0
- package/dist/renderables/progressBar.js +64 -0
- package/dist/renderables/prompt.d.ts +71 -0
- package/dist/renderables/prompt.js +165 -0
- package/dist/renderables/ratio.d.ts +123 -0
- package/dist/renderables/ratio.js +213 -0
- package/dist/renderables/rule.d.ts +26 -0
- package/dist/renderables/rule.js +86 -0
- package/dist/renderables/spinner.d.ts +57 -0
- package/dist/renderables/spinner.js +103 -0
- package/dist/renderables/status.d.ts +29 -0
- package/dist/renderables/status.js +46 -0
- package/dist/renderables/syntax.d.ts +65 -0
- package/dist/renderables/syntax.js +166 -0
- package/dist/renderables/table.d.ts +232 -0
- package/dist/renderables/table.js +973 -0
- package/dist/renderables/traceback.d.ts +32 -0
- package/dist/renderables/traceback.js +279 -0
- package/dist/renderables/tree.d.ts +76 -0
- package/dist/renderables/tree.js +177 -0
- package/dist/renderables/viewport.d.ts +114 -0
- package/dist/renderables/viewport.js +196 -0
- package/dist/template-bindings/color-funcs.d.ts +109 -0
- package/dist/template-bindings/color-funcs.js +243 -0
- package/dist/template-bindings/helpers.d.ts +26 -0
- package/dist/template-bindings/helpers.js +33 -0
- package/dist/template-bindings/index.d.ts +119 -0
- package/dist/template-bindings/index.js +162 -0
- package/dist/template-bindings/palette-funcs.d.ts +96 -0
- package/dist/template-bindings/palette-funcs.js +145 -0
- package/dist/template-bindings/style-funcs.d.ts +28 -0
- package/dist/template-bindings/style-funcs.js +157 -0
- package/dist/themes/buildPalette.d.ts +57 -0
- package/dist/themes/buildPalette.js +100 -0
- package/dist/themes/colorMath.d.ts +94 -0
- package/dist/themes/colorMath.js +280 -0
- package/dist/themes/colorRef.d.ts +36 -0
- package/dist/themes/colorRef.js +78 -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.d.ts +3 -0
- package/dist/themes/data/atom-one-dark.js +177 -0
- package/dist/themes/data/atom-one-light.d.ts +3 -0
- package/dist/themes/data/atom-one-light.js +177 -0
- package/dist/themes/data/catppuccin-frappe.d.ts +3 -0
- package/dist/themes/data/catppuccin-frappe.js +177 -0
- package/dist/themes/data/catppuccin-latte.d.ts +3 -0
- package/dist/themes/data/catppuccin-latte.js +177 -0
- package/dist/themes/data/catppuccin-macchiato.d.ts +3 -0
- package/dist/themes/data/catppuccin-macchiato.js +177 -0
- package/dist/themes/data/catppuccin-mocha.d.ts +3 -0
- package/dist/themes/data/catppuccin-mocha.js +177 -0
- package/dist/themes/data/cyberpunk.d.ts +17 -0
- package/dist/themes/data/cyberpunk.js +193 -0
- package/dist/themes/data/default.d.ts +3 -0
- package/dist/themes/data/default.js +160 -0
- package/dist/themes/data/dracula.d.ts +3 -0
- package/dist/themes/data/dracula.js +177 -0
- package/dist/themes/data/flexoki.d.ts +3 -0
- package/dist/themes/data/flexoki.js +177 -0
- package/dist/themes/data/gruvbox.d.ts +3 -0
- package/dist/themes/data/gruvbox.js +177 -0
- package/dist/themes/data/index.d.ts +35 -0
- package/dist/themes/data/index.js +64 -0
- package/dist/themes/data/monokai.d.ts +3 -0
- package/dist/themes/data/monokai.js +177 -0
- package/dist/themes/data/nord.d.ts +3 -0
- package/dist/themes/data/nord.js +177 -0
- package/dist/themes/data/rose-pine-dawn.d.ts +3 -0
- package/dist/themes/data/rose-pine-dawn.js +177 -0
- package/dist/themes/data/rose-pine-moon.d.ts +3 -0
- package/dist/themes/data/rose-pine-moon.js +177 -0
- package/dist/themes/data/rose-pine.d.ts +3 -0
- package/dist/themes/data/rose-pine.js +177 -0
- package/dist/themes/data/solarized-dark.d.ts +3 -0
- package/dist/themes/data/solarized-dark.js +177 -0
- package/dist/themes/data/solarized-light.d.ts +3 -0
- package/dist/themes/data/solarized-light.js +177 -0
- package/dist/themes/data/svg-export.d.ts +3 -0
- package/dist/themes/data/svg-export.js +177 -0
- package/dist/themes/data/textual-ansi.d.ts +3 -0
- package/dist/themes/data/textual-ansi.js +160 -0
- package/dist/themes/data/textual-dark.d.ts +3 -0
- package/dist/themes/data/textual-dark.js +160 -0
- package/dist/themes/data/textual-light.d.ts +3 -0
- package/dist/themes/data/textual-light.js +160 -0
- package/dist/themes/data/tokyo-night.d.ts +3 -0
- package/dist/themes/data/tokyo-night.js +177 -0
- package/dist/themes/data/types.d.ts +31 -0
- package/dist/themes/data/types.js +10 -0
- package/dist/themes/palette.d.ts +45 -0
- package/dist/themes/palette.js +71 -0
- package/dist/themes/ramp.d.ts +70 -0
- package/dist/themes/ramp.js +103 -0
- package/dist/themes/registry.d.ts +44 -0
- package/dist/themes/registry.js +105 -0
- package/dist/themes/terminalThemes.d.ts +49 -0
- package/dist/themes/terminalThemes.js +63 -0
- package/dist/themes/transpose.d.ts +87 -0
- package/dist/themes/transpose.js +138 -0
- package/dist/widgets/button.d.ts +45 -0
- package/dist/widgets/button.js +177 -0
- package/dist/widgets/checkbox.d.ts +38 -0
- package/dist/widgets/checkbox.js +135 -0
- package/dist/widgets/dropdown.d.ts +64 -0
- package/dist/widgets/dropdown.js +416 -0
- package/dist/widgets/event-router.d.ts +71 -0
- package/dist/widgets/event-router.js +629 -0
- package/dist/widgets/focus-manager.d.ts +45 -0
- package/dist/widgets/focus-manager.js +191 -0
- package/dist/widgets/hit.d.ts +64 -0
- package/dist/widgets/hit.js +113 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +14 -0
- package/dist/widgets/ink.d.ts +22 -0
- package/dist/widgets/ink.js +31 -0
- package/dist/widgets/slider.d.ts +54 -0
- package/dist/widgets/slider.js +247 -0
- package/dist/widgets/static-item.d.ts +34 -0
- package/dist/widgets/static-item.js +54 -0
- package/dist/widgets/text-input.d.ts +283 -0
- package/dist/widgets/text-input.js +1174 -0
- package/dist/widgets/themed-widget.d.ts +20 -0
- package/dist/widgets/themed-widget.js +89 -0
- package/dist/widgets/toggle.d.ts +41 -0
- package/dist/widgets/toggle.js +157 -0
- package/dist/widgets/types.d.ts +87 -0
- package/dist/widgets/types.js +30 -0
- package/dist/widgets/widget-app.d.ts +65 -0
- package/dist/widgets/widget-app.js +206 -0
- package/dist/widgets/widget-base.d.ts +42 -0
- package/dist/widgets/widget-base.js +172 -0
- package/package.json +16 -2
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markup — BBCode-inspired markup parser for inline styling.
|
|
3
|
+
* Parses `[bold red]text[/bold red]` into RichText with styled spans.
|
|
4
|
+
*
|
|
5
|
+
* Plugin tags ([LAW:locality-or-seam]): a `MarkupRegistry` lets consumers
|
|
6
|
+
* register tag handlers without forking the parser. When the parser sees
|
|
7
|
+
* `[name attrs...]inner[/name]` and `name` is registered, it parses attrs,
|
|
8
|
+
* renders the inner markup as the child `RichText`, and calls the handler —
|
|
9
|
+
* splicing the `RichText` it returns into the output. The
|
|
10
|
+
* built-in style dialect and the plugin dialect are routed by the registry,
|
|
11
|
+
* which is the single trust boundary between them.
|
|
12
|
+
*
|
|
13
|
+
* Plugin pairs must nest. The built-in dialect admits non-strict nesting
|
|
14
|
+
* (`[bold]a[italic]b[/bold]c[/italic]`) because a style is an annotation and
|
|
15
|
+
* annotations may overlap freely; a plugin tag is a replacement whose handler
|
|
16
|
+
* takes one contiguous `inner`, so an overlapping pair has no slice to hand it
|
|
17
|
+
* and is rejected with a `MarkupSyntaxError`. For the same reason a style tag
|
|
18
|
+
* may not open on one side of a plugin tag and close on the other.
|
|
19
|
+
*/
|
|
20
|
+
import { cellLen } from "./cells.js";
|
|
21
|
+
import { Style, StyleSyntaxError } from "./style.js";
|
|
22
|
+
import { RichText, stripControlChars } from "./text.js";
|
|
23
|
+
import { emojiReplace } from "./emoji.js";
|
|
24
|
+
// --- Tag ---
|
|
25
|
+
export class Tag {
|
|
26
|
+
name;
|
|
27
|
+
parameters;
|
|
28
|
+
constructor(name, parameters) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
this.parameters = parameters;
|
|
31
|
+
}
|
|
32
|
+
toString() {
|
|
33
|
+
return this.parameters !== undefined
|
|
34
|
+
? `${this.name} ${this.parameters}`
|
|
35
|
+
: this.name;
|
|
36
|
+
}
|
|
37
|
+
get markup() {
|
|
38
|
+
return this.parameters !== undefined
|
|
39
|
+
? `[${this.name}=${this.parameters}]`
|
|
40
|
+
: `[${this.name}]`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// --- MarkupError ---
|
|
44
|
+
export class MarkupError extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "MarkupError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// --- MarkupSyntaxError ---
|
|
51
|
+
/**
|
|
52
|
+
* A markup string that cannot be parsed, with where it went wrong.
|
|
53
|
+
*
|
|
54
|
+
* [LAW:types-are-the-program] A subclass rather than optional fields on
|
|
55
|
+
* `MarkupError`, because the parent is also what `MarkupRegistry.register`
|
|
56
|
+
* throws, and a registration has no source text to point into. Every error this
|
|
57
|
+
* class describes has a location, so none of its fields is optional and no
|
|
58
|
+
* caller has to ask whether one is present — `instanceof` is the only question.
|
|
59
|
+
*
|
|
60
|
+
* [LAW:one-source-of-truth] The constructor takes the facts the parser holds —
|
|
61
|
+
* the string, the offset of the offending tag, what was open — and derives
|
|
62
|
+
* `line`, `column` and the message from them, so the numbers a caller reads
|
|
63
|
+
* and the caret the message draws cannot disagree.
|
|
64
|
+
*/
|
|
65
|
+
export class MarkupSyntaxError extends MarkupError {
|
|
66
|
+
/** The problem alone, with no location, for callers building their own message. */
|
|
67
|
+
reason;
|
|
68
|
+
/** The whole markup string that failed, as the caller passed it. */
|
|
69
|
+
markup;
|
|
70
|
+
/** Index into `markup` of the tag the parser rejected. */
|
|
71
|
+
offset;
|
|
72
|
+
/** 1-based line of `offset`; lines are separated by `\n`. */
|
|
73
|
+
line;
|
|
74
|
+
/** 1-based column of `offset` within its line, in UTF-16 code units. */
|
|
75
|
+
column;
|
|
76
|
+
/**
|
|
77
|
+
* The opening tags still open at `offset`, outermost first, as written.
|
|
78
|
+
*/
|
|
79
|
+
openTags;
|
|
80
|
+
constructor(reason, markup, offset, openTags) {
|
|
81
|
+
const lineStart = markup.lastIndexOf("\n", offset - 1) + 1;
|
|
82
|
+
const lineEnd = markup.indexOf("\n", offset);
|
|
83
|
+
const text = markup.slice(lineStart, lineEnd === -1 ? markup.length : lineEnd);
|
|
84
|
+
const line = countNewlines(markup.slice(0, lineStart)) + 1;
|
|
85
|
+
const column = offset - lineStart + 1;
|
|
86
|
+
const open = openTags.length > 0 ? openTags.join(" ") : "none";
|
|
87
|
+
super(`${printable(reason)} (line ${line}, column ${column})\n` +
|
|
88
|
+
`${excerpt(text, offset - lineStart)}\n` +
|
|
89
|
+
`Open tags: ${printable(open)}`);
|
|
90
|
+
this.name = "MarkupSyntaxError";
|
|
91
|
+
this.reason = reason;
|
|
92
|
+
this.markup = markup;
|
|
93
|
+
this.offset = offset;
|
|
94
|
+
this.line = line;
|
|
95
|
+
this.column = column;
|
|
96
|
+
this.openTags = openTags;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function countNewlines(text) {
|
|
100
|
+
return text.split("\n").length - 1;
|
|
101
|
+
}
|
|
102
|
+
// Code points shown either side of the error before the excerpt is cut and
|
|
103
|
+
// marked with an ellipsis. Enough to recognise the surrounding tags on a line
|
|
104
|
+
// that is long, templated, or machine-assembled, without printing all of it.
|
|
105
|
+
const EXCERPT_CONTEXT = 30;
|
|
106
|
+
/**
|
|
107
|
+
* `text` with each C0 control and DEL shown as one space.
|
|
108
|
+
*
|
|
109
|
+
* [LAW:single-enforcer] Every part of the message that quotes the caller's
|
|
110
|
+
* markup passes through here: the reason and the open tags quote tag text, and
|
|
111
|
+
* the tag grammar admits control characters, so an `ESC ]` inside a tag would
|
|
112
|
+
* otherwise open an OSC sequence in the terminal printing the error. In the
|
|
113
|
+
* excerpt it also keeps the caret aligned, since a control occupies no cell.
|
|
114
|
+
* The fields keep the text as written; only the message is made printable.
|
|
115
|
+
*/
|
|
116
|
+
function printable(text) {
|
|
117
|
+
return text.replace(/[\x00-\x1f\x7f]/g, " ");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Two lines: the source line around `index`, and a caret under it.
|
|
121
|
+
*
|
|
122
|
+
* The window is cut in code points, so a surrogate pair is never split, and the
|
|
123
|
+
* caret is placed by cell width, so a wide character before the error still
|
|
124
|
+
* leaves it under the right cell.
|
|
125
|
+
*/
|
|
126
|
+
function excerpt(text, index) {
|
|
127
|
+
const chars = Array.from(printable(text));
|
|
128
|
+
const at = Array.from(text.slice(0, index)).length;
|
|
129
|
+
const from = Math.max(0, at - EXCERPT_CONTEXT);
|
|
130
|
+
const to = Math.min(chars.length, at + EXCERPT_CONTEXT);
|
|
131
|
+
const head = from > 0 ? "…" : "";
|
|
132
|
+
const tail = to < chars.length ? "…" : "";
|
|
133
|
+
const before = head + chars.slice(from, at).join("");
|
|
134
|
+
const shown = before + chars.slice(at, to).join("") + tail;
|
|
135
|
+
return ` ${shown}\n ${" ".repeat(cellLen(before))}^`;
|
|
136
|
+
}
|
|
137
|
+
// --- escape ---
|
|
138
|
+
/**
|
|
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.
|
|
159
|
+
*/
|
|
160
|
+
export function escape(text) {
|
|
161
|
+
return text
|
|
162
|
+
.replace(ESCAPE_RE, (_, run, live) => live === undefined ? `${run}\\[` : `${run}${run}\\[`)
|
|
163
|
+
.replace(TRAILING_RUN_RE, "$&$&");
|
|
164
|
+
}
|
|
165
|
+
// --- render ---
|
|
166
|
+
// [LAW:one-source-of-truth] The authority for this grammar is `RE_TAGS` in
|
|
167
|
+
// `rich/markup.py`, and this is deliberately its expression rather than a
|
|
168
|
+
// re-derivation, so the two can be read side by side; `[^[]*?` is lazy against
|
|
169
|
+
// the following `]`, which is what stops the body at the first one.
|
|
170
|
+
//
|
|
171
|
+
// The enumeration it replaced — `[a-zA-Z#][a-zA-Z0-9_.# -]*` — was a second
|
|
172
|
+
// drawing of the same territory and had drifted both ways at once: it invented
|
|
173
|
+
// uppercase, so `[INFO]` was taken as an opening tag and its text vanished from
|
|
174
|
+
// every `Console.print` with no error, and it omitted `@` and the punctuation
|
|
175
|
+
// the reference admits, so `[a+b]` stayed literal (rich-markup-sec).
|
|
176
|
+
// `markup-grammar.golden.txt` pins both directions.
|
|
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 });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const inner = captured.slice(1, -1); // Remove [ ]
|
|
220
|
+
const isClose = inner.startsWith("/");
|
|
221
|
+
const stylePart = isClose ? inner.slice(1) : inner;
|
|
222
|
+
// Check for parameters (name=value)
|
|
223
|
+
const eqIdx = stylePart.indexOf("=");
|
|
224
|
+
const styleName = eqIdx >= 0 ? stylePart.slice(0, eqIdx).trim() : stylePart.trim();
|
|
225
|
+
const parameters = eqIdx >= 0 ? stylePart.slice(eqIdx + 1) : undefined;
|
|
226
|
+
// [LAW:types-are-the-program] What separates `[/]` from `[/red]` is whether
|
|
227
|
+
// a name survives the slash, which is how the reference asks it too
|
|
228
|
+
// (`style_name = tag.name[1:].strip()`). Testing the raw tag text against
|
|
229
|
+
// the literal `"/"` answered a narrower question, and the widened grammar
|
|
230
|
+
// is what exposed the gap: `[/ ]` reaches here now, and under the old test
|
|
231
|
+
// it was an explicit close of the empty style rather than an implicit one.
|
|
232
|
+
const isImplicitClose = isClose && styleName === "";
|
|
233
|
+
const isClosing = isClose && !isImplicitClose;
|
|
234
|
+
tokens.push({
|
|
235
|
+
kind: "tag",
|
|
236
|
+
fullMatch: captured,
|
|
237
|
+
isClosing,
|
|
238
|
+
isImplicitClose,
|
|
239
|
+
styleName,
|
|
240
|
+
parameters,
|
|
241
|
+
start,
|
|
242
|
+
end,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (position < markup.length)
|
|
246
|
+
tokens.push({ kind: "text", text: markup.slice(position), start: position });
|
|
247
|
+
return tokens;
|
|
248
|
+
}
|
|
249
|
+
function isTag(token) {
|
|
250
|
+
return token.kind === "tag";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The style string an open tag closes into — `name`, or `name parameters` when
|
|
254
|
+
* the tag carried an `=`.
|
|
255
|
+
*
|
|
256
|
+
* [LAW:one-source-of-truth] One answer for the two sites that ask it: the span
|
|
257
|
+
* a tag closes into, whichever of `[/name]`, `[/]`, a plugin pair's end or the
|
|
258
|
+
* end of input closed it, and the search that matches `[/red on blue]` back to
|
|
259
|
+
* `[red on blue]`. It was once written out at each closing path, which is how
|
|
260
|
+
* those paths could — and did — drift apart on something none of the copies
|
|
261
|
+
* was about.
|
|
262
|
+
*/
|
|
263
|
+
function openTagStyle(opened) {
|
|
264
|
+
return opened.parameters !== undefined
|
|
265
|
+
? `${opened.styleName} ${opened.parameters}`
|
|
266
|
+
: opened.styleName;
|
|
267
|
+
}
|
|
268
|
+
/** Whether `[/name]` names the style tag `opened`. */
|
|
269
|
+
function closesByName(opened, name) {
|
|
270
|
+
// Handle "on" in style names for closing: [/red on blue] should match [red on blue]
|
|
271
|
+
const normalized = name.trim();
|
|
272
|
+
return openTagStyle(opened) === normalized || opened.styleName === normalized;
|
|
273
|
+
}
|
|
274
|
+
function findLastIndex(items, match) {
|
|
275
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
276
|
+
if (match(items[i]))
|
|
277
|
+
return i;
|
|
278
|
+
}
|
|
279
|
+
return -1;
|
|
280
|
+
}
|
|
281
|
+
// [LAW:one-source-of-truth] The one answer to "what is a legal plugin tag
|
|
282
|
+
// name?". Both sites that need it are derived from this fragment rather than
|
|
283
|
+
// each carrying its own scanner: `register` anchors it to reject a name markup
|
|
284
|
+
// could never address, and the match site anchors it at the head with a
|
|
285
|
+
// boundary lookahead. When those two disagreed, registering `table` silently
|
|
286
|
+
// destroyed every `table.*` built-in style and a name containing a dot could
|
|
287
|
+
// never fire.
|
|
288
|
+
//
|
|
289
|
+
// It opens on `a-z` because a plugin name has to be a name `TAG_RE` can reach,
|
|
290
|
+
// and that is strictly narrower than "a letter": nothing lowercases the tag
|
|
291
|
+
// text on the way in, so `[Foo]` is literal and a handler registered as `Foo`
|
|
292
|
+
// could never fire. This tracked `[A-Za-z]` while `TAG_RE` wrongly admitted
|
|
293
|
+
// uppercase, which is the same drift, one subset down (rich-markup-sec).
|
|
294
|
+
const PLUGIN_TAG_NAME_SRC = "[a-z][A-Za-z0-9_-]*";
|
|
295
|
+
const LEGAL_PLUGIN_TAG_NAME = new RegExp(`^${PLUGIN_TAG_NAME_SRC}$`);
|
|
296
|
+
export class MarkupRegistry {
|
|
297
|
+
_handlers = new Map();
|
|
298
|
+
register(name, handler) {
|
|
299
|
+
// [LAW:no-silent-failure] Registration used to accept any string, including
|
|
300
|
+
// names markup can never address (`a.b` stops the tag scan at the dot), and
|
|
301
|
+
// install a handler that could never fire.
|
|
302
|
+
if (!LEGAL_PLUGIN_TAG_NAME.test(name)) {
|
|
303
|
+
throw new MarkupError(`Cannot register markup tag "${name}": a tag name must be a lowercase letter followed by letters, digits, "_" or "-", so markup could never address this name.`);
|
|
304
|
+
}
|
|
305
|
+
if (isReservedTagName(name)) {
|
|
306
|
+
throw new MarkupError(`Cannot register markup tag "${name}": name is reserved by a built-in style.`);
|
|
307
|
+
}
|
|
308
|
+
this._handlers.set(name, handler);
|
|
309
|
+
}
|
|
310
|
+
unregister(name) {
|
|
311
|
+
this._handlers.delete(name);
|
|
312
|
+
}
|
|
313
|
+
has(name) {
|
|
314
|
+
return this._handlers.has(name);
|
|
315
|
+
}
|
|
316
|
+
get(name) {
|
|
317
|
+
return this._handlers.get(name);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// [LAW:one-source-of-truth] Reserved-name detection delegates to Style.parse —
|
|
321
|
+
// if a name is a valid built-in style, it cannot be hijacked by a plugin.
|
|
322
|
+
// There is no second list of "reserved names" to drift out of sync.
|
|
323
|
+
function isReservedTagName(name) {
|
|
324
|
+
try {
|
|
325
|
+
Style.parse(name);
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
if (err instanceof StyleSyntaxError)
|
|
330
|
+
return false;
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// [LAW:no-shared-mutable-globals] The process-wide default tag set, which
|
|
335
|
+
// `renderMarkup` falls back to when a caller passes no registry. It has one
|
|
336
|
+
// owner and one explicit API — `MarkupRegistry`'s own methods. There is
|
|
337
|
+
// deliberately no free-function façade over it: `registerMarkupTag(name, fn)`
|
|
338
|
+
// forwarding to `.register(name, fn)` would be a second way to say one thing,
|
|
339
|
+
// and the two would document the same invariants in two places.
|
|
340
|
+
export const globalMarkupRegistry = new MarkupRegistry();
|
|
341
|
+
// --- Plugin-aware tag parsing ---
|
|
342
|
+
function isAlpha(c) {
|
|
343
|
+
const cc = c.charCodeAt(0);
|
|
344
|
+
return (cc >= 65 && cc <= 90) || (cc >= 97 && cc <= 122);
|
|
345
|
+
}
|
|
346
|
+
function isIdentChar(c) {
|
|
347
|
+
if (isAlpha(c))
|
|
348
|
+
return true;
|
|
349
|
+
const cc = c.charCodeAt(0);
|
|
350
|
+
return (cc >= 48 && cc <= 57) || c === "_" || c === "-";
|
|
351
|
+
}
|
|
352
|
+
function isSpace(c) {
|
|
353
|
+
return c === " " || c === "\t";
|
|
354
|
+
}
|
|
355
|
+
// A registered name addresses a plugin only when the tag text ends there or
|
|
356
|
+
// continues with the whitespace that introduces attributes. Anything else —
|
|
357
|
+
// `[table.header]`, `[table=x]` — is the built-in dialect's, and falls through
|
|
358
|
+
// exactly as an unregistered name would.
|
|
359
|
+
const PLUGIN_TAG_HEAD = new RegExp(`^(${PLUGIN_TAG_NAME_SRC})(?=$|[ \\t])`);
|
|
360
|
+
function parsePluginAttrs(after) {
|
|
361
|
+
// Hand-written tokenizer. Walks `key=value` pairs separated by whitespace;
|
|
362
|
+
// values may be bare, single-quoted, or double-quoted. Matches the syntax
|
|
363
|
+
// the regex it replaces accepted.
|
|
364
|
+
const attrs = {};
|
|
365
|
+
let i = 0;
|
|
366
|
+
while (i < after.length) {
|
|
367
|
+
while (i < after.length && isSpace(after[i]))
|
|
368
|
+
i++;
|
|
369
|
+
if (i >= after.length)
|
|
370
|
+
break;
|
|
371
|
+
if (!isAlpha(after[i])) {
|
|
372
|
+
// Skip a stray non-name character to avoid infinite loops on malformed
|
|
373
|
+
// input — the legacy regex would simply fail to match here.
|
|
374
|
+
i++;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const nameStart = i;
|
|
378
|
+
while (i < after.length && isIdentChar(after[i]))
|
|
379
|
+
i++;
|
|
380
|
+
const name = after.slice(nameStart, i);
|
|
381
|
+
while (i < after.length && isSpace(after[i]))
|
|
382
|
+
i++;
|
|
383
|
+
if (after[i] !== "=")
|
|
384
|
+
continue;
|
|
385
|
+
i++;
|
|
386
|
+
while (i < after.length && isSpace(after[i]))
|
|
387
|
+
i++;
|
|
388
|
+
let value = "";
|
|
389
|
+
const quote = after[i];
|
|
390
|
+
if (quote === '"' || quote === "'") {
|
|
391
|
+
i++;
|
|
392
|
+
const valStart = i;
|
|
393
|
+
while (i < after.length && after[i] !== quote)
|
|
394
|
+
i++;
|
|
395
|
+
value = after.slice(valStart, i);
|
|
396
|
+
if (i < after.length)
|
|
397
|
+
i++;
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
const valStart = i;
|
|
401
|
+
while (i < after.length && !isSpace(after[i]) && after[i] !== "]")
|
|
402
|
+
i++;
|
|
403
|
+
value = after.slice(valStart, i);
|
|
404
|
+
}
|
|
405
|
+
attrs[name] = value;
|
|
406
|
+
}
|
|
407
|
+
return attrs;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Plugin-aware markup render. Always returns a `RichText`. Built-in style
|
|
411
|
+
* tags become spans; registered plugin tags are resolved by their handler,
|
|
412
|
+
* whose returned `RichText` stands in the output where its pair stood, under
|
|
413
|
+
* any style tag that encloses the pair.
|
|
414
|
+
*/
|
|
415
|
+
export function renderMarkup(markup, options) {
|
|
416
|
+
const registry = options?.registry ?? globalMarkupRegistry;
|
|
417
|
+
return compose(walk(tokenize(markup), markup, registry, options?.emoji !== false), options?.baseStyle);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A string as the text it draws, before any highlighting: read as markup
|
|
421
|
+
* unless `markup` is false, its emoji codes replaced either way, as Rich's
|
|
422
|
+
* `render_str` reads one, its `end` the default line end Rich's leaves it.
|
|
423
|
+
* [LAW:single-enforcer]
|
|
424
|
+
*/
|
|
425
|
+
export function readStr(source, markup) {
|
|
426
|
+
return markup ? renderMarkup(source) : new RichText(emojiReplace(source));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The highlighter a string is drawn with under `settings`: its `highlighter`
|
|
430
|
+
* while `highlight` is not false — Rich's `render_str`, which highlights with
|
|
431
|
+
* `console.highlighter` when `highlight` is true or left to the console.
|
|
432
|
+
* [LAW:single-enforcer]
|
|
433
|
+
*/
|
|
434
|
+
export function activeHighlighter(settings) {
|
|
435
|
+
return settings.highlight === false ? undefined : settings.highlighter;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A string as the text it draws, the way Rich's `render_str` makes one: read
|
|
439
|
+
* by `readStr` under `markup`, then highlighted by `activeHighlighter`.
|
|
440
|
+
* `print` and every renderable that is handed a string both draw it this way,
|
|
441
|
+
* so a console's settings mean the same thing wherever the string lands.
|
|
442
|
+
*/
|
|
443
|
+
export function renderStr(source, settings) {
|
|
444
|
+
const text = readStr(source, settings.markup !== false);
|
|
445
|
+
activeHighlighter(settings)?.highlight(text);
|
|
446
|
+
return text;
|
|
447
|
+
}
|
|
448
|
+
function markOf(out) {
|
|
449
|
+
return { at: out.length, chunks: out.chunks.length, spans: out.spans.length, spliced: out.spliced.length };
|
|
450
|
+
}
|
|
451
|
+
function write(out, text) {
|
|
452
|
+
out.chunks.push(text);
|
|
453
|
+
out.length += text.length;
|
|
454
|
+
}
|
|
455
|
+
/** Everything written since `mark`, taken off `out` and positioned from 0. */
|
|
456
|
+
function takeFrom(out, mark) {
|
|
457
|
+
out.length = mark.at;
|
|
458
|
+
return {
|
|
459
|
+
plain: out.chunks.splice(mark.chunks).join(""),
|
|
460
|
+
spans: out.spans.splice(mark.spans).map(({ start, end, style }) => ({ start: start - mark.at, end: end - mark.at, style })),
|
|
461
|
+
spliced: out.spliced.splice(mark.spliced).map(({ at, text }) => ({ at: at - mark.at, text })),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function isPlugin(opened) {
|
|
465
|
+
return opened.plugin !== undefined;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Reads a whole markup string's tokens into one frame: every style tag a
|
|
469
|
+
* span, every plugin pair resolved by its handler.
|
|
470
|
+
*
|
|
471
|
+
* [LAW:one-source-of-truth] One stack of open tags and one rule for what each
|
|
472
|
+
* closing tag closes — `namedBy` — for style tags and plugin tags alike. Plugin
|
|
473
|
+
* pairs used to be found by a walk of their own, ahead of the one that built
|
|
474
|
+
* spans, and each kept its own stack; the two had to agree on every closing
|
|
475
|
+
* rule, and review found three places they did not (rich-markup-pyq5). One
|
|
476
|
+
* walk also rejects in string order, since every tag is judged when it is
|
|
477
|
+
* reached: a crossing found by the pairing walk used to hide a stray closer
|
|
478
|
+
* before it.
|
|
479
|
+
*
|
|
480
|
+
* Module-private on purpose, as `compose` is: `renderMarkup` is this module's
|
|
481
|
+
* one crossing. [LAW:single-enforcer] The built-in parser was once exported,
|
|
482
|
+
* and two callers bound to it under the name `renderMarkup`, so a tag on
|
|
483
|
+
* `globalMarkupRegistry` resolved in a table cell and was silently eaten by
|
|
484
|
+
* `console.print` (rich-markup-pcp).
|
|
485
|
+
*
|
|
486
|
+
* A plugin tag's content is everything written after it, because the walk
|
|
487
|
+
* cannot know yet whether the tag will pair. When it pairs, that tail is taken
|
|
488
|
+
* off the output as the handler's `children`, and the handler's output is
|
|
489
|
+
* written in its place. When it never does — an outer pair's end or the end
|
|
490
|
+
* of the string closes it — nothing moves: the tag is a style span over its
|
|
491
|
+
* content, like any tag naming no style, and costs what one does.
|
|
492
|
+
*/
|
|
493
|
+
function walk(tokens, source, registry, emoji) {
|
|
494
|
+
const out = { chunks: [], length: 0, spans: [], spliced: [] };
|
|
495
|
+
const start = markOf(out);
|
|
496
|
+
const stack = [];
|
|
497
|
+
// Every tag opened so far, in source order, for a rejection to look back on.
|
|
498
|
+
const opened = [];
|
|
499
|
+
const openAt = (offset) => opened.filter((o) => o.tag.start < offset && (o.closed?.at ?? Infinity) >= offset).map((o) => o.tag.fullMatch);
|
|
500
|
+
const unparsable = (reason, offset) => new MarkupSyntaxError(reason, source, offset, openAt(offset));
|
|
501
|
+
const crossing = (style, closer, pair) => unparsable(`Closing tag ${closer.fullMatch} closes ${style.tag.fullMatch} ` +
|
|
502
|
+
`across the boundary of plugin tag ${pair.tag.fullMatch}: ` +
|
|
503
|
+
`a style tag must open and close on the same side of a plugin tag, ` +
|
|
504
|
+
`because a handler replaces the text between a plugin tag and its closer.`, closer.start);
|
|
505
|
+
// [LAW:no-silent-failure] A closing tag that closes nothing reaches for the
|
|
506
|
+
// last tag before it that it names. If a pair's end closed that tag, the
|
|
507
|
+
// closer reaches across the pair, and saying so beats "doesn't match".
|
|
508
|
+
const stray = (closer, plugin) => {
|
|
509
|
+
const named = opened.filter(namedBy(closer, plugin)).at(-1);
|
|
510
|
+
const closed = named?.closed;
|
|
511
|
+
const pair = closed?.byPair;
|
|
512
|
+
if (named === undefined || closed === undefined || pair === undefined) {
|
|
513
|
+
return unparsable(closer.isImplicitClose
|
|
514
|
+
? `Closing tag ${closer.fullMatch} has no open tag to close`
|
|
515
|
+
: `Closing tag ${closer.fullMatch} doesn't match any open tag`, closer.start);
|
|
516
|
+
}
|
|
517
|
+
if (!pairs(closer, plugin, named))
|
|
518
|
+
return crossing(named, closer, pair);
|
|
519
|
+
// Overlap is unrepresentable rather than unimplemented: a handler receives
|
|
520
|
+
// `children` as one contiguous slice, so a region straddling another pair's
|
|
521
|
+
// closing boundary has nothing to hand it. A style span may cross another
|
|
522
|
+
// style span because it annotates; a plugin pair may not because it
|
|
523
|
+
// replaces. The caret goes where nesting broke: the outer pair's end.
|
|
524
|
+
return unparsable(`Plugin tag [${named.plugin.name}] overlaps [${pair.plugin.name}]: plugin tags must nest, ` +
|
|
525
|
+
`because a handler receives one contiguous slice. ` +
|
|
526
|
+
`Close [/${named.plugin.name}] before [/${pair.plugin.name}].`, closed.at);
|
|
527
|
+
};
|
|
528
|
+
// Closes a tag as a style span over what was written since it opened. A
|
|
529
|
+
// plugin tag closed this way never paired.
|
|
530
|
+
const settle = (tag, at, byPair) => {
|
|
531
|
+
tag.closed = at === undefined ? undefined : { at, byPair };
|
|
532
|
+
out.spans.push({ start: tag.mark.at, end: out.length, style: openTagStyle(tag.tag) });
|
|
533
|
+
};
|
|
534
|
+
for (const token of tokens) {
|
|
535
|
+
if (!isTag(token)) {
|
|
536
|
+
// A `\[` in front of a bracket no tag starts at is unescaped here, per
|
|
537
|
+
// text token, as the reference does; a tag's own escape was settled by
|
|
538
|
+
// `tokenize` against its backslash run.
|
|
539
|
+
const text = token.text.replace(/\\\[/g, "[");
|
|
540
|
+
// Text reaches the output as `RichText` will hold it, so every offset
|
|
541
|
+
// counted along the way — a span, a splice point — indexes the text it
|
|
542
|
+
// lands on. Stripped only by the constructor, a control character
|
|
543
|
+
// shifted every style after it one cell right.
|
|
544
|
+
write(out, stripControlChars(emoji ? emojiReplace(text) : text));
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const plugin = pluginOf(token, registry);
|
|
548
|
+
if (!token.isClosing && !token.isImplicitClose) {
|
|
549
|
+
const entry = { tag: token, mark: markOf(out), plugin, closed: undefined };
|
|
550
|
+
stack.push(entry);
|
|
551
|
+
opened.push(entry);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const j = findLastIndex(stack, namedBy(token, plugin));
|
|
555
|
+
if (j === -1)
|
|
556
|
+
throw stray(token, plugin);
|
|
557
|
+
const target = stack[j];
|
|
558
|
+
if (!pairs(token, plugin, target)) {
|
|
559
|
+
// A closer that ends no pair may pass over style tags, which overlap
|
|
560
|
+
// freely, but not over a plugin tag: the handler replaces what is
|
|
561
|
+
// inside it.
|
|
562
|
+
const boundary = findLastIndex(stack, isPlugin);
|
|
563
|
+
if (boundary > j)
|
|
564
|
+
throw crossing(target, token, stack[boundary]);
|
|
565
|
+
stack.splice(j, 1);
|
|
566
|
+
settle(target, token.start, undefined);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// A pair's end closes everything opened inside it, innermost first, so the
|
|
570
|
+
// handler's `children` hold every span its content carries.
|
|
571
|
+
const inside = stack.splice(j).slice(1);
|
|
572
|
+
for (const tag of inside.reverse())
|
|
573
|
+
settle(tag, token.start, target);
|
|
574
|
+
target.closed = { at: token.start, byPair: undefined };
|
|
575
|
+
const { handler, attrs } = target.plugin;
|
|
576
|
+
const children = compose(takeFrom(out, target.mark));
|
|
577
|
+
const text = handler({ attrs, children, raw: source.slice(target.tag.end, token.start) });
|
|
578
|
+
out.spliced.push({ at: out.length, text });
|
|
579
|
+
write(out, text.plain);
|
|
580
|
+
}
|
|
581
|
+
// Close what is still open, innermost first. The reference pops its stack
|
|
582
|
+
// here (`while style_stack: start, tag = style_stack.pop()`), and popping is
|
|
583
|
+
// what keeps every span in the order its tag closed — the one order
|
|
584
|
+
// `compose`'s sort is defined against.
|
|
585
|
+
while (stack.length > 0)
|
|
586
|
+
settle(stack.pop(), undefined, undefined);
|
|
587
|
+
return takeFrom(out, start);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Which open tags a closing tag names: `[/]` every one, and `[/name]` a tag
|
|
591
|
+
* whose style it names or a plugin tag of the plugin it names.
|
|
592
|
+
*/
|
|
593
|
+
function namedBy(closer, plugin) {
|
|
594
|
+
if (closer.isImplicitClose)
|
|
595
|
+
return () => true;
|
|
596
|
+
return (opened) => closesByName(opened.tag, closer.styleName) || pairs(closer, plugin, opened);
|
|
597
|
+
}
|
|
598
|
+
/** Whether `closer` ends a plugin pair at `opened`: `[/]`, or its plugin's own name. */
|
|
599
|
+
function pairs(closer, plugin, opened) {
|
|
600
|
+
return opened.plugin !== undefined && (closer.isImplicitClose || opened.plugin.name === plugin?.name);
|
|
601
|
+
}
|
|
602
|
+
/** A frame as the `RichText` it draws, `baseStyle` beneath all of it. */
|
|
603
|
+
function compose(frame, baseStyle) {
|
|
604
|
+
// [LAW:one-source-of-truth] The paint order is the reference's, expressed
|
|
605
|
+
// rather than re-derived: `sorted(spans[::-1], key=attrgetter("start"))` in
|
|
606
|
+
// `rich/markup.py`. `RichText` applies spans in list order and each one adds
|
|
607
|
+
// over the last, so this list's order *is* which style wins where two
|
|
608
|
+
// overlap; sorting by start lays the outer span down first and lets the
|
|
609
|
+
// inner one repaint the run it sits in.
|
|
610
|
+
//
|
|
611
|
+
// The reversal is the whole of the tie-break, not decoration.
|
|
612
|
+
// `[red][blue]x[/blue][/red]` yields two spans that both start at 0, and a
|
|
613
|
+
// stable sort leaves those in the order they arrived — closing order,
|
|
614
|
+
// innermost first — which is the outer colour winning. Reversed first, the
|
|
615
|
+
// outer arrives first and the inner repaints it (rich-markup-krk).
|
|
616
|
+
const spans = [...frame.spans].reverse().sort((a, b) => a.start - b.start);
|
|
617
|
+
const result = new RichText(frame.plain);
|
|
618
|
+
if (baseStyle)
|
|
619
|
+
result.stylize(baseStyle);
|
|
620
|
+
// Apply link parameters as link styles
|
|
621
|
+
for (const { start, end, style } of spans) {
|
|
622
|
+
// Check if this is a link tag (name=url pattern was parsed)
|
|
623
|
+
const linkMatch = /^link\s+(.+)$/.exec(style);
|
|
624
|
+
if (linkMatch) {
|
|
625
|
+
result.stylize(new Style({ link: linkMatch[1] }), start, end);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
result.stylize(style, start, end);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// A handler's own styles go down last — its base style, then its spans.
|
|
632
|
+
// Every style span that reaches into a handler's output encloses all of it —
|
|
633
|
+
// no style tag may cross a plugin pair's boundary — so each of these is the
|
|
634
|
+
// inner span, and inner repaints outer exactly as the sort above has it for
|
|
635
|
+
// tags.
|
|
636
|
+
for (const { at, text } of frame.spliced) {
|
|
637
|
+
result.stylize(text.style, at, at + text.length);
|
|
638
|
+
for (const span of text.spans)
|
|
639
|
+
result.stylize(span.style, at + span.start, at + span.end);
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
function pluginOf(tag, registry) {
|
|
644
|
+
// The tokenizer splits at the first `=`, so tag.styleName for
|
|
645
|
+
// `[click verb=foo]` is "click verb". Re-read the tag text for a name that
|
|
646
|
+
// actually addresses a plugin.
|
|
647
|
+
const inner = tag.fullMatch.slice(1, -1).replace(/^\//, "");
|
|
648
|
+
const name = PLUGIN_TAG_HEAD.exec(inner)?.[1];
|
|
649
|
+
const handler = name === undefined ? undefined : registry.get(name);
|
|
650
|
+
if (name === undefined || handler === undefined)
|
|
651
|
+
return undefined;
|
|
652
|
+
const attrs = tag.isClosing ? {} : parsePluginAttrs(inner.slice(name.length));
|
|
653
|
+
return { name, handler, attrs };
|
|
654
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measurement — min/max cell width calculation for renderables.
|
|
3
|
+
*/
|
|
4
|
+
import { type Measurable, type Renderable, type RenderOptions } from "./protocol.js";
|
|
5
|
+
export declare class Measurement {
|
|
6
|
+
readonly minimum: number;
|
|
7
|
+
readonly maximum: number;
|
|
8
|
+
constructor(minimum: number, maximum: number);
|
|
9
|
+
get span(): number;
|
|
10
|
+
/**
|
|
11
|
+
* A range with no cell meaning becomes one with the nearest meaning there is:
|
|
12
|
+
* negative floors to zero, an inverted pair collapses to its ceiling, and NaN
|
|
13
|
+
* reads as zero cells the same way `cellCount` reads it.
|
|
14
|
+
*
|
|
15
|
+
* NaN and Infinity are not treated alike here, and the difference is the whole
|
|
16
|
+
* point. `Infinity` is a maximum a renderable means — `Table` reports it when
|
|
17
|
+
* a column asks for every cell there is, and `withBoundedWidth` throws on it
|
|
18
|
+
* so a caller learns their offer was unanswerable. Flooring it to zero would
|
|
19
|
+
* turn that loud failure into a table measured at no width at all.
|
|
20
|
+
*/
|
|
21
|
+
normalize(): Measurement;
|
|
22
|
+
withMaximum(width: number): Measurement;
|
|
23
|
+
withMinimum(width: number): Measurement;
|
|
24
|
+
clamp(minWidth: number, maxWidth: number): Measurement;
|
|
25
|
+
static get(options: RenderOptions, renderable: Renderable | Measurable): Measurement;
|
|
26
|
+
}
|
|
27
|
+
export declare function measureRenderables(options: RenderOptions, renderables: readonly (Renderable | Measurable)[]): Measurement;
|