@promptctl/rich-js 0.18.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 (221) hide show
  1. package/dist/core/anchor.d.ts +45 -0
  2. package/dist/core/anchor.js +39 -0
  3. package/dist/core/ansi.d.ts +65 -0
  4. package/dist/core/ansi.js +231 -0
  5. package/dist/core/box.d.ts +143 -0
  6. package/dist/core/box.js +379 -0
  7. package/dist/core/cells.d.ts +159 -0
  8. package/dist/core/cells.js +287 -0
  9. package/dist/core/color.d.ts +246 -0
  10. package/dist/core/color.js +1038 -0
  11. package/dist/core/console.d.ts +165 -0
  12. package/dist/core/console.js +527 -0
  13. package/dist/core/emoji.d.ts +24 -0
  14. package/dist/core/emoji.js +391 -0
  15. package/dist/core/export-html.d.ts +59 -0
  16. package/dist/core/export-html.js +200 -0
  17. package/dist/core/export-lines.d.ts +121 -0
  18. package/dist/core/export-lines.js +141 -0
  19. package/dist/core/highlighter.d.ts +33 -0
  20. package/dist/core/highlighter.js +124 -0
  21. package/dist/core/json.d.ts +27 -0
  22. package/dist/core/json.js +53 -0
  23. package/dist/core/markup.d.ts +119 -0
  24. package/dist/core/markup.js +731 -0
  25. package/dist/core/measure.d.ts +27 -0
  26. package/dist/core/measure.js +62 -0
  27. package/dist/core/oklch.d.ts +161 -0
  28. package/dist/core/oklch.js +312 -0
  29. package/dist/core/osc8.d.ts +68 -0
  30. package/dist/core/osc8.js +90 -0
  31. package/dist/core/place.d.ts +30 -0
  32. package/dist/core/place.js +51 -0
  33. package/dist/core/pretty.d.ts +144 -0
  34. package/dist/core/pretty.js +476 -0
  35. package/dist/core/protocol.d.ts +267 -0
  36. package/dist/core/protocol.js +163 -0
  37. package/dist/core/render.d.ts +84 -0
  38. package/dist/core/render.js +145 -0
  39. package/dist/core/segment.d.ts +161 -0
  40. package/dist/core/segment.js +492 -0
  41. package/dist/core/spinnerData.d.ts +12 -0
  42. package/dist/core/spinnerData.js +117 -0
  43. package/dist/core/strip.d.ts +141 -0
  44. package/dist/core/strip.js +305 -0
  45. package/dist/core/style.d.ts +169 -0
  46. package/dist/core/style.js +695 -0
  47. package/dist/core/subscription.d.ts +12 -0
  48. package/dist/core/subscription.js +12 -0
  49. package/dist/core/text.d.ts +256 -0
  50. package/dist/core/text.js +976 -0
  51. package/dist/core/wrap.d.ts +41 -0
  52. package/dist/core/wrap.js +127 -0
  53. package/dist/host/app.d.ts +121 -0
  54. package/dist/host/app.js +255 -0
  55. package/dist/host/host-environment.d.ts +26 -0
  56. package/dist/host/host-environment.js +44 -0
  57. package/dist/host/index.d.ts +5 -0
  58. package/dist/host/index.js +3 -0
  59. package/dist/host/terminal-host.d.ts +179 -0
  60. package/dist/host/terminal-host.js +186 -0
  61. package/dist/index.d.ts +91 -0
  62. package/dist/index.js +107 -0
  63. package/dist/node/prompt.d.ts +21 -0
  64. package/dist/node/prompt.js +30 -0
  65. package/dist/node/save.d.ts +17 -0
  66. package/dist/node/save.js +21 -0
  67. package/dist/node/terminal-host.d.ts +113 -0
  68. package/dist/node/terminal-host.js +227 -0
  69. package/dist/node/traceback.d.ts +37 -0
  70. package/dist/node/traceback.js +134 -0
  71. package/dist/renderables/align.d.ts +16 -0
  72. package/dist/renderables/align.js +33 -0
  73. package/dist/renderables/columns.d.ts +61 -0
  74. package/dist/renderables/columns.js +152 -0
  75. package/dist/renderables/constrain.d.ts +15 -0
  76. package/dist/renderables/constrain.js +37 -0
  77. package/dist/renderables/embed.d.ts +50 -0
  78. package/dist/renderables/embed.js +71 -0
  79. package/dist/renderables/flexStrip.d.ts +41 -0
  80. package/dist/renderables/flexStrip.js +200 -0
  81. package/dist/renderables/group.d.ts +11 -0
  82. package/dist/renderables/group.js +17 -0
  83. package/dist/renderables/layout.d.ts +92 -0
  84. package/dist/renderables/layout.js +312 -0
  85. package/dist/renderables/live.d.ts +50 -0
  86. package/dist/renderables/live.js +175 -0
  87. package/dist/renderables/markdown.d.ts +24 -0
  88. package/dist/renderables/markdown.js +184 -0
  89. package/dist/renderables/padding.d.ts +38 -0
  90. package/dist/renderables/padding.js +141 -0
  91. package/dist/renderables/panel.d.ts +116 -0
  92. package/dist/renderables/panel.js +307 -0
  93. package/dist/renderables/progress.d.ts +90 -0
  94. package/dist/renderables/progress.js +238 -0
  95. package/dist/renderables/progressBar.d.ts +31 -0
  96. package/dist/renderables/progressBar.js +53 -0
  97. package/dist/renderables/prompt.d.ts +49 -0
  98. package/dist/renderables/prompt.js +125 -0
  99. package/dist/renderables/rule.d.ts +25 -0
  100. package/dist/renderables/rule.js +90 -0
  101. package/dist/renderables/spinner.d.ts +31 -0
  102. package/dist/renderables/spinner.js +70 -0
  103. package/dist/renderables/status.d.ts +23 -0
  104. package/dist/renderables/status.js +60 -0
  105. package/dist/renderables/syntax.d.ts +35 -0
  106. package/dist/renderables/syntax.js +115 -0
  107. package/dist/renderables/table.d.ts +194 -0
  108. package/dist/renderables/table.js +795 -0
  109. package/dist/renderables/traceback.d.ts +31 -0
  110. package/dist/renderables/traceback.js +108 -0
  111. package/dist/renderables/tree.d.ts +68 -0
  112. package/dist/renderables/tree.js +150 -0
  113. package/dist/renderables/viewport.d.ts +114 -0
  114. package/dist/renderables/viewport.js +198 -0
  115. package/dist/template-bindings/color-funcs.d.ts +87 -0
  116. package/dist/template-bindings/color-funcs.js +217 -0
  117. package/dist/template-bindings/helpers.d.ts +24 -0
  118. package/dist/template-bindings/helpers.js +48 -0
  119. package/dist/template-bindings/index.d.ts +107 -0
  120. package/dist/template-bindings/index.js +146 -0
  121. package/dist/template-bindings/palette-funcs.d.ts +94 -0
  122. package/dist/template-bindings/palette-funcs.js +141 -0
  123. package/dist/template-bindings/style-funcs.d.ts +28 -0
  124. package/dist/template-bindings/style-funcs.js +155 -0
  125. package/dist/themes/buildPalette.d.ts +36 -0
  126. package/dist/themes/buildPalette.js +56 -0
  127. package/dist/themes/colorMath.d.ts +82 -0
  128. package/dist/themes/colorMath.js +254 -0
  129. package/dist/themes/colorRef.d.ts +51 -0
  130. package/dist/themes/colorRef.js +96 -0
  131. package/dist/themes/data/ansi-tables.d.ts +14 -0
  132. package/dist/themes/data/ansi-tables.js +49 -0
  133. package/dist/themes/data/atom-one-dark.d.ts +3 -0
  134. package/dist/themes/data/atom-one-dark.js +177 -0
  135. package/dist/themes/data/atom-one-light.d.ts +3 -0
  136. package/dist/themes/data/atom-one-light.js +177 -0
  137. package/dist/themes/data/catppuccin-frappe.d.ts +3 -0
  138. package/dist/themes/data/catppuccin-frappe.js +177 -0
  139. package/dist/themes/data/catppuccin-latte.d.ts +3 -0
  140. package/dist/themes/data/catppuccin-latte.js +177 -0
  141. package/dist/themes/data/catppuccin-macchiato.d.ts +3 -0
  142. package/dist/themes/data/catppuccin-macchiato.js +177 -0
  143. package/dist/themes/data/catppuccin-mocha.d.ts +3 -0
  144. package/dist/themes/data/catppuccin-mocha.js +177 -0
  145. package/dist/themes/data/cyberpunk.d.ts +17 -0
  146. package/dist/themes/data/cyberpunk.js +193 -0
  147. package/dist/themes/data/default.d.ts +3 -0
  148. package/dist/themes/data/default.js +160 -0
  149. package/dist/themes/data/dracula.d.ts +3 -0
  150. package/dist/themes/data/dracula.js +177 -0
  151. package/dist/themes/data/flexoki.d.ts +3 -0
  152. package/dist/themes/data/flexoki.js +177 -0
  153. package/dist/themes/data/gruvbox.d.ts +3 -0
  154. package/dist/themes/data/gruvbox.js +177 -0
  155. package/dist/themes/data/index.d.ts +35 -0
  156. package/dist/themes/data/index.js +64 -0
  157. package/dist/themes/data/monokai.d.ts +3 -0
  158. package/dist/themes/data/monokai.js +177 -0
  159. package/dist/themes/data/nord.d.ts +3 -0
  160. package/dist/themes/data/nord.js +177 -0
  161. package/dist/themes/data/rose-pine-dawn.d.ts +3 -0
  162. package/dist/themes/data/rose-pine-dawn.js +177 -0
  163. package/dist/themes/data/rose-pine-moon.d.ts +3 -0
  164. package/dist/themes/data/rose-pine-moon.js +177 -0
  165. package/dist/themes/data/rose-pine.d.ts +3 -0
  166. package/dist/themes/data/rose-pine.js +177 -0
  167. package/dist/themes/data/solarized-dark.d.ts +3 -0
  168. package/dist/themes/data/solarized-dark.js +177 -0
  169. package/dist/themes/data/solarized-light.d.ts +3 -0
  170. package/dist/themes/data/solarized-light.js +177 -0
  171. package/dist/themes/data/svg-export.d.ts +3 -0
  172. package/dist/themes/data/svg-export.js +177 -0
  173. package/dist/themes/data/textual-ansi.d.ts +3 -0
  174. package/dist/themes/data/textual-ansi.js +160 -0
  175. package/dist/themes/data/textual-dark.d.ts +3 -0
  176. package/dist/themes/data/textual-dark.js +160 -0
  177. package/dist/themes/data/textual-light.d.ts +3 -0
  178. package/dist/themes/data/textual-light.js +160 -0
  179. package/dist/themes/data/tokyo-night.d.ts +3 -0
  180. package/dist/themes/data/tokyo-night.js +177 -0
  181. package/dist/themes/data/types.d.ts +31 -0
  182. package/dist/themes/data/types.js +10 -0
  183. package/dist/themes/palette.d.ts +21 -0
  184. package/dist/themes/palette.js +28 -0
  185. package/dist/themes/ramp.d.ts +83 -0
  186. package/dist/themes/ramp.js +122 -0
  187. package/dist/themes/registry.d.ts +44 -0
  188. package/dist/themes/registry.js +94 -0
  189. package/dist/themes/terminalThemes.d.ts +49 -0
  190. package/dist/themes/terminalThemes.js +63 -0
  191. package/dist/themes/transpose.d.ts +87 -0
  192. package/dist/themes/transpose.js +138 -0
  193. package/dist/widgets/button.d.ts +49 -0
  194. package/dist/widgets/button.js +208 -0
  195. package/dist/widgets/checkbox.d.ts +41 -0
  196. package/dist/widgets/checkbox.js +157 -0
  197. package/dist/widgets/dropdown.d.ts +67 -0
  198. package/dist/widgets/dropdown.js +450 -0
  199. package/dist/widgets/event-router.d.ts +71 -0
  200. package/dist/widgets/event-router.js +629 -0
  201. package/dist/widgets/focus-manager.d.ts +45 -0
  202. package/dist/widgets/focus-manager.js +191 -0
  203. package/dist/widgets/hit.d.ts +64 -0
  204. package/dist/widgets/hit.js +113 -0
  205. package/dist/widgets/index.d.ts +20 -0
  206. package/dist/widgets/index.js +14 -0
  207. package/dist/widgets/slider.d.ts +57 -0
  208. package/dist/widgets/slider.js +270 -0
  209. package/dist/widgets/static-item.d.ts +34 -0
  210. package/dist/widgets/static-item.js +54 -0
  211. package/dist/widgets/text-input.d.ts +274 -0
  212. package/dist/widgets/text-input.js +1165 -0
  213. package/dist/widgets/toggle.d.ts +44 -0
  214. package/dist/widgets/toggle.js +180 -0
  215. package/dist/widgets/types.d.ts +87 -0
  216. package/dist/widgets/types.js +30 -0
  217. package/dist/widgets/widget-app.d.ts +63 -0
  218. package/dist/widgets/widget-app.js +199 -0
  219. package/dist/widgets/widget-base.d.ts +40 -0
  220. package/dist/widgets/widget-base.js +165 -0
  221. package/package.json +6 -2
@@ -0,0 +1,731 @@
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
+ * recursively renders the inner markup as a child Renderable, and calls the
9
+ * handler — splicing the handler's returned Renderable 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`.
18
+ */
19
+ import { cellLen } from "./cells.js";
20
+ import { Style, StyleSyntaxError } from "./style.js";
21
+ import { RichText, Span, stripControlChars } from "./text.js";
22
+ import { emojiReplace } from "./emoji.js";
23
+ // --- Tag ---
24
+ export class Tag {
25
+ name;
26
+ parameters;
27
+ constructor(name, parameters) {
28
+ this.name = name;
29
+ this.parameters = parameters;
30
+ }
31
+ toString() {
32
+ return this.parameters !== undefined
33
+ ? `${this.name} ${this.parameters}`
34
+ : this.name;
35
+ }
36
+ get markup() {
37
+ return this.parameters !== undefined
38
+ ? `[${this.name}=${this.parameters}]`
39
+ : `[${this.name}]`;
40
+ }
41
+ }
42
+ // --- MarkupError ---
43
+ export class MarkupError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = "MarkupError";
47
+ }
48
+ }
49
+ // --- MarkupSyntaxError ---
50
+ /**
51
+ * A markup string that cannot be parsed, with where it went wrong.
52
+ *
53
+ * [LAW:types-are-the-program] A subclass rather than optional fields on
54
+ * `MarkupError`, because the parent is also what `MarkupRegistry.register`
55
+ * throws, and a registration has no source text to point into. Every error this
56
+ * class describes has a location, so none of its fields is optional and no
57
+ * caller has to ask whether one is present — `instanceof` is the only question.
58
+ *
59
+ * [LAW:one-source-of-truth] The constructor takes the facts the parser holds —
60
+ * the string, the offset of the offending tag, what was open — and derives
61
+ * `line`, `column` and the message from them, so the numbers a caller reads
62
+ * and the caret the message draws cannot disagree.
63
+ */
64
+ export class MarkupSyntaxError extends MarkupError {
65
+ /** The problem alone, with no location, for callers building their own message. */
66
+ reason;
67
+ /** The whole markup string that failed, as the caller passed it. */
68
+ markup;
69
+ /** Index into `markup` of the tag the parser rejected. */
70
+ offset;
71
+ /** 1-based line of `offset`; lines are separated by `\n`. */
72
+ line;
73
+ /** 1-based column of `offset` within its line, in UTF-16 code units. */
74
+ column;
75
+ /**
76
+ * The opening tags still open at `offset`, outermost first, as written. For
77
+ * overlapping plugin tags, only plugin tags are named.
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 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;
271
+ }
272
+ /**
273
+ * Parses the built-in style dialect — `[bold red]text[/bold red]` — into a
274
+ * `RichText` with styled spans. Module-private on purpose: `renderMarkup` is
275
+ * this module's one crossing, and it hands every slice here once its plugin
276
+ * pairs are resolved.
277
+ *
278
+ * [LAW:single-enforcer] Exporting this is what let a caller bind to the inner
279
+ * layer, and two of them did — `console.ts` and `prompt.ts` imported it as
280
+ * `render as renderMarkup`, so the import line read identically to the
281
+ * plugin-aware sites and the difference was invisible at every point of use. A
282
+ * tag registered on `globalMarkupRegistry` resolved in a table cell and was
283
+ * silently eaten by `console.print`, the path almost every consumer takes
284
+ * (rich-markup-pcp). Unexported, that drift is a compile error rather than a
285
+ * rule someone has to keep remembering.
286
+ *
287
+ * [LAW:dataflow-not-control-flow] Rendering *without* plugins stays reachable
288
+ * as a value rather than a second name: `renderMarkup(s, { registry: new
289
+ * MarkupRegistry() })`. Two exported functions put that variability in the
290
+ * function names; an empty registry puts it in the data, where the one
291
+ * boundary admits both.
292
+ */
293
+ function render(parts, origin, baseStyle, options) {
294
+ const doEmoji = options?.emoji !== false;
295
+ // Build plain text and track spans
296
+ let plainText = "";
297
+ const spans = [];
298
+ const spliced = [];
299
+ const openStack = [];
300
+ const unparsable = (reason, tag) => new MarkupSyntaxError(reason, origin.source, tag.start, [
301
+ ...origin.enclosing,
302
+ ...openStack.map((opened) => opened.tag),
303
+ ]);
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;
312
+ }
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
+ });
353
+ }
354
+ }
355
+ }
356
+ // Auto-close what is still open, innermost first. The reference pops its
357
+ // stack here (`while style_stack: start, tag = style_stack.pop()`), and
358
+ // popping is what keeps every span in this list in the order its tag closed
359
+ // — the one order the sort below is defined against. Walking the stack
360
+ // forwards instead put the outermost unclosed tag in first, and that lone
361
+ // disagreement was invisible while nothing sorted: it cancelled the missing
362
+ // sort, so `[red][blue]x` was the one nesting this port already got right.
363
+ while (openStack.length > 0) {
364
+ const opened = openStack.pop();
365
+ spans.push(new Span(opened.textStart, plainText.length, openTagStyle(opened)));
366
+ }
367
+ // [LAW:one-source-of-truth] The paint order is the reference's, expressed
368
+ // rather than re-derived: `sorted(spans[::-1], key=attrgetter("start"))` in
369
+ // `rich/markup.py`. `RichText` applies spans in list order and each one adds
370
+ // over the last, so this list's order *is* which style wins where two
371
+ // overlap; sorting by start lays the outer span down first and lets the
372
+ // inner one repaint the run it sits in.
373
+ //
374
+ // The reversal is the whole of the tie-break, not decoration.
375
+ // `[red][blue]x[/blue][/red]` yields two spans that both start at 0, and a
376
+ // stable sort leaves those in the order they arrived — closing order,
377
+ // innermost first — which is the outer colour winning. Reversed first, the
378
+ // outer arrives first and the inner repaints it (rich-markup-krk).
379
+ spans.reverse();
380
+ spans.sort((a, b) => a.start - b.start);
381
+ const result = new RichText(plainText);
382
+ if (baseStyle)
383
+ result.stylize(baseStyle);
384
+ // Apply link parameters as link styles
385
+ for (const span of spans) {
386
+ const style = typeof span.style === "string" ? span.style : span.style.toString();
387
+ // Check if this is a link tag (name=url pattern was parsed)
388
+ const linkMatch = /^link\s+(.+)$/.exec(style);
389
+ if (linkMatch) {
390
+ result.stylize(new Style({ link: linkMatch[1] }), span.start, span.end);
391
+ }
392
+ else {
393
+ result.stylize(style, span.start, span.end);
394
+ }
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
+ }
406
+ return result;
407
+ }
408
+ /**
409
+ * The style string an open tag closes into — `name`, or `name parameters` when
410
+ * the tag carried an `=`.
411
+ *
412
+ * [LAW:one-source-of-truth] One answer for the four sites that ask it: the three
413
+ * ways a tag can close (`[/name]`, `[/]`, and end of input) and the search that
414
+ * matches `[/red on blue]` back to `[red on blue]`. It was written out at each,
415
+ * which is how the three closing paths could — and did — drift apart on
416
+ * something none of the four copies was about.
417
+ */
418
+ function openTagStyle(opened) {
419
+ return opened.parameters !== undefined
420
+ ? `${opened.styleName} ${opened.parameters}`
421
+ : opened.styleName;
422
+ }
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) {
432
+ // Handle "on" in style names for closing: [/red on blue] should match [red on blue]
433
+ const normalized = name.trim();
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]))
442
+ return i;
443
+ }
444
+ return -1;
445
+ }
446
+ // [LAW:one-source-of-truth] The one answer to "what is a legal plugin tag
447
+ // name?". Both sites that need it are derived from this fragment rather than
448
+ // each carrying its own scanner: `register` anchors it to reject a name markup
449
+ // could never address, and the match site anchors it at the head with a
450
+ // boundary lookahead. When those two disagreed, registering `table` silently
451
+ // destroyed every `table.*` built-in style and a name containing a dot could
452
+ // never fire.
453
+ //
454
+ // It opens on `a-z` because a plugin name has to be a name `TAG_RE` can reach,
455
+ // and that is strictly narrower than "a letter": nothing lowercases the tag
456
+ // text on the way in, so `[Foo]` is literal and a handler registered as `Foo`
457
+ // could never fire. This tracked `[A-Za-z]` while `TAG_RE` wrongly admitted
458
+ // uppercase, which is the same drift, one subset down (rich-markup-sec).
459
+ const PLUGIN_TAG_NAME_SRC = "[a-z][A-Za-z0-9_-]*";
460
+ const LEGAL_PLUGIN_TAG_NAME = new RegExp(`^${PLUGIN_TAG_NAME_SRC}$`);
461
+ export class MarkupRegistry {
462
+ _handlers = new Map();
463
+ register(name, handler) {
464
+ // [LAW:no-silent-failure] Registration used to accept any string, including
465
+ // names markup can never address (`a.b` stops the tag scan at the dot), and
466
+ // install a handler that could never fire.
467
+ if (!LEGAL_PLUGIN_TAG_NAME.test(name)) {
468
+ 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.`);
469
+ }
470
+ if (isReservedTagName(name)) {
471
+ throw new MarkupError(`Cannot register markup tag "${name}": name is reserved by a built-in style.`);
472
+ }
473
+ this._handlers.set(name, handler);
474
+ }
475
+ unregister(name) {
476
+ this._handlers.delete(name);
477
+ }
478
+ has(name) {
479
+ return this._handlers.has(name);
480
+ }
481
+ get(name) {
482
+ return this._handlers.get(name);
483
+ }
484
+ }
485
+ // [LAW:one-source-of-truth] Reserved-name detection delegates to Style.parse —
486
+ // if a name is a valid built-in style, it cannot be hijacked by a plugin.
487
+ // There is no second list of "reserved names" to drift out of sync.
488
+ function isReservedTagName(name) {
489
+ try {
490
+ Style.parse(name);
491
+ return true;
492
+ }
493
+ catch (err) {
494
+ if (err instanceof StyleSyntaxError)
495
+ return false;
496
+ throw err;
497
+ }
498
+ }
499
+ // [LAW:no-shared-mutable-globals] The process-wide default tag set, which
500
+ // `renderMarkup` falls back to when a caller passes no registry. It has one
501
+ // owner and one explicit API — `MarkupRegistry`'s own methods. There is
502
+ // deliberately no free-function façade over it: `registerMarkupTag(name, fn)`
503
+ // forwarding to `.register(name, fn)` would be a second way to say one thing,
504
+ // and the two would document the same invariants in two places.
505
+ export const globalMarkupRegistry = new MarkupRegistry();
506
+ // --- Plugin-aware tag parsing ---
507
+ function isAlpha(c) {
508
+ const cc = c.charCodeAt(0);
509
+ return (cc >= 65 && cc <= 90) || (cc >= 97 && cc <= 122);
510
+ }
511
+ function isIdentChar(c) {
512
+ if (isAlpha(c))
513
+ return true;
514
+ const cc = c.charCodeAt(0);
515
+ return (cc >= 48 && cc <= 57) || c === "_" || c === "-";
516
+ }
517
+ function isSpace(c) {
518
+ return c === " " || c === "\t";
519
+ }
520
+ // A registered name addresses a plugin only when the tag text ends there or
521
+ // continues with the whitespace that introduces attributes. Anything else —
522
+ // `[table.header]`, `[table=x]` — is the built-in dialect's, and falls through
523
+ // exactly as an unregistered name would.
524
+ const PLUGIN_TAG_HEAD = new RegExp(`^(${PLUGIN_TAG_NAME_SRC})(?=$|[ \\t])`);
525
+ function parsePluginAttrs(after) {
526
+ // Hand-written tokenizer. Walks `key=value` pairs separated by whitespace;
527
+ // values may be bare, single-quoted, or double-quoted. Matches the syntax
528
+ // the regex it replaces accepted.
529
+ const attrs = {};
530
+ let i = 0;
531
+ while (i < after.length) {
532
+ while (i < after.length && isSpace(after[i]))
533
+ i++;
534
+ if (i >= after.length)
535
+ break;
536
+ if (!isAlpha(after[i])) {
537
+ // Skip a stray non-name character to avoid infinite loops on malformed
538
+ // input — the legacy regex would simply fail to match here.
539
+ i++;
540
+ continue;
541
+ }
542
+ const nameStart = i;
543
+ while (i < after.length && isIdentChar(after[i]))
544
+ i++;
545
+ const name = after.slice(nameStart, i);
546
+ while (i < after.length && isSpace(after[i]))
547
+ i++;
548
+ if (after[i] !== "=")
549
+ continue;
550
+ i++;
551
+ while (i < after.length && isSpace(after[i]))
552
+ i++;
553
+ let value = "";
554
+ const quote = after[i];
555
+ if (quote === '"' || quote === "'") {
556
+ i++;
557
+ const valStart = i;
558
+ while (i < after.length && after[i] !== quote)
559
+ i++;
560
+ value = after.slice(valStart, i);
561
+ if (i < after.length)
562
+ i++;
563
+ }
564
+ else {
565
+ const valStart = i;
566
+ while (i < after.length && !isSpace(after[i]) && after[i] !== "]")
567
+ i++;
568
+ value = after.slice(valStart, i);
569
+ }
570
+ attrs[name] = value;
571
+ }
572
+ return attrs;
573
+ }
574
+ /**
575
+ * Plugin-aware markup render. Always returns a `RichText`. Built-in style
576
+ * tags become spans; registered plugin tags are resolved by their handler,
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`.
580
+ */
581
+ export function renderMarkup(markup, options) {
582
+ return renderSlice(tokenize(markup), { source: markup, enclosing: [] }, options);
583
+ }
584
+ function renderSlice(tokens, origin, options) {
585
+ const registry = options?.registry ?? globalMarkupRegistry;
586
+ // Pair each opening plugin tag with its matching closer up-front, so the
587
+ // splice walk can just iterate top-level pairs in source order with no
588
+ // nested-state book-keeping.
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 = [];
595
+ let cursor = 0;
596
+ for (const [openIdx, closeIdx] of tagPairs) {
597
+ const open = annotated[openIdx];
598
+ const close = annotated[closeIdx];
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 });
605
+ const handler = registry.get(open.pluginName);
606
+ parts.push({ kind: "rendered", text: handler({ attrs: open.attrs, children: innerRichText, raw: innerRaw }) });
607
+ cursor = close.end;
608
+ }
609
+ parts.push({ kind: "markup", tokens: within(tokens, cursor, Infinity) });
610
+ return render(parts, origin, options?.baseStyle, { emoji: options?.emoji !== false });
611
+ }
612
+ function pairPluginTags(tags, registry, origin) {
613
+ // Annotate tags with plugin info, then pair openers with closers. Only
614
+ // top-level pairs are returned; inner pairs will be re-discovered by the
615
+ // recursive `renderSlice` call on the inner slice.
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.
627
+ const stack = [];
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)]);
652
+ for (let i = 0; i < annotated.length; i++) {
653
+ const t = annotated[i];
654
+ if (isOpener(t)) {
655
+ stack.push(i);
656
+ continue;
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 });
690
+ }
691
+ stack.splice(j, 1);
692
+ closedAt.set(openIdx, i);
693
+ continue;
694
+ }
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);
702
+ }
703
+ pairs.set(openIdx, i);
704
+ }
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.
709
+ const topLevel = new Map();
710
+ let outerClose = -1;
711
+ for (const openIdx of [...pairs.keys()].sort((a, b) => a - b)) {
712
+ if (openIdx < outerClose)
713
+ continue;
714
+ outerClose = pairs.get(openIdx);
715
+ topLevel.set(openIdx, outerClose);
716
+ }
717
+ return { annotated, topLevel };
718
+ }
719
+ function annotatePluginTag(tag, registry) {
720
+ // The legacy parser splits at the first `=`, so tag.styleName for
721
+ // `[click verb=foo]` is "click verb". Re-read the tag text for a name that
722
+ // actually addresses a plugin.
723
+ const inner = tag.fullMatch.slice(1, -1).replace(/^\//, "");
724
+ const head = PLUGIN_TAG_HEAD.exec(inner);
725
+ const name = head?.[1];
726
+ if (name === undefined || !registry.has(name))
727
+ return tag;
728
+ const after = inner.slice(name.length);
729
+ const attrs = tag.isClosing ? {} : parsePluginAttrs(after);
730
+ return { ...tag, pluginName: name, attrs };
731
+ }