@markii/ansi 0.17.0-next.1 → 0.17.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ansi.d.ts CHANGED
@@ -46,8 +46,7 @@ export declare const inverse: (text: string, level: ColorLevel) => string;
46
46
  * at a line boundary are closed with a reset before the `\n` and reopened
47
47
  * at the start of the next line.
48
48
  *
49
- * This is the SYSTEMATIC fix for a real Ink behavior discovered during
50
- * batch 10's post-phase-2 review: Ink's `<Text>` does not treat an embedded
49
+ * This is the systematic fix for an Ink behavior: Ink's `<Text>` does not treat an embedded
51
50
  * raw ANSI escape as transparent across an embedded newline the way a
52
51
  * terminal receiving the same bytes directly would — it resets SGR state
53
52
  * at every line boundary within one `<Text>` value and does not reopen it.
package/dist/ansi.js CHANGED
@@ -72,8 +72,7 @@ const SGR_TOKEN = /\x1b\[([0-9;]*)m/g;
72
72
  * at a line boundary are closed with a reset before the `\n` and reopened
73
73
  * at the start of the next line.
74
74
  *
75
- * This is the SYSTEMATIC fix for a real Ink behavior discovered during
76
- * batch 10's post-phase-2 review: Ink's `<Text>` does not treat an embedded
75
+ * This is the systematic fix for an Ink behavior: Ink's `<Text>` does not treat an embedded
77
76
  * raw ANSI escape as transparent across an embedded newline the way a
78
77
  * terminal receiving the same bytes directly would — it resets SGR state
79
78
  * at every line boundary within one `<Text>` value and does not reopen it.
@@ -18,8 +18,7 @@ export declare function resolveCardBoxWidth(attributes: DirectiveAttributes, lay
18
18
  * children are built ONCE, directly at the correct inner width, and never
19
19
  * built wide and re-wrapped narrower afterward.
20
20
  *
21
- * That re-wrap-afterward shape was a real, shipped regression (caught
22
- * post-phase-2 review): re-wrapping an ALREADY-RENDERED child block with a
21
+ * That re-wrap-afterward shape was a real, shipped regression: re-wrapping an ALREADY-RENDERED child block with a
23
22
  * word-based wrapper corrupts anything in it that draws its own fixed-width
24
23
  * glyphs (a nested `card`/`callout`'s frame, a nested `row`'s columns) — the
25
24
  * word-wrapper has no notion that a run of box-drawing characters is not
@@ -31,8 +31,7 @@ export function resolveCardBoxWidth(attributes, layout, outerWidth) {
31
31
  * children are built ONCE, directly at the correct inner width, and never
32
32
  * built wide and re-wrapped narrower afterward.
33
33
  *
34
- * That re-wrap-afterward shape was a real, shipped regression (caught
35
- * post-phase-2 review): re-wrapping an ALREADY-RENDERED child block with a
34
+ * That re-wrap-afterward shape was a real, shipped regression: re-wrapping an ALREADY-RENDERED child block with a
36
35
  * word-based wrapper corrupts anything in it that draws its own fixed-width
37
36
  * glyphs (a nested `card`/`callout`'s frame, a nested `row`'s columns) — the
38
37
  * word-wrapper has no notion that a run of box-drawing characters is not
@@ -1,7 +1,7 @@
1
1
  import type { AnsiComponent, DirectiveAttributes } from '../registry.js';
2
2
  declare const TEXT_ALIGNS: readonly ["left", "center", "right"];
3
3
  export type RowTextAlign = (typeof TEXT_ALIGNS)[number];
4
- /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
4
+ /** Below this width, cells stack vertically instead of sitting side by side — a named constant, not a magic number. */
5
5
  export declare const ROW_COLUMN_THRESHOLD = 60;
6
6
  /** The blank columns between two cells of a grid row. Shared by both emitters (the Ink flex `Box` and the flattened string). */
7
7
  export declare const ROW_GUTTER = 1;
@@ -6,7 +6,7 @@ const TEXT_ALIGNS = ['left', 'center', 'right'];
6
6
  function isTextAlign(value) {
7
7
  return TEXT_ALIGNS.includes(value);
8
8
  }
9
- /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
9
+ /** Below this width, cells stack vertically instead of sitting side by side — a named constant, not a magic number. */
10
10
  export const ROW_COLUMN_THRESHOLD = 60;
11
11
  /** The blank columns between two cells of a grid row. Shared by both emitters (the Ink flex `Box` and the flattened string). */
12
12
  export const ROW_GUTTER = 1;
@@ -39,6 +39,8 @@ export declare function invalidAttributeValueTitle(directive: string, attribute:
39
39
  export declare function unsafeImageSrcTitle(directive: string): string;
40
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
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;
42
44
  /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
43
45
  export declare function formMismatchTitle(directive: string): string;
44
46
  /**
@@ -103,6 +103,10 @@ export function unsafeImageSrcTitle(directive) {
103
103
  export function codeRendererFailedTitle() {
104
104
  return 'code block: the host code renderer failed, so the plain block was shown';
105
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
+ }
106
110
  /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
107
111
  export function formMismatchTitle(directive) {
108
112
  return `${directive}: block component was written inline and was not rendered`;
@@ -3,8 +3,7 @@ import type { ReactElement } from 'react';
3
3
  * Mounts `element` with Ink at `columns` wide, captures the first (and only)
4
4
  * frame as a plain string, and unmounts. `ink` is imported dynamically
5
5
  * because `yoga-layout` (Ink's layout engine) resolves its WASM module via a
6
- * top-level `await` at ES-module-evaluation time (`SPIKE-10-findings.md`'s
7
- * "Item 3"): the first call to this function pays that one-time cost, and
6
+ * top-level `await` at ES-module-evaluation time: the first call to this function pays that one-time cost, and
8
7
  * every exported entry point that reaches here is therefore `async`. A
9
8
  * later call in the same process reuses the already-evaluated module graph,
10
9
  * the ordinary ESM module-cache guarantee.
@@ -20,8 +20,7 @@ function makeCollectingStream(columns) {
20
20
  * Mounts `element` with Ink at `columns` wide, captures the first (and only)
21
21
  * frame as a plain string, and unmounts. `ink` is imported dynamically
22
22
  * because `yoga-layout` (Ink's layout engine) resolves its WASM module via a
23
- * top-level `await` at ES-module-evaluation time (`SPIKE-10-findings.md`'s
24
- * "Item 3"): the first call to this function pays that one-time cost, and
23
+ * top-level `await` at ES-module-evaluation time: the first call to this function pays that one-time cost, and
25
24
  * every exported entry point that reaches here is therefore `async`. A
26
25
  * later call in the same process reuses the already-evaluated module graph,
27
26
  * the ordinary ESM module-cache guarantee.
@@ -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,13 +47,13 @@ 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 {
36
54
  /** Every focusable component the walk assigned a `focusId` to, in `focusId` order (empty for none). */
37
55
  focusables: readonly FocusableDescriptor[];
38
- /** Called when the reader presses `q`. The engine owns no process state: it never calls `process.exit` itself (AGENTS.md, batch-10 brief). */
56
+ /** Called when the reader presses `q`. The engine owns no process state: it never calls `process.exit` itself. */
39
57
  onExit?: () => void;
40
58
  color: ColorLevel;
41
59
  theme: AnsiTheme;
@@ -55,7 +73,7 @@ export interface InteractiveRootProps {
55
73
  /**
56
74
  * The root of an interactive render: owns which `focusId` is focused, moves
57
75
  * it on up/down/j/k, calls `onExit` on `q`, and renders the one-line status
58
- * bar (batch 10.1) naming the focused block and the key legend. Rendered
76
+ * bar naming the focused block and the key legend. Rendered
59
77
  * only when the caller actually wants a live, keyboard-driven view:
60
78
  * `buildMarkElement` mounts this when `interactive` is true, but the
61
79
  * render-ONCE string path (`ink-string.ts`, `renderRootToString`) never
@@ -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);
@@ -48,7 +78,7 @@ export function useFocusAnchor(focusId) {
48
78
  /**
49
79
  * The root of an interactive render: owns which `focusId` is focused, moves
50
80
  * it on up/down/j/k, calls `onExit` on `q`, and renders the one-line status
51
- * bar (batch 10.1) naming the focused block and the key legend. Rendered
81
+ * bar naming the focused block and the key legend. Rendered
52
82
  * only when the caller actually wants a live, keyboard-driven view:
53
83
  * `buildMarkElement` mounts this when `interactive` is true, but the
54
84
  * render-ONCE string path (`ink-string.ts`, `renderRootToString`) never
@@ -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;
@@ -8,14 +8,11 @@ import type { ResolvedLayoutAttributes } from './layout.js';
8
8
  import type { ResolveImageSrc } from './image-resolve.js';
9
9
  import type { ResolveHref } from './href-resolve.js';
10
10
  /**
11
- * The registry contract for the terminal engine, batch-10 (the Ink
12
- * rewrite): a component is a React function component receiving
11
+ * The registry contract for the terminal engine: a component is a React function component receiving
13
12
  * `{ attributes, children, ctx }`, where `children` is ALREADY a built Ink
14
13
  * element tree (a `ReactNode`) rather than the lazy string-producing
15
14
  * `AnsiChildren` handle the pre-Ink engine used. Building children eagerly
16
- * is the batch-10 brief's own instruction (`AGENTS.md`'s architecture
17
- * section is silent on this; the brief is explicit: "Delete... the lazy-
18
- * children machinery"): Ink/Yoga lays a tree out AFTER it is built, exactly
15
+ * is how this engine works: Ink/Yoga lays a tree out AFTER it is built, exactly
19
16
  * like a browser reflows HTML, so a component no longer needs to ask for a
20
17
  * narrower re-render of its own body the way the old string engine did —
21
18
  * the one standard component that still needs a per-cell WIDTH NUMBER before
@@ -171,6 +168,16 @@ export interface AnsiRegistryEntry {
171
168
  * Ink primitives Yoga can reflow.
172
169
  */
173
170
  selfLayout?: boolean;
171
+ /**
172
+ * Marks a BLOCK component whose returned Ink element the live viewer
173
+ * mounts and lets take focus, instead of drawing it as a static
174
+ * snapshot. It joins the focus order (up/down/j/k) and, while focused,
175
+ * its element's `useInput` calls receive keys (a pack reads
176
+ * `__markiiInk.useInput`, which is gated this way). In a render-once
177
+ * pass, or inside a framed body (a callout, a card) it renders as
178
+ * before: a snapshot with no focus.
179
+ */
180
+ focusable?: boolean;
174
181
  }
175
182
  /** One alias: a second name for an existing component, optionally carrying preset attributes. */
176
183
  export interface RegistryAlias {
@@ -223,7 +230,7 @@ export declare function resolveDirectiveAlias(registry: AnsiRegistry, name: stri
223
230
  * top comment on why those three are the only ones that get a real Ink
224
231
  * element tree instead. `children` is still typed `ReactNode` on
225
232
  * `AnsiComponentProps` (the honest public contract: "an Ink element tree",
226
- * per the batch-10 brief, and a string IS a valid `ReactNode`), so a
233
+ * and a string IS a valid `ReactNode`), so a
227
234
  * component that needs its body AS TEXT (to measure it, frame it, indent
228
235
  * it, or check whether it is empty) reads it through this helper rather
229
236
  * than assuming the type; a non-string `children` degrades to `''` rather
package/dist/registry.js CHANGED
@@ -117,7 +117,7 @@ export function resolveDirectiveAlias(registry, name, attributes) {
117
117
  * top comment on why those three are the only ones that get a real Ink
118
118
  * element tree instead. `children` is still typed `ReactNode` on
119
119
  * `AnsiComponentProps` (the honest public contract: "an Ink element tree",
120
- * per the batch-10 brief, and a string IS a valid `ReactNode`), so a
120
+ * and a string IS a valid `ReactNode`), so a
121
121
  * component that needs its body AS TEXT (to measure it, frame it, indent
122
122
  * it, or check whether it is empty) reads it through this helper rather
123
123
  * than assuming the type; a non-string `children` degrades to `''` rather
package/dist/render.d.ts CHANGED
@@ -57,6 +57,13 @@ export interface RenderMarkOptions {
57
57
  readonly resolveImageSrc?: ResolveImageSrc;
58
58
  readonly resolveHref?: ResolveHref;
59
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;
60
67
  /**
61
68
  * Components of installed packs that this render cannot draw, as plain
62
69
  * data from the host. A directive named here renders the usual fallback
@@ -92,8 +99,7 @@ export interface RenderMarkOptions {
92
99
  * defaults to `defaultAnsiRegistry`.
93
100
  *
94
101
  * `async` since the first call in a process pays Ink/Yoga's one-time module-
95
- * evaluation cost (`ink-string.ts`'s doc comment; `SPIKE-10-findings.md`'s
96
- * "Item 3"). Never throws: parsing is tolerant, unknown names always render
102
+ * evaluation cost (`ink-string.ts`'s doc comment). Never throws: parsing is tolerant, unknown names always render
97
103
  * a fallback, and any unexpected internal error degrades to the "failed to
98
104
  * render document" line.
99
105
  *
@@ -118,7 +124,7 @@ export declare function renderMarkNodeToAnsi(node: MarkNode | MarkRoot, registry
118
124
  */
119
125
  export declare function renderMarkInlineToAnsi(text: string, registry?: AnsiRegistry, store?: AnsiValueStore, vault?: AnsiVaultStore, options?: RenderMarkOptions): Promise<string>;
120
126
  /**
121
- * The live-viewer entry point (phase 3 consumes this): builds the same Ink
127
+ * The live-viewer entry point: builds the same Ink
122
128
  * element tree `renderMarkToAnsi` would render to a string, but returns the
123
129
  * element itself so a host (the CLI's `markii view`) can mount it directly
124
130
  * with Ink's own `render()` instead of re-rendering a string on every
package/dist/render.js CHANGED
@@ -15,23 +15,23 @@ 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, codeRendererFailedTitle, formMismatchTitle, NO_ENGINE_VIEW_LABEL, noEngineViewTitle, packLoadFailedLabel, packLoadFailedTitle, 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';
28
28
  /**
29
- * The hast walk for the terminal engine (batch 10: the Ink rewrite). Every
29
+ * The hast walk for the terminal engine (the Ink rewrite). Every
30
30
  * DECISION this walk makes is unchanged from the pre-Ink engine (alias
31
31
  * resolution, layout resolution, data binding, form mismatch, invalid-enum
32
32
  * notice, empty-inline notice, never-throw component containment, script-
33
33
  * fence markers, link and image handling) — see AGENTS.md's architecture
34
- * rules and the batch-10 brief. What changed is the OUTPUT shape: instead of
34
+ * rules. The OUTPUT is a tree of Ink elements: instead of
35
35
  * building one big plain string, the walk builds a tree of Ink elements
36
36
  * (`Box`/`Text`) and hands it to `ink-string.ts` (the render-once path) or
37
37
  * returns it directly (the new live-viewer export, `buildMarkElement`).
@@ -82,7 +82,7 @@ const ROW_NAME = 'row';
82
82
  const TABS_NAME = 'tabs';
83
83
  const DETAILS_NAME = 'details';
84
84
  /**
85
- * `card` and `figure` join this list (batch 11's Ink-frames phase): both are
85
+ * `card` and `figure` join this list: both are
86
86
  * containers whose body previously only ever reached STRING mode, so a
87
87
  * `tabs`/`details` nested inside one could never become focusable. `card`
88
88
  * draws its frame with an Ink `Box` border (`renderCardElement`); `figure`
@@ -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;
@@ -389,6 +412,11 @@ function renderDirectiveContentString(name, kind, isBlock, attributes, childrenT
389
412
  });
390
413
  return unknownDirective(name, inline, safeChildrenText(childrenText), missingView.loadFailed ? 'pack-load-failed' : 'no-engine-view', width, wctx.color, wctx.glyphs, missingView.pack);
391
414
  }
415
+ reportDiagnostic(wctx.onDiagnostic, {
416
+ kind: 'unknown-directive',
417
+ directive: name || '(unnamed)',
418
+ message: unknownDirectiveTitle(name || '(unnamed)'),
419
+ });
392
420
  return unknownDirective(name || '(unnamed)', inline, safeChildrenText(childrenText), 'unregistered', width, wctx.color, wctx.glyphs);
393
421
  }
394
422
  if (isFormMismatch(entry, kind)) {
@@ -812,7 +840,8 @@ function renderBlocksString(nodes, width, indent, wctx) {
812
840
  }
813
841
  return blocks.join('\n\n');
814
842
  }
815
- function renderDirectiveString(element, width, indent, wctx) {
843
+ function renderDirectiveString(element, width, indent, walk) {
844
+ const wctx = withNodePosition(walk, element);
816
845
  const written = stringProperty(element, 'data-mk-name') ?? '';
817
846
  const kind = stringProperty(element, 'data-mk-kind');
818
847
  const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
@@ -848,7 +877,7 @@ function renderDirectiveString(element, width, indent, wctx) {
848
877
  // own attributes (never from the body), so the walk resolves it here,
849
878
  // BEFORE building children, and builds children exactly ONCE at that
850
879
  // final width — never at the outer width followed by a re-wrap. A
851
- // re-wrap-afterward shipped as a real regression (post-phase-2 review):
880
+ // re-wrap-afterward shipped as a regression:
852
881
  // it corrupts anything self-drawing nested inside (a card's frame, a
853
882
  // row's columns), since a word-based re-wrapper has no notion that a run
854
883
  // of box-drawing characters is not prose. See `resolveCardInnerWidth`'s
@@ -983,13 +1012,11 @@ function renderAlignedBodyElement(nodes, width, indent, wctx, align) {
983
1012
  * Renders one `row` cell at `cellWidth`. `text=left` (the default) keeps
984
1013
  * the cell a real Ink element tree (`renderBlockElement`), so anything
985
1014
  * focusable inside it stays focusable. `text=center`/`text=right` instead
986
- * falls back to a flattened, pre-justified STRING — batch 11's justification
1015
+ * falls back to a flattened, pre-justified STRING — the justification
987
1016
  * rule: exactly ONE implementation, `text-grid.ts`'s `padText`, never a
988
- * second Ink-`alignItems`-based mechanism. Before this batch, `row`'s
989
- * ELEMENT-mode grid silently ignored `text=` altogether (only the STRING-
990
- * mode fallback, reached when a `row` is nested inside a self-drawing
991
- * container, honored it); this closes that gap using the SAME helper the
992
- * STRING mode already uses, rather than inventing a second mechanism.
1017
+ * second Ink-`alignItems`-based mechanism. The ELEMENT-mode grid and the STRING-mode fallback (reached when a `row`
1018
+ * is nested inside a self-drawing container) both honor `text=` through
1019
+ * that one helper.
993
1020
  */
994
1021
  function renderRowCellElement(node, cellWidth, indent, wctx, align) {
995
1022
  if (align === 'left')
@@ -1100,7 +1127,7 @@ function selfLayoutAlignElement(element, boxWidth, layout, width) {
1100
1127
  }
1101
1128
  /**
1102
1129
  * `:::card{title="..." text=left|center|right} ... :::` as a real Ink
1103
- * element (batch 11: card moves off `frameBlock`'s hand-drawn string frame
1130
+ * element (card moves off `frameBlock`'s hand-drawn string frame
1104
1131
  * onto Ink's own `Box` border, so a `tabs`/`details` nested in its body can
1105
1132
  * become a real focusable — this function's `body` is built through
1106
1133
  * `renderBlocksElement`, ELEMENT mode, whenever that is possible).
@@ -1112,9 +1139,7 @@ function selfLayoutAlignElement(element, boxWidth, layout, width) {
1112
1139
  * `frameGlyphs`'s character set makes byte-identical to `frameBlock`'s own
1113
1140
  * untitled output (proven against the committed fixtures).
1114
1141
  *
1115
- * `text=center`/`text=right` (batch 11's justification rule: exactly ONE
1116
- * implementation, `text-grid.ts`'s `padText`, pre-justifying a STRING before
1117
- * Ink ever sees it, never a second Ink-`alignItems`-based mechanism) falls
1142
+ * `text=center`/`text=right` falls
1118
1143
  * back to building the body as one flattened, pre-justified string, exactly
1119
1144
  * like the pre-Ink engine did — an accepted, documented trade: a `tabs`/
1120
1145
  * `details` nested inside a CENTERED or RIGHT-aligned card body is not
@@ -1197,7 +1222,7 @@ function renderCalloutElement(element, attributes, width, indent, wctx, layout)
1197
1222
  }
1198
1223
  /**
1199
1224
  * `:::figure{src="..." alt="..."} caption markdown :::` as a real Ink
1200
- * element (batch 11: `figure` never drew a hand-drawn frame — it is a plain
1225
+ * element (`figure` never drew a hand-drawn frame — it is a plain
1201
1226
  * dim alt/src line followed by its caption body — but its body still only
1202
1227
  * ever reached STRING mode before this change, so a `tabs`/`details` in its
1203
1228
  * caption could never become focusable. `body` here is built through
@@ -1241,7 +1266,8 @@ function renderFigureElement(element, attributes, width, indent, wctx) {
1241
1266
  return (_jsxs(Box, { flexDirection: "column", children: [headerElement, body] }));
1242
1267
  }
1243
1268
  /** 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>`. */
1244
- function renderDirectiveElement(element, width, indent, wctx) {
1269
+ function renderDirectiveElement(element, width, indent, walk) {
1270
+ const wctx = withNodePosition(walk, element);
1245
1271
  const written = stringProperty(element, 'data-mk-name') ?? '';
1246
1272
  const kind = stringProperty(element, 'data-mk-kind');
1247
1273
  const rawAttributes = parseAttributes(stringProperty(element, 'data-mk-attrs'));
@@ -1312,8 +1338,48 @@ function renderDirectiveElement(element, width, indent, wctx) {
1312
1338
  return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color, wctx.glyphs));
1313
1339
  }
1314
1340
  }
1341
+ if (entry?.focusable === true && !formMismatch && wctx.interactive) {
1342
+ const focusable = renderFocusablePackElement(element, name, aliased, width, indent, wctx);
1343
+ if (focusable !== undefined)
1344
+ return focusable;
1345
+ }
1315
1346
  return styledText(renderDirectiveString(element, width, indent, wctx));
1316
1347
  }
1348
+ /**
1349
+ * A `focusable` registry component in the live viewer: the component runs
1350
+ * as usual, but an Ink element it returns is mounted (not drawn once) in a
1351
+ * `PackFocusScope`, so it joins the focus order and receives keys while
1352
+ * focused. Returns `undefined` when the component returns a string or
1353
+ * throws, so the caller falls back to the string path (which reports the
1354
+ * failure the usual way). The component draws two columns narrower: the
1355
+ * scope's gutter carries the focus marker.
1356
+ */
1357
+ function renderFocusablePackElement(element, name, attributes, width, indent, wctx) {
1358
+ const component = readRegistryComponent(wctx.registry[name]);
1359
+ if (component == null)
1360
+ return undefined;
1361
+ const innerWidth = Math.max(1, width - 2);
1362
+ const binding = resolveDataAttribute(attributes, wctx.scope);
1363
+ const focusId = nextFocusId(wctx);
1364
+ recordFocusable(wctx, { kind: 'pack', label: stripControlCharacters(name) });
1365
+ const ctx = buildComponentContext(innerWidth, indent, wctx, binding, undefined, focusId);
1366
+ let result;
1367
+ try {
1368
+ result = component({
1369
+ attributes: binding.attributes,
1370
+ children: renderDirectiveChildrenString(element.children, innerWidth, indent, wctx),
1371
+ ctx,
1372
+ });
1373
+ }
1374
+ catch {
1375
+ // Same as the string path: the failure is contained to a neutral box.
1376
+ return styledText(componentError(name || '(unnamed)', false, '', width, wctx.color, wctx.glyphs));
1377
+ }
1378
+ if (!isValidElement(result)) {
1379
+ return styledText(typeof result === 'string' ? result : '');
1380
+ }
1381
+ return _jsx(PackFocusScope, { focusId: focusId, children: result });
1382
+ }
1317
1383
  /** Renders one block-level hast node as a real Ink element, or `null` for a node that carries no content (whitespace-only text). */
1318
1384
  function renderBlockElement(node, width, indent, wctx) {
1319
1385
  if (node.type === 'text') {
@@ -1409,9 +1475,26 @@ function buildWalkContext(registry, scope, color, theme, glyphs, interactive, re
1409
1475
  focusables: undefined,
1410
1476
  };
1411
1477
  }
1478
+ /**
1479
+ * The `onDiagnostic` a render hands its walk: the caller's own, minus
1480
+ * `'unknown-directive'` events unless `reportUnknownDirectives` asked for
1481
+ * them. The fallback box already marks an unknown directive in the output,
1482
+ * so a host that does not place findings in a source (the viewer, an
1483
+ * export) keeps the quiet diagnostics surface it always had.
1484
+ */
1485
+ function diagnosticsFor(options) {
1486
+ const downstream = options?.onDiagnostic;
1487
+ if (downstream === undefined || options?.reportUnknownDirectives === true) {
1488
+ return downstream;
1489
+ }
1490
+ return (event) => {
1491
+ if (event.kind !== 'unknown-directive')
1492
+ downstream(event);
1493
+ };
1494
+ }
1412
1495
  /** The columns assumed when a caller supplies no `width` option. */
1413
1496
  const DEFAULT_WIDTH = 80;
1414
- /** 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. */
1497
+ /** Builds the root Ink element for `root`, given every resolved render input. Exported for `buildMarkElement` (the live-viewer entry point) as well as this module's own three string-producing entry points. */
1415
1498
  function buildRootElement(root, registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, width, renderCode) {
1416
1499
  const wctx = buildWalkContext(registry, scope, color, theme, glyphs, interactive, resolveImageSrc, resolveHref, onDiagnostic, unavailable, renderCode);
1417
1500
  const body = renderBlocksElement(root.children, width, '', wctx);
@@ -1429,8 +1512,7 @@ async function renderRootToString(root, registry, scope, color, theme, glyphs, i
1429
1512
  * defaults to `defaultAnsiRegistry`.
1430
1513
  *
1431
1514
  * `async` since the first call in a process pays Ink/Yoga's one-time module-
1432
- * evaluation cost (`ink-string.ts`'s doc comment; `SPIKE-10-findings.md`'s
1433
- * "Item 3"). Never throws: parsing is tolerant, unknown names always render
1515
+ * evaluation cost (`ink-string.ts`'s doc comment). Never throws: parsing is tolerant, unknown names always render
1434
1516
  * a fallback, and any unexpected internal error degrades to the "failed to
1435
1517
  * render document" line.
1436
1518
  *
@@ -1443,7 +1525,7 @@ async function renderRootToString(root, registry, scope, color, theme, glyphs, i
1443
1525
  export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, store, vault, options) {
1444
1526
  const color = resolveColorOption(options?.color);
1445
1527
  try {
1446
- 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, options?.renderCode);
1528
+ 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);
1447
1529
  }
1448
1530
  catch (error) {
1449
1531
  return renderFailureFallback(error, color);
@@ -1458,7 +1540,7 @@ export async function renderMarkToAnsi(text, registry = defaultAnsiRegistry, sto
1458
1540
  export async function renderMarkNodeToAnsi(node, registry = defaultAnsiRegistry, store, vault, options) {
1459
1541
  const color = resolveColorOption(options?.color);
1460
1542
  try {
1461
- 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, options?.renderCode);
1543
+ 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);
1462
1544
  }
1463
1545
  catch (error) {
1464
1546
  return renderFailureFallback(error, color);
@@ -1497,7 +1579,7 @@ export async function renderMarkInlineToAnsi(text, registry = defaultAnsiRegistr
1497
1579
  return renderMarkToAnsi(text, registry, store, vault, options);
1498
1580
  }
1499
1581
  /**
1500
- * The live-viewer entry point (phase 3 consumes this): builds the same Ink
1582
+ * The live-viewer entry point: builds the same Ink
1501
1583
  * element tree `renderMarkToAnsi` would render to a string, but returns the
1502
1584
  * element itself so a host (the CLI's `markii view`) can mount it directly
1503
1585
  * with Ink's own `render()` instead of re-rendering a string on every
@@ -1522,7 +1604,7 @@ export function buildMarkElement(document, registry = defaultAnsiRegistry, optio
1522
1604
  root = { type: 'root', children: [] };
1523
1605
  }
1524
1606
  const unavailable = createUnavailableComponentLookup(options?.unavailableComponents);
1525
- const content = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, options?.resolveImageSrc, options?.resolveHref, options?.onDiagnostic, unavailable, width, options?.renderCode);
1607
+ const content = buildRootElement(root, registry, scope, color, theme, glyphs, interactive, options?.resolveImageSrc, options?.resolveHref, diagnosticsFor(options), unavailable, width, options?.renderCode);
1526
1608
  if (!interactive)
1527
1609
  return content;
1528
1610
  // The walk above already assigned every focusable component's `focusId`
@@ -3,9 +3,8 @@
3
3
  * handful of STANDARD COMPONENTS that draw their own fixed-width glyphs
4
4
  * (`card`, `callout`, `divider`, `chart`, `table`, and the plain-markdown
5
5
  * table grid in `render.tsx`). Everything else in this engine wraps prose
6
- * through Ink's own layout (`ink-string.ts`'s doc comment and AGENTS.md's
7
- * batch-10 brief: "Drop wrap/pad/columns/frame/rule: Ink's layout replaces
8
- * them").
6
+ * through Ink's own layout (`ink-string.ts`'s doc comment): Ink's layout replaces
7
+ * wrap, pad, columns, frame and rule.
9
8
  *
10
9
  * A self-drawing component cannot lean on Ink for this: the string it builds
11
10
  * (a box-drawing frame, a sparkline, a table grid) is a fixed block of
@@ -13,9 +12,8 @@
13
12
  * `<Text>` renders it verbatim rather than reflowing pre-drawn glyphs. This
14
13
  * module is therefore kept as a small, PRIVATE (not exported from
15
14
  * `src/index.ts`) implementation detail of those few components, replacing
16
- * the old public `measure.ts`/`box.ts` modules the batch-10 brief has this
17
- * engine delete. The width-measurement approximation is unchanged from the
18
- * old `measure.ts`: Latin/CJK/emoji/combining-mark ranges, not a full
15
+ * the old public `measure.ts`/`box.ts` modules. The width-measurement
16
+ * approximation covers Latin/CJK/emoji/combining-mark ranges, not a full
19
17
  * Unicode grapheme-segmentation table (see the doc comment below for the one
20
18
  * documented limitation).
21
19
  */
package/dist/text-grid.js CHANGED
@@ -3,9 +3,8 @@
3
3
  * handful of STANDARD COMPONENTS that draw their own fixed-width glyphs
4
4
  * (`card`, `callout`, `divider`, `chart`, `table`, and the plain-markdown
5
5
  * table grid in `render.tsx`). Everything else in this engine wraps prose
6
- * through Ink's own layout (`ink-string.ts`'s doc comment and AGENTS.md's
7
- * batch-10 brief: "Drop wrap/pad/columns/frame/rule: Ink's layout replaces
8
- * them").
6
+ * through Ink's own layout (`ink-string.ts`'s doc comment): Ink's layout replaces
7
+ * wrap, pad, columns, frame and rule.
9
8
  *
10
9
  * A self-drawing component cannot lean on Ink for this: the string it builds
11
10
  * (a box-drawing frame, a sparkline, a table grid) is a fixed block of
@@ -13,9 +12,8 @@
13
12
  * `<Text>` renders it verbatim rather than reflowing pre-drawn glyphs. This
14
13
  * module is therefore kept as a small, PRIVATE (not exported from
15
14
  * `src/index.ts`) implementation detail of those few components, replacing
16
- * the old public `measure.ts`/`box.ts` modules the batch-10 brief has this
17
- * engine delete. The width-measurement approximation is unchanged from the
18
- * old `measure.ts`: Latin/CJK/emoji/combining-mark ranges, not a full
15
+ * the old public `measure.ts`/`box.ts` modules. The width-measurement
16
+ * approximation covers Latin/CJK/emoji/combining-mark ranges, not a full
19
17
  * Unicode grapheme-segmentation table (see the doc comment below for the one
20
18
  * documented limitation).
21
19
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@markii/ansi`'s own structural view of `@markii/runtime`'s value-store
3
3
  * contract. This package's dependency list is deliberately minimal (exactly
4
- * `@markii/core` and `@markii/stdlib`; see AGENTS.md and the batch brief),
4
+ * `@markii/core` and `@markii/stdlib`; see AGENTS.md),
5
5
  * so it never imports `@markii/runtime` at runtime: declaring the shapes it
6
6
  * needs here, rather than importing them, keeps a terminal render usable in
7
7
  * a context that never pulled in the runtime package at all (a CLI that
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@markii/ansi`'s own structural view of `@markii/runtime`'s value-store
3
3
  * contract. This package's dependency list is deliberately minimal (exactly
4
- * `@markii/core` and `@markii/stdlib`; see AGENTS.md and the batch brief),
4
+ * `@markii/core` and `@markii/stdlib`; see AGENTS.md),
5
5
  * so it never imports `@markii/runtime` at runtime: declaring the shapes it
6
6
  * needs here, rather than importing them, keeps a terminal render usable in
7
7
  * a context that never pulled in the runtime package at all (a CLI that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/ansi",
3
- "version": "0.17.0-next.1",
3
+ "version": "0.17.0-next.3",
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.1",
55
- "@markii/pack": "^0.17.0-next.1",
56
- "@markii/stdlib": "^0.17.0-next.1",
54
+ "@markii/core": "^0.17.0-next.3",
55
+ "@markii/pack": "^0.17.0-next.3",
56
+ "@markii/stdlib": "^0.17.0-next.3",
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.1",
62
+ "@markii/runtime": "^0.17.0-next.3",
63
63
  "@types/react": "^19.2.0"
64
64
  }
65
65
  }