@markii/ansi 0.15.0 → 0.17.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ansi.d.ts +26 -0
  3. package/dist/ansi.js +51 -0
  4. package/dist/components/badge.d.ts +1 -1
  5. package/dist/components/badge.js +5 -4
  6. package/dist/components/callout.d.ts +38 -13
  7. package/dist/components/callout.js +68 -31
  8. package/dist/components/card.d.ts +34 -2
  9. package/dist/components/card.js +55 -18
  10. package/dist/components/cell.d.ts +5 -11
  11. package/dist/components/cell.js +11 -15
  12. package/dist/components/chart.d.ts +4 -10
  13. package/dist/components/chart.js +16 -24
  14. package/dist/components/details-string.d.ts +26 -0
  15. package/dist/components/details-string.js +39 -0
  16. package/dist/components/details.d.ts +32 -10
  17. package/dist/components/details.js +28 -25
  18. package/dist/components/divider.js +12 -16
  19. package/dist/components/figure.d.ts +5 -16
  20. package/dist/components/figure.js +9 -19
  21. package/dist/components/index.d.ts +14 -10
  22. package/dist/components/index.js +19 -16
  23. package/dist/components/kbd.d.ts +1 -1
  24. package/dist/components/kbd.js +3 -2
  25. package/dist/components/layout-wrapper.d.ts +7 -22
  26. package/dist/components/layout-wrapper.js +9 -23
  27. package/dist/components/progress.d.ts +3 -11
  28. package/dist/components/progress.js +8 -17
  29. package/dist/components/rating.d.ts +1 -2
  30. package/dist/components/rating.js +3 -4
  31. package/dist/components/row.d.ts +35 -20
  32. package/dist/components/row.js +35 -51
  33. package/dist/components/stat.d.ts +3 -8
  34. package/dist/components/stat.js +5 -11
  35. package/dist/components/tab.d.ts +9 -8
  36. package/dist/components/tab.js +12 -10
  37. package/dist/components/table-grid.d.ts +2 -1
  38. package/dist/components/table-grid.js +14 -12
  39. package/dist/components/table.d.ts +5 -10
  40. package/dist/components/table.js +8 -13
  41. package/dist/components/tabs-string.d.ts +17 -0
  42. package/dist/components/tabs-string.js +30 -0
  43. package/dist/components/tabs.d.ts +31 -14
  44. package/dist/components/tabs.js +55 -30
  45. package/dist/failure-presentation.d.ts +15 -0
  46. package/dist/failure-presentation.js +22 -0
  47. package/dist/glyphs.d.ts +73 -0
  48. package/dist/glyphs.js +88 -0
  49. package/dist/index.d.ts +6 -4
  50. package/dist/index.js +10 -7
  51. package/dist/ink-string.d.ts +23 -0
  52. package/dist/ink-string.js +65 -0
  53. package/dist/interactive.d.ts +66 -0
  54. package/dist/interactive.js +207 -0
  55. package/dist/layout.js +1 -2
  56. package/dist/pack-loader.d.ts +64 -0
  57. package/dist/pack-loader.js +172 -0
  58. package/dist/pack-script.d.ts +44 -0
  59. package/dist/pack-script.js +135 -0
  60. package/dist/registry.d.ts +106 -100
  61. package/dist/registry.js +17 -9
  62. package/dist/render.d.ts +63 -25
  63. package/dist/render.js +1001 -380
  64. package/dist/text-grid.d.ts +90 -0
  65. package/dist/text-grid.js +300 -0
  66. package/package.json +9 -5
  67. package/dist/box.d.ts +0 -47
  68. package/dist/box.js +0 -209
  69. package/dist/measure.d.ts +0 -25
  70. package/dist/measure.js +0 -108
package/dist/render.js CHANGED
@@ -1,40 +1,71 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, isValidElement } from 'react';
3
+ import { Box, Text } from 'ink';
1
4
  import { parse, toHast, nodeToHast, isSafeUrl, parseMetaAttributes, isValidScriptName, } from '@markii/core';
2
- import { bold, dim, hyperlink, inverse, italic, resolveColorOption, underline, } from './ansi.js';
5
+ import { bold, carrySgrAcrossLines, dim, hyperlink, inverse, italic, resolveColorOption, underline, } from './ansi.js';
3
6
  import { style } from './style.js';
4
7
  import { defaultAnsiTheme } from './theme.js';
5
- import { columns, frame, pad, rule, wrap } from './box.js';
6
- import { measure } from './measure.js';
8
+ import { columnsBlock, frameBlock, frameTopLine, inkBorderStyle, isTextAlign, measureWidth, padText, ruleLine, wrapText, } from './text-grid.js';
9
+ import { drawTableGrid } from './components/table-grid.js';
10
+ import { ROW_GUTTER, resolveRowLayout, } from './components/row.js';
11
+ import { CARD_FRAME_OVERHEAD, resolveCardBoxWidth, resolveCardInnerWidth, } from './components/card.js';
12
+ import { Callout, CALLOUT_TOKENS, headerTextFor, resolveCalloutBoxWidth, resolveCalloutInnerWidth, resolveCalloutType, } from './components/callout.js';
13
+ import { resolveDetailsInnerWidth } from './components/details-string.js';
7
14
  import { sanitizeBlockText, sanitizeUrlText, stripControlCharacters, } from './sanitize.js';
8
15
  import { readRegistryComponent, registryLayoutAxis, registrySelfLayout, resolveDirectiveAlias, } from './registry.js';
9
16
  import { applyLayout, resolveLayoutAttributes, } from './layout.js';
10
17
  import { resolveScopedPath } from './resolve.js';
11
- import { dataStateSuffix, emptyInlineTitle, failureToken, invalidAttributeValueLabel, invalidAttributeValueTitle, } from './failure-presentation.js';
12
- import { formatValue, getContract, reportDiagnostic, } from '@markii/stdlib';
18
+ import { dataStateSuffix, emptyInlineTitle, failureToken, formMismatchTitle, NO_ENGINE_VIEW_LABEL, noEngineViewTitle, invalidAttributeValueLabel, invalidAttributeValueTitle, unsafeImageSrcLabel, unsafeImageSrcTitle, } from './failure-presentation.js';
19
+ import { createUnavailableComponentLookup, formatValue, getContract, reportDiagnostic, } from '@markii/stdlib';
13
20
  import { resolveImageAttribute, } from './image-resolve.js';
14
21
  import { resolveHrefAttribute } from './href-resolve.js';
15
22
  import { defaultAnsiRegistry } from './components/index.js';
16
- import { drawTableGrid } from './components/table-grid.js';
23
+ import { renderInkToString, renderInkToStringSync } from './ink-string.js';
24
+ import { InteractiveRoot } from './interactive.js';
25
+ import { glyphsFor } from './glyphs.js';
26
+ import { InteractiveTabs } from './components/tabs.js';
27
+ import { InteractiveDetails } from './components/details.js';
17
28
  /**
18
- * The hast walk for the terminal engine, mirroring `@markii/html`'s
19
- * `render.ts` structure closely enough that a reader of one can read the
20
- * other: the same directive-resolution pipeline (alias, layout, data
21
- * binding, form mismatch, invalid-enum notice, empty-inline notice,
22
- * never-throw component containment), applied to a WIDTH-AWARE plain-text
23
- * output instead of an HTML string.
29
+ * The hast walk for the terminal engine (batch 10: the Ink rewrite). Every
30
+ * DECISION this walk makes is unchanged from the pre-Ink engine (alias
31
+ * resolution, layout resolution, data binding, form mismatch, invalid-enum
32
+ * notice, empty-inline notice, never-throw component containment, script-
33
+ * fence markers, link and image handling) — see AGENTS.md's architecture
34
+ * rules and the batch-10 brief. What changed is the OUTPUT shape: instead of
35
+ * building one big plain string, the walk builds a tree of Ink elements
36
+ * (`Box`/`Text`) and hands it to `ink-string.ts` (the render-once path) or
37
+ * returns it directly (the new live-viewer export, `buildMarkElement`).
38
+ *
39
+ * Two rendering "modes" coexist deliberately, and BOTH still exist in the
40
+ * pre-Ink engine's shape:
24
41
  *
25
- * The one structural difference `@markii/html` does not have: wrapping is
26
- * not free here. A browser reflows HTML at whatever width the viewport
27
- * gives it; a terminal line is fixed the moment this engine emits it. Every
28
- * block-rendering function below therefore takes an explicit `width`
29
- * (columns available) and, for a directive, an `indent` (the prefix an
30
- * enclosing block has already applied), and produces its OWN wrapped lines
31
- * rather than leaving wrapping to something downstream.
42
+ * - STRING mode (`render*String` functions below): the pre-Ink algorithm,
43
+ * almost verbatim, still building one flattened, pre-wrapped, pre-aligned
44
+ * string. This is what a self-drawing component's body uses when it sits
45
+ * inside a string-drawn ancestor (a standalone `cell`, a GFM table cell),
46
+ * and what a non-left `text=` body uses in the render-once path, because
47
+ * Ink has no per-line paragraph-justify primitive. String mode is also the ONLY mode inline
48
+ * content ever uses, at any nesting depth: an inline directive's body is
49
+ * phrasing content by definition (docs/spec.md), never a layout the reader
50
+ * would want Ink to lay out as boxes.
51
+ * - ELEMENT mode (`render*Element` functions below): real Ink elements,
52
+ * used for the document's own block sequencing (so ordinary prose wraps
53
+ * through Ink's own layout instead of this engine's manual `wrap()`) and
54
+ * for the block directives that genuinely need Ink's real layout or
55
+ * real component state: `row` (a flex `Box` row), `tabs` and `details`
56
+ * (real React state, for `ctx.interactive`'s keyboard-driven switching/
57
+ * toggling), and the containers `card`, `callout` and `figure`, whose
58
+ * bodies are Ink subtrees so those can sit inside them. A `row`/`tabs`/
59
+ * `details` reached through a STRING-mode ancestor (a standalone `cell`,
60
+ * or a GFM table cell) falls back to a flattened STRING rendering of
61
+ * itself with no interactivity, an accepted boundary: a body drawn as a
62
+ * string cannot hold live state. See `renderRowString` and
63
+ * `renderDirectiveString` for that fallback.
32
64
  *
33
- * Interactive components have no meaning in a terminal: `@markii/stdlib`'s
34
- * `INTERACTIVE_ATTRIBUTE` marks an element a host should treat as a live
35
- * control (a script-marker `<summary>`, say); this engine has no notion of
36
- * a live control at all, so it never reads that attribute and never emits
37
- * a marker for it — there is nothing to degrade, unlike a genuine failure.
65
+ * Interactive components have no meaning for a NON-interactive render at
66
+ * all: `@markii/stdlib`'s `INTERACTIVE_ATTRIBUTE` marks an element a HOST
67
+ * should treat as a live control (a script-marker `<summary>`, say); this
68
+ * engine never reads that attribute and never emits a marker for it.
38
69
  */
39
70
  /** The hast tag name `@markii/core` marks every directive with. */
40
71
  const DIRECTIVE_TAG = 'mk-directive';
@@ -46,6 +77,27 @@ const VALUE_DIRECTIVE_NAME = 'value';
46
77
  const DATA_ATTRIBUTE_KEY = 'data';
47
78
  /** The attribute `@markii/core` preserves a code fence's raw `meta` string onto. */
48
79
  const CODE_META_ATTR = 'data-mk-meta';
80
+ /** The block directive names given real Ink layout/state (see this file's top comment). */
81
+ const ROW_NAME = 'row';
82
+ const TABS_NAME = 'tabs';
83
+ const DETAILS_NAME = 'details';
84
+ /**
85
+ * `card` and `figure` join this list (batch 11's Ink-frames phase): both are
86
+ * containers whose body previously only ever reached STRING mode, so a
87
+ * `tabs`/`details` nested inside one could never become focusable. `card`
88
+ * draws its frame with an Ink `Box` border (`renderCardElement`); `figure`
89
+ * never drew a frame at all (it is a caption + optional image line), so it
90
+ * gains no border, only ELEMENT-mode children.
91
+ *
92
+ * `callout` joins them too. Its colored bar is a one-column left border
93
+ * on an Ink `Box` whose border glyph is the already-styled bar string: Ink
94
+ * repeats that glyph down the body's whole height, whatever the height turns
95
+ * out to be, and the color still comes from this engine's own SGR helpers,
96
+ * never an Ink color prop.
97
+ */
98
+ const CARD_NAME = 'card';
99
+ const CALLOUT_NAME = 'callout';
100
+ const FIGURE_NAME = 'figure';
49
101
  /** Block-level hast tags this engine recognizes; anything else inside a directive's children is treated as inline content. */
50
102
  const BLOCK_TAGS = new Set([
51
103
  'p',
@@ -74,6 +126,30 @@ function nodeOrRootToHast(node) {
74
126
  }
75
127
  return { type: 'root', children };
76
128
  }
129
+ function nextFocusId(wctx) {
130
+ const id = wctx.focusCounter.current;
131
+ wctx.focusCounter.current += 1;
132
+ return id;
133
+ }
134
+ /** Records `descriptor` for `focusId` when `wctx` is collecting them (a no-op otherwise). Call sites push in the same order they call `nextFocusId`, so `focusId` always equals the descriptor's index. */
135
+ function recordFocusable(wctx, descriptor) {
136
+ wctx.focusables?.push(descriptor);
137
+ }
138
+ /**
139
+ * The ONE place a STRING built by this file's string engine becomes an Ink
140
+ * `<Text>` leaf. Every such string passes through `ansi.js`'s
141
+ * `carrySgrAcrossLines` first — see that function's doc comment for why:
142
+ * Ink resets SGR state at every embedded line boundary within one `<Text>`
143
+ * value, so a multi-line styled string (a dim-wrapped frame, a wrapped
144
+ * bold heading, a paragraph whose bold span straddled a wrap point) must
145
+ * have every physical line already self-contained before Ink ever sees it.
146
+ * This is the SYSTEMATIC fix (a single choke point) rather than a per-
147
+ * call-site one: every `<Text>` in this file that renders a plain string
148
+ * uses this, never a bare `<Text>{str}</Text>`.
149
+ */
150
+ function styledText(text) {
151
+ return _jsx(Text, { children: carrySgrAcrossLines(text) });
152
+ }
77
153
  /** A directive property read back as a string, or `undefined` if absent or non-string. */
78
154
  function stringProperty(element, name) {
79
155
  const value = element.properties?.[name];
@@ -134,6 +210,8 @@ function isRegisteredInline(entry) {
134
210
  /** The fallback label line, worded for the reason and the form the directive was written in. Plain text (no escapes). */
135
211
  function fallbackLabel(name, inline, reason) {
136
212
  const code = `\`${stripControlCharacters(name)}\``;
213
+ if (reason === 'no-engine-view')
214
+ return `${NO_ENGINE_VIEW_LABEL} ${code}`;
137
215
  if (reason === 'form-mismatch') {
138
216
  return inline
139
217
  ? `block component ${code} written inline`
@@ -141,30 +219,9 @@ function fallbackLabel(name, inline, reason) {
141
219
  }
142
220
  return `unknown component ${code}`;
143
221
  }
144
- /**
145
- * Renders `children`'s default (whole-body) text, never throwing. Used only
146
- * on already-exceptional paths (an unregistered directive, a form mismatch,
147
- * a component that itself threw): the render walk underneath `children` is
148
- * documented never-throw, but these three call sites are exactly the ones
149
- * built to contain a failure, so they must not become a new way to produce
150
- * one themselves.
151
- */
152
- function safeChildrenText(children) {
153
- try {
154
- return children();
155
- }
156
- catch {
157
- return '';
158
- }
159
- }
160
222
  /** The unknown-directive fallback: dim inline text for a text directive, a dashed dim frame for a block directive. Wording matches `@markii/html`'s `fallbackLabel` exactly. */
161
- function unknownDirective(name, inline, childrenText, reason, width, color) {
223
+ function unknownDirective(name, inline, childrenText, reason, width, color, glyphs) {
162
224
  const label = fallbackLabel(name, inline, reason);
163
- // The label and the passed-through content need a visible boundary. The
164
- // HTML and React engines get one from the fallback's own styling, which a
165
- // terminal cannot rely on: with color off there is nothing at all between
166
- // the two, so the label runs straight into the author's words. A colon is
167
- // the separator, and only when there is content to separate.
168
225
  if (inline) {
169
226
  return childrenText === ''
170
227
  ? dim(label, color)
@@ -174,24 +231,23 @@ function unknownDirective(name, inline, childrenText, reason, width, color) {
174
231
  const wrappedChildren = childrenText
175
232
  ? childrenText
176
233
  .split('\n')
177
- .flatMap((line) => wrap(line, innerWidth))
234
+ .flatMap((line) => wrapText(line, innerWidth))
178
235
  .join('\n')
179
236
  : '';
180
237
  const body = wrappedChildren ? `${label}\n\n${wrappedChildren}` : label;
181
- return dim(frame(body, { style: 'dashed', width }), color);
238
+ return dim(frameBlock(body, { style: 'dashed', width, glyphs }), color);
182
239
  }
183
240
  /** A registered component that threw while rendering, contained to a neutral box rather than failing the whole document. Never dumps the error text. */
184
- function componentError(name, inline, childrenText, width, color) {
241
+ function componentError(name, inline, childrenText, width, color, glyphs) {
185
242
  const label = `component \`${stripControlCharacters(name)}\` failed to render`;
186
- // Same boundary rule as `unknownDirective`'s inline branch above.
187
243
  if (inline) {
188
244
  return childrenText === ''
189
245
  ? dim(label, color)
190
246
  : `${dim(`${label}:`, color)} ${childrenText}`;
191
247
  }
192
- return dim(frame(label, { style: 'dashed', width }), color);
248
+ return dim(frameBlock(label, { style: 'dashed', width, glyphs }), color);
193
249
  }
194
- /** Splits a `data=<name>` attribute off `attributes` and resolves it against `scope`. Never throws. Mirrors `@markii/html`'s `resolveDataAttribute`. */
250
+ /** Splits a `data=<name>` attribute off `attributes` and resolves it against `scope`. Never throws. */
195
251
  function resolveDataAttribute(attributes, scope) {
196
252
  if (!Object.hasOwn(attributes, DATA_ATTRIBUTE_KEY))
197
253
  return { attributes };
@@ -207,7 +263,7 @@ function resolveDataAttribute(attributes, scope) {
207
263
  dataFailureKind: resolved.status === 'error' ? resolved.failureKind : undefined,
208
264
  };
209
265
  }
210
- /** Whether one of `name`'s `@markii/stdlib` contract attributes with a closed `enum` is present with a value outside it. Mirrors `@markii/html`'s identical check. */
266
+ /** Whether one of `name`'s `@markii/stdlib` contract attributes with a closed `enum` is present with a value outside it. */
211
267
  function invalidEnumAttribute(name, attributes) {
212
268
  const contract = getContract(name);
213
269
  if (!contract)
@@ -231,14 +287,7 @@ function resolveValue(scope, name) {
231
287
  return { value: undefined, status: 'missing' };
232
288
  return resolveScopedPath(scope, trimmed);
233
289
  }
234
- /**
235
- * Builds the missing/stale/resolved marker text for a resolved value name.
236
- * A plain miss (no error attached) shows only the `{name}` placeholder —
237
- * there is nothing more to say. An `'error'` status, or a stale value, gets
238
- * `failure-presentation.ts`'s `dataStateSuffix` appended as visible trailing
239
- * text: a terminal has no tooltip channel, so the reason has to reach the
240
- * text itself (AGENTS.md's "clean is not silent").
241
- */
290
+ /** Builds the missing/stale/resolved marker text for a resolved value name. */
242
291
  function renderValueMarker(name, resolved, format, decimals, color, theme) {
243
292
  if (resolved.status === 'missing' || resolved.status === 'error') {
244
293
  const failureKind = resolved.status === 'error' ? resolved.failureKind : undefined;
@@ -256,24 +305,21 @@ function renderValueMarker(name, resolved, format, decimals, color, theme) {
256
305
  return suffix ? `${styled}${dim(suffix, color)}` : styled;
257
306
  }
258
307
  /** Builds the `AnsiRenderContext` handed to one component invocation. */
259
- function buildComponentContext(width, indent, wctx, binding, layout) {
308
+ function buildComponentContext(width, indent, wctx, binding, layout, focusId) {
260
309
  const { color, theme, scope } = wctx;
261
310
  const ctx = {
262
311
  width,
263
312
  indent,
264
313
  color,
265
314
  theme,
315
+ glyphs: wctx.glyphs,
316
+ interactive: wctx.interactive,
266
317
  style: (text, token) => style(text, token, theme, color),
267
318
  bold: (text) => bold(text, color),
268
319
  dim: (text) => dim(text, color),
269
320
  italic: (text) => italic(text, color),
270
321
  underline: (text) => underline(text, color),
271
322
  inverse: (text) => inverse(text, color),
272
- wrap: (text, w) => wrap(text, w),
273
- pad: (text, w, align) => pad(text, w, align),
274
- columns: (blocks, widths, gutter) => columns(blocks, widths, gutter),
275
- frame: (block, options) => frame(block, options),
276
- rule: (w, char) => rule(w, char),
277
323
  text: (value) => stripControlCharacters(value),
278
324
  resolve: (name) => resolveValue(scope, name),
279
325
  valueMarker: (name, format, decimals) => {
@@ -287,52 +333,86 @@ function buildComponentContext(width, indent, wctx, binding, layout) {
287
333
  resolveHref: wctx.resolveHref,
288
334
  onDiagnostic: wctx.onDiagnostic,
289
335
  };
336
+ if (focusId !== undefined)
337
+ ctx.focusId = focusId;
290
338
  if ('data' in binding) {
291
339
  ctx.data = binding.data;
292
340
  ctx.dataStatus = binding.dataStatus;
293
341
  ctx.dataError = binding.dataError;
294
342
  ctx.dataFailureKind = binding.dataFailureKind;
343
+ ctx.dataSuffix = dataStateSuffix(binding.dataStatus, binding.dataFailureKind);
295
344
  }
296
345
  if (layout)
297
346
  ctx.layout = layout;
298
347
  return ctx;
299
348
  }
300
- /** Resolves one directive (registry component, `:value[...]`, or a fallback), given its layout-stripped attributes. Never throws. */
301
- function renderDirectiveContent(name, kind, isBlock, attributes, children, width, indent, wctx, layoutForComponent) {
349
+ // ---------------------------------------------------------------------------
350
+ // STRING MODE — the pre-Ink algorithm. Used for every inline position (at
351
+ // any depth) and for a directive's own body once it is not one of the three
352
+ // Ink-element-aware directives (`row`/`tabs`/`details`).
353
+ // ---------------------------------------------------------------------------
354
+ function safeChildrenText(text) {
355
+ return text ?? '';
356
+ }
357
+ function isBlockLevelChildren(children) {
358
+ for (const child of children) {
359
+ if (child.type !== 'element')
360
+ continue;
361
+ if (BLOCK_TAGS.has(child.tagName))
362
+ return true;
363
+ if (child.tagName === DIRECTIVE_TAG) {
364
+ const kind = stringProperty(child, 'data-mk-kind');
365
+ if (kind !== TEXT_DIRECTIVE_KIND)
366
+ return true;
367
+ }
368
+ }
369
+ return false;
370
+ }
371
+ function renderDirectiveContentString(name, kind, isBlock, attributes, childrenText, width, indent, wctx, layoutForComponent) {
302
372
  const inline = !isBlock;
303
373
  const entry = Object.hasOwn(wctx.registry, name)
304
374
  ? wctx.registry[name]
305
375
  : undefined;
306
376
  const component = readRegistryComponent(entry);
307
377
  if (!entry || component == null) {
308
- return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'unregistered', width, wctx.color);
378
+ const missingView = wctx.unavailable?.(name);
379
+ if (missingView !== undefined) {
380
+ reportDiagnostic(wctx.onDiagnostic, {
381
+ kind: 'no-engine-view',
382
+ directive: name,
383
+ message: noEngineViewTitle(name, missingView.pack, missingView.engines),
384
+ });
385
+ return unknownDirective(name, inline, safeChildrenText(childrenText), 'no-engine-view', width, wctx.color, wctx.glyphs);
386
+ }
387
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'unregistered', width, wctx.color, wctx.glyphs);
309
388
  }
310
389
  if (isFormMismatch(entry, kind)) {
311
- return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'form-mismatch', width, wctx.color);
390
+ reportDiagnostic(wctx.onDiagnostic, {
391
+ kind: 'form-mismatch',
392
+ directive: name,
393
+ message: formMismatchTitle(name),
394
+ });
395
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'form-mismatch', width, wctx.color, wctx.glyphs);
312
396
  }
313
397
  const binding = resolveDataAttribute(attributes, wctx.scope);
314
- const ctx = buildComponentContext(width, indent, wctx, binding, layoutForComponent);
398
+ const ctx = buildComponentContext(width, indent, wctx, binding, layoutForComponent, undefined);
315
399
  let rendered;
316
400
  try {
317
- rendered = component(binding.attributes, children, ctx);
401
+ const result = component({
402
+ attributes: binding.attributes,
403
+ children: childrenText,
404
+ ctx,
405
+ });
406
+ rendered =
407
+ typeof result === 'string'
408
+ ? result
409
+ : isValidElement(result)
410
+ ? renderInkToStringSync(result, width)
411
+ : String(result ?? '');
318
412
  }
319
413
  catch {
320
- return componentError(name || '(unnamed)', inline, safeChildrenText(children), width, wctx.color);
321
- }
322
- // The silent-value-drop mechanism (AGENTS.md "clean is not silent"): a
323
- // known attribute's value outside its closed enum still renders the
324
- // component exactly as registered, with a short labeled marker appended
325
- // (there is no tooltip channel in a terminal, so the full reason cannot
326
- // live inline without becoming body text again). The full sentence is
327
- // reported to `onDiagnostic` for a host's own diagnostics surface; a
328
- // caller that passes no `onDiagnostic` simply gets the marker with no
329
- // reason anywhere, exactly like the browser engines with no tooltip read.
330
- //
331
- // A block component's marker goes on its OWN line. Appended to the same
332
- // line it would land against the bottom edge of whatever the component
333
- // drew, turning a clean box into a box with a label stuck to its corner.
334
- // Only an inline component, which is already part of a line of text,
335
- // takes the marker inline.
414
+ return componentError(name || '(unnamed)', inline, safeChildrenText(childrenText), width, wctx.color, wctx.glyphs);
415
+ }
336
416
  const markerSeparator = inline ? ' ' : '\n';
337
417
  const invalidEnum = invalidEnumAttribute(name, binding.attributes);
338
418
  if (invalidEnum) {
@@ -345,221 +425,121 @@ function renderDirectiveContent(name, kind, isBlock, attributes, children, width
345
425
  const label = invalidAttributeValueLabel(name, invalidEnum.attribute);
346
426
  rendered = `${rendered}${markerSeparator}${dim(`[${label}]`, wctx.color)}`;
347
427
  }
348
- // An `inline: true` component with no content still renders exactly as
349
- // registered, with the same quiet trailing marker.
350
- if (isRegisteredInline(entry) && safeChildrenText(children).trim() === '') {
428
+ if (isRegisteredInline(entry) &&
429
+ safeChildrenText(childrenText).trim() === '') {
351
430
  rendered = `${rendered}${dim(` (${emptyInlineTitle(name)})`, wctx.color)}`;
352
431
  }
353
432
  return rendered;
354
433
  }
355
- /** Whether `children` reads as block-level content (a container directive's body) rather than inline/phrasing content (a leaf directive's body). */
356
- function isBlockLevelChildren(children) {
357
- for (const child of children) {
358
- if (child.type !== 'element')
359
- continue;
360
- if (BLOCK_TAGS.has(child.tagName))
361
- return true;
362
- if (child.tagName === DIRECTIVE_TAG) {
363
- const kind = stringProperty(child, 'data-mk-kind');
364
- if (kind !== TEXT_DIRECTIVE_KIND)
365
- return true;
366
- }
367
- }
368
- return false;
369
- }
370
- function renderDirectiveChildren(children, width, indent, wctx) {
434
+ function renderDirectiveChildrenString(children, width, indent, wctx) {
371
435
  if (isBlockLevelChildren(children)) {
372
- return renderBlocks(children, width, indent, wctx);
373
- }
374
- return renderInlineChildren(children, width, indent, wctx);
375
- }
376
- /** The resolved directive name for a `<mk-directive>` element, after alias resolution — the name `AnsiChildPart.name` reports. Attributes play no part in identifying a child this way, so an empty attribute map is enough. */
377
- function resolvedChildDirectiveName(element, registry) {
378
- const written = stringProperty(element, 'data-mk-name') ?? '';
379
- return resolveDirectiveAlias(registry, written, {}).name;
380
- }
381
- /**
382
- * Builds one `AnsiChildPart` per top-level child of a directive's BLOCK
383
- * body (a pure whitespace-only text node between blocks, the same kind
384
- * `renderBlocks` already treats as carrying no content, contributes no
385
- * part). Each part renders lazily via the same per-node `renderBlock` the
386
- * ordinary block walk uses, so a container that calls `part.render` at its
387
- * own chosen width gets exactly what a document-level render at that width
388
- * would have produced — no separate rendering path to drift from the
389
- * ordinary one.
390
- */
391
- function buildBlockChildParts(nodes, width, indent, wctx) {
392
- const parts = [];
393
- for (const node of nodes) {
394
- if (node.type === 'text' && node.value.trim() === '')
395
- continue;
396
- const name = node.type === 'element' && node.tagName === DIRECTIVE_TAG
397
- ? resolvedChildDirectiveName(node, wctx.registry)
398
- : undefined;
399
- parts.push({
400
- name,
401
- render: (options) => renderBlock(node, options?.width ?? width, options?.indent ?? indent, wctx),
402
- });
436
+ return renderBlocksString(children, width, indent, wctx);
403
437
  }
404
- return parts;
405
- }
406
- /**
407
- * Builds the `AnsiChildren` handle a component receives (`registry.ts`'s
408
- * doc comment on the type): calling it renders the WHOLE body, lazily, at
409
- * an optionally narrower width/indent; `.parts` exposes each top-level
410
- * child separately for a container (`row`) that must size each one before
411
- * it is drawn. Nothing under `element` is rendered until a caller actually
412
- * invokes one of these functions.
413
- */
414
- function buildChildren(element, width, indent, wctx) {
415
- const isBlock = isBlockLevelChildren(element.children);
416
- const fn = ((options) => renderDirectiveChildren(element.children, options?.width ?? width, options?.indent ?? indent, wctx));
417
- Object.defineProperty(fn, 'parts', {
418
- enumerable: true,
419
- get: () => isBlock
420
- ? buildBlockChildParts(element.children, width, indent, wctx)
421
- : [
422
- {
423
- render: (options) => renderInlineChildren(element.children, options?.width ?? width, options?.indent ?? indent, wctx),
424
- },
425
- ],
426
- });
427
- return fn;
438
+ return renderInlineChildrenString(children, width, indent, wctx);
428
439
  }
429
- /** Turns one `<mk-directive>` element into its rendered text, including the layout adjustment for a block directive whose layout it does not own. */
430
- function renderDirective(element, width, indent, wctx) {
431
- const written = stringProperty(element, 'data-mk-name') ?? '';
432
- const kind = stringProperty(element, 'data-mk-kind');
433
- const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
434
- const isBlock = kind !== TEXT_DIRECTIVE_KIND;
435
- if (written === VALUE_DIRECTIVE_NAME) {
436
- const plainLabel = extractPlainText(element.children);
437
- const resolved = resolveValue(wctx.scope, plainLabel);
438
- return renderValueMarker(plainLabel.trim(), resolved, rawAttributes.format ?? undefined, rawAttributes.decimals ?? undefined, wctx.color, wctx.theme);
440
+ function renderRowString(element, attributes, width, indent, wctx) {
441
+ const cellNodes = element.children.filter((node) => !(node.type === 'text' && node.value.trim() === ''));
442
+ const layout = resolveRowLayout(attributes, cellNodes.length, width);
443
+ if (cellNodes.length === 0)
444
+ return '';
445
+ const place = (text, w) => layout.align === 'left'
446
+ ? text
447
+ : text
448
+ .split('\n')
449
+ .map((line) => padText(line, w, layout.align))
450
+ .join('\n');
451
+ if (layout.stacked) {
452
+ return cellNodes
453
+ .map((node) => place(renderBlockString(node, width, indent, wctx), width))
454
+ .join('\n\n');
439
455
  }
440
- const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
441
- const ownedAxis = registryLayoutAxis(wctx.registry, name);
442
- const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, isBlock ? ownedAxis : undefined);
443
- const isLayoutScope = ownedAxis !== undefined && isBlock;
444
- // A `selfLayout` component (a box-drawing standard component: card,
445
- // callout, table, chart) also owns applying its own width/align — see
446
- // `registry.ts`'s `AnsiRegistryEntry.selfLayout` doc comment for why the
447
- // generic post-render `applyLayout` below would corrupt its frame.
448
- const isSelfLayout = isBlock && registrySelfLayout(wctx.registry, name);
449
- const children = buildChildren(element, width, indent, wctx);
450
- const content = renderDirectiveContent(name, kind, isBlock, attributes, children, width, indent, wctx, isLayoutScope || isSelfLayout ? layoutResolved : undefined);
451
- if (isBlock && layoutResolved && !isLayoutScope && !isSelfLayout) {
452
- return applyLayout(content, layoutResolved, width);
456
+ const placedCells = cellNodes.map((node) => place(renderBlockString(node, layout.colWidth, indent, wctx), layout.colWidth));
457
+ const gridRows = [];
458
+ for (let index = 0; index < placedCells.length; index += layout.columnCount) {
459
+ const rowCells = placedCells.slice(index, index + layout.columnCount);
460
+ gridRows.push(columnsBlock(rowCells, rowCells.map(() => layout.colWidth), ROW_GUTTER));
453
461
  }
454
- return content;
462
+ return gridRows.join('\n\n');
455
463
  }
456
- /** The plain text content of an element's descendants, sanitized (used for inline `<code>`, which never carries nested styling). */
457
- function getPlainText(node) {
458
- let text = '';
459
- for (const child of node.children) {
460
- if (child.type === 'text')
461
- text += stripControlCharacters(child.value);
462
- else if (child.type === 'element')
463
- text += getPlainText(child);
464
+ const DEFAULT_TAB_LABEL = 'Tab';
465
+ /** Extracts each top-level `tab` child's own label attribute and rendered body, used by BOTH the string fallback and the real interactive builder — a structural replacement for the pre-Ink engine's fragile "split the flattened string on a blank line" heuristic. */
466
+ function extractTabPanels(element, width, indent, wctx) {
467
+ const panels = [];
468
+ for (const node of element.children) {
469
+ if (node.type === 'text' && node.value.trim() === '')
470
+ continue;
471
+ if (node.type !== 'element')
472
+ continue;
473
+ let label = DEFAULT_TAB_LABEL;
474
+ let isTab = false;
475
+ if (node.tagName === DIRECTIVE_TAG) {
476
+ const written = stringProperty(node, 'data-mk-name') ?? '';
477
+ const attrs = parseAttributes(stringProperty(node, 'data-mk-attrs'));
478
+ const { name } = resolveDirectiveAlias(wctx.registry, written, attrs);
479
+ if (name === 'tab') {
480
+ label = attrs.label ?? DEFAULT_TAB_LABEL;
481
+ isTab = true;
482
+ }
483
+ }
484
+ // A `tab` child's own label is drawn ONCE, by the heading `tabs`
485
+ // itself builds below — rendering the whole `tab` directive here (as
486
+ // for any other block) would ALSO invoke `tab.ts`'s own component,
487
+ // which draws its OWN bold heading from the same attribute, printing
488
+ // the label twice. Render only the tab's INNER children instead. A
489
+ // non-`tab` block (an unlabeled panel) still renders as a whole block,
490
+ // unchanged.
491
+ const body = isTab
492
+ ? renderDirectiveChildrenString(node.children, width, indent, wctx)
493
+ : renderBlockString(node, width, indent, wctx);
494
+ panels.push({ label, body });
464
495
  }
465
- return text;
496
+ return panels;
466
497
  }
467
- /**
468
- * A link renders as an OSC 8 hyperlink ONLY when: the color level allows
469
- * escapes at all, `@markii/core`'s `isSafeUrl` accepts the (resolved)
470
- * href, AND `./sanitize.js`'s `sanitizeUrlText` leaves the href UNCHANGED —
471
- * that last check is the one that refuses a href carrying a BEL, ESC, or
472
- * space rather than silently embedding a sanitized-down version of it,
473
- * matching this module's `hyperlink.probe.test.ts`. Anything else falls
474
- * back to plain text: the URL once, if the link's own text already IS the
475
- * URL, otherwise the text followed by `(url)`.
476
- */
477
- function renderLink(node, width, indent, wctx) {
478
- const hrefRaw = typeof node.properties?.href === 'string' ? node.properties.href : '';
479
- const resolvedHref = resolveHrefAttribute(hrefRaw, wctx.resolveHref);
480
- const text = renderInlineChildren(node.children, width, indent, wctx);
481
- const sanitizedHref = stripControlCharacters(resolvedHref);
482
- const canHyperlink = wctx.color !== 'none' &&
483
- resolvedHref.trim() !== '' &&
484
- isSafeUrl(resolvedHref) &&
485
- sanitizeUrlText(resolvedHref) === resolvedHref;
486
- if (canHyperlink)
487
- return hyperlink(text, resolvedHref, wctx.color);
488
- const plainText = text.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07\x1b]*\x07/g, '');
489
- if (plainText === sanitizedHref)
490
- return sanitizedHref;
491
- return `${text} (${sanitizedHref})`;
492
- }
493
- function renderImage(node, wctx) {
494
- const altRaw = typeof node.properties?.alt === 'string' ? node.properties.alt : '';
495
- const alt = stripControlCharacters(altRaw);
496
- const srcRaw = typeof node.properties?.src === 'string' ? node.properties.src : '';
497
- const resolvedSrc = resolveImageAttribute(srcRaw, wctx.resolveImageSrc);
498
- const src = stripControlCharacters(resolvedSrc);
499
- return `[${alt}] (${src})`;
500
- }
501
- function renderInlineNode(node, width, indent, wctx) {
498
+ const ACTIVE_MARKER = ' (active)';
499
+ function renderBlockString(node, width, indent, wctx) {
502
500
  if (node.type === 'text')
503
501
  return stripControlCharacters(node.value);
504
502
  if (node.type !== 'element')
505
503
  return '';
506
- switch (node.tagName) {
507
- case DIRECTIVE_TAG:
508
- return renderDirective(node, width, indent, wctx);
509
- case 'strong':
510
- return bold(renderInlineChildren(node.children, width, indent, wctx), wctx.color);
511
- case 'em':
512
- return italic(renderInlineChildren(node.children, width, indent, wctx), wctx.color);
513
- case 'del':
514
- return dim(`~${renderInlineChildren(node.children, width, indent, wctx)}~`, wctx.color);
515
- case 'code': {
516
- const inner = getPlainText(node);
517
- return wctx.color === 'none'
518
- ? dim(inner, wctx.color)
519
- : inverse(inner, wctx.color);
520
- }
521
- case 'a':
522
- return renderLink(node, width, indent, wctx);
523
- case 'img':
524
- return renderImage(node, wctx);
525
- case 'br':
526
- return '\n';
527
- case 'input':
528
- // Task-list checkboxes are consumed by `renderListItem`; a stray one
529
- // (outside a list item) produces nothing rather than leaking markup.
530
- return '';
531
- default:
532
- return renderInlineChildren(node.children ?? [], width, indent, wctx);
504
+ if (node.tagName === DIRECTIVE_TAG) {
505
+ return renderDirectiveString(node, width, indent, wctx);
533
506
  }
507
+ if (/^h[1-6]$/.test(node.tagName)) {
508
+ const level = Number(node.tagName.slice(1));
509
+ const text = renderInlineChildrenString(node.children, width, indent, wctx);
510
+ const styled = level === 1
511
+ ? underline(bold(text, wctx.color), wctx.color)
512
+ : bold(text, wctx.color);
513
+ return wrapText(styled, width).join('\n');
514
+ }
515
+ if (node.tagName === 'p') {
516
+ return wrapText(renderInlineChildrenString(node.children, width, indent, wctx), width).join('\n');
517
+ }
518
+ if (node.tagName === 'blockquote') {
519
+ const innerWidth = Math.max(1, width - 2);
520
+ const inner = renderBlocksString(node.children, innerWidth, `${indent}${wctx.glyphs.quoteBar}`, wctx);
521
+ const prefix = dim(wctx.glyphs.quoteBar, wctx.color);
522
+ return inner
523
+ .split('\n')
524
+ .map((line) => `${prefix}${line}`)
525
+ .join('\n');
526
+ }
527
+ if (node.tagName === 'ul' || node.tagName === 'ol') {
528
+ return renderListString(node, width, indent, wctx);
529
+ }
530
+ if (node.tagName === 'hr') {
531
+ return dim(ruleLine(width, wctx.glyphs.rule), wctx.color);
532
+ }
533
+ if (node.tagName === 'table')
534
+ return renderGfmTableString(node, width, wctx);
535
+ if (node.tagName === 'pre') {
536
+ const marker = renderScriptMarker(node, wctx.color, wctx.glyphs);
537
+ if (marker !== undefined)
538
+ return marker;
539
+ return renderCodeBlock(node, wctx.color);
540
+ }
541
+ return wrapText(renderInlineChildrenString(node.children ?? [], width, indent, wctx), width).join('\n');
534
542
  }
535
- function renderInlineChildren(children, width, indent, wctx) {
536
- let result = '';
537
- for (const child of children)
538
- result += renderInlineNode(child, width, indent, wctx);
539
- return result;
540
- }
541
- function renderHeading(el, width, indent, wctx) {
542
- const level = Number(el.tagName.slice(1));
543
- const text = renderInlineChildren(el.children, width, indent, wctx);
544
- const styled = level === 1
545
- ? underline(bold(text, wctx.color), wctx.color)
546
- : bold(text, wctx.color);
547
- return wrap(styled, width).join('\n');
548
- }
549
- function renderParagraph(el, width, indent, wctx) {
550
- const text = renderInlineChildren(el.children, width, indent, wctx);
551
- return wrap(text, width).join('\n');
552
- }
553
- function renderBlockquote(el, width, indent, wctx) {
554
- const innerWidth = Math.max(1, width - 2);
555
- const inner = renderBlocks(el.children, innerWidth, `${indent}│ `, wctx);
556
- const prefix = dim('│ ', wctx.color);
557
- return inner
558
- .split('\n')
559
- .map((line) => `${prefix}${line}`)
560
- .join('\n');
561
- }
562
- /** The task-checkbox state of a list item, or `undefined` for an ordinary item. */
563
543
  function taskState(li) {
564
544
  for (const child of li.children) {
565
545
  if (child.type !== 'element')
@@ -567,13 +547,11 @@ function taskState(li) {
567
547
  if (child.tagName === 'input') {
568
548
  return { checked: Boolean(child.properties?.checked) };
569
549
  }
570
- // A task checkbox is always the first element in `remark-gfm`'s output;
571
- // any other element first means this is not a task item.
572
550
  return undefined;
573
551
  }
574
552
  return undefined;
575
553
  }
576
- function renderListItem(li, marker, width, indent, wctx) {
554
+ function renderListItemString(li, marker, width, indent, wctx) {
577
555
  const task = taskState(li);
578
556
  const contentChildren = li.children.filter((child) => !(task && child.type === 'element' && child.tagName === 'input'));
579
557
  const nestedLists = [];
@@ -588,11 +566,11 @@ function renderListItem(li, marker, width, indent, wctx) {
588
566
  }
589
567
  }
590
568
  const prefix = `${marker}${task ? (task.checked ? '[x] ' : '[ ] ') : ''}`;
591
- const prefixWidth = measure(prefix);
569
+ const prefixWidth = measureWidth(prefix);
592
570
  const bodyWidth = Math.max(1, width - prefixWidth);
593
571
  const bodyText = isBlockLevelChildren(restChildren)
594
- ? renderBlocks(restChildren, bodyWidth, `${indent}${' '.repeat(prefixWidth)}`, wctx)
595
- : wrap(renderInlineChildren(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
572
+ ? renderBlocksString(restChildren, bodyWidth, `${indent}${' '.repeat(prefixWidth)}`, wctx)
573
+ : wrapText(renderInlineChildrenString(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
596
574
  const bodyLines = bodyText === '' ? [''] : bodyText.split('\n');
597
575
  const firstLine = `${prefix}${bodyLines[0] ?? ''}`;
598
576
  const restLines = bodyLines
@@ -600,7 +578,7 @@ function renderListItem(li, marker, width, indent, wctx) {
600
578
  .map((line) => `${' '.repeat(prefixWidth)}${line}`);
601
579
  let result = [firstLine, ...restLines].join('\n');
602
580
  for (const nested of nestedLists) {
603
- const nestedText = renderBlock(nested, Math.max(1, width - 2), `${indent} `, wctx);
581
+ const nestedText = renderBlockString(nested, Math.max(1, width - 2), `${indent} `, wctx);
604
582
  result += `\n${nestedText
605
583
  .split('\n')
606
584
  .map((line) => ` ${line}`)
@@ -608,7 +586,7 @@ function renderListItem(li, marker, width, indent, wctx) {
608
586
  }
609
587
  return result;
610
588
  }
611
- function renderList(el, width, indent, wctx) {
589
+ function renderListString(el, width, indent, wctx) {
612
590
  const ordered = el.tagName === 'ol';
613
591
  const startRaw = el.properties?.start;
614
592
  const start = ordered && typeof startRaw === 'number' ? startRaw : 1;
@@ -616,7 +594,7 @@ function renderList(el, width, indent, wctx) {
616
594
  return items
617
595
  .map((li, index) => {
618
596
  const marker = ordered ? `${start + index}. ` : '- ';
619
- return renderListItem(li, marker, width, indent, wctx);
597
+ return renderListItemString(li, marker, width, indent, wctx);
620
598
  })
621
599
  .join('\n');
622
600
  }
@@ -647,15 +625,7 @@ function getCodeText(codeNode) {
647
625
  }
648
626
  return text.endsWith('\n') ? text.slice(0, -1) : text;
649
627
  }
650
- /**
651
- * Folds a script code block into a one-line collapsed marker, matching
652
- * `@markii/html`'s `renderScriptMarker` summary wording exactly (a
653
- * terminal has no expand/collapse affordance, so the body is never shown —
654
- * see this module's top comment on interactive components). Returns
655
- * `undefined` for any other `<pre>` so the caller renders it as an
656
- * ordinary code block.
657
- */
658
- function renderScriptMarker(node, color) {
628
+ function renderScriptMarker(node, color, glyphs) {
659
629
  try {
660
630
  const codeNode = findChildElement(node, 'code');
661
631
  const meta = codeNode
@@ -669,7 +639,9 @@ function renderScriptMarker(node, color) {
669
639
  return undefined;
670
640
  const src = attrs.src || undefined;
671
641
  const detail = src ?? getLanguage(codeNode);
672
- const summary = detail ? `⚙ ${name} · ${detail}` : `⚙ ${name}`;
642
+ const summary = detail
643
+ ? `${glyphs.script} ${name} ${glyphs.scriptSeparator} ${detail}`
644
+ : `${glyphs.script} ${name}`;
673
645
  return dim(summary, color);
674
646
  }
675
647
  catch {
@@ -687,18 +659,7 @@ function renderCodeBlock(node, color) {
687
659
  lines.push(dim(` ${line}`, color));
688
660
  return lines.join('\n');
689
661
  }
690
- /**
691
- * An ordinary GFM markdown table, box-drawn with a bold header row through
692
- * `./components/table-grid.js`'s `drawTableGrid` — the SAME box-drawing and
693
- * width-negotiation routine the data-bound `::table` component
694
- * (`./components/table.ts`) draws with, so this engine has exactly one
695
- * table-drawing implementation rather than two. Rendered at the directive's
696
- * real available `width`, not a hardcoded budget: each cell's inline
697
- * markdown is rendered once at that width so its own links/emphasis/code
698
- * are preserved, and `drawTableGrid` then negotiates how much of `width`
699
- * each column actually gets.
700
- */
701
- function renderTable(tableEl, width, wctx) {
662
+ function renderGfmTableString(tableEl, width, wctx) {
702
663
  const headerCells = [];
703
664
  const bodyRows = [];
704
665
  let sawHeader = false;
@@ -708,7 +669,7 @@ function renderTable(tableEl, width, wctx) {
708
669
  continue;
709
670
  if (cell.tagName !== 'td' && cell.tagName !== 'th')
710
671
  continue;
711
- into.push(renderInlineChildren(cell.children, width, '', wctx));
672
+ into.push(renderInlineChildrenString(cell.children, width, '', wctx));
712
673
  }
713
674
  };
714
675
  for (const section of tableEl.children) {
@@ -731,53 +692,658 @@ function renderTable(tableEl, width, wctx) {
731
692
  }
732
693
  }
733
694
  }
734
- return drawTableGrid(headerCells.length > 0 ? headerCells : undefined, bodyRows, width, (text) => bold(text, wctx.color));
695
+ return drawTableGrid(headerCells.length > 0 ? headerCells : undefined, bodyRows, width, wctx.glyphs, (text) => bold(text, wctx.color));
735
696
  }
736
- function renderBlock(node, width, indent, wctx) {
697
+ function getPlainText(node) {
698
+ let text = '';
699
+ for (const child of node.children) {
700
+ if (child.type === 'text')
701
+ text += stripControlCharacters(child.value);
702
+ else if (child.type === 'element')
703
+ text += getPlainText(child);
704
+ }
705
+ return text;
706
+ }
707
+ function renderLink(node, width, indent, wctx) {
708
+ const hrefRaw = typeof node.properties?.href === 'string' ? node.properties.href : '';
709
+ const resolvedHref = resolveHrefAttribute(hrefRaw, wctx.resolveHref);
710
+ const text = renderInlineChildrenString(node.children, width, indent, wctx);
711
+ const sanitizedHref = stripControlCharacters(resolvedHref);
712
+ const canHyperlink = wctx.color !== 'none' &&
713
+ resolvedHref.trim() !== '' &&
714
+ isSafeUrl(resolvedHref) &&
715
+ sanitizeUrlText(resolvedHref) === resolvedHref;
716
+ if (canHyperlink)
717
+ return hyperlink(text, resolvedHref, wctx.color);
718
+ const plainText = text.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07\x1b]*\x07/g, '');
719
+ if (plainText === sanitizedHref)
720
+ return sanitizedHref;
721
+ return `${text} (${sanitizedHref})`;
722
+ }
723
+ function renderImage(node, wctx) {
724
+ const altRaw = typeof node.properties?.alt === 'string' ? node.properties.alt : '';
725
+ const alt = stripControlCharacters(altRaw);
726
+ const srcRaw = typeof node.properties?.src === 'string' ? node.properties.src : '';
727
+ const resolvedSrc = resolveImageAttribute(srcRaw, wctx.resolveImageSrc);
728
+ const src = stripControlCharacters(resolvedSrc);
729
+ return `[${alt}] (${src})`;
730
+ }
731
+ function renderInlineNodeString(node, width, indent, wctx) {
737
732
  if (node.type === 'text')
738
733
  return stripControlCharacters(node.value);
739
734
  if (node.type !== 'element')
740
735
  return '';
741
- if (node.tagName === DIRECTIVE_TAG)
742
- return renderDirective(node, width, indent, wctx);
743
- if (/^h[1-6]$/.test(node.tagName))
744
- return renderHeading(node, width, indent, wctx);
745
- if (node.tagName === 'p')
746
- return renderParagraph(node, width, indent, wctx);
747
- if (node.tagName === 'blockquote')
748
- return renderBlockquote(node, width, indent, wctx);
749
- if (node.tagName === 'ul' || node.tagName === 'ol')
750
- return renderList(node, width, indent, wctx);
751
- if (node.tagName === 'hr')
752
- return dim(rule(width), wctx.color);
753
- if (node.tagName === 'table')
754
- return renderTable(node, width, wctx);
755
- if (node.tagName === 'pre') {
756
- const marker = renderScriptMarker(node, wctx.color);
757
- if (marker !== undefined)
758
- return marker;
759
- return renderCodeBlock(node, wctx.color);
736
+ switch (node.tagName) {
737
+ case DIRECTIVE_TAG:
738
+ return renderDirectiveString(node, width, indent, wctx);
739
+ case 'strong':
740
+ return bold(renderInlineChildrenString(node.children, width, indent, wctx), wctx.color);
741
+ case 'em':
742
+ return italic(renderInlineChildrenString(node.children, width, indent, wctx), wctx.color);
743
+ case 'del':
744
+ return dim(`~${renderInlineChildrenString(node.children, width, indent, wctx)}~`, wctx.color);
745
+ case 'code': {
746
+ const inner = getPlainText(node);
747
+ return wctx.color === 'none'
748
+ ? dim(inner, wctx.color)
749
+ : inverse(inner, wctx.color);
750
+ }
751
+ case 'a':
752
+ return renderLink(node, width, indent, wctx);
753
+ case 'img':
754
+ return renderImage(node, wctx);
755
+ case 'br':
756
+ return '\n';
757
+ case 'input':
758
+ return '';
759
+ default:
760
+ return renderInlineChildrenString(node.children ?? [], width, indent, wctx);
760
761
  }
761
- return wrap(renderInlineChildren(node.children ?? [], width, indent, wctx), width).join('\n');
762
762
  }
763
- function renderBlocks(nodes, width, indent, wctx) {
763
+ function renderInlineChildrenString(children, width, indent, wctx) {
764
+ let result = '';
765
+ for (const child of children)
766
+ result += renderInlineNodeString(child, width, indent, wctx);
767
+ return result;
768
+ }
769
+ function renderBlocksString(nodes, width, indent, wctx) {
764
770
  const blocks = [];
765
771
  for (const node of nodes) {
766
- const rendered = renderBlock(node, width, indent, wctx);
767
- // Whitespace-only output is dropped, not just the empty string.
768
- // remark-rehype puts a formatting `\n` TEXT node between every pair of
769
- // block elements, and `stripControlCharacters` turns a line feed into a
770
- // space rather than deleting it (see its doc comment: inline text reads
771
- // better that way). Kept, each of those would become a block of its own
772
- // holding one space, so every pair of real blocks would be separated by
773
- // a blank line, a space line, and another blank line instead of a single
774
- // blank line. Nothing a reader can see is ever lost by this test: a
775
- // block whose entire rendered form is whitespace carries no content.
772
+ const rendered = renderBlockString(node, width, indent, wctx);
776
773
  if (rendered.trim() !== '')
777
774
  blocks.push(rendered);
778
775
  }
779
776
  return blocks.join('\n\n');
780
777
  }
778
+ function renderDirectiveString(element, width, indent, wctx) {
779
+ const written = stringProperty(element, 'data-mk-name') ?? '';
780
+ const kind = stringProperty(element, 'data-mk-kind');
781
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
782
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
783
+ if (written === VALUE_DIRECTIVE_NAME) {
784
+ const plainLabel = extractPlainText(element.children);
785
+ const resolved = resolveValue(wctx.scope, plainLabel);
786
+ return renderValueMarker(plainLabel.trim(), resolved, rawAttributes.format ?? undefined, rawAttributes.decimals ?? undefined, wctx.color, wctx.theme);
787
+ }
788
+ const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
789
+ // A `row`/`tabs` reached through STRING mode (nested inside a self-drawing
790
+ // container's own body) falls back to a flattened rendering with no real
791
+ // flex layout / interactivity — see this file's top comment.
792
+ if (isBlock && name === ROW_NAME && hasRealComponent(wctx.registry, name)) {
793
+ return renderRowString(element, aliased, width, indent, wctx);
794
+ }
795
+ if (isBlock && name === TABS_NAME && hasRealComponent(wctx.registry, name)) {
796
+ const panels = extractTabPanels(element, width, indent, wctx);
797
+ return panels
798
+ .map((panel, index) => {
799
+ const heading = bold(`${panel.label}${index === 0 ? ACTIVE_MARKER : ''}`, wctx.color);
800
+ return panel.body ? `${heading}\n${panel.body}` : heading;
801
+ })
802
+ .join('\n\n');
803
+ }
804
+ const ownedAxis = registryLayoutAxis(wctx.registry, name);
805
+ const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, isBlock ? ownedAxis : undefined);
806
+ const isLayoutScope = ownedAxis !== undefined && isBlock;
807
+ const isSelfLayout = isBlock && registrySelfLayout(wctx.registry, name);
808
+ const layoutForWidth = isLayoutScope || isSelfLayout ? layoutResolved : undefined;
809
+ // `card`/`callout`/`details` narrow their own body before drawing a frame
810
+ // or an indent around it. Each computes that inner width from ONLY its
811
+ // own attributes (never from the body), so the walk resolves it here,
812
+ // BEFORE building children, and builds children exactly ONCE at that
813
+ // final width — never at the outer width followed by a re-wrap. A
814
+ // re-wrap-afterward shipped as a real regression (post-phase-2 review):
815
+ // it corrupts anything self-drawing nested inside (a card's frame, a
816
+ // row's columns), since a word-based re-wrapper has no notion that a run
817
+ // of box-drawing characters is not prose. See `resolveCardInnerWidth`'s
818
+ // doc comment.
819
+ let childrenWidth = width;
820
+ if (isBlock && name === 'card') {
821
+ childrenWidth = resolveCardInnerWidth(attributes, layoutForWidth, width);
822
+ }
823
+ else if (isBlock && name === 'callout') {
824
+ childrenWidth = resolveCalloutInnerWidth(attributes, layoutForWidth, width, wctx.glyphs);
825
+ }
826
+ else if (isBlock && name === DETAILS_NAME) {
827
+ childrenWidth = resolveDetailsInnerWidth(width);
828
+ }
829
+ const childrenText = renderDirectiveChildrenString(element.children, childrenWidth, indent, wctx);
830
+ const content = renderDirectiveContentString(name, kind, isBlock, attributes, childrenText, width, indent, wctx, layoutForWidth);
831
+ if (isBlock && layoutResolved && !isLayoutScope && !isSelfLayout) {
832
+ return applyLayout(content, layoutResolved, width);
833
+ }
834
+ return content;
835
+ }
836
+ function hasRealComponent(registry, name) {
837
+ if (!Object.hasOwn(registry, name))
838
+ return false;
839
+ const component = readRegistryComponent(registry[name]);
840
+ if (component == null)
841
+ return false;
842
+ // The element form of a callout replaces the standard component's string
843
+ // drawing. A host that registered its own `callout` keeps its own output,
844
+ // so only the standard one takes the element path.
845
+ if (name === CALLOUT_NAME)
846
+ return component === Callout;
847
+ return true;
848
+ }
849
+ // ---------------------------------------------------------------------------
850
+ // ELEMENT MODE — real Ink elements, used for block sequencing and for
851
+ // `row`/`tabs`/`details`. See this file's top comment.
852
+ // ---------------------------------------------------------------------------
853
+ /** Wraps `element` per `layout`'s resolved width/align, the JSX-based twin of `layout.ts`'s string-based `applyLayout`, for the three directives whose own output is a real Ink element rather than a string. */
854
+ function applyLayoutElement(element, layout, width) {
855
+ if (!layout)
856
+ return element;
857
+ let target = width;
858
+ if (layout.width === 'narrow')
859
+ target = Math.max(20, Math.round(width / 2));
860
+ const align = layout.align;
861
+ const alignItems = align === 'center'
862
+ ? 'center'
863
+ : align === 'right'
864
+ ? 'flex-end'
865
+ : 'flex-start';
866
+ if (target === width && !align)
867
+ return element;
868
+ return (_jsx(Box, { width: width, flexDirection: "column", alignItems: alignItems, children: _jsx(Box, { width: target, flexDirection: "column", children: element }) }));
869
+ }
870
+ function invalidEnumMarkerElement(name, attributes, wctx) {
871
+ const invalidEnum = invalidEnumAttribute(name, attributes);
872
+ if (!invalidEnum)
873
+ return null;
874
+ reportDiagnostic(wctx.onDiagnostic, {
875
+ kind: 'invalid-attribute-value',
876
+ directive: name,
877
+ attribute: invalidEnum.attribute,
878
+ message: invalidAttributeValueTitle(name, invalidEnum.attribute, invalidEnum.value),
879
+ });
880
+ const label = invalidAttributeValueLabel(name, invalidEnum.attribute);
881
+ return styledText(dim(`[${label}]`, wctx.color));
882
+ }
883
+ /**
884
+ * Whether `node` is a block directive whose element form can hold real
885
+ * state or focus (`row`, `tabs`, `details`, `card`, `callout`, `figure`).
886
+ * Used only to decide which children of a justified body stay Ink subtrees.
887
+ */
888
+ function isInteractiveCapableBlock(node, wctx) {
889
+ if (node.type !== 'element' || node.tagName !== DIRECTIVE_TAG)
890
+ return false;
891
+ if (stringProperty(node, 'data-mk-kind') === TEXT_DIRECTIVE_KIND) {
892
+ return false;
893
+ }
894
+ const { name } = resolveDirectiveAlias(wctx.registry, stringProperty(node, 'data-mk-name') ?? '', parseAttributes(stringProperty(node, 'data-mk-attrs')));
895
+ return ((name === ROW_NAME ||
896
+ name === TABS_NAME ||
897
+ name === DETAILS_NAME ||
898
+ name === CARD_NAME ||
899
+ name === CALLOUT_NAME ||
900
+ name === FIGURE_NAME) &&
901
+ hasRealComponent(wctx.registry, name));
902
+ }
903
+ /**
904
+ * The body of a self-drawing container (`card`, `callout`, a `row` cell)
905
+ * as one Ink element, honoring its `text=` alignment.
906
+ *
907
+ * `left` keeps the whole body a real Ink subtree. `center`/`right` justify
908
+ * each block with `text-grid.ts`'s `padText`, the one justification
909
+ * implementation, which needs a string. In the render-once path that means
910
+ * the whole body is one flattened, justified string, exactly as the
911
+ * pre-Ink engine drew it (the committed fixtures depend on it). In the
912
+ * live viewer a block that can hold focus (a `tabs`, a `details`, a nested
913
+ * container) stays an Ink subtree and is placed at the left of the body,
914
+ * because a subtree cannot be justified line by line; every other block is
915
+ * still justified. So the live view trades alignment of those blocks'
916
+ * headings for being usable.
917
+ */
918
+ function renderAlignedBodyElement(nodes, width, indent, wctx, align) {
919
+ if (align === 'left')
920
+ return renderBlocksElement(nodes, width, indent, wctx);
921
+ const justify = (text) => text
922
+ .split('\n')
923
+ .map((line) => padText(line, width, align))
924
+ .join('\n');
925
+ if (!wctx.interactive) {
926
+ const text = renderDirectiveChildrenString(nodes, width, indent, wctx);
927
+ return text === '' ? null : styledText(justify(text));
928
+ }
929
+ const elements = [];
930
+ for (const node of nodes) {
931
+ let el;
932
+ if (isInteractiveCapableBlock(node, wctx)) {
933
+ el = renderBlockElement(node, width, indent, wctx);
934
+ }
935
+ else {
936
+ const text = renderBlockString(node, width, indent, wctx);
937
+ el = text.trim() === '' ? null : styledText(justify(text));
938
+ }
939
+ if (el === null)
940
+ continue;
941
+ elements.push(_jsx(Box, { marginTop: elements.length > 0 ? 1 : 0, flexDirection: "column", children: el }, elements.length));
942
+ }
943
+ return elements.length === 0 ? null : _jsx(_Fragment, { children: elements });
944
+ }
945
+ /**
946
+ * Renders one `row` cell at `cellWidth`. `text=left` (the default) keeps
947
+ * the cell a real Ink element tree (`renderBlockElement`), so anything
948
+ * focusable inside it stays focusable. `text=center`/`text=right` instead
949
+ * falls back to a flattened, pre-justified STRING — batch 11's justification
950
+ * rule: exactly ONE implementation, `text-grid.ts`'s `padText`, never a
951
+ * second Ink-`alignItems`-based mechanism. Before this batch, `row`'s
952
+ * ELEMENT-mode grid silently ignored `text=` altogether (only the STRING-
953
+ * mode fallback, reached when a `row` is nested inside a self-drawing
954
+ * container, honored it); this closes that gap using the SAME helper the
955
+ * STRING mode already uses, rather than inventing a second mechanism.
956
+ */
957
+ function renderRowCellElement(node, cellWidth, indent, wctx, align) {
958
+ if (align === 'left')
959
+ return renderBlockElement(node, cellWidth, indent, wctx);
960
+ if (wctx.interactive) {
961
+ return renderAlignedBodyElement([node], cellWidth, indent, wctx, align);
962
+ }
963
+ const text = renderBlockString(node, cellWidth, indent, wctx);
964
+ if (text.trim() === '')
965
+ return null;
966
+ const justified = text
967
+ .split('\n')
968
+ .map((line) => padText(line, cellWidth, align))
969
+ .join('\n');
970
+ return styledText(justified);
971
+ }
972
+ function renderRowElement(element, attributes, width, indent, wctx) {
973
+ const cellNodes = element.children.filter((node) => !(node.type === 'text' && node.value.trim() === ''));
974
+ if (cellNodes.length === 0)
975
+ return null;
976
+ const layout = resolveRowLayout(attributes, cellNodes.length, width);
977
+ if (layout.stacked) {
978
+ const items = cellNodes
979
+ .map((node, index) => (_jsx(Box, { marginTop: index > 0 ? 1 : 0, flexDirection: "column", children: renderRowCellElement(node, width, indent, wctx, layout.align) }, index)))
980
+ .filter(Boolean);
981
+ return _jsx(Box, { flexDirection: "column", children: items });
982
+ }
983
+ const rows = [];
984
+ for (let index = 0; index < cellNodes.length; index += layout.columnCount) {
985
+ const rowCells = cellNodes.slice(index, index + layout.columnCount);
986
+ rows.push(_jsx(Box, { flexDirection: "row", marginTop: index > 0 ? 1 : 0, children: rowCells.map((node, cellIndex) => (_jsx(Box, { width: layout.colWidth, marginRight: cellIndex < rowCells.length - 1 ? ROW_GUTTER : 0, flexDirection: "column", children: renderRowCellElement(node, layout.colWidth, indent, wctx, layout.align) }, cellIndex))) }, index));
987
+ }
988
+ return _jsx(Box, { flexDirection: "column", children: rows });
989
+ }
990
+ function renderTabsElement(element, width, indent, wctx) {
991
+ const panels = extractTabPanelsElement(element, width, indent, wctx);
992
+ if (panels.length === 0)
993
+ return null;
994
+ const focusId = wctx.interactive ? nextFocusId(wctx) : undefined;
995
+ if (focusId !== undefined) {
996
+ recordFocusable(wctx, {
997
+ kind: 'tabs',
998
+ label: panels[0]?.label ?? DEFAULT_TAB_LABEL,
999
+ });
1000
+ }
1001
+ return (_jsx(InteractiveTabs, { panels: panels, color: wctx.color, glyphs: wctx.glyphs, interactive: wctx.interactive, focusId: focusId }));
1002
+ }
1003
+ function extractTabPanelsElement(element, width, indent, wctx) {
1004
+ const panels = [];
1005
+ for (const node of element.children) {
1006
+ if (node.type === 'text' && node.value.trim() === '')
1007
+ continue;
1008
+ if (node.type !== 'element')
1009
+ continue;
1010
+ let label = DEFAULT_TAB_LABEL;
1011
+ let isTab = false;
1012
+ if (node.tagName === DIRECTIVE_TAG) {
1013
+ const written = stringProperty(node, 'data-mk-name') ?? '';
1014
+ const attrs = parseAttributes(stringProperty(node, 'data-mk-attrs'));
1015
+ const { name } = resolveDirectiveAlias(wctx.registry, written, attrs);
1016
+ if (name === 'tab') {
1017
+ label = attrs.label ?? DEFAULT_TAB_LABEL;
1018
+ isTab = true;
1019
+ }
1020
+ }
1021
+ // See `extractTabPanels`'s (string-mode) identical comment: rendering
1022
+ // the whole `tab` directive here would ALSO invoke `tab.ts`'s own
1023
+ // component, printing the label a second time.
1024
+ const body = isTab
1025
+ ? renderBlocksElement(node.children, width, indent, wctx)
1026
+ : renderBlockElement(node, width, indent, wctx);
1027
+ panels.push({ label, body });
1028
+ }
1029
+ return panels;
1030
+ }
1031
+ function renderDetailsElement(element, attributes, width, indent, wctx) {
1032
+ const title = attributes.title ?? 'Details';
1033
+ const open = Object.hasOwn(attributes, 'open');
1034
+ const innerWidth = resolveDetailsInnerWidth(width);
1035
+ const body = renderBlocksElement(element.children, innerWidth, indent, wctx);
1036
+ const focusId = wctx.interactive ? nextFocusId(wctx) : undefined;
1037
+ if (focusId !== undefined) {
1038
+ recordFocusable(wctx, {
1039
+ kind: 'details',
1040
+ label: stripControlCharacters(title),
1041
+ });
1042
+ }
1043
+ return (_jsx(InteractiveDetails, { title: stripControlCharacters(title), body: body, defaultOpen: open, color: wctx.color, glyphs: wctx.glyphs, interactive: wctx.interactive, focusId: focusId }));
1044
+ }
1045
+ /**
1046
+ * Places a self-sized element (a `card`'s own bordered `Box`, already drawn
1047
+ * at `boxWidth`) within the full `width` per the resolved `align` preset —
1048
+ * the ELEMENT-mode twin of `layout.ts`'s string-based `selfLayoutAlign`,
1049
+ * kept separate from `applyLayoutElement` below because a self-drawing
1050
+ * component (unlike `row`/`tabs`/`details`) has ALREADY resolved its own
1051
+ * width (including `fit`, which `applyLayoutElement` does not implement)
1052
+ * and must never be narrowed a second time.
1053
+ */
1054
+ function selfLayoutAlignElement(element, boxWidth, layout, width) {
1055
+ if (!layout?.align)
1056
+ return element;
1057
+ const alignItems = layout.align === 'center'
1058
+ ? 'center'
1059
+ : layout.align === 'right'
1060
+ ? 'flex-end'
1061
+ : 'flex-start';
1062
+ return (_jsx(Box, { width: width, flexDirection: "column", alignItems: alignItems, children: _jsx(Box, { width: boxWidth, flexDirection: "column", children: element }) }));
1063
+ }
1064
+ /**
1065
+ * `:::card{title="..." text=left|center|right} ... :::` as a real Ink
1066
+ * element (batch 11: card moves off `frameBlock`'s hand-drawn string frame
1067
+ * onto Ink's own `Box` border, so a `tabs`/`details` nested in its body can
1068
+ * become a real focusable — this function's `body` is built through
1069
+ * `renderBlocksElement`, ELEMENT mode, whenever that is possible).
1070
+ *
1071
+ * Ink's border has no primitive for weaving text into an edge, so a titled
1072
+ * card still draws its OWN top line by hand (`frameTopLine`, the exact same
1073
+ * string `frameBlock` would have produced) and tells Ink not to draw a top
1074
+ * border at all; an untitled card lets Ink draw all four sides, which
1075
+ * `frameGlyphs`'s character set makes byte-identical to `frameBlock`'s own
1076
+ * untitled output (proven against the committed fixtures).
1077
+ *
1078
+ * `text=center`/`text=right` (batch 11's justification rule: exactly ONE
1079
+ * implementation, `text-grid.ts`'s `padText`, pre-justifying a STRING before
1080
+ * Ink ever sees it, never a second Ink-`alignItems`-based mechanism) falls
1081
+ * back to building the body as one flattened, pre-justified string, exactly
1082
+ * like the pre-Ink engine did — an accepted, documented trade: a `tabs`/
1083
+ * `details` nested inside a CENTERED or RIGHT-aligned card body is not
1084
+ * focusable, the same "self-drawing container's own body is a fixed string"
1085
+ * boundary this engine already draws elsewhere (this file's top comment).
1086
+ * `text=left` (the default, and by far the common case) keeps full nested
1087
+ * interactivity.
1088
+ */
1089
+ function renderCardElement(element, attributes, width, indent, wctx, layout) {
1090
+ const title = attributes.title ?? null;
1091
+ const titleText = title ? stripControlCharacters(title) : undefined;
1092
+ const rawTextAlign = attributes.text;
1093
+ const align = rawTextAlign && isTextAlign(rawTextAlign) ? rawTextAlign : 'left';
1094
+ const boxWidth = resolveCardBoxWidth({ ...attributes, title: titleText ?? null }, layout, width);
1095
+ const innerWidth = Math.max(1, boxWidth - CARD_FRAME_OVERHEAD);
1096
+ const bodyElement = renderAlignedBodyElement(element.children, innerWidth, indent, wctx, align);
1097
+ const bordered = (_jsx(Box, { width: boxWidth, flexDirection: "column", borderStyle: inkBorderStyle('solid', wctx.glyphs), borderTop: !titleText, children: bodyElement ?? _jsx(Text, { children: " " }) }));
1098
+ const framed = titleText ? (_jsxs(Box, { flexDirection: "column", width: boxWidth, children: [styledText(frameTopLine({
1099
+ style: 'solid',
1100
+ title: titleText,
1101
+ width: boxWidth,
1102
+ glyphs: wctx.glyphs,
1103
+ })), bordered] })) : (bordered);
1104
+ const positioned = selfLayoutAlignElement(framed, boxWidth, layout, width);
1105
+ // The invalid-`text=`-value marker is appended AFTER alignment, on its
1106
+ // own unindented line, exactly like the STRING engine's own
1107
+ // `renderDirectiveContentString` (the marker is never itself aligned or
1108
+ // framed) — see `invalidEnumMarkerElement`'s call sites elsewhere in this
1109
+ // file for the same shape.
1110
+ const marker = invalidEnumMarkerElement(CARD_NAME, attributes, wctx);
1111
+ if (!marker)
1112
+ return positioned;
1113
+ return (_jsxs(Box, { flexDirection: "column", children: [positioned, marker] }));
1114
+ }
1115
+ /**
1116
+ * `:::callout{type=info|warning|danger title="..." text=left|center|right}`
1117
+ * as a real Ink element, so a `tabs`/`details` in its body can be focused.
1118
+ *
1119
+ * The colored bar is the left border of an Ink `Box`, one column wide, whose
1120
+ * border glyph is the bar already styled by this engine's own SGR helpers
1121
+ * (never an Ink color prop). Ink repeats that glyph down the full height of
1122
+ * the body, so the bar needs no knowledge of the body's line count. The
1123
+ * bar glyph's trailing space becomes the box's left padding, so the layout
1124
+ * matches the string form column for column.
1125
+ *
1126
+ * The icon/label line and the title line are justified per `text=` with
1127
+ * `padText`; the body goes through `renderAlignedBodyElement`.
1128
+ */
1129
+ function renderCalloutElement(element, attributes, width, indent, wctx, layout) {
1130
+ const type = resolveCalloutType(attributes);
1131
+ const token = CALLOUT_TOKENS[type];
1132
+ const titleText = attributes.title
1133
+ ? stripControlCharacters(attributes.title)
1134
+ : undefined;
1135
+ const rawTextAlign = attributes.text;
1136
+ const align = rawTextAlign && isTextAlign(rawTextAlign) ? rawTextAlign : 'left';
1137
+ const { glyphs, color, theme } = wctx;
1138
+ const barWidth = measureWidth(glyphs.calloutBar);
1139
+ const boxWidth = resolveCalloutBoxWidth(type, titleText ?? null, layout, width, glyphs);
1140
+ const innerWidth = Math.max(1, boxWidth - barWidth);
1141
+ const placeLine = (line) => align === 'left' ? line : padText(line, innerWidth, align);
1142
+ const barGlyph = glyphs.calloutBar.trimEnd();
1143
+ const border = {
1144
+ topLeft: '',
1145
+ top: '',
1146
+ topRight: '',
1147
+ right: '',
1148
+ bottomRight: '',
1149
+ bottom: '',
1150
+ bottomLeft: '',
1151
+ left: style(barGlyph, token, theme, color),
1152
+ };
1153
+ const body = renderAlignedBodyElement(element.children, innerWidth, indent, wctx, align);
1154
+ const box = (_jsxs(Box, { width: boxWidth, flexDirection: "column", borderStyle: border, borderTop: false, borderRight: false, borderBottom: false, paddingLeft: barWidth - measureWidth(barGlyph), children: [styledText(placeLine(style(headerTextFor(type, glyphs), token, theme, color))), titleText ? styledText(placeLine(bold(titleText, color))) : null, body] }));
1155
+ const positioned = selfLayoutAlignElement(box, boxWidth, layout, width);
1156
+ const marker = invalidEnumMarkerElement(CALLOUT_NAME, attributes, wctx);
1157
+ if (!marker)
1158
+ return positioned;
1159
+ return (_jsxs(Box, { flexDirection: "column", children: [positioned, marker] }));
1160
+ }
1161
+ /**
1162
+ * `:::figure{src="..." alt="..."} caption markdown :::` as a real Ink
1163
+ * element (batch 11: `figure` never drew a hand-drawn frame — it is a plain
1164
+ * dim alt/src line followed by its caption body — but its body still only
1165
+ * ever reached STRING mode before this change, so a `tabs`/`details` in its
1166
+ * caption could never become focusable. `body` here is built through
1167
+ * `renderBlocksElement`, ELEMENT mode, exactly mirroring `figure.ts`'s own
1168
+ * STRING-mode component line for line: the header lines (alt/src/refused)
1169
+ * stack directly above the caption with NO blank row between them, matching
1170
+ * `Figure`'s own `lines.join('\n')` (a single line break, not a blank
1171
+ * paragraph gap) — `Box`'s default zero margin between column children
1172
+ * reproduces that exactly.
1173
+ */
1174
+ const FIGURE_DIRECTIVE_NAME = 'figure';
1175
+ function renderFigureElement(element, attributes, width, indent, wctx) {
1176
+ const rawSrc = attributes.src ?? null;
1177
+ const alt = attributes.alt ?? '';
1178
+ const refused = Boolean(rawSrc) && !isSafeUrl(rawSrc);
1179
+ const safeSrc = rawSrc && !refused ? rawSrc : null;
1180
+ const src = safeSrc
1181
+ ? stripControlCharacters(resolveImageAttribute(safeSrc, wctx.resolveImageSrc))
1182
+ : null;
1183
+ const headerLines = [];
1184
+ if (rawSrc) {
1185
+ if (alt) {
1186
+ headerLines.push(dim(`alt: ${stripControlCharacters(alt)}`, wctx.color));
1187
+ }
1188
+ if (refused) {
1189
+ reportDiagnostic(wctx.onDiagnostic, {
1190
+ kind: 'unsafe-image-src',
1191
+ directive: FIGURE_DIRECTIVE_NAME,
1192
+ message: unsafeImageSrcTitle(FIGURE_DIRECTIVE_NAME),
1193
+ });
1194
+ headerLines.push(dim(`[${unsafeImageSrcLabel(FIGURE_DIRECTIVE_NAME)}]`, wctx.color));
1195
+ }
1196
+ else if (src) {
1197
+ headerLines.push(dim(src, wctx.color));
1198
+ }
1199
+ }
1200
+ const headerElement = headerLines.length > 0 ? styledText(headerLines.join('\n')) : null;
1201
+ const body = renderBlocksElement(element.children, width, indent, wctx);
1202
+ if (!headerElement && !body)
1203
+ return null;
1204
+ return (_jsxs(Box, { flexDirection: "column", children: [headerElement, body] }));
1205
+ }
1206
+ /** Renders one directive as a real Ink element (block position only). Dispatches `row`/`tabs`/`details`/`card`/`figure` to their real builders; every other directive falls back to the string engine, wrapped in one `<Text>`. */
1207
+ function renderDirectiveElement(element, width, indent, wctx) {
1208
+ const written = stringProperty(element, 'data-mk-name') ?? '';
1209
+ const kind = stringProperty(element, 'data-mk-kind');
1210
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
1211
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
1212
+ if (!isBlock) {
1213
+ // A stray inline (text) directive at a block position (rare; hast
1214
+ // structure keeps these inside a paragraph in practice) still renders,
1215
+ // via the string engine.
1216
+ return styledText(renderDirectiveString(element, width, indent, wctx));
1217
+ }
1218
+ const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
1219
+ const entry = Object.hasOwn(wctx.registry, name)
1220
+ ? wctx.registry[name]
1221
+ : undefined;
1222
+ const formMismatch = entry ? isFormMismatch(entry, kind) : false;
1223
+ if (!formMismatch &&
1224
+ (name === ROW_NAME ||
1225
+ name === TABS_NAME ||
1226
+ name === DETAILS_NAME ||
1227
+ name === CARD_NAME ||
1228
+ name === CALLOUT_NAME ||
1229
+ name === FIGURE_NAME) &&
1230
+ hasRealComponent(wctx.registry, name)) {
1231
+ // None of these six own a layout axis (`registryLayoutAxis` is
1232
+ // undefined for all of them in the standard registry), so, unlike a
1233
+ // layout-wrapper scope, a `width=`/`align=` the author wrote still needs
1234
+ // resolving here rather than being handled by the directive's own name.
1235
+ const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, undefined);
1236
+ try {
1237
+ if (name === CARD_NAME) {
1238
+ // `card` resolves its OWN width (including `fit`, which the generic
1239
+ // `applyLayoutElement` below does not implement) and aligns itself
1240
+ // with `selfLayoutAlignElement` — see `renderCardElement`'s doc
1241
+ // comment. It never goes through the generic post-render wrap.
1242
+ return renderCardElement(element, attributes, width, indent, wctx, layoutResolved);
1243
+ }
1244
+ if (name === CALLOUT_NAME && !wctx.interactive && layoutResolved?.align) {
1245
+ // The string form places each line of a callout on its own, so
1246
+ // `align=` leaves the bars ragged; the element form places the block
1247
+ // as a unit. The render-once output stays the string form's, byte for
1248
+ // byte, and only the live viewer places the callout as a unit.
1249
+ return styledText(renderDirectiveString(element, width, indent, wctx));
1250
+ }
1251
+ if (name === CALLOUT_NAME) {
1252
+ // Self-sized and self-aligned like `card`: see `renderCalloutElement`.
1253
+ return renderCalloutElement(element, attributes, width, indent, wctx, layoutResolved);
1254
+ }
1255
+ let el = null;
1256
+ if (name === ROW_NAME) {
1257
+ el = renderRowElement(element, attributes, width, indent, wctx);
1258
+ }
1259
+ else if (name === TABS_NAME) {
1260
+ el = renderTabsElement(element, width, indent, wctx);
1261
+ }
1262
+ else if (name === DETAILS_NAME) {
1263
+ el = renderDetailsElement(element, attributes, width, indent, wctx);
1264
+ }
1265
+ else {
1266
+ el = renderFigureElement(element, attributes, width, indent, wctx);
1267
+ }
1268
+ if (!el)
1269
+ return null;
1270
+ const marker = invalidEnumMarkerElement(name, attributes, wctx);
1271
+ const withMarker = marker ? (_jsxs(Box, { flexDirection: "column", children: [el, marker] })) : (el);
1272
+ return applyLayoutElement(withMarker, layoutResolved, width);
1273
+ }
1274
+ catch {
1275
+ return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color, wctx.glyphs));
1276
+ }
1277
+ }
1278
+ return styledText(renderDirectiveString(element, width, indent, wctx));
1279
+ }
1280
+ /** Renders one block-level hast node as a real Ink element, or `null` for a node that carries no content (whitespace-only text). */
1281
+ function renderBlockElement(node, width, indent, wctx) {
1282
+ if (node.type === 'text') {
1283
+ return node.value.trim() === ''
1284
+ ? null
1285
+ : styledText(stripControlCharacters(node.value));
1286
+ }
1287
+ if (node.type !== 'element')
1288
+ return null;
1289
+ if (node.tagName === DIRECTIVE_TAG) {
1290
+ return renderDirectiveElement(node, width, indent, wctx);
1291
+ }
1292
+ if (node.tagName === 'blockquote') {
1293
+ const innerWidth = Math.max(1, width - 2);
1294
+ const inner = renderBlocksElement(node.children, innerWidth, `${indent}${wctx.glyphs.quoteBar}`, wctx);
1295
+ if (!inner)
1296
+ return null;
1297
+ return (_jsxs(Box, { width: width, children: [styledText(dim(wctx.glyphs.quoteBar, wctx.color)), _jsx(Box, { flexDirection: "column", width: innerWidth, children: inner })] }));
1298
+ }
1299
+ if (node.tagName === 'ul' || node.tagName === 'ol') {
1300
+ return renderListElement(node, width, indent, wctx);
1301
+ }
1302
+ const text = renderBlockString(node, width, indent, wctx);
1303
+ if (text.trim() === '')
1304
+ return null;
1305
+ return styledText(text);
1306
+ }
1307
+ function renderListElement(el, width, indent, wctx) {
1308
+ const ordered = el.tagName === 'ol';
1309
+ const startRaw = el.properties?.start;
1310
+ const start = ordered && typeof startRaw === 'number' ? startRaw : 1;
1311
+ const items = el.children.filter((child) => child.type === 'element' && child.tagName === 'li');
1312
+ if (items.length === 0)
1313
+ return null;
1314
+ const items$ = items.map((li, index) => {
1315
+ const marker = ordered ? `${start + index}. ` : '- ';
1316
+ const task = taskState(li);
1317
+ const contentChildren = li.children.filter((child) => !(task && child.type === 'element' && child.tagName === 'input'));
1318
+ const prefix = `${marker}${task ? (task.checked ? '[x] ' : '[ ] ') : ''}`;
1319
+ const prefixWidth = measureWidth(prefix);
1320
+ const bodyWidth = Math.max(1, width - prefixWidth);
1321
+ const nestedLists = contentChildren.filter((child) => child.type === 'element' &&
1322
+ (child.tagName === 'ul' || child.tagName === 'ol'));
1323
+ const restChildren = contentChildren.filter((child) => !nestedLists.includes(child));
1324
+ const bodyIsBlock = isBlockLevelChildren(restChildren);
1325
+ const bodyText = bodyIsBlock
1326
+ ? undefined
1327
+ : wrapText(renderInlineChildrenString(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
1328
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [styledText(prefix), _jsx(Box, { width: bodyWidth, flexDirection: "column", children: bodyIsBlock
1329
+ ? renderBlocksElement(restChildren, bodyWidth, indent, wctx)
1330
+ : styledText(bodyText ?? '') })] }), nestedLists.length > 0 && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: nestedLists.map((nested, nestedIndex) => (_jsx(Fragment, { children: renderBlockElement(nested, Math.max(1, width - 2), `${indent} `, wctx) }, nestedIndex))) }))] }, index));
1331
+ });
1332
+ return _jsx(Box, { flexDirection: "column", children: items$ });
1333
+ }
1334
+ /** Renders a sequence of block-level hast nodes as a real Ink element (a column `Box`), one blank row (`marginTop`) between consecutive non-empty blocks — the Ink-element twin of `renderBlocksString`'s `'\n\n'.join`. */
1335
+ function renderBlocksElement(nodes, width, indent, wctx) {
1336
+ const elements = [];
1337
+ for (const node of nodes) {
1338
+ const el = renderBlockElement(node, width, indent, wctx);
1339
+ if (el === null)
1340
+ continue;
1341
+ elements.push(_jsx(Box, { marginTop: elements.length > 0 ? 1 : 0, flexDirection: "column", children: el }, elements.length));
1342
+ }
1343
+ if (elements.length === 0)
1344
+ return null;
1345
+ return _jsx(_Fragment, { children: elements });
1346
+ }
781
1347
  /** Collapses trailing blank lines to exactly one terminating `\n`, and leaves an empty document as `''`. */
782
1348
  function finalizeOutput(text) {
783
1349
  if (text === '')
@@ -789,34 +1355,45 @@ function renderFailureFallback(error, color) {
789
1355
  const message = error instanceof Error ? error.message : String(error);
790
1356
  return `${dim('failed to render document', color)}\n${stripControlCharacters(message)}\n`;
791
1357
  }
792
- function renderRoot(root, registry, scope, color, theme, resolveImageSrc, resolveHref, onDiagnostic, width) {
793
- const wctx = {
1358
+ function buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable) {
1359
+ return {
794
1360
  registry,
795
1361
  scope,
796
1362
  color,
797
1363
  theme,
1364
+ glyphs,
1365
+ interactive,
798
1366
  resolveImageSrc,
799
1367
  resolveHref,
800
1368
  onDiagnostic,
1369
+ unavailable,
1370
+ focusCounter: { current: 0 },
1371
+ focusables: undefined,
801
1372
  };
802
- return finalizeOutput(renderBlocks(root.children, width, '', wctx));
803
1373
  }
804
1374
  /** The columns assumed when a caller supplies no `width` option. */
805
1375
  const DEFAULT_WIDTH = 80;
1376
+ /** Builds the root Ink element for `root`, given every resolved render input. Exported for `buildMarkElement` (the live-viewer entry point, phase 3) as well as this module's own three string-producing entry points. */
1377
+ function buildRootElement(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width) {
1378
+ const wctx = buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable);
1379
+ const body = renderBlocksElement(root.children, width, '', wctx);
1380
+ return (_jsx(Box, { width: width, flexDirection: "column", children: body ?? _jsx(Text, { children: " " }) }));
1381
+ }
1382
+ async function renderRootToString(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width) {
1383
+ const element = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width);
1384
+ const raw = await renderInkToString(element, width);
1385
+ return finalizeOutput(raw);
1386
+ }
806
1387
  /**
807
1388
  * Renders Markii text to a plain string (optionally carrying ANSI SGR/OSC 8
808
1389
  * escapes) using `registry` to resolve directive names. `registry` is
809
1390
  * OPTIONAL here, unlike the other two engines' required parameter, and
810
- * defaults to `defaultAnsiRegistry` (empty in this phase; every directive
811
- * therefore renders the unknown-component fallback).
1391
+ * defaults to `defaultAnsiRegistry`.
812
1392
  *
813
- * Pipeline: `@markii/core`'s `toHast` (parse -> tag directive nodes ->
814
- * remark-rehype -> sanitize URLs) -> a width-aware hast walk that swaps
815
- * directive elements for registry components (or the unknown-directive
816
- * fallback), folds script fences into a one-line marker, and wraps every
817
- * paragraph, heading, and list item to `options.width` (default 80). Pure
818
- * and never-throwing: parsing is tolerant, unknown names always render a
819
- * fallback, and any unexpected internal error degrades to the "failed to
1393
+ * `async` since the first call in a process pays Ink/Yoga's one-time module-
1394
+ * evaluation cost (`ink-string.ts`'s doc comment; `SPIKE-10-findings.md`'s
1395
+ * "Item 3"). Never throws: parsing is tolerant, unknown names always render
1396
+ * a fallback, and any unexpected internal error degrades to the "failed to
820
1397
  * render document" line.
821
1398
  *
822
1399
  * `options.color` resolves via `./ansi.js`'s `resolveColorOption`: `'auto'`
@@ -824,14 +1401,11 @@ const DEFAULT_WIDTH = 80;
824
1401
  * environment knowledge of its own — a caller that wants automatic
825
1402
  * detection calls `detectColorLevel` itself and passes the result as
826
1403
  * `'16'`/`'256'`/`'truecolor'`.
827
- *
828
- * `store`/`vault` and `resolveImageSrc`/`resolveHref`/`onDiagnostic` carry
829
- * the same meaning as `@markii/html`'s identical options.
830
1404
  */
831
- export function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1405
+ export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
832
1406
  const color = resolveColorOption(options?.color);
833
1407
  try {
834
- return renderRoot(toHast(text), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
1408
+ return await renderRootToString(toHast(text), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, glyphsFor(options?.box), options?.interactive ?? false, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, createUnavailableComponentLookup(options?.unavailableComponents), options?.width ?? DEFAULT_WIDTH);
835
1409
  }
836
1410
  catch (error) {
837
1411
  return renderFailureFallback(error, color);
@@ -839,21 +1413,20 @@ export function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, va
839
1413
  }
840
1414
  /**
841
1415
  * The block-level twin of `renderMarkToAnsi`: renders one already-parsed
842
- * mdast node OR a whole already-parsed mdast document (`@markii/core`'s
843
- * `MarkNode`, or the `Root` `parse` itself returns) instead of raw document
844
- * text. Same registry resolution, same fallbacks, same purity and
1416
+ * mdast node OR a whole already-parsed mdast document instead of raw
1417
+ * document text. Same registry resolution, same fallbacks, same purity and
845
1418
  * never-throw guarantees, same options.
846
1419
  */
847
- export function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
1420
+ export async function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
848
1421
  const color = resolveColorOption(options?.color);
849
1422
  try {
850
- return renderRoot(nodeOrRootToHast(node), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
1423
+ return await renderRootToString(nodeOrRootToHast(node), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, glyphsFor(options?.box), options?.interactive ?? false, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, createUnavailableComponentLookup(options?.unavailableComponents), options?.width ?? DEFAULT_WIDTH);
851
1424
  }
852
1425
  catch (error) {
853
1426
  return renderFailureFallback(error, color);
854
1427
  }
855
1428
  }
856
- /** Whether `root` is exactly one paragraph holding exactly one text directive (`:name[...]`) and nothing else. Mirrors `@markii/html`'s identical `loneInlineDirective`. */
1429
+ /** Whether `root` is exactly one paragraph holding exactly one text directive (`:name[...]`) and nothing else. */
857
1430
  function loneInlineDirective(root) {
858
1431
  if (root.children.length !== 1)
859
1432
  return undefined;
@@ -869,17 +1442,15 @@ function loneInlineDirective(root) {
869
1442
  }
870
1443
  /**
871
1444
  * Renders `text` as a single, standalone inline directive when that is all
872
- * it is, mirroring `@markii/html`'s identical `renderMarkInlineToHtml`: a
873
- * source whose only parsed block is a paragraph holding exactly one text
874
- * directive renders that directive alone. Any other source falls back to
875
- * the exact same rendering `renderMarkToAnsi` would produce.
1445
+ * it is. Any other source falls back to the exact same rendering
1446
+ * `renderMarkToAnsi` would produce.
876
1447
  */
877
- export function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1448
+ export async function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
878
1449
  try {
879
1450
  const root = parse(text);
880
1451
  const lone = loneInlineDirective(root);
881
1452
  if (lone) {
882
- return renderMarkNodeToAnsi(lone, registry, store, vault, options);
1453
+ return await renderMarkNodeToAnsi(lone, registry, store, vault, options);
883
1454
  }
884
1455
  }
885
1456
  catch {
@@ -887,3 +1458,53 @@ export function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, sto
887
1458
  }
888
1459
  return renderMarkToAnsi(text, registry, store, vault, options);
889
1460
  }
1461
+ /**
1462
+ * The live-viewer entry point (phase 3 consumes this): builds the same Ink
1463
+ * element tree `renderMarkToAnsi` would render to a string, but returns the
1464
+ * element itself so a host (the CLI's `markii view`) can mount it directly
1465
+ * with Ink's own `render()` instead of re-rendering a string on every
1466
+ * keystroke/resize. `document` accepts raw text OR an already-parsed mdast
1467
+ * node/root, mirroring the three string entry points' flexibility.
1468
+ */
1469
+ export function buildMarkElement(document, registry = defaultAnsiRegistry, options) {
1470
+ const color = resolveColorOption(options?.color);
1471
+ const theme = options?.theme ?? defaultAnsiTheme;
1472
+ const glyphs = glyphsFor(options?.box);
1473
+ const scope = { store: options?.store, vault: options?.vault };
1474
+ const width = options?.width ?? DEFAULT_WIDTH;
1475
+ const interactive = options?.interactive ?? true;
1476
+ let root;
1477
+ try {
1478
+ root =
1479
+ typeof document === 'string'
1480
+ ? toHast(document)
1481
+ : nodeOrRootToHast(document);
1482
+ }
1483
+ catch {
1484
+ root = { type: 'root', children: [] };
1485
+ }
1486
+ const unavailable = createUnavailableComponentLookup(options?.unavailableComponents);
1487
+ const content = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, unavailable, width);
1488
+ if (!interactive)
1489
+ return content;
1490
+ // The walk above already assigned every focusable component's `focusId`
1491
+ // (`WalkContext.focusCounter`) and, incidentally, discarded each one's
1492
+ // descriptor (`WalkContext.focusables` is left `undefined` on the real
1493
+ // render, since nothing there needs it). Rebuilding once more, this time
1494
+ // collecting descriptors, is wasteful only in the sense that the tree is
1495
+ // constructed twice; simpler and safer than threading mutable state out
1496
+ // of a pure element-builder, and this entry point is called once per
1497
+ // mount/resize, not per keystroke.
1498
+ const counter = { current: 0 };
1499
+ const focusables = [];
1500
+ const countingWctx = buildWalkContext(registry, scope, color, theme, glyphs, true, options?.resolveImageSrc, options?.resolveHref,
1501
+ // No `onDiagnostic`: the real walk above already reported every notice
1502
+ // this document produces, and this second walk exists only to collect
1503
+ // focus descriptors. Passing the callback here would report each notice
1504
+ // twice on every mount and again on every resize.
1505
+ undefined, unavailable);
1506
+ countingWctx.focusCounter = counter;
1507
+ countingWctx.focusables = focusables;
1508
+ renderBlocksElement(root.children, width, '', countingWctx);
1509
+ return (_jsx(InteractiveRoot, { focusables: focusables, onExit: options?.onExit, color: color, theme: theme, keyLegend: glyphs.keyLegend, viewportRows: options?.viewportRows, width: width, children: content }));
1510
+ }