@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.
- package/LICENSE +21 -0
- package/dist/ansi.d.ts +26 -0
- package/dist/ansi.js +51 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +5 -4
- package/dist/components/callout.d.ts +38 -13
- package/dist/components/callout.js +68 -31
- package/dist/components/card.d.ts +34 -2
- package/dist/components/card.js +55 -18
- package/dist/components/cell.d.ts +5 -11
- package/dist/components/cell.js +11 -15
- package/dist/components/chart.d.ts +4 -10
- package/dist/components/chart.js +16 -24
- package/dist/components/details-string.d.ts +26 -0
- package/dist/components/details-string.js +39 -0
- package/dist/components/details.d.ts +32 -10
- package/dist/components/details.js +28 -25
- package/dist/components/divider.js +12 -16
- package/dist/components/figure.d.ts +5 -16
- package/dist/components/figure.js +9 -19
- package/dist/components/index.d.ts +14 -10
- package/dist/components/index.js +19 -16
- package/dist/components/kbd.d.ts +1 -1
- package/dist/components/kbd.js +3 -2
- package/dist/components/layout-wrapper.d.ts +7 -22
- package/dist/components/layout-wrapper.js +9 -23
- package/dist/components/progress.d.ts +3 -11
- package/dist/components/progress.js +8 -17
- package/dist/components/rating.d.ts +1 -2
- package/dist/components/rating.js +3 -4
- package/dist/components/row.d.ts +35 -20
- package/dist/components/row.js +35 -51
- package/dist/components/stat.d.ts +3 -8
- package/dist/components/stat.js +5 -11
- package/dist/components/tab.d.ts +9 -8
- package/dist/components/tab.js +12 -10
- package/dist/components/table-grid.d.ts +2 -1
- package/dist/components/table-grid.js +14 -12
- package/dist/components/table.d.ts +5 -10
- package/dist/components/table.js +8 -13
- package/dist/components/tabs-string.d.ts +17 -0
- package/dist/components/tabs-string.js +30 -0
- package/dist/components/tabs.d.ts +31 -14
- package/dist/components/tabs.js +55 -30
- package/dist/failure-presentation.d.ts +15 -0
- package/dist/failure-presentation.js +22 -0
- package/dist/glyphs.d.ts +73 -0
- package/dist/glyphs.js +88 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +10 -7
- package/dist/ink-string.d.ts +23 -0
- package/dist/ink-string.js +65 -0
- package/dist/interactive.d.ts +66 -0
- package/dist/interactive.js +207 -0
- package/dist/layout.js +1 -2
- package/dist/pack-loader.d.ts +64 -0
- package/dist/pack-loader.js +172 -0
- package/dist/pack-script.d.ts +44 -0
- package/dist/pack-script.js +135 -0
- package/dist/registry.d.ts +106 -100
- package/dist/registry.js +17 -9
- package/dist/render.d.ts +63 -25
- package/dist/render.js +1001 -380
- package/dist/text-grid.d.ts +90 -0
- package/dist/text-grid.js +300 -0
- package/package.json +9 -5
- package/dist/box.d.ts +0 -47
- package/dist/box.js +0 -209
- package/dist/measure.d.ts +0 -25
- 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 './
|
|
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
|
+
}
|