@markii/ansi 0.15.0 → 0.17.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ansi.d.ts +26 -0
  3. package/dist/ansi.js +51 -0
  4. package/dist/components/badge.d.ts +1 -1
  5. package/dist/components/badge.js +5 -4
  6. package/dist/components/callout.d.ts +38 -13
  7. package/dist/components/callout.js +68 -31
  8. package/dist/components/card.d.ts +34 -2
  9. package/dist/components/card.js +55 -18
  10. package/dist/components/cell.d.ts +5 -11
  11. package/dist/components/cell.js +11 -15
  12. package/dist/components/chart.d.ts +4 -10
  13. package/dist/components/chart.js +16 -24
  14. package/dist/components/details-string.d.ts +26 -0
  15. package/dist/components/details-string.js +39 -0
  16. package/dist/components/details.d.ts +32 -10
  17. package/dist/components/details.js +28 -25
  18. package/dist/components/divider.js +12 -16
  19. package/dist/components/figure.d.ts +5 -16
  20. package/dist/components/figure.js +9 -19
  21. package/dist/components/index.d.ts +14 -10
  22. package/dist/components/index.js +19 -16
  23. package/dist/components/kbd.d.ts +1 -1
  24. package/dist/components/kbd.js +3 -2
  25. package/dist/components/layout-wrapper.d.ts +7 -22
  26. package/dist/components/layout-wrapper.js +9 -23
  27. package/dist/components/progress.d.ts +3 -11
  28. package/dist/components/progress.js +8 -17
  29. package/dist/components/rating.d.ts +1 -2
  30. package/dist/components/rating.js +3 -4
  31. package/dist/components/row.d.ts +35 -20
  32. package/dist/components/row.js +35 -51
  33. package/dist/components/stat.d.ts +3 -8
  34. package/dist/components/stat.js +5 -11
  35. package/dist/components/tab.d.ts +9 -8
  36. package/dist/components/tab.js +12 -10
  37. package/dist/components/table-grid.d.ts +2 -1
  38. package/dist/components/table-grid.js +14 -12
  39. package/dist/components/table.d.ts +5 -10
  40. package/dist/components/table.js +8 -13
  41. package/dist/components/tabs-string.d.ts +17 -0
  42. package/dist/components/tabs-string.js +30 -0
  43. package/dist/components/tabs.d.ts +31 -14
  44. package/dist/components/tabs.js +55 -30
  45. package/dist/failure-presentation.d.ts +15 -0
  46. package/dist/failure-presentation.js +22 -0
  47. package/dist/glyphs.d.ts +73 -0
  48. package/dist/glyphs.js +88 -0
  49. package/dist/index.d.ts +6 -4
  50. package/dist/index.js +10 -7
  51. package/dist/ink-string.d.ts +23 -0
  52. package/dist/ink-string.js +65 -0
  53. package/dist/interactive.d.ts +66 -0
  54. package/dist/interactive.js +207 -0
  55. package/dist/layout.js +1 -2
  56. package/dist/pack-loader.d.ts +64 -0
  57. package/dist/pack-loader.js +172 -0
  58. package/dist/pack-script.d.ts +44 -0
  59. package/dist/pack-script.js +135 -0
  60. package/dist/registry.d.ts +106 -100
  61. package/dist/registry.js +17 -9
  62. package/dist/render.d.ts +63 -25
  63. package/dist/render.js +1001 -380
  64. package/dist/text-grid.d.ts +90 -0
  65. package/dist/text-grid.js +300 -0
  66. package/package.json +9 -5
  67. package/dist/box.d.ts +0 -47
  68. package/dist/box.js +0 -209
  69. package/dist/measure.d.ts +0 -25
  70. package/dist/measure.js +0 -108
@@ -0,0 +1,207 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ import { Box, Text, measureElement, useInput } from 'ink';
4
+ import { dim } from './ansi.js';
5
+ import { style } from './style.js';
6
+ import { cutToWidth } from './text-grid.js';
7
+ const FocusContext = createContext({
8
+ interactive: false,
9
+ focusedId: undefined,
10
+ focusMarker: (text) => text,
11
+ reportActiveLabel: () => { },
12
+ registerAnchor: () => { },
13
+ });
14
+ /** 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
+ export function useIsFocused(focusId) {
16
+ const api = useContext(FocusContext);
17
+ return focusId !== undefined && api.interactive && api.focusedId === focusId;
18
+ }
19
+ /** Wraps `text` in the accent-colored focus marker when interactive AND `focusId` is the focused one; otherwise returns `text` unchanged. */
20
+ export function useFocusMarker(text, focusId) {
21
+ const api = useContext(FocusContext);
22
+ const focused = focusId !== undefined && api.interactive && api.focusedId === focusId;
23
+ return focused ? api.focusMarker(text) : text;
24
+ }
25
+ /**
26
+ * Lets a `tabs` instance report its currently active panel's label to
27
+ * `InteractiveRoot`, so the status line names the panel showing rather than
28
+ * the block's first one. Call it from an effect, never during render (see
29
+ * `FocusApi.reportActiveLabel`).
30
+ */
31
+ export function useReportActiveLabel() {
32
+ const api = useContext(FocusContext);
33
+ return api.reportActiveLabel;
34
+ }
35
+ /**
36
+ * The ref callback a focusable component puts on its outermost `Box`, so
37
+ * the live viewer can scroll the focused block into view. A no-op without a
38
+ * `focusId` or outside an interactive render.
39
+ */
40
+ export function useFocusAnchor(focusId) {
41
+ const { registerAnchor } = useContext(FocusContext);
42
+ return useCallback((node) => {
43
+ if (focusId === undefined)
44
+ return;
45
+ registerAnchor(focusId, node ?? undefined);
46
+ }, [focusId, registerAnchor]);
47
+ }
48
+ /**
49
+ * The root of an interactive render: owns which `focusId` is focused, moves
50
+ * 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
52
+ * only when the caller actually wants a live, keyboard-driven view:
53
+ * `buildMarkElement` mounts this when `interactive` is true, but the
54
+ * render-ONCE string path (`ink-string.ts`, `renderRootToString`) never
55
+ * reaches it at all — `tabs`/`details` still fall back to `FocusContext`'s
56
+ * default (uninteractive) value there, so a string render never sees a
57
+ * focus marker, a glyph, or a status line.
58
+ */
59
+ export function InteractiveRoot({ focusables, onExit, color, theme, keyLegend, viewportRows, width, children, }) {
60
+ const focusableCount = focusables.length;
61
+ const [focusIndex, setFocusIndex] = useState(0);
62
+ // `focusId -> the label its last active-panel report carried`. Only a
63
+ // `tabs` descriptor is ever reported against; `details` never calls
64
+ // `reportActiveLabel`, so its entry here simply never appears. Absent
65
+ // keys are expected (mount, or a `tabs` the reader has not switched yet)
66
+ // and fall back to the descriptor's own stable `label` below.
67
+ const [activeLabels, setActiveLabels] = useState({});
68
+ const viewport = viewportRows !== undefined ? Math.max(1, viewportRows - 1) : undefined;
69
+ const [offset, setOffset] = useState(0);
70
+ const contentRef = useRef(null);
71
+ const anchors = useRef(new Map());
72
+ // What the last reveal pass saw, so a reveal runs when focus MOVES or the
73
+ // focused block changes height (a details opening), and not on every
74
+ // render: a reader who scrolled away on purpose is left where they are.
75
+ const lastRevealed = useRef({
76
+ id: undefined,
77
+ height: -1,
78
+ });
79
+ const contentHeight = () => contentRef.current ? measureElement(contentRef.current).height : 0;
80
+ const maxOffset = () => viewport === undefined ? 0 : Math.max(0, contentHeight() - viewport);
81
+ const scrollBy = (delta) => {
82
+ if (viewport === undefined)
83
+ return;
84
+ const limit = maxOffset();
85
+ setOffset((current) => Math.min(limit, Math.max(0, current + delta)));
86
+ };
87
+ const scrollTo = (target) => {
88
+ if (viewport === undefined)
89
+ return;
90
+ setOffset(target === 'top' ? 0 : maxOffset());
91
+ };
92
+ useInput((input, key) => {
93
+ if (input === 'q') {
94
+ onExit?.();
95
+ return;
96
+ }
97
+ if (viewport !== undefined) {
98
+ const page = Math.max(1, viewport - 1);
99
+ if (key.pageDown || input === ' ' || (key.ctrl && input === 'f')) {
100
+ scrollBy(page);
101
+ return;
102
+ }
103
+ if (key.pageUp || input === 'b' || (key.ctrl && input === 'b')) {
104
+ scrollBy(-page);
105
+ return;
106
+ }
107
+ if (key.home || input === 'g') {
108
+ scrollTo('top');
109
+ return;
110
+ }
111
+ if (key.end || input === 'G') {
112
+ scrollTo('end');
113
+ return;
114
+ }
115
+ if (key.ctrl && input === 'e') {
116
+ scrollBy(1);
117
+ return;
118
+ }
119
+ if (key.ctrl && input === 'y') {
120
+ scrollBy(-1);
121
+ return;
122
+ }
123
+ }
124
+ const down = key.downArrow || input === 'j';
125
+ const up = key.upArrow || input === 'k';
126
+ if (focusableCount === 0) {
127
+ // Nothing to focus: the same keys scroll a line at a time.
128
+ if (down)
129
+ scrollBy(1);
130
+ else if (up)
131
+ scrollBy(-1);
132
+ return;
133
+ }
134
+ if (down) {
135
+ setFocusIndex((current) => (current + 1) % focusableCount);
136
+ }
137
+ else if (up) {
138
+ setFocusIndex((current) => (current - 1 + focusableCount) % focusableCount);
139
+ }
140
+ }, { isActive: true });
141
+ const focusedId = focusableCount > 0 ? focusIndex % focusableCount : undefined;
142
+ // Invoked from `tabs.tsx`'s KEY HANDLER, never during render — see
143
+ // `FocusApi.reportActiveLabel`'s doc comment. The equality guard avoids an
144
+ // unnecessary re-render when a switch reports the label it already had
145
+ // (e.g. wrapping from the last panel back past the first is still a real
146
+ // change, but re-reporting the same active panel is not).
147
+ const reportActiveLabel = useCallback((focusId, label) => {
148
+ setActiveLabels((current) => current[focusId] === label ? current : { ...current, [focusId]: label });
149
+ }, []);
150
+ const registerAnchor = useCallback((focusId, node) => {
151
+ if (node)
152
+ anchors.current.set(focusId, node);
153
+ else
154
+ anchors.current.delete(focusId);
155
+ }, []);
156
+ const api = useMemo(() => ({
157
+ interactive: true,
158
+ focusedId,
159
+ focusMarker: (text) => style(text, '--mk-accent', theme, color),
160
+ reportActiveLabel,
161
+ registerAnchor,
162
+ }), [focusedId, color, theme, reportActiveLabel, registerAnchor]);
163
+ // After every commit Ink has laid the tree out, so this is where the
164
+ // viewport is kept honest: the offset never exceeds the document (a
165
+ // resize or a closing details can shrink it), and a focus move, or a
166
+ // focused block growing, scrolls that block into view.
167
+ useEffect(() => {
168
+ if (viewport === undefined || !contentRef.current)
169
+ return;
170
+ const content = measureElement(contentRef.current);
171
+ const limit = Math.max(0, content.height - viewport);
172
+ let next = Math.min(offset, limit);
173
+ const anchor = focusedId !== undefined ? anchors.current.get(focusedId) : undefined;
174
+ if (anchor) {
175
+ const box = measureElement(anchor);
176
+ const seen = lastRevealed.current;
177
+ if (seen.id !== focusedId || seen.height !== box.height) {
178
+ lastRevealed.current = { id: focusedId, height: box.height };
179
+ const top = box.y - content.y;
180
+ const bottom = top + box.height;
181
+ if (top < next)
182
+ next = top;
183
+ else if (bottom > next + viewport)
184
+ next = Math.min(top, bottom - viewport);
185
+ next = Math.min(limit, Math.max(0, next));
186
+ }
187
+ }
188
+ if (next !== offset)
189
+ setOffset(next);
190
+ });
191
+ const focused = focusedId !== undefined ? focusables[focusedId] : undefined;
192
+ const focusedLabel = focused && focusedId !== undefined
193
+ ? (activeLabels[focusedId] ?? focused.label)
194
+ : undefined;
195
+ const statusLabel = focused
196
+ ? `${focused.kind} "${focusedLabel}"`
197
+ : 'nothing focusable';
198
+ // Cut to the width on plain text: the status line must stay one row (the
199
+ // viewport math counts it as one), and a label or legend that wrapped
200
+ // would push the document up.
201
+ const statusLine = dim(cutToWidth(`${statusLabel} ${keyLegend}`, width), color);
202
+ const status = _jsx(Text, { children: statusLine });
203
+ if (viewport === undefined) {
204
+ return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", children: [children, status] }) }));
205
+ }
206
+ return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", height: viewport + 1, children: [_jsx(Box, { flexDirection: "column", height: viewport, flexShrink: 0, overflow: "hidden", children: _jsx(Box, { ref: contentRef, flexDirection: "column", flexShrink: 0, marginTop: -offset, children: children }) }), status] }) }));
207
+ }
package/dist/layout.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { ALIGN_PRESETS, LAYOUT_ATTRIBUTE_KEYS, WIDTH_PRESETS, } from '@markii/stdlib';
2
- import { pad, wrap } from './box.js';
3
- import { measure } from './measure.js';
2
+ import { padText as pad, wrapText as wrap, measureWidth as measure, } from './text-grid.js';
4
3
  /**
5
4
  * The terminal counterpart of `@markii/html`'s `layout.ts`: the same closed
6
5
  * `width`/`align` vocabulary from `@markii/stdlib`, but resolved to COLUMN
@@ -0,0 +1,64 @@
1
+ import type { PackManifest } from '@markii/pack';
2
+ import type { AnsiRegistry, AnsiRegistryEntry } from './registry.js';
3
+ /**
4
+ * This engine's id in a pack manifest's per-component `sources` map
5
+ * (docs/packs.md). A component with an `ansi` source has a terminal view.
6
+ */
7
+ export declare const ANSI_ENGINE_ID = "ansi";
8
+ /**
9
+ * The component modules a host hands to `loadAnsiPack`, keyed by the
10
+ * manifest's LOCAL component name. Each is the `{ component, inline? }`
11
+ * pair a pack's `terminal.js` registered (`./pack-script.ts`).
12
+ */
13
+ export type AnsiPackComponentModules = Record<string, AnsiRegistryEntry>;
14
+ /**
15
+ * Returns `value` with every string in it run through
16
+ * `stripControlCharacters`, so a pack that prints a stored value cannot
17
+ * print a control character a script smuggled in. Arrays and plain objects
18
+ * are copied (null-prototype for objects, hasOwn-guarded), never mutated.
19
+ * Anything past `MAX_SANITIZE_DEPTH`, and every non-JSON value, becomes
20
+ * `undefined`. Never throws: a throwing getter reads as `undefined`.
21
+ */
22
+ export declare function sanitizeValueForPack(value: unknown, depth?: number): unknown;
23
+ /** What `loadAnsiPack` reports about a pack it could not run. */
24
+ export interface LoadAnsiPackResult {
25
+ registry: AnsiRegistry;
26
+ /** Present when the pack has components but none has an `ansi` source: the pack contributes nothing. `engine` names the engines it does declare. */
27
+ dropped?: {
28
+ name: string;
29
+ engine: string;
30
+ };
31
+ }
32
+ /**
33
+ * Loads one pack's terminal components into a namespaced `AnsiRegistry`,
34
+ * mirroring `@markii/react`'s `loadPack`: gating is per component (only
35
+ * components with an `ansi` source register; the rest fall back to the
36
+ * unknown-component box), a manifest component with no module is skipped,
37
+ * a stray module is ignored, and a declared `kind` decides the `inline`
38
+ * flag. Reads are `Object.hasOwn`-guarded. Never throws.
39
+ */
40
+ export declare function loadAnsiPack(manifest: PackManifest, componentModules: AnsiPackComponentModules): LoadAnsiPackResult;
41
+ /** One pack ready to install: its manifest plus its registered component modules. */
42
+ export interface AnsiPackToInstall {
43
+ manifest: PackManifest;
44
+ componentModules: AnsiPackComponentModules;
45
+ }
46
+ export type InstallAnsiPacksResult = {
47
+ ok: true;
48
+ registry: AnsiRegistry;
49
+ dropped: {
50
+ name: string;
51
+ engine: string;
52
+ }[];
53
+ } | {
54
+ ok: false;
55
+ collisions: readonly string[];
56
+ };
57
+ /**
58
+ * Installs several packs into one registry on top of `base`, rejecting the
59
+ * whole install when two share a namespace (the install-time rule in
60
+ * docs/packs.md), exactly like `@markii/react`'s `installPacks`. A host
61
+ * that wants keep-first behavior (a bundled pack wins over a user pack of
62
+ * the same namespace) filters the list before calling.
63
+ */
64
+ export declare function installAnsiPacks(packs: readonly AnsiPackToInstall[], base?: AnsiRegistry): InstallAnsiPacksResult;
@@ -0,0 +1,172 @@
1
+ import { composeDirectiveName, detectNamespaceCollisions, packComponentsForEngine, packDeclaredEngines, packSupportsEngine, } from '@markii/pack';
2
+ import { createAnsiRegistry, mergeAnsiRegistries } from './registry.js';
3
+ import { stripControlCharacters } from './sanitize.js';
4
+ /**
5
+ * This engine's id in a pack manifest's per-component `sources` map
6
+ * (docs/packs.md). A component with an `ansi` source has a terminal view.
7
+ */
8
+ export const ANSI_ENGINE_ID = 'ansi';
9
+ /** The deepest a stored value is walked when sanitizing it for a pack. */
10
+ const MAX_SANITIZE_DEPTH = 16;
11
+ /**
12
+ * Returns `value` with every string in it run through
13
+ * `stripControlCharacters`, so a pack that prints a stored value cannot
14
+ * print a control character a script smuggled in. Arrays and plain objects
15
+ * are copied (null-prototype for objects, hasOwn-guarded), never mutated.
16
+ * Anything past `MAX_SANITIZE_DEPTH`, and every non-JSON value, becomes
17
+ * `undefined`. Never throws: a throwing getter reads as `undefined`.
18
+ */
19
+ export function sanitizeValueForPack(value, depth = 0) {
20
+ if (depth > MAX_SANITIZE_DEPTH)
21
+ return undefined;
22
+ if (typeof value === 'string')
23
+ return stripControlCharacters(value);
24
+ if (value === null ||
25
+ typeof value === 'number' ||
26
+ typeof value === 'boolean') {
27
+ return value;
28
+ }
29
+ try {
30
+ if (Array.isArray(value)) {
31
+ return value.map((item) => sanitizeValueForPack(item, depth + 1));
32
+ }
33
+ if (typeof value === 'object') {
34
+ const out = Object.create(null);
35
+ for (const key of Object.keys(value)) {
36
+ if (!Object.hasOwn(value, key))
37
+ continue;
38
+ out[stripControlCharacters(key)] = sanitizeValueForPack(value[key], depth + 1);
39
+ }
40
+ return out;
41
+ }
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ return undefined;
47
+ }
48
+ /** A copy of `attributes` with every string value (and key) stripped of control characters. A bare attribute stays `null`. */
49
+ function sanitizeAttributes(attributes) {
50
+ const out = Object.create(null);
51
+ try {
52
+ for (const key of Object.keys(attributes)) {
53
+ if (!Object.hasOwn(attributes, key))
54
+ continue;
55
+ const raw = attributes[key];
56
+ out[stripControlCharacters(key)] =
57
+ typeof raw === 'string' ? stripControlCharacters(raw) : raw;
58
+ }
59
+ }
60
+ catch {
61
+ // A hostile attribute object yields whatever copied so far.
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ * The ctx a pack component receives: the engine's ctx with `resolve` and
67
+ * `data` replaced by sanitized-copy versions. `ctx.text` stays the engine's
68
+ * sanitizer, the way a pack prints any other string it composes.
69
+ */
70
+ function packContext(ctx) {
71
+ const packCtx = {
72
+ ...ctx,
73
+ resolve: (name) => {
74
+ const resolution = ctx.resolve(name);
75
+ return {
76
+ ...resolution,
77
+ value: sanitizeValueForPack(resolution.value),
78
+ ...(resolution.error !== undefined
79
+ ? { error: stripControlCharacters(resolution.error) }
80
+ : {}),
81
+ };
82
+ },
83
+ };
84
+ if ('data' in ctx)
85
+ packCtx.data = sanitizeValueForPack(ctx.data);
86
+ if (ctx.dataError !== undefined) {
87
+ packCtx.dataError = stripControlCharacters(ctx.dataError);
88
+ }
89
+ return packCtx;
90
+ }
91
+ /**
92
+ * Wraps a pack's component so the engine, not the pack, decides what text
93
+ * reaches it. The wrapper sanitizes the attributes and the stored-value
94
+ * view of the ctx before calling the pack, and a throw inside the pack's
95
+ * render is contained by the walk's existing per-component boundary.
96
+ *
97
+ * This bounds what AUTHOR text can do through a pack. It cannot bound the
98
+ * pack's own code: a terminal pack runs in the host's process with the
99
+ * host's privileges and can write to stdout itself (docs/security.md).
100
+ */
101
+ function wrapPackComponent(component) {
102
+ // Called as a plain function, like every registry component: it returns
103
+ // a string or an Ink element (the engine lays the element out at the
104
+ // directive's width). Components the pack's view renders INSIDE that
105
+ // element are ordinary React components and may use hooks.
106
+ return function PackComponent(props) {
107
+ return component({
108
+ attributes: sanitizeAttributes(props.attributes),
109
+ children: props.children,
110
+ ctx: packContext(props.ctx),
111
+ });
112
+ };
113
+ }
114
+ /**
115
+ * Loads one pack's terminal components into a namespaced `AnsiRegistry`,
116
+ * mirroring `@markii/react`'s `loadPack`: gating is per component (only
117
+ * components with an `ansi` source register; the rest fall back to the
118
+ * unknown-component box), a manifest component with no module is skipped,
119
+ * a stray module is ignored, and a declared `kind` decides the `inline`
120
+ * flag. Reads are `Object.hasOwn`-guarded. Never throws.
121
+ */
122
+ export function loadAnsiPack(manifest, componentModules) {
123
+ if (!packSupportsEngine(manifest, ANSI_ENGINE_ID)) {
124
+ return {
125
+ registry: createAnsiRegistry(),
126
+ dropped: { name: manifest.name, engine: packDeclaredEngines(manifest) },
127
+ };
128
+ }
129
+ const entries = {};
130
+ for (const { localName, kind } of packComponentsForEngine(manifest, ANSI_ENGINE_ID)) {
131
+ if (!Object.hasOwn(componentModules, localName))
132
+ continue;
133
+ const module = componentModules[localName];
134
+ if (module == null || typeof module.component !== 'function')
135
+ continue;
136
+ const composed = composeDirectiveName(manifest.name, localName);
137
+ if (!composed.ok)
138
+ continue;
139
+ const entry = {
140
+ component: wrapPackComponent(module.component),
141
+ };
142
+ const inline = kind === undefined ? module.inline : kind === 'inline';
143
+ if (inline !== undefined)
144
+ entry.inline = inline;
145
+ entries[composed.name] = entry;
146
+ }
147
+ return { registry: createAnsiRegistry(entries) };
148
+ }
149
+ /**
150
+ * Installs several packs into one registry on top of `base`, rejecting the
151
+ * whole install when two share a namespace (the install-time rule in
152
+ * docs/packs.md), exactly like `@markii/react`'s `installPacks`. A host
153
+ * that wants keep-first behavior (a bundled pack wins over a user pack of
154
+ * the same namespace) filters the list before calling.
155
+ */
156
+ export function installAnsiPacks(packs, base = createAnsiRegistry()) {
157
+ const collisions = detectNamespaceCollisions(packs.map((pack) => pack.manifest.name));
158
+ if (collisions.length > 0) {
159
+ return {
160
+ ok: false,
161
+ collisions: collisions.map((collision) => collision.namespace),
162
+ };
163
+ }
164
+ const dropped = [];
165
+ const loaded = packs.map((pack) => {
166
+ const result = loadAnsiPack(pack.manifest, pack.componentModules);
167
+ if (result.dropped)
168
+ dropped.push(result.dropped);
169
+ return result.registry;
170
+ });
171
+ return { ok: true, registry: mergeAnsiRegistries(base, ...loaded), dropped };
172
+ }
@@ -0,0 +1,44 @@
1
+ import type { AnsiPackToInstall } from './pack-loader.js';
2
+ /**
3
+ * The terminal pack registration contract (docs/packs.md). A pack's
4
+ * prebuilt `terminal.js` is an ESM module. On evaluation it calls
5
+ *
6
+ * globalThis.__markiiRegisterTerminalPack(manifestJson, componentModules)
7
+ *
8
+ * with the raw `pack.json` text and an own-property map of
9
+ * `{ component, inline? }` keyed by local component name, the same shape
10
+ * `__markiiRegisterPack` takes for the webview. React and Ink are NOT
11
+ * bundled into the script. The host sets `globalThis.__markiiReactInk`
12
+ * (the React instance Ink uses) and `globalThis.__markiiInk` (Ink's
13
+ * exports) and a pack reads them only inside render-time functions, so
14
+ * evaluating the script with both undefined must not throw.
15
+ *
16
+ * SECURITY: evaluating this script runs the pack's code in the calling
17
+ * process with that process's full privileges. Only a host's own device-
18
+ * local install store and bundled packs may reach `loadTerminalPackScript`:
19
+ * never a note, a bundle, the working directory, or an environment
20
+ * variable (docs/security.md).
21
+ */
22
+ export declare const TERMINAL_REGISTER_GLOBAL = "__markiiRegisterTerminalPack";
23
+ export declare const TERMINAL_REACT_GLOBAL = "__markiiReactInk";
24
+ export declare const TERMINAL_INK_GLOBAL = "__markiiInk";
25
+ /**
26
+ * Publishes this package's own React and Ink instances for pack scripts to
27
+ * read at render time. Idempotent. Left in place for the life of the
28
+ * process, since a pack reads them each time its component renders.
29
+ */
30
+ export declare function installTerminalPackGlobals(): void;
31
+ export type LoadTerminalPackScriptResult = {
32
+ ok: true;
33
+ pack: AnsiPackToInstall;
34
+ } | {
35
+ ok: false;
36
+ reason: string;
37
+ };
38
+ /**
39
+ * Evaluates one pack's `terminal.js` text and returns the validated pack
40
+ * ready for `installAnsiPacks`, or the reason it could not be used. Never
41
+ * rejects. SECURITY: runs the script's code in this process; see the
42
+ * module comment for who may call it.
43
+ */
44
+ export declare function loadTerminalPackScript(scriptText: string): Promise<LoadTerminalPackScriptResult>;
@@ -0,0 +1,135 @@
1
+ import * as React from 'react';
2
+ import * as Ink from 'ink';
3
+ import { parsePackManifest } from '@markii/pack';
4
+ /**
5
+ * The terminal pack registration contract (docs/packs.md). A pack's
6
+ * prebuilt `terminal.js` is an ESM module. On evaluation it calls
7
+ *
8
+ * globalThis.__markiiRegisterTerminalPack(manifestJson, componentModules)
9
+ *
10
+ * with the raw `pack.json` text and an own-property map of
11
+ * `{ component, inline? }` keyed by local component name, the same shape
12
+ * `__markiiRegisterPack` takes for the webview. React and Ink are NOT
13
+ * bundled into the script. The host sets `globalThis.__markiiReactInk`
14
+ * (the React instance Ink uses) and `globalThis.__markiiInk` (Ink's
15
+ * exports) and a pack reads them only inside render-time functions, so
16
+ * evaluating the script with both undefined must not throw.
17
+ *
18
+ * SECURITY: evaluating this script runs the pack's code in the calling
19
+ * process with that process's full privileges. Only a host's own device-
20
+ * local install store and bundled packs may reach `loadTerminalPackScript`:
21
+ * never a note, a bundle, the working directory, or an environment
22
+ * variable (docs/security.md).
23
+ */
24
+ export const TERMINAL_REGISTER_GLOBAL = '__markiiRegisterTerminalPack';
25
+ export const TERMINAL_REACT_GLOBAL = '__markiiReactInk';
26
+ export const TERMINAL_INK_GLOBAL = '__markiiInk';
27
+ /**
28
+ * Publishes this package's own React and Ink instances for pack scripts to
29
+ * read at render time. Idempotent. Left in place for the life of the
30
+ * process, since a pack reads them each time its component renders.
31
+ */
32
+ export function installTerminalPackGlobals() {
33
+ const g = globalThis;
34
+ g[TERMINAL_REACT_GLOBAL] = React;
35
+ g[TERMINAL_INK_GLOBAL] = Ink;
36
+ }
37
+ function isPlainObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+ /** Own-property validation of the registered map, same rule as the webview convention. */
41
+ function toComponentModules(value) {
42
+ if (!isPlainObject(value))
43
+ return undefined;
44
+ const out = Object.create(null);
45
+ for (const key of Object.keys(value)) {
46
+ if (!Object.hasOwn(value, key))
47
+ continue;
48
+ const entry = value[key];
49
+ if (!isPlainObject(entry) || typeof entry.component !== 'function') {
50
+ return undefined;
51
+ }
52
+ if (Object.hasOwn(entry, 'inline') && typeof entry.inline !== 'boolean') {
53
+ return undefined;
54
+ }
55
+ out[key] = {
56
+ component: entry.component,
57
+ ...(typeof entry.inline === 'boolean' ? { inline: entry.inline } : {}),
58
+ };
59
+ }
60
+ return out;
61
+ }
62
+ // Evaluations are serialized: the register hook is one process global.
63
+ let queue = Promise.resolve();
64
+ let nonce = 0;
65
+ async function evaluate(scriptText) {
66
+ installTerminalPackGlobals();
67
+ const g = globalThis;
68
+ const previous = g[TERMINAL_REGISTER_GLOBAL];
69
+ const registrations = [];
70
+ g[TERMINAL_REGISTER_GLOBAL] = (manifestJson, modules) => {
71
+ registrations.push({ manifestJson, modules });
72
+ };
73
+ try {
74
+ nonce += 1;
75
+ const source = `${scriptText}\n//# markiiTerminalPack=${String(nonce)}`;
76
+ const url = `data:text/javascript;base64,${Buffer.from(source, 'utf8').toString('base64')}`;
77
+ try {
78
+ await import(/* @vite-ignore */ url);
79
+ }
80
+ catch (error) {
81
+ const detail = error instanceof Error ? error.message : String(error);
82
+ return {
83
+ ok: false,
84
+ reason: `terminal.js threw while loading: ${detail}`,
85
+ };
86
+ }
87
+ }
88
+ finally {
89
+ if (previous === undefined)
90
+ delete g[TERMINAL_REGISTER_GLOBAL];
91
+ else
92
+ g[TERMINAL_REGISTER_GLOBAL] = previous;
93
+ }
94
+ if (registrations.length !== 1) {
95
+ return {
96
+ ok: false,
97
+ reason: registrations.length === 0
98
+ ? `terminal.js did not call ${TERMINAL_REGISTER_GLOBAL}`
99
+ : `terminal.js called ${TERMINAL_REGISTER_GLOBAL} ${String(registrations.length)} times; exactly one registration is allowed`,
100
+ };
101
+ }
102
+ const { manifestJson, modules } = registrations[0];
103
+ if (typeof manifestJson !== 'string') {
104
+ return {
105
+ ok: false,
106
+ reason: 'terminal.js did not provide a manifest JSON string',
107
+ };
108
+ }
109
+ const parsed = parsePackManifest(manifestJson);
110
+ if (!parsed.ok) {
111
+ return {
112
+ ok: false,
113
+ reason: `terminal.js manifest is invalid (${parsed.errors.join('; ')})`,
114
+ };
115
+ }
116
+ const componentModules = toComponentModules(modules);
117
+ if (componentModules === undefined) {
118
+ return { ok: false, reason: 'terminal.js registered malformed components' };
119
+ }
120
+ return { ok: true, pack: { manifest: parsed.manifest, componentModules } };
121
+ }
122
+ /**
123
+ * Evaluates one pack's `terminal.js` text and returns the validated pack
124
+ * ready for `installAnsiPacks`, or the reason it could not be used. Never
125
+ * rejects. SECURITY: runs the script's code in this process; see the
126
+ * module comment for who may call it.
127
+ */
128
+ export function loadTerminalPackScript(scriptText) {
129
+ const run = queue.then(() => evaluate(scriptText));
130
+ queue = run.catch(() => undefined);
131
+ return run.catch((error) => ({
132
+ ok: false,
133
+ reason: `terminal.js could not be evaluated: ${error instanceof Error ? error.message : String(error)}`,
134
+ }));
135
+ }