@markii/ansi 0.15.0 → 0.16.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 (60) hide show
  1. package/dist/ansi.d.ts +26 -0
  2. package/dist/ansi.js +51 -0
  3. package/dist/components/badge.d.ts +1 -1
  4. package/dist/components/badge.js +5 -4
  5. package/dist/components/callout.d.ts +24 -13
  6. package/dist/components/callout.js +45 -21
  7. package/dist/components/card.d.ts +24 -2
  8. package/dist/components/card.js +41 -12
  9. package/dist/components/cell.d.ts +5 -11
  10. package/dist/components/cell.js +11 -15
  11. package/dist/components/chart.d.ts +4 -10
  12. package/dist/components/chart.js +9 -16
  13. package/dist/components/details-string.d.ts +26 -0
  14. package/dist/components/details-string.js +39 -0
  15. package/dist/components/details.d.ts +30 -10
  16. package/dist/components/details.js +27 -25
  17. package/dist/components/divider.js +9 -11
  18. package/dist/components/figure.d.ts +5 -16
  19. package/dist/components/figure.js +9 -19
  20. package/dist/components/index.d.ts +14 -10
  21. package/dist/components/index.js +19 -16
  22. package/dist/components/kbd.d.ts +1 -1
  23. package/dist/components/kbd.js +3 -2
  24. package/dist/components/layout-wrapper.d.ts +7 -22
  25. package/dist/components/layout-wrapper.js +9 -23
  26. package/dist/components/progress.d.ts +3 -11
  27. package/dist/components/progress.js +6 -14
  28. package/dist/components/rating.d.ts +1 -2
  29. package/dist/components/rating.js +2 -3
  30. package/dist/components/row.d.ts +33 -20
  31. package/dist/components/row.js +33 -51
  32. package/dist/components/stat.d.ts +3 -8
  33. package/dist/components/stat.js +4 -9
  34. package/dist/components/tab.d.ts +9 -8
  35. package/dist/components/tab.js +12 -10
  36. package/dist/components/table-grid.js +1 -2
  37. package/dist/components/table.d.ts +5 -10
  38. package/dist/components/table.js +7 -12
  39. package/dist/components/tabs-string.d.ts +17 -0
  40. package/dist/components/tabs-string.js +30 -0
  41. package/dist/components/tabs.d.ts +29 -14
  42. package/dist/components/tabs.js +37 -30
  43. package/dist/index.d.ts +2 -4
  44. package/dist/index.js +6 -7
  45. package/dist/ink-string.d.ts +12 -0
  46. package/dist/ink-string.js +41 -0
  47. package/dist/interactive.d.ts +41 -0
  48. package/dist/interactive.js +64 -0
  49. package/dist/layout.js +1 -2
  50. package/dist/registry.d.ts +91 -100
  51. package/dist/registry.js +17 -9
  52. package/dist/render.d.ts +39 -24
  53. package/dist/render.js +676 -373
  54. package/dist/text-grid.d.ts +54 -0
  55. package/dist/text-grid.js +249 -0
  56. package/package.json +7 -4
  57. package/dist/box.d.ts +0 -47
  58. package/dist/box.js +0 -209
  59. package/dist/measure.d.ts +0 -25
  60. package/dist/measure.js +0 -108
package/dist/render.js CHANGED
@@ -1,9 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Fragment } 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 { frameBlock, measureWidth, padText, ruleLine, wrapText, } from './text-grid.js';
9
+ import { drawTableGrid } from './components/table-grid.js';
10
+ import { resolveRowLayout } from './components/row.js';
11
+ import { resolveCardInnerWidth } from './components/card.js';
12
+ import { resolveCalloutInnerWidth } 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';
@@ -13,28 +20,55 @@ import { 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 } from './ink-string.js';
24
+ import { InteractiveRoot } from './interactive.js';
25
+ import { InteractiveTabs } from './components/tabs.js';
26
+ import { InteractiveDetails } from './components/details.js';
17
27
  /**
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.
28
+ * The hast walk for the terminal engine (batch 10: the Ink rewrite). Every
29
+ * DECISION this walk makes is unchanged from the pre-Ink engine (alias
30
+ * resolution, layout resolution, data binding, form mismatch, invalid-enum
31
+ * notice, empty-inline notice, never-throw component containment, script-
32
+ * fence markers, link and image handling) — see AGENTS.md's architecture
33
+ * rules and the batch-10 brief. What changed is the OUTPUT shape: instead of
34
+ * building one big plain string, the walk builds a tree of Ink elements
35
+ * (`Box`/`Text`) and hands it to `ink-string.ts` (the render-once path) or
36
+ * returns it directly (the new live-viewer export, `buildMarkElement`).
37
+ *
38
+ * Two rendering "modes" coexist deliberately, and BOTH still exist in the
39
+ * pre-Ink engine's shape:
24
40
  *
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.
41
+ * - STRING mode (`render*String` functions below): the pre-Ink algorithm,
42
+ * almost verbatim, still building one flattened, pre-wrapped, pre-aligned
43
+ * string. This is what every standard component's OWN body still uses —
44
+ * `card` frames a string, `callout` bars a string, `details` indents a
45
+ * string — because these components draw FIXED-WIDTH GLYPHS that must be
46
+ * correct the moment the string is built; Ink lays a tree out AFTER it is
47
+ * built, so it cannot reflow a box-drawing frame or re-justify per-line
48
+ * text alignment the way `text=center` needs (Ink has no per-line
49
+ * paragraph-justify primitive). String mode is also the ONLY mode inline
50
+ * content ever uses, at any nesting depth: an inline directive's body is
51
+ * phrasing content by definition (docs/spec.md), never a layout the reader
52
+ * would want Ink to lay out as boxes.
53
+ * - ELEMENT mode (`render*Element` functions below): real Ink elements,
54
+ * used for the document's own block sequencing (so ordinary prose wraps
55
+ * through Ink's own layout instead of this engine's manual `wrap()`) and
56
+ * for the three block directives that genuinely need Ink's real layout or
57
+ * real component state: `row` (a flex `Box` row instead of the deleted
58
+ * `columns()` string helper), `tabs` and `details` (real React state, for
59
+ * `ctx.interactive`'s keyboard-driven switching/toggling). A `row`/`tabs`/
60
+ * `details` reached through a STRING-mode ancestor (nested inside `card`,
61
+ * `callout`, `figure`, a standalone `cell`, or a GFM table cell) falls
62
+ * back to a flattened STRING rendering of itself with no interactivity —
63
+ * an accepted, documented boundary: a self-drawing container's body is, by
64
+ * its own nature, a fixed string, so nothing nested inside one can be
65
+ * live. See this file's `renderRowString`/`renderTabsString` for that
66
+ * fallback.
32
67
  *
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.
68
+ * Interactive components have no meaning for a NON-interactive render at
69
+ * all: `@markii/stdlib`'s `INTERACTIVE_ATTRIBUTE` marks an element a HOST
70
+ * should treat as a live control (a script-marker `<summary>`, say); this
71
+ * engine never reads that attribute and never emits a marker for it.
38
72
  */
39
73
  /** The hast tag name `@markii/core` marks every directive with. */
40
74
  const DIRECTIVE_TAG = 'mk-directive';
@@ -46,6 +80,10 @@ const VALUE_DIRECTIVE_NAME = 'value';
46
80
  const DATA_ATTRIBUTE_KEY = 'data';
47
81
  /** The attribute `@markii/core` preserves a code fence's raw `meta` string onto. */
48
82
  const CODE_META_ATTR = 'data-mk-meta';
83
+ /** The three block directive names given real Ink layout/state (see this file's top comment). */
84
+ const ROW_NAME = 'row';
85
+ const TABS_NAME = 'tabs';
86
+ const DETAILS_NAME = 'details';
49
87
  /** Block-level hast tags this engine recognizes; anything else inside a directive's children is treated as inline content. */
50
88
  const BLOCK_TAGS = new Set([
51
89
  'p',
@@ -74,6 +112,30 @@ function nodeOrRootToHast(node) {
74
112
  }
75
113
  return { type: 'root', children };
76
114
  }
115
+ function nextFocusId(wctx) {
116
+ const id = wctx.focusCounter.current;
117
+ wctx.focusCounter.current += 1;
118
+ return id;
119
+ }
120
+ /** 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. */
121
+ function recordFocusable(wctx, descriptor) {
122
+ wctx.focusables?.push(descriptor);
123
+ }
124
+ /**
125
+ * The ONE place a STRING built by this file's string engine becomes an Ink
126
+ * `<Text>` leaf. Every such string passes through `ansi.js`'s
127
+ * `carrySgrAcrossLines` first — see that function's doc comment for why:
128
+ * Ink resets SGR state at every embedded line boundary within one `<Text>`
129
+ * value, so a multi-line styled string (a dim-wrapped frame, a wrapped
130
+ * bold heading, a paragraph whose bold span straddled a wrap point) must
131
+ * have every physical line already self-contained before Ink ever sees it.
132
+ * This is the SYSTEMATIC fix (a single choke point) rather than a per-
133
+ * call-site one: every `<Text>` in this file that renders a plain string
134
+ * uses this, never a bare `<Text>{str}</Text>`.
135
+ */
136
+ function styledText(text) {
137
+ return _jsx(Text, { children: carrySgrAcrossLines(text) });
138
+ }
77
139
  /** A directive property read back as a string, or `undefined` if absent or non-string. */
78
140
  function stringProperty(element, name) {
79
141
  const value = element.properties?.[name];
@@ -141,30 +203,9 @@ function fallbackLabel(name, inline, reason) {
141
203
  }
142
204
  return `unknown component ${code}`;
143
205
  }
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
206
  /** 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
207
  function unknownDirective(name, inline, childrenText, reason, width, color) {
162
208
  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
209
  if (inline) {
169
210
  return childrenText === ''
170
211
  ? dim(label, color)
@@ -174,24 +215,23 @@ function unknownDirective(name, inline, childrenText, reason, width, color) {
174
215
  const wrappedChildren = childrenText
175
216
  ? childrenText
176
217
  .split('\n')
177
- .flatMap((line) => wrap(line, innerWidth))
218
+ .flatMap((line) => wrapText(line, innerWidth))
178
219
  .join('\n')
179
220
  : '';
180
221
  const body = wrappedChildren ? `${label}\n\n${wrappedChildren}` : label;
181
- return dim(frame(body, { style: 'dashed', width }), color);
222
+ return dim(frameBlock(body, { style: 'dashed', width }), color);
182
223
  }
183
224
  /** A registered component that threw while rendering, contained to a neutral box rather than failing the whole document. Never dumps the error text. */
184
225
  function componentError(name, inline, childrenText, width, color) {
185
226
  const label = `component \`${stripControlCharacters(name)}\` failed to render`;
186
- // Same boundary rule as `unknownDirective`'s inline branch above.
187
227
  if (inline) {
188
228
  return childrenText === ''
189
229
  ? dim(label, color)
190
230
  : `${dim(`${label}:`, color)} ${childrenText}`;
191
231
  }
192
- return dim(frame(label, { style: 'dashed', width }), color);
232
+ return dim(frameBlock(label, { style: 'dashed', width }), color);
193
233
  }
194
- /** Splits a `data=<name>` attribute off `attributes` and resolves it against `scope`. Never throws. Mirrors `@markii/html`'s `resolveDataAttribute`. */
234
+ /** Splits a `data=<name>` attribute off `attributes` and resolves it against `scope`. Never throws. */
195
235
  function resolveDataAttribute(attributes, scope) {
196
236
  if (!Object.hasOwn(attributes, DATA_ATTRIBUTE_KEY))
197
237
  return { attributes };
@@ -207,7 +247,7 @@ function resolveDataAttribute(attributes, scope) {
207
247
  dataFailureKind: resolved.status === 'error' ? resolved.failureKind : undefined,
208
248
  };
209
249
  }
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. */
250
+ /** Whether one of `name`'s `@markii/stdlib` contract attributes with a closed `enum` is present with a value outside it. */
211
251
  function invalidEnumAttribute(name, attributes) {
212
252
  const contract = getContract(name);
213
253
  if (!contract)
@@ -231,14 +271,7 @@ function resolveValue(scope, name) {
231
271
  return { value: undefined, status: 'missing' };
232
272
  return resolveScopedPath(scope, trimmed);
233
273
  }
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
- */
274
+ /** Builds the missing/stale/resolved marker text for a resolved value name. */
242
275
  function renderValueMarker(name, resolved, format, decimals, color, theme) {
243
276
  if (resolved.status === 'missing' || resolved.status === 'error') {
244
277
  const failureKind = resolved.status === 'error' ? resolved.failureKind : undefined;
@@ -256,24 +289,20 @@ function renderValueMarker(name, resolved, format, decimals, color, theme) {
256
289
  return suffix ? `${styled}${dim(suffix, color)}` : styled;
257
290
  }
258
291
  /** Builds the `AnsiRenderContext` handed to one component invocation. */
259
- function buildComponentContext(width, indent, wctx, binding, layout) {
292
+ function buildComponentContext(width, indent, wctx, binding, layout, focusId) {
260
293
  const { color, theme, scope } = wctx;
261
294
  const ctx = {
262
295
  width,
263
296
  indent,
264
297
  color,
265
298
  theme,
299
+ interactive: wctx.interactive,
266
300
  style: (text, token) => style(text, token, theme, color),
267
301
  bold: (text) => bold(text, color),
268
302
  dim: (text) => dim(text, color),
269
303
  italic: (text) => italic(text, color),
270
304
  underline: (text) => underline(text, color),
271
305
  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
306
  text: (value) => stripControlCharacters(value),
278
307
  resolve: (name) => resolveValue(scope, name),
279
308
  valueMarker: (name, format, decimals) => {
@@ -287,6 +316,8 @@ function buildComponentContext(width, indent, wctx, binding, layout) {
287
316
  resolveHref: wctx.resolveHref,
288
317
  onDiagnostic: wctx.onDiagnostic,
289
318
  };
319
+ if (focusId !== undefined)
320
+ ctx.focusId = focusId;
290
321
  if ('data' in binding) {
291
322
  ctx.data = binding.data;
292
323
  ctx.dataStatus = binding.dataStatus;
@@ -297,42 +328,54 @@ function buildComponentContext(width, indent, wctx, binding, layout) {
297
328
  ctx.layout = layout;
298
329
  return ctx;
299
330
  }
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) {
331
+ // ---------------------------------------------------------------------------
332
+ // STRING MODE — the pre-Ink algorithm. Used for every inline position (at
333
+ // any depth) and for a directive's own body once it is not one of the three
334
+ // Ink-element-aware directives (`row`/`tabs`/`details`).
335
+ // ---------------------------------------------------------------------------
336
+ function safeChildrenText(text) {
337
+ return text ?? '';
338
+ }
339
+ function isBlockLevelChildren(children) {
340
+ for (const child of children) {
341
+ if (child.type !== 'element')
342
+ continue;
343
+ if (BLOCK_TAGS.has(child.tagName))
344
+ return true;
345
+ if (child.tagName === DIRECTIVE_TAG) {
346
+ const kind = stringProperty(child, 'data-mk-kind');
347
+ if (kind !== TEXT_DIRECTIVE_KIND)
348
+ return true;
349
+ }
350
+ }
351
+ return false;
352
+ }
353
+ function renderDirectiveContentString(name, kind, isBlock, attributes, childrenText, width, indent, wctx, layoutForComponent) {
302
354
  const inline = !isBlock;
303
355
  const entry = Object.hasOwn(wctx.registry, name)
304
356
  ? wctx.registry[name]
305
357
  : undefined;
306
358
  const component = readRegistryComponent(entry);
307
359
  if (!entry || component == null) {
308
- return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'unregistered', width, wctx.color);
360
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'unregistered', width, wctx.color);
309
361
  }
310
362
  if (isFormMismatch(entry, kind)) {
311
- return unknownDirective(name || '(unnamed)', inline, safeChildrenText(children), 'form-mismatch', width, wctx.color);
363
+ return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'form-mismatch', width, wctx.color);
312
364
  }
313
365
  const binding = resolveDataAttribute(attributes, wctx.scope);
314
- const ctx = buildComponentContext(width, indent, wctx, binding, layoutForComponent);
366
+ const ctx = buildComponentContext(width, indent, wctx, binding, layoutForComponent, undefined);
315
367
  let rendered;
316
368
  try {
317
- rendered = component(binding.attributes, children, ctx);
369
+ const result = component({
370
+ attributes: binding.attributes,
371
+ children: childrenText,
372
+ ctx,
373
+ });
374
+ rendered = typeof result === 'string' ? result : String(result ?? '');
318
375
  }
319
376
  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.
377
+ return componentError(name || '(unnamed)', inline, safeChildrenText(childrenText), width, wctx.color);
378
+ }
336
379
  const markerSeparator = inline ? ' ' : '\n';
337
380
  const invalidEnum = invalidEnumAttribute(name, binding.attributes);
338
381
  if (invalidEnum) {
@@ -345,221 +388,129 @@ function renderDirectiveContent(name, kind, isBlock, attributes, children, width
345
388
  const label = invalidAttributeValueLabel(name, invalidEnum.attribute);
346
389
  rendered = `${rendered}${markerSeparator}${dim(`[${label}]`, wctx.color)}`;
347
390
  }
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() === '') {
391
+ if (isRegisteredInline(entry) &&
392
+ safeChildrenText(childrenText).trim() === '') {
351
393
  rendered = `${rendered}${dim(` (${emptyInlineTitle(name)})`, wctx.color)}`;
352
394
  }
353
395
  return rendered;
354
396
  }
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) {
397
+ function renderDirectiveChildrenString(children, width, indent, wctx) {
371
398
  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
- });
399
+ return renderBlocksString(children, width, indent, wctx);
403
400
  }
404
- return parts;
401
+ return renderInlineChildrenString(children, width, indent, wctx);
405
402
  }
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;
428
- }
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);
403
+ function renderRowString(element, attributes, width, indent, wctx) {
404
+ const cellNodes = element.children.filter((node) => !(node.type === 'text' && node.value.trim() === ''));
405
+ const layout = resolveRowLayout(attributes, cellNodes.length, width);
406
+ if (cellNodes.length === 0)
407
+ return '';
408
+ const place = (text, w) => layout.align === 'left'
409
+ ? text
410
+ : text
411
+ .split('\n')
412
+ .map((line) => padText(line, w, layout.align))
413
+ .join('\n');
414
+ if (layout.stacked) {
415
+ return cellNodes
416
+ .map((node) => place(renderBlockString(node, width, indent, wctx), width))
417
+ .join('\n\n');
439
418
  }
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);
419
+ const placedCells = cellNodes.map((node) => place(renderBlockString(node, layout.colWidth, indent, wctx), layout.colWidth));
420
+ const gridRows = [];
421
+ for (let index = 0; index < placedCells.length; index += layout.columnCount) {
422
+ const rowCells = placedCells.slice(index, index + layout.columnCount);
423
+ const lineArrays = rowCells.map((cell) => cell.split('\n'));
424
+ const rowHeight = Math.max(0, ...lineArrays.map((lines) => lines.length));
425
+ const lines = [];
426
+ for (let line = 0; line < rowHeight; line += 1) {
427
+ lines.push(lineArrays
428
+ .map((cellLines) => padText(cellLines[line] ?? '', layout.colWidth, 'left'))
429
+ .join(' ')
430
+ .replace(/[ ]+$/, ''));
431
+ }
432
+ gridRows.push(lines.join('\n'));
453
433
  }
454
- return content;
434
+ return gridRows.join('\n\n');
455
435
  }
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);
436
+ const DEFAULT_TAB_LABEL = 'Tab';
437
+ /** 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. */
438
+ function extractTabPanels(element, width, indent, wctx) {
439
+ const panels = [];
440
+ for (const node of element.children) {
441
+ if (node.type === 'text' && node.value.trim() === '')
442
+ continue;
443
+ if (node.type !== 'element')
444
+ continue;
445
+ let label = DEFAULT_TAB_LABEL;
446
+ let isTab = false;
447
+ if (node.tagName === DIRECTIVE_TAG) {
448
+ const written = stringProperty(node, 'data-mk-name') ?? '';
449
+ const attrs = parseAttributes(stringProperty(node, 'data-mk-attrs'));
450
+ const { name } = resolveDirectiveAlias(wctx.registry, written, attrs);
451
+ if (name === 'tab') {
452
+ label = attrs.label ?? DEFAULT_TAB_LABEL;
453
+ isTab = true;
454
+ }
455
+ }
456
+ // A `tab` child's own label is drawn ONCE, by the heading `tabs`
457
+ // itself builds below — rendering the whole `tab` directive here (as
458
+ // for any other block) would ALSO invoke `tab.ts`'s own component,
459
+ // which draws its OWN bold heading from the same attribute, printing
460
+ // the label twice. Render only the tab's INNER children instead. A
461
+ // non-`tab` block (an unlabeled panel) still renders as a whole block,
462
+ // unchanged.
463
+ const body = isTab
464
+ ? renderDirectiveChildrenString(node.children, width, indent, wctx)
465
+ : renderBlockString(node, width, indent, wctx);
466
+ panels.push({ label, body });
464
467
  }
465
- return text;
466
- }
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})`;
468
+ return panels;
492
469
  }
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) {
470
+ const ACTIVE_MARKER = ' (active)';
471
+ function renderBlockString(node, width, indent, wctx) {
502
472
  if (node.type === 'text')
503
473
  return stripControlCharacters(node.value);
504
474
  if (node.type !== 'element')
505
475
  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);
476
+ if (node.tagName === DIRECTIVE_TAG) {
477
+ return renderDirectiveString(node, width, indent, wctx);
533
478
  }
479
+ if (/^h[1-6]$/.test(node.tagName)) {
480
+ const level = Number(node.tagName.slice(1));
481
+ const text = renderInlineChildrenString(node.children, width, indent, wctx);
482
+ const styled = level === 1
483
+ ? underline(bold(text, wctx.color), wctx.color)
484
+ : bold(text, wctx.color);
485
+ return wrapText(styled, width).join('\n');
486
+ }
487
+ if (node.tagName === 'p') {
488
+ return wrapText(renderInlineChildrenString(node.children, width, indent, wctx), width).join('\n');
489
+ }
490
+ if (node.tagName === 'blockquote') {
491
+ const innerWidth = Math.max(1, width - 2);
492
+ const inner = renderBlocksString(node.children, innerWidth, `${indent}│ `, wctx);
493
+ const prefix = dim('│ ', wctx.color);
494
+ return inner
495
+ .split('\n')
496
+ .map((line) => `${prefix}${line}`)
497
+ .join('\n');
498
+ }
499
+ if (node.tagName === 'ul' || node.tagName === 'ol') {
500
+ return renderListString(node, width, indent, wctx);
501
+ }
502
+ if (node.tagName === 'hr')
503
+ return dim(ruleLine(width), wctx.color);
504
+ if (node.tagName === 'table')
505
+ return renderGfmTableString(node, width, wctx);
506
+ if (node.tagName === 'pre') {
507
+ const marker = renderScriptMarker(node, wctx.color);
508
+ if (marker !== undefined)
509
+ return marker;
510
+ return renderCodeBlock(node, wctx.color);
511
+ }
512
+ return wrapText(renderInlineChildrenString(node.children ?? [], width, indent, wctx), width).join('\n');
534
513
  }
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
514
  function taskState(li) {
564
515
  for (const child of li.children) {
565
516
  if (child.type !== 'element')
@@ -567,13 +518,11 @@ function taskState(li) {
567
518
  if (child.tagName === 'input') {
568
519
  return { checked: Boolean(child.properties?.checked) };
569
520
  }
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
521
  return undefined;
573
522
  }
574
523
  return undefined;
575
524
  }
576
- function renderListItem(li, marker, width, indent, wctx) {
525
+ function renderListItemString(li, marker, width, indent, wctx) {
577
526
  const task = taskState(li);
578
527
  const contentChildren = li.children.filter((child) => !(task && child.type === 'element' && child.tagName === 'input'));
579
528
  const nestedLists = [];
@@ -588,11 +537,11 @@ function renderListItem(li, marker, width, indent, wctx) {
588
537
  }
589
538
  }
590
539
  const prefix = `${marker}${task ? (task.checked ? '[x] ' : '[ ] ') : ''}`;
591
- const prefixWidth = measure(prefix);
540
+ const prefixWidth = measureWidth(prefix);
592
541
  const bodyWidth = Math.max(1, width - prefixWidth);
593
542
  const bodyText = isBlockLevelChildren(restChildren)
594
- ? renderBlocks(restChildren, bodyWidth, `${indent}${' '.repeat(prefixWidth)}`, wctx)
595
- : wrap(renderInlineChildren(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
543
+ ? renderBlocksString(restChildren, bodyWidth, `${indent}${' '.repeat(prefixWidth)}`, wctx)
544
+ : wrapText(renderInlineChildrenString(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
596
545
  const bodyLines = bodyText === '' ? [''] : bodyText.split('\n');
597
546
  const firstLine = `${prefix}${bodyLines[0] ?? ''}`;
598
547
  const restLines = bodyLines
@@ -600,7 +549,7 @@ function renderListItem(li, marker, width, indent, wctx) {
600
549
  .map((line) => `${' '.repeat(prefixWidth)}${line}`);
601
550
  let result = [firstLine, ...restLines].join('\n');
602
551
  for (const nested of nestedLists) {
603
- const nestedText = renderBlock(nested, Math.max(1, width - 2), `${indent} `, wctx);
552
+ const nestedText = renderBlockString(nested, Math.max(1, width - 2), `${indent} `, wctx);
604
553
  result += `\n${nestedText
605
554
  .split('\n')
606
555
  .map((line) => ` ${line}`)
@@ -608,7 +557,7 @@ function renderListItem(li, marker, width, indent, wctx) {
608
557
  }
609
558
  return result;
610
559
  }
611
- function renderList(el, width, indent, wctx) {
560
+ function renderListString(el, width, indent, wctx) {
612
561
  const ordered = el.tagName === 'ol';
613
562
  const startRaw = el.properties?.start;
614
563
  const start = ordered && typeof startRaw === 'number' ? startRaw : 1;
@@ -616,7 +565,7 @@ function renderList(el, width, indent, wctx) {
616
565
  return items
617
566
  .map((li, index) => {
618
567
  const marker = ordered ? `${start + index}. ` : '- ';
619
- return renderListItem(li, marker, width, indent, wctx);
568
+ return renderListItemString(li, marker, width, indent, wctx);
620
569
  })
621
570
  .join('\n');
622
571
  }
@@ -647,14 +596,6 @@ function getCodeText(codeNode) {
647
596
  }
648
597
  return text.endsWith('\n') ? text.slice(0, -1) : text;
649
598
  }
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
599
  function renderScriptMarker(node, color) {
659
600
  try {
660
601
  const codeNode = findChildElement(node, 'code');
@@ -687,18 +628,7 @@ function renderCodeBlock(node, color) {
687
628
  lines.push(dim(` ${line}`, color));
688
629
  return lines.join('\n');
689
630
  }
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) {
631
+ function renderGfmTableString(tableEl, width, wctx) {
702
632
  const headerCells = [];
703
633
  const bodyRows = [];
704
634
  let sawHeader = false;
@@ -708,7 +638,7 @@ function renderTable(tableEl, width, wctx) {
708
638
  continue;
709
639
  if (cell.tagName !== 'td' && cell.tagName !== 'th')
710
640
  continue;
711
- into.push(renderInlineChildren(cell.children, width, '', wctx));
641
+ into.push(renderInlineChildrenString(cell.children, width, '', wctx));
712
642
  }
713
643
  };
714
644
  for (const section of tableEl.children) {
@@ -733,51 +663,373 @@ function renderTable(tableEl, width, wctx) {
733
663
  }
734
664
  return drawTableGrid(headerCells.length > 0 ? headerCells : undefined, bodyRows, width, (text) => bold(text, wctx.color));
735
665
  }
736
- function renderBlock(node, width, indent, wctx) {
666
+ function getPlainText(node) {
667
+ let text = '';
668
+ for (const child of node.children) {
669
+ if (child.type === 'text')
670
+ text += stripControlCharacters(child.value);
671
+ else if (child.type === 'element')
672
+ text += getPlainText(child);
673
+ }
674
+ return text;
675
+ }
676
+ function renderLink(node, width, indent, wctx) {
677
+ const hrefRaw = typeof node.properties?.href === 'string' ? node.properties.href : '';
678
+ const resolvedHref = resolveHrefAttribute(hrefRaw, wctx.resolveHref);
679
+ const text = renderInlineChildrenString(node.children, width, indent, wctx);
680
+ const sanitizedHref = stripControlCharacters(resolvedHref);
681
+ const canHyperlink = wctx.color !== 'none' &&
682
+ resolvedHref.trim() !== '' &&
683
+ isSafeUrl(resolvedHref) &&
684
+ sanitizeUrlText(resolvedHref) === resolvedHref;
685
+ if (canHyperlink)
686
+ return hyperlink(text, resolvedHref, wctx.color);
687
+ const plainText = text.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07\x1b]*\x07/g, '');
688
+ if (plainText === sanitizedHref)
689
+ return sanitizedHref;
690
+ return `${text} (${sanitizedHref})`;
691
+ }
692
+ function renderImage(node, wctx) {
693
+ const altRaw = typeof node.properties?.alt === 'string' ? node.properties.alt : '';
694
+ const alt = stripControlCharacters(altRaw);
695
+ const srcRaw = typeof node.properties?.src === 'string' ? node.properties.src : '';
696
+ const resolvedSrc = resolveImageAttribute(srcRaw, wctx.resolveImageSrc);
697
+ const src = stripControlCharacters(resolvedSrc);
698
+ return `[${alt}] (${src})`;
699
+ }
700
+ function renderInlineNodeString(node, width, indent, wctx) {
737
701
  if (node.type === 'text')
738
702
  return stripControlCharacters(node.value);
739
703
  if (node.type !== 'element')
740
704
  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);
705
+ switch (node.tagName) {
706
+ case DIRECTIVE_TAG:
707
+ return renderDirectiveString(node, width, indent, wctx);
708
+ case 'strong':
709
+ return bold(renderInlineChildrenString(node.children, width, indent, wctx), wctx.color);
710
+ case 'em':
711
+ return italic(renderInlineChildrenString(node.children, width, indent, wctx), wctx.color);
712
+ case 'del':
713
+ return dim(`~${renderInlineChildrenString(node.children, width, indent, wctx)}~`, wctx.color);
714
+ case 'code': {
715
+ const inner = getPlainText(node);
716
+ return wctx.color === 'none'
717
+ ? dim(inner, wctx.color)
718
+ : inverse(inner, wctx.color);
719
+ }
720
+ case 'a':
721
+ return renderLink(node, width, indent, wctx);
722
+ case 'img':
723
+ return renderImage(node, wctx);
724
+ case 'br':
725
+ return '\n';
726
+ case 'input':
727
+ return '';
728
+ default:
729
+ return renderInlineChildrenString(node.children ?? [], width, indent, wctx);
760
730
  }
761
- return wrap(renderInlineChildren(node.children ?? [], width, indent, wctx), width).join('\n');
762
731
  }
763
- function renderBlocks(nodes, width, indent, wctx) {
732
+ function renderInlineChildrenString(children, width, indent, wctx) {
733
+ let result = '';
734
+ for (const child of children)
735
+ result += renderInlineNodeString(child, width, indent, wctx);
736
+ return result;
737
+ }
738
+ function renderBlocksString(nodes, width, indent, wctx) {
764
739
  const blocks = [];
765
740
  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.
741
+ const rendered = renderBlockString(node, width, indent, wctx);
776
742
  if (rendered.trim() !== '')
777
743
  blocks.push(rendered);
778
744
  }
779
745
  return blocks.join('\n\n');
780
746
  }
747
+ function renderDirectiveString(element, width, indent, wctx) {
748
+ const written = stringProperty(element, 'data-mk-name') ?? '';
749
+ const kind = stringProperty(element, 'data-mk-kind');
750
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
751
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
752
+ if (written === VALUE_DIRECTIVE_NAME) {
753
+ const plainLabel = extractPlainText(element.children);
754
+ const resolved = resolveValue(wctx.scope, plainLabel);
755
+ return renderValueMarker(plainLabel.trim(), resolved, rawAttributes.format ?? undefined, rawAttributes.decimals ?? undefined, wctx.color, wctx.theme);
756
+ }
757
+ const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
758
+ // A `row`/`tabs` reached through STRING mode (nested inside a self-drawing
759
+ // container's own body) falls back to a flattened rendering with no real
760
+ // flex layout / interactivity — see this file's top comment.
761
+ if (isBlock && name === ROW_NAME && hasRealComponent(wctx.registry, name)) {
762
+ return renderRowString(element, aliased, width, indent, wctx);
763
+ }
764
+ if (isBlock && name === TABS_NAME && hasRealComponent(wctx.registry, name)) {
765
+ const panels = extractTabPanels(element, width, indent, wctx);
766
+ return panels
767
+ .map((panel, index) => {
768
+ const heading = bold(`${panel.label}${index === 0 ? ACTIVE_MARKER : ''}`, wctx.color);
769
+ return panel.body ? `${heading}\n${panel.body}` : heading;
770
+ })
771
+ .join('\n\n');
772
+ }
773
+ const ownedAxis = registryLayoutAxis(wctx.registry, name);
774
+ const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, isBlock ? ownedAxis : undefined);
775
+ const isLayoutScope = ownedAxis !== undefined && isBlock;
776
+ const isSelfLayout = isBlock && registrySelfLayout(wctx.registry, name);
777
+ const layoutForWidth = isLayoutScope || isSelfLayout ? layoutResolved : undefined;
778
+ // `card`/`callout`/`details` narrow their own body before drawing a frame
779
+ // or an indent around it. Each computes that inner width from ONLY its
780
+ // own attributes (never from the body), so the walk resolves it here,
781
+ // BEFORE building children, and builds children exactly ONCE at that
782
+ // final width — never at the outer width followed by a re-wrap. A
783
+ // re-wrap-afterward shipped as a real regression (post-phase-2 review):
784
+ // it corrupts anything self-drawing nested inside (a card's frame, a
785
+ // row's columns), since a word-based re-wrapper has no notion that a run
786
+ // of box-drawing characters is not prose. See `resolveCardInnerWidth`'s
787
+ // doc comment.
788
+ let childrenWidth = width;
789
+ if (isBlock && name === 'card') {
790
+ childrenWidth = resolveCardInnerWidth(attributes, layoutForWidth, width);
791
+ }
792
+ else if (isBlock && name === 'callout') {
793
+ childrenWidth = resolveCalloutInnerWidth(attributes, layoutForWidth, width);
794
+ }
795
+ else if (isBlock && name === DETAILS_NAME) {
796
+ childrenWidth = resolveDetailsInnerWidth(width);
797
+ }
798
+ const childrenText = renderDirectiveChildrenString(element.children, childrenWidth, indent, wctx);
799
+ const content = renderDirectiveContentString(name, kind, isBlock, attributes, childrenText, width, indent, wctx, layoutForWidth);
800
+ if (isBlock && layoutResolved && !isLayoutScope && !isSelfLayout) {
801
+ return applyLayout(content, layoutResolved, width);
802
+ }
803
+ return content;
804
+ }
805
+ function hasRealComponent(registry, name) {
806
+ return (Object.hasOwn(registry, name) &&
807
+ readRegistryComponent(registry[name]) != null);
808
+ }
809
+ // ---------------------------------------------------------------------------
810
+ // ELEMENT MODE — real Ink elements, used for block sequencing and for
811
+ // `row`/`tabs`/`details`. See this file's top comment.
812
+ // ---------------------------------------------------------------------------
813
+ /** 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. */
814
+ function applyLayoutElement(element, layout, width) {
815
+ if (!layout)
816
+ return element;
817
+ let target = width;
818
+ if (layout.width === 'narrow')
819
+ target = Math.max(20, Math.round(width / 2));
820
+ const align = layout.align;
821
+ const alignItems = align === 'center'
822
+ ? 'center'
823
+ : align === 'right'
824
+ ? 'flex-end'
825
+ : 'flex-start';
826
+ if (target === width && !align)
827
+ return element;
828
+ return (_jsx(Box, { width: width, flexDirection: "column", alignItems: alignItems, children: _jsx(Box, { width: target, flexDirection: "column", children: element }) }));
829
+ }
830
+ function invalidEnumMarkerElement(name, attributes, wctx) {
831
+ const invalidEnum = invalidEnumAttribute(name, attributes);
832
+ if (!invalidEnum)
833
+ return null;
834
+ reportDiagnostic(wctx.onDiagnostic, {
835
+ kind: 'invalid-attribute-value',
836
+ directive: name,
837
+ attribute: invalidEnum.attribute,
838
+ message: invalidAttributeValueTitle(name, invalidEnum.attribute, invalidEnum.value),
839
+ });
840
+ const label = invalidAttributeValueLabel(name, invalidEnum.attribute);
841
+ return styledText(dim(`[${label}]`, wctx.color));
842
+ }
843
+ function renderRowElement(element, attributes, width, indent, wctx) {
844
+ const cellNodes = element.children.filter((node) => !(node.type === 'text' && node.value.trim() === ''));
845
+ if (cellNodes.length === 0)
846
+ return null;
847
+ const layout = resolveRowLayout(attributes, cellNodes.length, width);
848
+ if (layout.stacked) {
849
+ const items = cellNodes
850
+ .map((node, index) => (_jsx(Box, { marginTop: index > 0 ? 1 : 0, flexDirection: "column", children: renderBlockElement(node, width, indent, wctx) }, index)))
851
+ .filter(Boolean);
852
+ return _jsx(Box, { flexDirection: "column", children: items });
853
+ }
854
+ const gutter = 1;
855
+ const rows = [];
856
+ for (let index = 0; index < cellNodes.length; index += layout.columnCount) {
857
+ const rowCells = cellNodes.slice(index, index + layout.columnCount);
858
+ 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 ? gutter : 0, flexDirection: "column", children: renderBlockElement(node, layout.colWidth, indent, wctx) }, cellIndex))) }, index));
859
+ }
860
+ return _jsx(Box, { flexDirection: "column", children: rows });
861
+ }
862
+ function renderTabsElement(element, width, indent, wctx) {
863
+ const panels = extractTabPanelsElement(element, width, indent, wctx);
864
+ if (panels.length === 0)
865
+ return null;
866
+ const focusId = wctx.interactive ? nextFocusId(wctx) : undefined;
867
+ if (focusId !== undefined) {
868
+ recordFocusable(wctx, {
869
+ kind: 'tabs',
870
+ label: panels[0]?.label ?? DEFAULT_TAB_LABEL,
871
+ });
872
+ }
873
+ return (_jsx(InteractiveTabs, { panels: panels, color: wctx.color, interactive: wctx.interactive, focusId: focusId }));
874
+ }
875
+ function extractTabPanelsElement(element, width, indent, wctx) {
876
+ const panels = [];
877
+ for (const node of element.children) {
878
+ if (node.type === 'text' && node.value.trim() === '')
879
+ continue;
880
+ if (node.type !== 'element')
881
+ continue;
882
+ let label = DEFAULT_TAB_LABEL;
883
+ let isTab = false;
884
+ if (node.tagName === DIRECTIVE_TAG) {
885
+ const written = stringProperty(node, 'data-mk-name') ?? '';
886
+ const attrs = parseAttributes(stringProperty(node, 'data-mk-attrs'));
887
+ const { name } = resolveDirectiveAlias(wctx.registry, written, attrs);
888
+ if (name === 'tab') {
889
+ label = attrs.label ?? DEFAULT_TAB_LABEL;
890
+ isTab = true;
891
+ }
892
+ }
893
+ // See `extractTabPanels`'s (string-mode) identical comment: rendering
894
+ // the whole `tab` directive here would ALSO invoke `tab.ts`'s own
895
+ // component, printing the label a second time.
896
+ const body = isTab
897
+ ? renderBlocksElement(node.children, width, indent, wctx)
898
+ : renderBlockElement(node, width, indent, wctx);
899
+ panels.push({ label, body });
900
+ }
901
+ return panels;
902
+ }
903
+ function renderDetailsElement(element, attributes, width, indent, wctx) {
904
+ const title = attributes.title ?? 'Details';
905
+ const open = Object.hasOwn(attributes, 'open');
906
+ const innerWidth = resolveDetailsInnerWidth(width);
907
+ const body = renderBlocksElement(element.children, innerWidth, indent, wctx);
908
+ const focusId = wctx.interactive ? nextFocusId(wctx) : undefined;
909
+ if (focusId !== undefined) {
910
+ recordFocusable(wctx, {
911
+ kind: 'details',
912
+ label: stripControlCharacters(title),
913
+ });
914
+ }
915
+ return (_jsx(InteractiveDetails, { title: stripControlCharacters(title), body: body, defaultOpen: open, color: wctx.color, interactive: wctx.interactive, focusId: focusId }));
916
+ }
917
+ /** Renders one directive as a real Ink element (block position only). Dispatches `row`/`tabs`/`details` to their real builders; every other directive falls back to the string engine, wrapped in one `<Text>`. */
918
+ function renderDirectiveElement(element, width, indent, wctx) {
919
+ const written = stringProperty(element, 'data-mk-name') ?? '';
920
+ const kind = stringProperty(element, 'data-mk-kind');
921
+ const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
922
+ const isBlock = kind !== TEXT_DIRECTIVE_KIND;
923
+ if (!isBlock) {
924
+ // A stray inline (text) directive at a block position (rare; hast
925
+ // structure keeps these inside a paragraph in practice) still renders,
926
+ // via the string engine.
927
+ return styledText(renderDirectiveString(element, width, indent, wctx));
928
+ }
929
+ const { name, attributes: aliased } = resolveDirectiveAlias(wctx.registry, written, rawAttributes);
930
+ const entry = Object.hasOwn(wctx.registry, name)
931
+ ? wctx.registry[name]
932
+ : undefined;
933
+ const formMismatch = entry ? isFormMismatch(entry, kind) : false;
934
+ if (!formMismatch &&
935
+ (name === ROW_NAME || name === TABS_NAME || name === DETAILS_NAME) &&
936
+ hasRealComponent(wctx.registry, name)) {
937
+ // None of `row`/`tabs`/`details` own a layout axis (`registryLayoutAxis`
938
+ // is undefined for all three in the standard registry), so, unlike a
939
+ // layout-wrapper scope, a `width=`/`align=` the author wrote still needs
940
+ // the GENERIC post-render treatment — `applyLayoutElement` below, the
941
+ // JSX-based twin of `layout.ts`'s string `applyLayout`.
942
+ const { attributes, resolved: layoutResolved } = resolveLayoutAttributes(aliased, undefined);
943
+ try {
944
+ let el = null;
945
+ if (name === ROW_NAME) {
946
+ el = renderRowElement(element, attributes, width, indent, wctx);
947
+ }
948
+ else if (name === TABS_NAME) {
949
+ el = renderTabsElement(element, width, indent, wctx);
950
+ }
951
+ else {
952
+ el = renderDetailsElement(element, attributes, width, indent, wctx);
953
+ }
954
+ if (!el)
955
+ return null;
956
+ const marker = invalidEnumMarkerElement(name, attributes, wctx);
957
+ const withMarker = marker ? (_jsxs(Box, { flexDirection: "column", children: [el, marker] })) : (el);
958
+ return applyLayoutElement(withMarker, layoutResolved, width);
959
+ }
960
+ catch {
961
+ return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color));
962
+ }
963
+ }
964
+ return styledText(renderDirectiveString(element, width, indent, wctx));
965
+ }
966
+ /** Renders one block-level hast node as a real Ink element, or `null` for a node that carries no content (whitespace-only text). */
967
+ function renderBlockElement(node, width, indent, wctx) {
968
+ if (node.type === 'text') {
969
+ return node.value.trim() === ''
970
+ ? null
971
+ : styledText(stripControlCharacters(node.value));
972
+ }
973
+ if (node.type !== 'element')
974
+ return null;
975
+ if (node.tagName === DIRECTIVE_TAG) {
976
+ return renderDirectiveElement(node, width, indent, wctx);
977
+ }
978
+ if (node.tagName === 'blockquote') {
979
+ const innerWidth = Math.max(1, width - 2);
980
+ const inner = renderBlocksElement(node.children, innerWidth, `${indent}│ `, wctx);
981
+ if (!inner)
982
+ return null;
983
+ return (_jsxs(Box, { width: width, children: [styledText(dim('│ ', wctx.color)), _jsx(Box, { flexDirection: "column", width: innerWidth, children: inner })] }));
984
+ }
985
+ if (node.tagName === 'ul' || node.tagName === 'ol') {
986
+ return renderListElement(node, width, indent, wctx);
987
+ }
988
+ const text = renderBlockString(node, width, indent, wctx);
989
+ if (text.trim() === '')
990
+ return null;
991
+ return styledText(text);
992
+ }
993
+ function renderListElement(el, width, indent, wctx) {
994
+ const ordered = el.tagName === 'ol';
995
+ const startRaw = el.properties?.start;
996
+ const start = ordered && typeof startRaw === 'number' ? startRaw : 1;
997
+ const items = el.children.filter((child) => child.type === 'element' && child.tagName === 'li');
998
+ if (items.length === 0)
999
+ return null;
1000
+ const items$ = items.map((li, index) => {
1001
+ const marker = ordered ? `${start + index}. ` : '- ';
1002
+ const task = taskState(li);
1003
+ const contentChildren = li.children.filter((child) => !(task && child.type === 'element' && child.tagName === 'input'));
1004
+ const prefix = `${marker}${task ? (task.checked ? '[x] ' : '[ ] ') : ''}`;
1005
+ const prefixWidth = measureWidth(prefix);
1006
+ const bodyWidth = Math.max(1, width - prefixWidth);
1007
+ const nestedLists = contentChildren.filter((child) => child.type === 'element' &&
1008
+ (child.tagName === 'ul' || child.tagName === 'ol'));
1009
+ const restChildren = contentChildren.filter((child) => !nestedLists.includes(child));
1010
+ const bodyIsBlock = isBlockLevelChildren(restChildren);
1011
+ const bodyText = bodyIsBlock
1012
+ ? undefined
1013
+ : wrapText(renderInlineChildrenString(restChildren, bodyWidth, indent, wctx), bodyWidth).join('\n');
1014
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [styledText(prefix), _jsx(Box, { width: bodyWidth, flexDirection: "column", children: bodyIsBlock
1015
+ ? renderBlocksElement(restChildren, bodyWidth, indent, wctx)
1016
+ : 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));
1017
+ });
1018
+ return _jsx(Box, { flexDirection: "column", children: items$ });
1019
+ }
1020
+ /** 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`. */
1021
+ function renderBlocksElement(nodes, width, indent, wctx) {
1022
+ const elements = [];
1023
+ for (const node of nodes) {
1024
+ const el = renderBlockElement(node, width, indent, wctx);
1025
+ if (el === null)
1026
+ continue;
1027
+ elements.push(_jsx(Box, { marginTop: elements.length > 0 ? 1 : 0, flexDirection: "column", children: el }, elements.length));
1028
+ }
1029
+ if (elements.length === 0)
1030
+ return null;
1031
+ return _jsx(_Fragment, { children: elements });
1032
+ }
781
1033
  /** Collapses trailing blank lines to exactly one terminating `\n`, and leaves an empty document as `''`. */
782
1034
  function finalizeOutput(text) {
783
1035
  if (text === '')
@@ -789,34 +1041,43 @@ function renderFailureFallback(error, color) {
789
1041
  const message = error instanceof Error ? error.message : String(error);
790
1042
  return `${dim('failed to render document', color)}\n${stripControlCharacters(message)}\n`;
791
1043
  }
792
- function renderRoot(root, registry, scope, color, theme, resolveImageSrc, resolveHref, onDiagnostic, width) {
793
- const wctx = {
1044
+ function buildWalkContext(registry, scope, color, theme, interactive, resolveImageSrc, resolveHref, onDiagnostic) {
1045
+ return {
794
1046
  registry,
795
1047
  scope,
796
1048
  color,
797
1049
  theme,
1050
+ interactive,
798
1051
  resolveImageSrc,
799
1052
  resolveHref,
800
1053
  onDiagnostic,
1054
+ focusCounter: { current: 0 },
1055
+ focusables: undefined,
801
1056
  };
802
- return finalizeOutput(renderBlocks(root.children, width, '', wctx));
803
1057
  }
804
1058
  /** The columns assumed when a caller supplies no `width` option. */
805
1059
  const DEFAULT_WIDTH = 80;
1060
+ /** 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. */
1061
+ function buildRootElement(root, registry, scope, color, theme, interactive, resolveImageSrc, resolveHref, onDiagnostic, width) {
1062
+ const wctx = buildWalkContext(registry, scope, color, theme, interactive, resolveImageSrc, resolveHref, onDiagnostic);
1063
+ const body = renderBlocksElement(root.children, width, '', wctx);
1064
+ return (_jsx(Box, { width: width, flexDirection: "column", children: body ?? _jsx(Text, { children: " " }) }));
1065
+ }
1066
+ async function renderRootToString(root, registry, scope, color, theme, interactive, resolveImageSrc, resolveHref, onDiagnostic, width) {
1067
+ const element = buildRootElement(root, registry, scope, color, theme, interactive, resolveImageSrc, resolveHref, onDiagnostic, width);
1068
+ const raw = await renderInkToString(element, width);
1069
+ return finalizeOutput(raw);
1070
+ }
806
1071
  /**
807
1072
  * Renders Markii text to a plain string (optionally carrying ANSI SGR/OSC 8
808
1073
  * escapes) using `registry` to resolve directive names. `registry` is
809
1074
  * 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).
1075
+ * defaults to `defaultAnsiRegistry`.
812
1076
  *
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
1077
+ * `async` since the first call in a process pays Ink/Yoga's one-time module-
1078
+ * evaluation cost (`ink-string.ts`'s doc comment; `SPIKE-10-findings.md`'s
1079
+ * "Item 3"). Never throws: parsing is tolerant, unknown names always render
1080
+ * a fallback, and any unexpected internal error degrades to the "failed to
820
1081
  * render document" line.
821
1082
  *
822
1083
  * `options.color` resolves via `./ansi.js`'s `resolveColorOption`: `'auto'`
@@ -824,14 +1085,11 @@ const DEFAULT_WIDTH = 80;
824
1085
  * environment knowledge of its own — a caller that wants automatic
825
1086
  * detection calls `detectColorLevel` itself and passes the result as
826
1087
  * `'16'`/`'256'`/`'truecolor'`.
827
- *
828
- * `store`/`vault` and `resolveImageSrc`/`resolveHref`/`onDiagnostic` carry
829
- * the same meaning as `@markii/html`'s identical options.
830
1088
  */
831
- export function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1089
+ export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
832
1090
  const color = resolveColorOption(options?.color);
833
1091
  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);
1092
+ return await renderRootToString(toHast(text), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.interactive ?? false, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
835
1093
  }
836
1094
  catch (error) {
837
1095
  return renderFailureFallback(error, color);
@@ -839,21 +1097,20 @@ export function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, va
839
1097
  }
840
1098
  /**
841
1099
  * 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
1100
+ * mdast node OR a whole already-parsed mdast document instead of raw
1101
+ * document text. Same registry resolution, same fallbacks, same purity and
845
1102
  * never-throw guarantees, same options.
846
1103
  */
847
- export function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
1104
+ export async function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
848
1105
  const color = resolveColorOption(options?.color);
849
1106
  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);
1107
+ return await renderRootToString(nodeOrRootToHast(node), registry, { store: options?.store ?? store, vault: options?.vault ?? vault }, color, options?.theme ?? defaultAnsiTheme, options?.interactive ?? false, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, options?.width ?? DEFAULT_WIDTH);
851
1108
  }
852
1109
  catch (error) {
853
1110
  return renderFailureFallback(error, color);
854
1111
  }
855
1112
  }
856
- /** Whether `root` is exactly one paragraph holding exactly one text directive (`:name[...]`) and nothing else. Mirrors `@markii/html`'s identical `loneInlineDirective`. */
1113
+ /** Whether `root` is exactly one paragraph holding exactly one text directive (`:name[...]`) and nothing else. */
857
1114
  function loneInlineDirective(root) {
858
1115
  if (root.children.length !== 1)
859
1116
  return undefined;
@@ -869,17 +1126,15 @@ function loneInlineDirective(root) {
869
1126
  }
870
1127
  /**
871
1128
  * 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.
1129
+ * it is. Any other source falls back to the exact same rendering
1130
+ * `renderMarkToAnsi` would produce.
876
1131
  */
877
- export function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1132
+ export async function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
878
1133
  try {
879
1134
  const root = parse(text);
880
1135
  const lone = loneInlineDirective(root);
881
1136
  if (lone) {
882
- return renderMarkNodeToAnsi(lone, registry, store, vault, options);
1137
+ return await renderMarkNodeToAnsi(lone, registry, store, vault, options);
883
1138
  }
884
1139
  }
885
1140
  catch {
@@ -887,3 +1142,51 @@ export function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistry, sto
887
1142
  }
888
1143
  return renderMarkToAnsi(text, registry, store, vault, options);
889
1144
  }
1145
+ /**
1146
+ * The live-viewer entry point (phase 3 consumes this): builds the same Ink
1147
+ * element tree `renderMarkToAnsi` would render to a string, but returns the
1148
+ * element itself so a host (the CLI's `markii view`) can mount it directly
1149
+ * with Ink's own `render()` instead of re-rendering a string on every
1150
+ * keystroke/resize. `document` accepts raw text OR an already-parsed mdast
1151
+ * node/root, mirroring the three string entry points' flexibility.
1152
+ */
1153
+ export function buildMarkElement(document, registry = defaultAnsiRegistry, options) {
1154
+ const color = resolveColorOption(options?.color);
1155
+ const theme = options?.theme ?? defaultAnsiTheme;
1156
+ const scope = { store: options?.store, vault: options?.vault };
1157
+ const width = options?.width ?? DEFAULT_WIDTH;
1158
+ const interactive = options?.interactive ?? true;
1159
+ let root;
1160
+ try {
1161
+ root =
1162
+ typeof document === 'string'
1163
+ ? toHast(document)
1164
+ : nodeOrRootToHast(document);
1165
+ }
1166
+ catch {
1167
+ root = { type: 'root', children: [] };
1168
+ }
1169
+ const content = buildRootElement(root, registry, scope, color, theme, interactive, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, width);
1170
+ if (!interactive)
1171
+ return content;
1172
+ // The walk above already assigned every focusable component's `focusId`
1173
+ // (`WalkContext.focusCounter`) and, incidentally, discarded each one's
1174
+ // descriptor (`WalkContext.focusables` is left `undefined` on the real
1175
+ // render, since nothing there needs it). Rebuilding once more, this time
1176
+ // collecting descriptors, is wasteful only in the sense that the tree is
1177
+ // constructed twice; simpler and safer than threading mutable state out
1178
+ // of a pure element-builder, and this entry point is called once per
1179
+ // mount/resize, not per keystroke.
1180
+ const counter = { current: 0 };
1181
+ const focusables = [];
1182
+ const countingWctx = buildWalkContext(registry, scope, color, theme, true, options?.resolveImageSrc, options?.resolveHref,
1183
+ // No `onDiagnostic`: the real walk above already reported every notice
1184
+ // this document produces, and this second walk exists only to collect
1185
+ // focus descriptors. Passing the callback here would report each notice
1186
+ // twice on every mount and again on every resize.
1187
+ undefined);
1188
+ countingWctx.focusCounter = counter;
1189
+ countingWctx.focusables = focusables;
1190
+ renderBlocksElement(root.children, width, '', countingWctx);
1191
+ return (_jsx(InteractiveRoot, { focusables: focusables, onExit: options?.onExit, color: color, theme: theme, children: content }));
1192
+ }