@markii/ansi 0.17.0-next.0 → 0.17.0-next.2

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.
@@ -37,6 +37,10 @@ export declare function emptyInlineTitle(name: string): string;
37
37
  export declare function invalidAttributeValueTitle(directive: string, attribute: string, value: string): string;
38
38
  /** The marker text for a component-built image whose `src` was refused as unsafe. Wording identical to `@markii/html`'s `unsafeImageSrcTitle`. */
39
39
  export declare function unsafeImageSrcTitle(directive: string): string;
40
+ /** The diagnostic text for a host `renderCode` callback that threw; the code block rendered as usual. Wording identical to `@markii/html`'s `codeRendererFailedTitle`. */
41
+ export declare function codeRendererFailedTitle(): string;
42
+ /** The diagnostic text for a directive that names no registered component; the unknown-component box was shown. */
43
+ export declare function unknownDirectiveTitle(directive: string): string;
40
44
  /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
41
45
  export declare function formMismatchTitle(directive: string): string;
42
46
  /**
@@ -71,3 +75,14 @@ export declare const NO_ENGINE_VIEW_LABEL = "no terminal view for";
71
75
  * and is never printed in the note.
72
76
  */
73
77
  export declare function noEngineViewTitle(directive: string, pack: string, engines: readonly string[]): string;
78
+ /**
79
+ * The fallback label for a directive of a pack whose terminal view script
80
+ * could not be loaded. Followed by the directive name in backticks, like the
81
+ * other fallback labels. `pack` is stripped by the caller's sanitizer.
82
+ */
83
+ export declare function packLoadFailedLabel(pack: string): string;
84
+ /**
85
+ * The diagnostic text for the same case. The loader's own line (what threw)
86
+ * is on the diagnostics surface already; this one names the directive.
87
+ */
88
+ export declare function packLoadFailedTitle(directive: string, pack: string): string;
@@ -99,6 +99,14 @@ export function invalidAttributeValueTitle(directive, attribute, value) {
99
99
  export function unsafeImageSrcTitle(directive) {
100
100
  return `${directive}: image source was refused as unsafe and was not shown`;
101
101
  }
102
+ /** The diagnostic text for a host `renderCode` callback that threw; the code block rendered as usual. Wording identical to `@markii/html`'s `codeRendererFailedTitle`. */
103
+ export function codeRendererFailedTitle() {
104
+ return 'code block: the host code renderer failed, so the plain block was shown';
105
+ }
106
+ /** The diagnostic text for a directive that names no registered component; the unknown-component box was shown. */
107
+ export function unknownDirectiveTitle(directive) {
108
+ return `${directive}: unknown component, shown as a placeholder`;
109
+ }
102
110
  /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
103
111
  export function formMismatchTitle(directive) {
104
112
  return `${directive}: block component was written inline and was not rendered`;
@@ -144,3 +152,18 @@ export function noEngineViewTitle(directive, pack, engines) {
144
152
  : 'it has no views for any engine';
145
153
  return `The ${pack} pack has no terminal view for this component; ${has}`;
146
154
  }
155
+ /**
156
+ * The fallback label for a directive of a pack whose terminal view script
157
+ * could not be loaded. Followed by the directive name in backticks, like the
158
+ * other fallback labels. `pack` is stripped by the caller's sanitizer.
159
+ */
160
+ export function packLoadFailedLabel(pack) {
161
+ return `the \`${pack}\` pack failed to load, no terminal view for`;
162
+ }
163
+ /**
164
+ * The diagnostic text for the same case. The loader's own line (what threw)
165
+ * is on the diagnostics surface already; this one names the directive.
166
+ */
167
+ export function packLoadFailedTitle(directive, pack) {
168
+ return `The ${pack} pack failed to load in the terminal, so ${directive} has no view`;
169
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderMarkOptions, } from './render.js';
1
+ export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderCode, type RenderCodeBlock, type RenderMarkOptions, } from './render.js';
2
2
  export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, type ColorLevel, type ColorOption, type AnsiColor, } from './ansi.js';
3
3
  export { defaultAnsiTheme, type AnsiTheme, type Tier1Token } from './theme.js';
4
4
  export { BOX_STYLES, isBoxStyle, glyphsFor, type BoxStyle, type FrameEdges, type GlyphSet, } from './glyphs.js';
@@ -1,7 +1,25 @@
1
1
  import type { ReactNode } from 'react';
2
+ import { useInput } from 'ink';
2
3
  import type { DOMElement } from 'ink';
3
4
  import type { AnsiTheme } from './theme.js';
4
5
  import type { ColorLevel } from './ansi.js';
6
+ /**
7
+ * Wraps a focusable pack component's live element. It draws a two-column
8
+ * gutter (the accent marker while the component has focus, blanks
9
+ * otherwise) and names the component's focus id for `usePackInput`. The
10
+ * pack draws inside the width that is left.
11
+ */
12
+ export declare function PackFocusScope({ focusId, children, }: {
13
+ focusId: number;
14
+ children: ReactNode;
15
+ }): ReactNode;
16
+ /**
17
+ * Ink's `useInput`, for a terminal pack: the same handler and options, but
18
+ * keys reach the handler only while the pack component whose subtree calls
19
+ * it has focus. Outside a focusable pack component it never fires. A pack
20
+ * reads this as `__markiiInk.useInput`, so it never sees raw stdin.
21
+ */
22
+ export declare function usePackInput(handler: Parameters<typeof useInput>[0], options?: Parameters<typeof useInput>[1]): void;
5
23
  /** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
6
24
  export declare function useIsFocused(focusId: number | undefined): boolean;
7
25
  /** Wraps `text` in the accent-colored focus marker when interactive AND `focusId` is the focused one; otherwise returns `text` unchanged. */
@@ -29,7 +47,7 @@ export declare function useFocusAnchor(focusId: number | undefined): (node: DOME
29
47
  * yet (mount, or a reader who has not switched panels).
30
48
  */
31
49
  export interface FocusableDescriptor {
32
- kind: 'tabs' | 'details';
50
+ kind: 'tabs' | 'details' | 'pack';
33
51
  label: string;
34
52
  }
35
53
  export interface InteractiveRootProps {
@@ -11,6 +11,36 @@ const FocusContext = createContext({
11
11
  reportActiveLabel: () => { },
12
12
  registerAnchor: () => { },
13
13
  });
14
+ /**
15
+ * The `focusId` of the pack component a subtree belongs to, or `undefined`
16
+ * outside one. Provided by `PackFocusScope`; read only by `usePackInput`.
17
+ */
18
+ const PackFocusContext = createContext(undefined);
19
+ /**
20
+ * Wraps a focusable pack component's live element. It draws a two-column
21
+ * gutter (the accent marker while the component has focus, blanks
22
+ * otherwise) and names the component's focus id for `usePackInput`. The
23
+ * pack draws inside the width that is left.
24
+ */
25
+ export function PackFocusScope({ focusId, children, }) {
26
+ const gutter = useFocusMarker('▌ ', focusId);
27
+ const focused = useIsFocused(focusId);
28
+ return (_jsx(PackFocusContext.Provider, { value: focusId, children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: focused ? gutter : ' ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: children })] }) }));
29
+ }
30
+ /**
31
+ * Ink's `useInput`, for a terminal pack: the same handler and options, but
32
+ * keys reach the handler only while the pack component whose subtree calls
33
+ * it has focus. Outside a focusable pack component it never fires. A pack
34
+ * reads this as `__markiiInk.useInput`, so it never sees raw stdin.
35
+ */
36
+ export function usePackInput(handler, options = {}) {
37
+ const focusId = useContext(PackFocusContext);
38
+ const focused = useIsFocused(focusId);
39
+ useInput(handler, {
40
+ ...options,
41
+ isActive: focused && options.isActive !== false,
42
+ });
43
+ }
14
44
  /** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
15
45
  export function useIsFocused(focusId) {
16
46
  const api = useContext(FocusContext);
@@ -142,6 +142,9 @@ export function loadAnsiPack(manifest, componentModules) {
142
142
  const inline = kind === undefined ? module.inline : kind === 'inline';
143
143
  if (inline !== undefined)
144
144
  entry.inline = inline;
145
+ // Focus only makes sense for a block view.
146
+ if (module.focusable === true && inline !== true)
147
+ entry.focusable = true;
145
148
  entries[composed.name] = entry;
146
149
  }
147
150
  return { registry: createAnsiRegistry(entries) };
@@ -6,7 +6,7 @@ import type { AnsiPackToInstall } from './pack-loader.js';
6
6
  * globalThis.__markiiRegisterTerminalPack(manifestJson, componentModules)
7
7
  *
8
8
  * with the raw `pack.json` text and an own-property map of
9
- * `{ component, inline? }` keyed by local component name, the same shape
9
+ * `{ component, inline?, focusable? }` keyed by local component name, the same shape
10
10
  * `__markiiRegisterPack` takes for the webview. React and Ink are NOT
11
11
  * bundled into the script. The host sets `globalThis.__markiiReactInk`
12
12
  * (the React instance Ink uses) and `globalThis.__markiiInk` (Ink's
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import * as Ink from 'ink';
3
+ import { usePackInput } from './interactive.js';
3
4
  import { parsePackManifest } from '@markii/pack';
4
5
  /**
5
6
  * The terminal pack registration contract (docs/packs.md). A pack's
@@ -8,7 +9,7 @@ import { parsePackManifest } from '@markii/pack';
8
9
  * globalThis.__markiiRegisterTerminalPack(manifestJson, componentModules)
9
10
  *
10
11
  * with the raw `pack.json` text and an own-property map of
11
- * `{ component, inline? }` keyed by local component name, the same shape
12
+ * `{ component, inline?, focusable? }` keyed by local component name, the same shape
12
13
  * `__markiiRegisterPack` takes for the webview. React and Ink are NOT
13
14
  * bundled into the script. The host sets `globalThis.__markiiReactInk`
14
15
  * (the React instance Ink uses) and `globalThis.__markiiInk` (Ink's
@@ -32,7 +33,9 @@ export const TERMINAL_INK_GLOBAL = '__markiiInk';
32
33
  export function installTerminalPackGlobals() {
33
34
  const g = globalThis;
34
35
  g[TERMINAL_REACT_GLOBAL] = React;
35
- g[TERMINAL_INK_GLOBAL] = Ink;
36
+ // Ink's exports with `useInput` replaced by the focus-gated one: a pack
37
+ // gets keys while its component has focus and never raw stdin.
38
+ g[TERMINAL_INK_GLOBAL] = { ...Ink, useInput: usePackInput };
36
39
  }
37
40
  function isPlainObject(value) {
38
41
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -52,9 +55,16 @@ function toComponentModules(value) {
52
55
  if (Object.hasOwn(entry, 'inline') && typeof entry.inline !== 'boolean') {
53
56
  return undefined;
54
57
  }
58
+ if (Object.hasOwn(entry, 'focusable') &&
59
+ typeof entry.focusable !== 'boolean') {
60
+ return undefined;
61
+ }
55
62
  out[key] = {
56
63
  component: entry.component,
57
64
  ...(typeof entry.inline === 'boolean' ? { inline: entry.inline } : {}),
65
+ ...(typeof entry.focusable === 'boolean'
66
+ ? { focusable: entry.focusable }
67
+ : {}),
58
68
  };
59
69
  }
60
70
  return out;
@@ -171,6 +171,16 @@ export interface AnsiRegistryEntry {
171
171
  * Ink primitives Yoga can reflow.
172
172
  */
173
173
  selfLayout?: boolean;
174
+ /**
175
+ * Marks a BLOCK component whose returned Ink element the live viewer
176
+ * mounts and lets take focus, instead of drawing it as a static
177
+ * snapshot. It joins the focus order (up/down/j/k) and, while focused,
178
+ * its element's `useInput` calls receive keys (a pack reads
179
+ * `__markiiInk.useInput`, which is gated this way). In a render-once
180
+ * pass, or inside a framed body (a callout, a card) it renders as
181
+ * before: a snapshot with no focus.
182
+ */
183
+ focusable?: boolean;
174
184
  }
175
185
  /** One alias: a second name for an existing component, optionally carrying preset attributes. */
176
186
  export interface RegistryAlias {
package/dist/render.d.ts CHANGED
@@ -14,12 +14,36 @@ export interface ElementTabPanel {
14
14
  label: string;
15
15
  body: ReactNode;
16
16
  }
17
+ /** A fenced code block as offered to `RenderMarkOptions.renderCode`. */
18
+ export interface RenderCodeBlock {
19
+ /** The info-string language, or `undefined` when the fence names none. */
20
+ readonly lang: string | undefined;
21
+ /** The code text, control characters removed and tabs expanded. */
22
+ readonly code: string;
23
+ }
24
+ /** The `renderCode` callback: a string for a code block, or `undefined` to draw it as usual. */
25
+ export type RenderCode = (code: RenderCodeBlock) => string | undefined;
17
26
  /**
18
27
  * Options for `renderMarkToAnsi`/`renderMarkNodeToAnsi`/`renderMarkInlineToAnsi`.
19
28
  * `store`/`vault` are offered both here and as positional parameters; when
20
29
  * both are given, the option wins.
21
30
  */
22
31
  export interface RenderMarkOptions {
32
+ /**
33
+ * Replaces the output for an ordinary fenced code block. Called with the
34
+ * info-string language (`undefined` when the fence names none) and the
35
+ * code text, already stripped of control characters and with tabs
36
+ * expanded (the engine's own sanitizer, so author escapes cannot reach
37
+ * the terminal through a host that echoes it). Return a string to use it,
38
+ * or `undefined` to draw the block as usual. The returned string is
39
+ * HOST-TRUSTED and inserted as-is, so the host may add its own SGR
40
+ * styling and owns keeping control characters out of anything it adds.
41
+ * Never called for script fences or inline code. A callback that throws
42
+ * draws the block as usual and reports a `'code-renderer-failed'` event
43
+ * through `onDiagnostic`. Element results are not supported: fenced blocks
44
+ * are drawn as strings in this engine.
45
+ */
46
+ readonly renderCode?: RenderCode;
23
47
  readonly width?: number;
24
48
  readonly color?: ColorOption;
25
49
  readonly theme?: AnsiTheme;
@@ -33,6 +57,13 @@ export interface RenderMarkOptions {
33
57
  readonly resolveImageSrc?: ResolveImageSrc;
34
58
  readonly resolveHref?: ResolveHref;
35
59
  readonly onDiagnostic?: OnDiagnostic;
60
+ /**
61
+ * Also report a directive that names no registered component as an
62
+ * `'unknown-directive'` event. Off by default: the fallback box already
63
+ * marks it in the output. A host that places findings in the note's
64
+ * source (a language server) turns it on.
65
+ */
66
+ readonly reportUnknownDirectives?: boolean;
36
67
  /**
37
68
  * Components of installed packs that this render cannot draw, as plain
38
69
  * data from the host. A directive named here renders the usual fallback
package/dist/render.js CHANGED
@@ -15,13 +15,13 @@ import { sanitizeBlockText, sanitizeUrlText, stripControlCharacters, } from './s
15
15
  import { readRegistryComponent, registryLayoutAxis, registrySelfLayout, resolveDirectiveAlias, } from './registry.js';
16
16
  import { applyLayout, resolveLayoutAttributes, } from './layout.js';
17
17
  import { resolveScopedPath } from './resolve.js';
18
- import { dataStateSuffix, emptyInlineTitle, failureToken, formMismatchTitle, NO_ENGINE_VIEW_LABEL, noEngineViewTitle, invalidAttributeValueLabel, invalidAttributeValueTitle, unsafeImageSrcLabel, unsafeImageSrcTitle, } from './failure-presentation.js';
18
+ import { dataStateSuffix, emptyInlineTitle, failureToken, codeRendererFailedTitle, formMismatchTitle, NO_ENGINE_VIEW_LABEL, noEngineViewTitle, packLoadFailedLabel, packLoadFailedTitle, invalidAttributeValueLabel, invalidAttributeValueTitle, unsafeImageSrcLabel, unknownDirectiveTitle, unsafeImageSrcTitle, } from './failure-presentation.js';
19
19
  import { createUnavailableComponentLookup, formatValue, getContract, reportDiagnostic, } from '@markii/stdlib';
20
20
  import { resolveImageAttribute, } from './image-resolve.js';
21
21
  import { resolveHrefAttribute } from './href-resolve.js';
22
22
  import { defaultAnsiRegistry } from './components/index.js';
23
23
  import { renderInkToString, renderInkToStringSync } from './ink-string.js';
24
- import { InteractiveRoot } from './interactive.js';
24
+ import { InteractiveRoot, PackFocusScope } from './interactive.js';
25
25
  import { glyphsFor } from './glyphs.js';
26
26
  import { InteractiveTabs } from './components/tabs.js';
27
27
  import { InteractiveDetails } from './components/details.js';
@@ -126,6 +126,29 @@ function nodeOrRootToHast(node) {
126
126
  }
127
127
  return { type: 'root', children };
128
128
  }
129
+ /**
130
+ * A walk context whose `onDiagnostic` stamps each event with the source
131
+ * range of `element`, unless an inner directive already did. This is how a
132
+ * host (the language server) places a finding in the note without any
133
+ * component knowing about positions. A node with no position, or a render
134
+ * with no `onDiagnostic`, returns `wctx` itself.
135
+ */
136
+ function withNodePosition(wctx, element) {
137
+ const downstream = wctx.onDiagnostic;
138
+ const range = element.position;
139
+ if (downstream === undefined || range === undefined)
140
+ return wctx;
141
+ const position = {
142
+ startLine: range.start.line,
143
+ startColumn: range.start.column,
144
+ endLine: range.end.line,
145
+ endColumn: range.end.column,
146
+ };
147
+ return {
148
+ ...wctx,
149
+ onDiagnostic: (event) => downstream(event.position === undefined ? { ...event, position } : event),
150
+ };
151
+ }
129
152
  function nextFocusId(wctx) {
130
153
  const id = wctx.focusCounter.current;
131
154
  wctx.focusCounter.current += 1;
@@ -208,8 +231,11 @@ function isRegisteredInline(entry) {
208
231
  }
209
232
  }
210
233
  /** The fallback label line, worded for the reason and the form the directive was written in. Plain text (no escapes). */
211
- function fallbackLabel(name, inline, reason) {
234
+ function fallbackLabel(name, inline, reason, pack) {
212
235
  const code = `\`${stripControlCharacters(name)}\``;
236
+ if (reason === 'pack-load-failed') {
237
+ return `${packLoadFailedLabel(stripControlCharacters(pack ?? ''))} ${code}`;
238
+ }
213
239
  if (reason === 'no-engine-view')
214
240
  return `${NO_ENGINE_VIEW_LABEL} ${code}`;
215
241
  if (reason === 'form-mismatch') {
@@ -220,8 +246,8 @@ function fallbackLabel(name, inline, reason) {
220
246
  return `unknown component ${code}`;
221
247
  }
222
248
  /** 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. */
223
- function unknownDirective(name, inline, childrenText, reason, width, color, glyphs) {
224
- const label = fallbackLabel(name, inline, reason);
249
+ function unknownDirective(name, inline, childrenText, reason, width, color, glyphs, pack) {
250
+ const label = fallbackLabel(name, inline, reason, pack);
225
251
  if (inline) {
226
252
  return childrenText === ''
227
253
  ? dim(label, color)
@@ -380,10 +406,17 @@ function renderDirectiveContentString(name, kind, isBlock, attributes, childrenT
380
406
  reportDiagnostic(wctx.onDiagnostic, {
381
407
  kind: 'no-engine-view',
382
408
  directive: name,
383
- message: noEngineViewTitle(name, missingView.pack, missingView.engines),
409
+ message: missingView.loadFailed
410
+ ? packLoadFailedTitle(name, missingView.pack)
411
+ : noEngineViewTitle(name, missingView.pack, missingView.engines),
384
412
  });
385
- return unknownDirective(name, inline, safeChildrenText(childrenText), 'no-engine-view', width, wctx.color, wctx.glyphs);
413
+ return unknownDirective(name, inline, safeChildrenText(childrenText), missingView.loadFailed ? 'pack-load-failed' : 'no-engine-view', width, wctx.color, wctx.glyphs, missingView.pack);
386
414
  }
415
+ reportDiagnostic(wctx.onDiagnostic, {
416
+ kind: 'unknown-directive',
417
+ directive: name || '(unnamed)',
418
+ message: unknownDirectiveTitle(name || '(unnamed)'),
419
+ });
387
420
  return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'unregistered', width, wctx.color, wctx.glyphs);
388
421
  }
389
422
  if (isFormMismatch(entry, kind)) {
@@ -536,6 +569,9 @@ function renderBlockString(node, width, indent, wctx) {
536
569
  const marker = renderScriptMarker(node, wctx.color, wctx.glyphs);
537
570
  if (marker !== undefined)
538
571
  return marker;
572
+ const custom = applyRenderCode(node, wctx);
573
+ if (custom !== undefined)
574
+ return custom;
539
575
  return renderCodeBlock(node, wctx.color);
540
576
  }
541
577
  return wrapText(renderInlineChildrenString(node.children ?? [], width, indent, wctx), width).join('\n');
@@ -648,6 +684,35 @@ function renderScriptMarker(node, color, glyphs) {
648
684
  return undefined;
649
685
  }
650
686
  }
687
+ /**
688
+ * Offers one ordinary fenced code block to the host's `renderCode`. The
689
+ * text handed over is already run through `sanitizeBlockText` (control
690
+ * characters dropped, tabs expanded), so a host that echoes it cannot
691
+ * smuggle author escapes into the terminal. Returns the host's string, or
692
+ * `undefined` to draw the block as usual: no callback, a decline, a
693
+ * non-string result, or a throw (reported through `onDiagnostic`).
694
+ */
695
+ function applyRenderCode(node, wctx) {
696
+ if (!wctx.renderCode)
697
+ return undefined;
698
+ const codeNode = findChildElement(node, 'code');
699
+ if (!codeNode)
700
+ return undefined;
701
+ try {
702
+ const result = wctx.renderCode({
703
+ lang: getLanguage(codeNode) || undefined,
704
+ code: sanitizeBlockText(getCodeText(codeNode)),
705
+ });
706
+ return typeof result === 'string' ? result : undefined;
707
+ }
708
+ catch {
709
+ reportDiagnostic(wctx.onDiagnostic, {
710
+ kind: 'code-renderer-failed',
711
+ message: codeRendererFailedTitle(),
712
+ });
713
+ return undefined;
714
+ }
715
+ }
651
716
  function renderCodeBlock(node, color) {
652
717
  const codeNode = findChildElement(node, 'code');
653
718
  const language = getLanguage(codeNode);
@@ -775,7 +840,8 @@ function renderBlocksString(nodes, width, indent, wctx) {
775
840
  }
776
841
  return blocks.join('\n\n');
777
842
  }
778
- function renderDirectiveString(element, width, indent, wctx) {
843
+ function renderDirectiveString(element, width, indent, walk) {
844
+ const wctx = withNodePosition(walk, element);
779
845
  const written = stringProperty(element, 'data-mk-name') ?? '';
780
846
  const kind = stringProperty(element, 'data-mk-kind');
781
847
  const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
@@ -1204,7 +1270,8 @@ function renderFigureElement(element, attributes, width, indent, wctx) {
1204
1270
  return (_jsxs(Box, { flexDirection: "column", children: [headerElement, body] }));
1205
1271
  }
1206
1272
  /** 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) {
1273
+ function renderDirectiveElement(element, width, indent, walk) {
1274
+ const wctx = withNodePosition(walk, element);
1208
1275
  const written = stringProperty(element, 'data-mk-name') ?? '';
1209
1276
  const kind = stringProperty(element, 'data-mk-kind');
1210
1277
  const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
@@ -1275,8 +1342,48 @@ function renderDirectiveElement(element, width, indent, wctx) {
1275
1342
  return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color, wctx.glyphs));
1276
1343
  }
1277
1344
  }
1345
+ if (entry?.focusable === true && !formMismatch && wctx.interactive) {
1346
+ const focusable = renderFocusablePackElement(element, name, aliased, width, indent, wctx);
1347
+ if (focusable !== undefined)
1348
+ return focusable;
1349
+ }
1278
1350
  return styledText(renderDirectiveString(element, width, indent, wctx));
1279
1351
  }
1352
+ /**
1353
+ * A `focusable` registry component in the live viewer: the component runs
1354
+ * as usual, but an Ink element it returns is mounted (not drawn once) in a
1355
+ * `PackFocusScope`, so it joins the focus order and receives keys while
1356
+ * focused. Returns `undefined` when the component returns a string or
1357
+ * throws, so the caller falls back to the string path (which reports the
1358
+ * failure the usual way). The component draws two columns narrower: the
1359
+ * scope's gutter carries the focus marker.
1360
+ */
1361
+ function renderFocusablePackElement(element, name, attributes, width, indent, wctx) {
1362
+ const component = readRegistryComponent(wctx.registry[name]);
1363
+ if (component == null)
1364
+ return undefined;
1365
+ const innerWidth = Math.max(1, width - 2);
1366
+ const binding = resolveDataAttribute(attributes, wctx.scope);
1367
+ const focusId = nextFocusId(wctx);
1368
+ recordFocusable(wctx, { kind: 'pack', label: stripControlCharacters(name) });
1369
+ const ctx = buildComponentContext(innerWidth, indent, wctx, binding, undefined, focusId);
1370
+ let result;
1371
+ try {
1372
+ result = component({
1373
+ attributes: binding.attributes,
1374
+ children: renderDirectiveChildrenString(element.children, innerWidth, indent, wctx),
1375
+ ctx,
1376
+ });
1377
+ }
1378
+ catch {
1379
+ // Same as the string path: the failure is contained to a neutral box.
1380
+ return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color, wctx.glyphs));
1381
+ }
1382
+ if (!isValidElement(result)) {
1383
+ return styledText(typeof result === 'string' ? result : '');
1384
+ }
1385
+ return _jsx(PackFocusScope, { focusId: focusId, children: result });
1386
+ }
1280
1387
  /** Renders one block-level hast node as a real Ink element, or `null` for a node that carries no content (whitespace-only text). */
1281
1388
  function renderBlockElement(node, width, indent, wctx) {
1282
1389
  if (node.type === 'text') {
@@ -1355,7 +1462,7 @@ function renderFailureFallback(error, color) {
1355
1462
  const message = error instanceof Error ? error.message : String(error);
1356
1463
  return `${dim('failed to render document', color)}\n${stripControlCharacters(message)}\n`;
1357
1464
  }
1358
- function buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable) {
1465
+ function buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, renderCode) {
1359
1466
  return {
1360
1467
  registry,
1361
1468
  scope,
@@ -1367,20 +1474,38 @@ function buildWalkContext(registry, scope, color, theme, glyphs, interactive, re
1367
1474
  resolveHref,
1368
1475
  onDiagnostic,
1369
1476
  unavailable,
1477
+ renderCode,
1370
1478
  focusCounter: { current: 0 },
1371
1479
  focusables: undefined,
1372
1480
  };
1373
1481
  }
1482
+ /**
1483
+ * The `onDiagnostic` a render hands its walk: the caller's own, minus
1484
+ * `'unknown-directive'` events unless `reportUnknownDirectives` asked for
1485
+ * them. The fallback box already marks an unknown directive in the output,
1486
+ * so a host that does not place findings in a source (the viewer, an
1487
+ * export) keeps the quiet diagnostics surface it always had.
1488
+ */
1489
+ function diagnosticsFor(options) {
1490
+ const downstream = options?.onDiagnostic;
1491
+ if (downstream === undefined || options?.reportUnknownDirectives === true) {
1492
+ return downstream;
1493
+ }
1494
+ return (event) => {
1495
+ if (event.kind !== 'unknown-directive')
1496
+ downstream(event);
1497
+ };
1498
+ }
1374
1499
  /** The columns assumed when a caller supplies no `width` option. */
1375
1500
  const DEFAULT_WIDTH = 80;
1376
1501
  /** 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);
1502
+ function buildRootElement(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width, renderCode) {
1503
+ const wctx = buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, renderCode);
1379
1504
  const body = renderBlocksElement(root.children, width, '', wctx);
1380
1505
  return (_jsx(Box, { width: width, flexDirection: "column", children: body ?? _jsx(Text, { children: " " }) }));
1381
1506
  }
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);
1507
+ async function renderRootToString(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width, renderCode) {
1508
+ const element = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width, renderCode);
1384
1509
  const raw = await renderInkToString(element, width);
1385
1510
  return finalizeOutput(raw);
1386
1511
  }
@@ -1405,7 +1530,7 @@ async function renderRootToString(root, registry, scope, color, theme, glyphs, i
1405
1530
  export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1406
1531
  const color = resolveColorOption(options?.color);
1407
1532
  try {
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);
1533
+ 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, diagnosticsFor(options), createUnavailableComponentLookup(options?.unavailableComponents), options?.width ?? DEFAULT_WIDTH, options?.renderCode);
1409
1534
  }
1410
1535
  catch (error) {
1411
1536
  return renderFailureFallback(error, color);
@@ -1420,7 +1545,7 @@ export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, sto
1420
1545
  export async function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
1421
1546
  const color = resolveColorOption(options?.color);
1422
1547
  try {
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);
1548
+ 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, diagnosticsFor(options), createUnavailableComponentLookup(options?.unavailableComponents), options?.width ?? DEFAULT_WIDTH, options?.renderCode);
1424
1549
  }
1425
1550
  catch (error) {
1426
1551
  return renderFailureFallback(error, color);
@@ -1484,7 +1609,7 @@ export function buildMarkElement(document, registry = defaultAnsiRegistry, optio
1484
1609
  root = { type: 'root', children: [] };
1485
1610
  }
1486
1611
  const unavailable = createUnavailableComponentLookup(options?.unavailableComponents);
1487
- const content = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, unavailable, width);
1612
+ const content = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, options?.resolveImageSrc, options?.resolveHref, diagnosticsFor(options), unavailable, width, options?.renderCode);
1488
1613
  if (!interactive)
1489
1614
  return content;
1490
1615
  // The walk above already assigned every focusable component's `focusId`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/ansi",
3
- "version": "0.17.0-next.0",
3
+ "version": "0.17.0-next.2",
4
4
  "description": "The terminal renderer for Markii (.mk.md): a registry-driven hast-to-terminal engine built on Ink. Renders a note once as a string with optional color, for a pipe or a log, or as a live Ink tree a host can mount.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -51,15 +51,15 @@
51
51
  "regenerate:fixtures": "npm run build:dist && node scripts/regenerate-ansi-fixtures.ts"
52
52
  },
53
53
  "dependencies": {
54
- "@markii/core": "^0.17.0-next.0",
55
- "@markii/pack": "^0.17.0-next.0",
56
- "@markii/stdlib": "^0.17.0-next.0",
54
+ "@markii/core": "^0.17.0-next.2",
55
+ "@markii/pack": "^0.17.0-next.2",
56
+ "@markii/stdlib": "^0.17.0-next.2",
57
57
  "ink": "^7.1.1",
58
58
  "react": "^19.2.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/hast": "^3.0.4",
62
- "@markii/runtime": "^0.17.0-next.0",
62
+ "@markii/runtime": "^0.17.0-next.2",
63
63
  "@types/react": "^19.2.0"
64
64
  }
65
65
  }