@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -299
- package/dist/ansi.d.ts +223 -0
- package/dist/ansi.js +2 -0
- package/dist/{devtools-QpCMm9JH.mjs → devtools-BhYGjb7h.js} +1 -1
- package/dist/index-DDVME65c.d.ts +919 -0
- package/dist/index.d.ts +1657 -0
- package/dist/index.js +4643 -0
- package/dist/sgr-CMfEpjSk.d.ts +91 -0
- package/dist/truncate-CBiyyZzw.js +2156 -0
- package/dist/yoga-5jKhYCJC.js +3465 -0
- package/dist/yoga.d.ts +2 -0
- package/dist/yoga.js +2 -0
- package/package.json +37 -17
- package/src/ansi/chalk.ts +179 -0
- package/src/ansi/cursor.ts +48 -0
- package/src/ansi/east-asian-width.ts +215 -0
- package/src/ansi/escapes.ts +128 -0
- package/src/ansi/index.ts +27 -0
- package/src/ansi/sgr.ts +237 -0
- package/src/ansi/slice.ts +43 -0
- package/src/ansi/string-width.ts +236 -0
- package/src/ansi/strip.ts +33 -0
- package/src/ansi/supports-color.ts +213 -0
- package/src/ansi/tokenize.ts +453 -0
- package/src/ansi/truncate.ts +194 -0
- package/src/ansi/widest-line.ts +12 -0
- package/src/ansi/wrap.ts +766 -0
- package/src/ansi-tokenizer.ts +510 -0
- package/src/auto-bind.ts +41 -0
- package/src/boxes.ts +100 -0
- package/src/code-excerpt.ts +39 -0
- package/src/colorize.ts +60 -0
- package/src/components/AccessibilityContext.ts +5 -0
- package/src/components/AnimationContext.ts +24 -0
- package/src/components/App.tsx +781 -0
- package/src/components/AppContext.ts +111 -0
- package/src/components/BackgroundContext.ts +8 -0
- package/src/components/Box.tsx +116 -0
- package/src/components/CursorContext.ts +19 -0
- package/src/components/ErrorBoundary.tsx +38 -0
- package/src/components/ErrorOverview.tsx +133 -0
- package/src/components/FocusContext.ts +30 -0
- package/src/components/Newline.tsx +15 -0
- package/src/components/Spacer.tsx +10 -0
- package/src/components/Static.tsx +59 -0
- package/src/components/StderrContext.ts +26 -0
- package/src/components/StdinContext.ts +49 -0
- package/src/components/StdoutContext.ts +28 -0
- package/src/components/Text.tsx +144 -0
- package/src/components/Transform.tsx +37 -0
- package/src/cursor-position.ts +103 -0
- package/src/devtools-window-polyfill.ts +73 -0
- package/src/devtools.ts +43 -0
- package/src/dom.ts +292 -0
- package/src/get-max-width.ts +11 -0
- package/src/global.d.ts +36 -0
- package/src/hooks/use-animation.ts +142 -0
- package/src/hooks/use-app.ts +8 -0
- package/src/hooks/use-box-metrics.ts +134 -0
- package/src/hooks/use-cursor.ts +33 -0
- package/src/hooks/use-focus-manager.ts +62 -0
- package/src/hooks/use-focus.ts +83 -0
- package/src/hooks/use-input.ts +267 -0
- package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
- package/src/hooks/use-paste.ts +78 -0
- package/src/hooks/use-stderr.ts +8 -0
- package/src/hooks/use-stdin.ts +10 -0
- package/src/hooks/use-stdout.ts +8 -0
- package/src/hooks/use-window-size.ts +41 -0
- package/src/indent-string.ts +16 -0
- package/src/index.ts +44 -0
- package/src/ink.tsx +1506 -0
- package/src/input-parser.ts +283 -0
- package/src/instances.ts +9 -0
- package/src/is-in-ci.ts +7 -0
- package/src/kitty-keyboard.ts +57 -0
- package/src/log-update.ts +370 -0
- package/src/measure-element.ts +62 -0
- package/src/measure-text.ts +31 -0
- package/src/output.ts +308 -0
- package/src/parse-keypress.ts +516 -0
- package/src/parse-stack-line.ts +139 -0
- package/src/patch-console.ts +62 -0
- package/src/quick-lru.ts +85 -0
- package/src/reconciler.ts +451 -0
- package/src/render-background.ts +38 -0
- package/src/render-border.ts +134 -0
- package/src/render-node-to-output.ts +191 -0
- package/src/render-to-string.ts +131 -0
- package/src/render.ts +276 -0
- package/src/renderer.ts +73 -0
- package/src/sanitize-ansi.ts +33 -0
- package/src/signal-exit.ts +107 -0
- package/src/squash-text-nodes.ts +40 -0
- package/src/stream.ts +30 -0
- package/src/styles.ts +748 -0
- package/src/terminal-size.ts +57 -0
- package/src/throttle.ts +73 -0
- package/src/types.ts +15 -0
- package/src/utils.ts +40 -0
- package/src/wrap-text.ts +50 -0
- package/src/write-synchronized.ts +9 -0
- package/src/yoga/config.ts +57 -0
- package/src/yoga/core/absoluteLayout.ts +626 -0
- package/src/yoga/core/baseline.ts +66 -0
- package/src/yoga/core/cache.ts +136 -0
- package/src/yoga/core/calculateLayout.ts +2920 -0
- package/src/yoga/core/config.ts +104 -0
- package/src/yoga/core/flexLine.ts +177 -0
- package/src/yoga/core/helpers.ts +293 -0
- package/src/yoga/core/layoutResults.ts +167 -0
- package/src/yoga/core/node.ts +611 -0
- package/src/yoga/core/numeric.ts +44 -0
- package/src/yoga/core/pixelGrid.ts +151 -0
- package/src/yoga/core/style.ts +887 -0
- package/src/yoga/core/types.ts +224 -0
- package/src/yoga/generated/YGEnums.ts +263 -0
- package/src/yoga/index.ts +19 -0
- package/src/yoga/node.ts +1140 -0
- package/dist/index.d.mts +0 -2379
- package/dist/index.mjs +0 -10072
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { widestLine } from "./ansi/widest-line.ts";
|
|
2
|
+
import { type DOMElement } from "./dom.ts";
|
|
3
|
+
import { getMaxWidth } from "./get-max-width.ts";
|
|
4
|
+
import { indentString } from "./indent-string.ts";
|
|
5
|
+
import type { Output } from "./output.ts";
|
|
6
|
+
import { renderBackground } from "./render-background.ts";
|
|
7
|
+
import { renderBorder } from "./render-border.ts";
|
|
8
|
+
import { squashTextNodes } from "./squash-text-nodes.ts";
|
|
9
|
+
import { wrapText } from "./wrap-text.ts";
|
|
10
|
+
import { Yoga } from "./yoga/index.ts";
|
|
11
|
+
|
|
12
|
+
// If parent container is `<Box>`, text nodes will be treated as separate nodes in
|
|
13
|
+
// the tree and will have their own coordinates in the layout.
|
|
14
|
+
// To ensure text nodes are aligned correctly, take X and Y of the first text node
|
|
15
|
+
// and use it as offset for the rest of the nodes
|
|
16
|
+
// Only first node is taken into account, because other text nodes can't have margin or padding,
|
|
17
|
+
// so their coordinates will be relative to the first node anyway
|
|
18
|
+
const applyPaddingToText = (node: DOMElement, text: string): string => {
|
|
19
|
+
const yogaNode = node.childNodes[0]?.yogaNode;
|
|
20
|
+
|
|
21
|
+
if (yogaNode) {
|
|
22
|
+
const offsetX = yogaNode.getComputedLeft();
|
|
23
|
+
const offsetY = yogaNode.getComputedTop();
|
|
24
|
+
text = "\n".repeat(offsetY) + indentString(text, offsetX);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return text;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type OutputTransformer = (s: string, index: number) => string;
|
|
31
|
+
|
|
32
|
+
export const renderNodeToScreenReaderOutput = (
|
|
33
|
+
node: DOMElement,
|
|
34
|
+
options: {
|
|
35
|
+
parentRole?: string;
|
|
36
|
+
skipStaticElements?: boolean;
|
|
37
|
+
} = {},
|
|
38
|
+
): string => {
|
|
39
|
+
if (options.skipStaticElements && node.internal_static) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (node.yogaNode?.getDisplay() === Yoga.DISPLAY_NONE) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let output = "";
|
|
48
|
+
|
|
49
|
+
if (node.nodeName === "ink-text") {
|
|
50
|
+
output = squashTextNodes(node);
|
|
51
|
+
} else if (node.nodeName === "ink-box" || node.nodeName === "ink-root") {
|
|
52
|
+
const separator =
|
|
53
|
+
node.style.flexDirection === "row" || node.style.flexDirection === "row-reverse" ? " " : "\n";
|
|
54
|
+
|
|
55
|
+
const childNodes =
|
|
56
|
+
node.style.flexDirection === "row-reverse" || node.style.flexDirection === "column-reverse"
|
|
57
|
+
? [...node.childNodes].reverse()
|
|
58
|
+
: [...node.childNodes];
|
|
59
|
+
|
|
60
|
+
output = childNodes
|
|
61
|
+
.map((childNode) => {
|
|
62
|
+
const screenReaderOutput = renderNodeToScreenReaderOutput(childNode as DOMElement, {
|
|
63
|
+
parentRole: node.internal_accessibility?.role,
|
|
64
|
+
skipStaticElements: options.skipStaticElements,
|
|
65
|
+
});
|
|
66
|
+
return screenReaderOutput;
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join(separator);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (node.internal_accessibility) {
|
|
73
|
+
const { role, state } = node.internal_accessibility;
|
|
74
|
+
|
|
75
|
+
if (state) {
|
|
76
|
+
const stateKeys = Object.keys(state) as Array<keyof typeof state>;
|
|
77
|
+
const stateDescription = stateKeys.filter((key) => state[key]).join(", ");
|
|
78
|
+
|
|
79
|
+
if (stateDescription) {
|
|
80
|
+
output = `(${stateDescription}) ${output}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (role && role !== options.parentRole) {
|
|
85
|
+
output = `${role}: ${output}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return output;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// After nodes are laid out, render each to output object, which later gets rendered to terminal
|
|
93
|
+
export const renderNodeToOutput = (
|
|
94
|
+
node: DOMElement,
|
|
95
|
+
output: Output,
|
|
96
|
+
options: {
|
|
97
|
+
offsetX?: number;
|
|
98
|
+
offsetY?: number;
|
|
99
|
+
transformers?: OutputTransformer[];
|
|
100
|
+
skipStaticElements: boolean;
|
|
101
|
+
},
|
|
102
|
+
) => {
|
|
103
|
+
const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements } = options;
|
|
104
|
+
|
|
105
|
+
if (skipStaticElements && node.internal_static) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { yogaNode } = node;
|
|
110
|
+
|
|
111
|
+
if (yogaNode) {
|
|
112
|
+
if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Left and top positions in Yoga are relative to their parent node
|
|
117
|
+
const x = offsetX + yogaNode.getComputedLeft();
|
|
118
|
+
const y = offsetY + yogaNode.getComputedTop();
|
|
119
|
+
|
|
120
|
+
// Transformers are functions that transform final text output of each component
|
|
121
|
+
// See Output class for logic that applies transformers
|
|
122
|
+
let newTransformers = transformers;
|
|
123
|
+
|
|
124
|
+
if (typeof node.internal_transform === "function") {
|
|
125
|
+
newTransformers = [node.internal_transform, ...transformers];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (node.nodeName === "ink-text") {
|
|
129
|
+
let text = squashTextNodes(node);
|
|
130
|
+
|
|
131
|
+
if (text.length > 0) {
|
|
132
|
+
const currentWidth = widestLine(text);
|
|
133
|
+
const maxWidth = getMaxWidth(yogaNode);
|
|
134
|
+
|
|
135
|
+
if (currentWidth > maxWidth) {
|
|
136
|
+
const textWrap = node.style.textWrap ?? "wrap";
|
|
137
|
+
text = wrapText(text, maxWidth, textWrap);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
text = applyPaddingToText(node, text);
|
|
141
|
+
|
|
142
|
+
output.write(x, y, text, { transformers: newTransformers });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let clipped = false;
|
|
149
|
+
|
|
150
|
+
if (node.nodeName === "ink-box") {
|
|
151
|
+
renderBackground(x, y, node, output);
|
|
152
|
+
renderBorder(x, y, node, output);
|
|
153
|
+
|
|
154
|
+
const clipHorizontally =
|
|
155
|
+
node.style.overflowX === "hidden" || node.style.overflow === "hidden";
|
|
156
|
+
const clipVertically = node.style.overflowY === "hidden" || node.style.overflow === "hidden";
|
|
157
|
+
|
|
158
|
+
if (clipHorizontally || clipVertically) {
|
|
159
|
+
const x1 = clipHorizontally ? x + yogaNode.getComputedBorder(Yoga.EDGE_LEFT) : undefined;
|
|
160
|
+
|
|
161
|
+
const x2 = clipHorizontally
|
|
162
|
+
? x + yogaNode.getComputedWidth() - yogaNode.getComputedBorder(Yoga.EDGE_RIGHT)
|
|
163
|
+
: undefined;
|
|
164
|
+
|
|
165
|
+
const y1 = clipVertically ? y + yogaNode.getComputedBorder(Yoga.EDGE_TOP) : undefined;
|
|
166
|
+
|
|
167
|
+
const y2 = clipVertically
|
|
168
|
+
? y + yogaNode.getComputedHeight() - yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM)
|
|
169
|
+
: undefined;
|
|
170
|
+
|
|
171
|
+
output.clip({ x1, x2, y1, y2 });
|
|
172
|
+
clipped = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (node.nodeName === "ink-root" || node.nodeName === "ink-box") {
|
|
177
|
+
for (const childNode of node.childNodes) {
|
|
178
|
+
renderNodeToOutput(childNode as DOMElement, output, {
|
|
179
|
+
offsetX: x,
|
|
180
|
+
offsetY: y,
|
|
181
|
+
transformers: newTransformers,
|
|
182
|
+
skipStaticElements,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (clipped) {
|
|
187
|
+
output.unclip();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { LegacyRoot } from "react-reconciler/constants.js";
|
|
3
|
+
|
|
4
|
+
import { createNode, type DOMElement } from "./dom.ts";
|
|
5
|
+
import { reconciler } from "./reconciler.ts";
|
|
6
|
+
import { renderer } from "./renderer.ts";
|
|
7
|
+
import { Yoga } from "./yoga/index.ts";
|
|
8
|
+
|
|
9
|
+
export type RenderToStringOptions = {
|
|
10
|
+
/**
|
|
11
|
+
Width of the virtual terminal in columns.
|
|
12
|
+
|
|
13
|
+
@default 80
|
|
14
|
+
*/
|
|
15
|
+
columns?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
Render a React element to a string synchronously. Unlike `render()`, this function does not write to stdout, does not set up any terminal event listeners, and returns the rendered output as a string.
|
|
20
|
+
|
|
21
|
+
Useful for generating documentation, writing output to files, testing, or any scenario where you need the rendered output as a string without starting a persistent terminal application.
|
|
22
|
+
|
|
23
|
+
**Notes:**
|
|
24
|
+
|
|
25
|
+
- Terminal-specific hooks (`useInput`, `useStdin`, `useStdout`, `useStderr`, `useApp`, `useFocus`, `useFocusManager`) return default no-op values since there is no terminal session. They will not throw, but they will not function as in a live terminal.
|
|
26
|
+
- `useEffect` callbacks will execute during rendering (due to synchronous rendering mode), but state updates they trigger will not affect the returned output, which reflects the initial render.
|
|
27
|
+
- `useLayoutEffect` callbacks fire synchronously during commit, so state updates they trigger **will** be reflected in the output.
|
|
28
|
+
- The `<Static>` component is supported — its output is prepended to the dynamic output.
|
|
29
|
+
- If a component throws during rendering, the error is propagated to the caller after cleanup.
|
|
30
|
+
|
|
31
|
+
@example
|
|
32
|
+
```
|
|
33
|
+
import {renderToString, Text, Box} from 'ink';
|
|
34
|
+
|
|
35
|
+
const output = renderToString(
|
|
36
|
+
<Box padding={1}>
|
|
37
|
+
<Text color="green">Hello World</Text>
|
|
38
|
+
</Box>,
|
|
39
|
+
{columns: 40}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
console.log(output);
|
|
43
|
+
```
|
|
44
|
+
*/
|
|
45
|
+
export const renderToString = (node: ReactNode, options?: RenderToStringOptions): string => {
|
|
46
|
+
const columns = options?.columns ?? 80;
|
|
47
|
+
|
|
48
|
+
// Create a standalone root node — no stdout, stdin, or terminal bindings
|
|
49
|
+
const rootNode: DOMElement = createNode("ink-root");
|
|
50
|
+
|
|
51
|
+
// Capture static output from intermediate renders.
|
|
52
|
+
// The <Static> component uses useLayoutEffect to clear its children after
|
|
53
|
+
// the first commit. The reconciler's resetAfterCommit calls onImmediateRender
|
|
54
|
+
// when static content is dirty (and returns early, skipping the normal
|
|
55
|
+
// onRender callback), giving us a chance to capture it before it's cleared
|
|
56
|
+
// by the subsequent re-render.
|
|
57
|
+
let capturedStaticOutput = "";
|
|
58
|
+
|
|
59
|
+
rootNode.onComputeLayout = () => {
|
|
60
|
+
rootNode.yogaNode!.setWidth(columns);
|
|
61
|
+
rootNode.yogaNode!.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
rootNode.onImmediateRender = () => {
|
|
65
|
+
const { staticOutput } = renderer(rootNode, false);
|
|
66
|
+
if (staticOutput && staticOutput !== "\n") {
|
|
67
|
+
capturedStaticOutput += staticOutput;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Capture the first uncaught error so we can re-throw it after cleanup.
|
|
72
|
+
// React's reconciler catches component errors internally and reports them
|
|
73
|
+
// via onUncaughtError rather than letting them propagate. For a synchronous
|
|
74
|
+
// utility like renderToString, callers expect errors to throw.
|
|
75
|
+
let uncaughtError: unknown;
|
|
76
|
+
|
|
77
|
+
// Create a reconciler container in legacy (synchronous) mode.
|
|
78
|
+
// The four trailing callbacks are: onUncaughtError, onCaughtError,
|
|
79
|
+
// onRecoverableError, and onHostTransitionComplete.
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
81
|
+
const container = reconciler.createContainer(
|
|
82
|
+
rootNode,
|
|
83
|
+
LegacyRoot,
|
|
84
|
+
null,
|
|
85
|
+
false,
|
|
86
|
+
null,
|
|
87
|
+
"render-to-string",
|
|
88
|
+
(error: unknown) => {
|
|
89
|
+
uncaughtError ??= error;
|
|
90
|
+
},
|
|
91
|
+
() => {},
|
|
92
|
+
() => {},
|
|
93
|
+
() => {},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Synchronously render the React tree into the container
|
|
97
|
+
reconciler.updateContainerSync(node, container, null, () => {});
|
|
98
|
+
reconciler.flushSyncWork();
|
|
99
|
+
|
|
100
|
+
// Yoga layout has already been calculated by onComputeLayout during commit.
|
|
101
|
+
// Render the DOM tree to a string — this captures the dynamic (non-static) output.
|
|
102
|
+
const { output } = renderer(rootNode, false);
|
|
103
|
+
|
|
104
|
+
// Tear down: unmount the tree so the reconciler cleans up child nodes
|
|
105
|
+
// and runs effect cleanup functions. The reconciler detaches removed
|
|
106
|
+
// subtrees (removeChildFromContainer → detachYogaSubtree); the garbage
|
|
107
|
+
// collector reclaims the Yoga nodes.
|
|
108
|
+
reconciler.updateContainerSync(null, container, null, () => {});
|
|
109
|
+
reconciler.flushSyncWork();
|
|
110
|
+
|
|
111
|
+
// Re-throw after full cleanup so callers see the original error.
|
|
112
|
+
if (uncaughtError !== undefined) {
|
|
113
|
+
throw uncaughtError instanceof Error
|
|
114
|
+
? uncaughtError
|
|
115
|
+
: // eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
116
|
+
new Error(String(uncaughtError));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The renderer appends a trailing newline to static output for terminal
|
|
120
|
+
// rendering (so dynamic output starts on a fresh line). Strip it here
|
|
121
|
+
// so renderToString returns clean output.
|
|
122
|
+
const normalizedStaticOutput = capturedStaticOutput.endsWith("\n")
|
|
123
|
+
? capturedStaticOutput.slice(0, -1)
|
|
124
|
+
: capturedStaticOutput;
|
|
125
|
+
|
|
126
|
+
if (normalizedStaticOutput && output) {
|
|
127
|
+
return normalizedStaticOutput + "\n" + output;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return normalizedStaticOutput || output;
|
|
131
|
+
};
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { Stream, type Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { Ink, type Options as InkOptions, type RenderMetrics } from "./ink.tsx";
|
|
7
|
+
import { instances } from "./instances.ts";
|
|
8
|
+
import { type KittyKeyboardOptions } from "./kitty-keyboard.ts";
|
|
9
|
+
|
|
10
|
+
export type RenderOptions = {
|
|
11
|
+
/**
|
|
12
|
+
Output stream where the app will be rendered.
|
|
13
|
+
|
|
14
|
+
@default process.stdout
|
|
15
|
+
*/
|
|
16
|
+
stdout?: NodeJS.WritableStream;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
Input stream where app will listen for input.
|
|
20
|
+
|
|
21
|
+
@default process.stdin
|
|
22
|
+
*/
|
|
23
|
+
stdin?: NodeJS.ReadableStream;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
Error stream.
|
|
27
|
+
@default process.stderr
|
|
28
|
+
*/
|
|
29
|
+
stderr?: NodeJS.WritableStream;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
If true, each update will be rendered as separate output, without replacing the previous one.
|
|
33
|
+
|
|
34
|
+
@default false
|
|
35
|
+
*/
|
|
36
|
+
debug?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
Configure whether Ink should listen for Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and the process is expected to handle it manually.
|
|
40
|
+
|
|
41
|
+
@default true
|
|
42
|
+
*/
|
|
43
|
+
exitOnCtrlC?: boolean;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
Patch console methods to ensure console output doesn't mix with Ink's output.
|
|
47
|
+
|
|
48
|
+
Pass `"stdio"` to additionally intercept direct `stdout.write` / `stderr.write` calls on the streams Ink renders to (output from dependencies, native warnings, child tooling). Captured chunks are line-buffered and spliced above the live frame like console output; partial lines are flushed at unmount. Use `onCapturedOutput` to observe captured chunks or take over their display.
|
|
49
|
+
|
|
50
|
+
Note: Once unmount starts, Ink restores the native console (and stream writes) before React cleanup runs. Teardown-time output then follows the normal behavior instead of being rerouted through Ink.
|
|
51
|
+
|
|
52
|
+
@default true
|
|
53
|
+
*/
|
|
54
|
+
patchConsole?: boolean | "stdio";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
Observe output captured by `patchConsole` before Ink displays it.
|
|
58
|
+
|
|
59
|
+
Receives each captured chunk with its origin (`"console"` or `"stdio"`). Return `true` to take ownership of the chunk: Ink will not display it, so the app can render it itself — for example inside a `<Static>` transcript.
|
|
60
|
+
*/
|
|
61
|
+
onCapturedOutput?: (
|
|
62
|
+
stream: "stdout" | "stderr",
|
|
63
|
+
data: string,
|
|
64
|
+
source: "console" | "stdio",
|
|
65
|
+
) => boolean | undefined | void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
Runs the given callback after each render and re-render with render metrics.
|
|
69
|
+
|
|
70
|
+
Note: this callback runs after Ink commits a frame, but it does not wait for `stdout`/`stderr` stream callbacks.
|
|
71
|
+
To run code after output is flushed, use `waitUntilRenderFlush()`.
|
|
72
|
+
*/
|
|
73
|
+
onRender?: (metrics: RenderMetrics) => void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
Enable screen reader support. See https://github.com/vadimdemedes/ink/blob/master/readme.md#screen-reader-support
|
|
77
|
+
|
|
78
|
+
@default process.env['SIGIL_SCREEN_READER'] === 'true'
|
|
79
|
+
*/
|
|
80
|
+
isScreenReaderEnabled?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
Maximum frames per second for render updates.
|
|
84
|
+
This controls how frequently the UI can update to prevent excessive re-rendering.
|
|
85
|
+
Higher values allow more frequent updates but may impact performance.
|
|
86
|
+
|
|
87
|
+
@default 30
|
|
88
|
+
*/
|
|
89
|
+
maxFps?: number;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
Enable incremental rendering mode which only updates changed lines instead of redrawing the entire output.
|
|
93
|
+
This can reduce flickering and improve performance for frequently updating UIs.
|
|
94
|
+
|
|
95
|
+
@default false
|
|
96
|
+
*/
|
|
97
|
+
incrementalRendering?: boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
Enable React Concurrent Rendering mode.
|
|
101
|
+
|
|
102
|
+
When enabled:
|
|
103
|
+
- Suspense boundaries work correctly with async data
|
|
104
|
+
- `useTransition` and `useDeferredValue` are fully functional
|
|
105
|
+
- Updates can be interrupted for higher priority work
|
|
106
|
+
|
|
107
|
+
Note: Concurrent mode changes the timing of renders. Some tests may need to use `act()` to properly await updates. Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change the rendering mode or create a fresh instance.
|
|
108
|
+
|
|
109
|
+
@default false
|
|
110
|
+
*/
|
|
111
|
+
concurrent?: boolean;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
Configure kitty keyboard protocol support for enhanced keyboard input.
|
|
115
|
+
Enables additional modifiers (super, hyper, capsLock, numLock) and
|
|
116
|
+
disambiguated key events in terminals that support the protocol.
|
|
117
|
+
|
|
118
|
+
@see https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
119
|
+
*/
|
|
120
|
+
kittyKeyboard?: KittyKeyboardOptions;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
Override automatic interactive mode detection.
|
|
124
|
+
|
|
125
|
+
By default, Ink detects whether the environment is interactive based on CI detection (via [`is-in-ci`](https://github.com/sindresorhus/is-in-ci)) and `stdout.isTTY`. Most users should not need to set this.
|
|
126
|
+
|
|
127
|
+
When non-interactive, Ink disables ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and kitty keyboard auto-detection, writing only the final frame at unmount.
|
|
128
|
+
|
|
129
|
+
Set to `false` to force non-interactive mode or `true` to force interactive mode when the automatic detection doesn't suit your use case.
|
|
130
|
+
|
|
131
|
+
Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance.
|
|
132
|
+
|
|
133
|
+
@default true (false if in CI or `stdout.isTTY` is falsy)
|
|
134
|
+
*/
|
|
135
|
+
interactive?: boolean;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
Render the app in the terminal's alternate screen buffer. When enabled, the app renders on a separate screen, and the original terminal content is restored when the app exits. This is the same mechanism used by programs like vim, htop, and less.
|
|
139
|
+
|
|
140
|
+
Note: The terminal's scrollback buffer is not available while in the alternate screen. This is standard terminal behavior; programs like vim use the alternate screen specifically to avoid polluting the user's scrollback history.
|
|
141
|
+
|
|
142
|
+
Note: Ink intentionally treats alternate-screen teardown output as disposable. It does not preserve or replay teardown-time frames, hook writes, or `console.*` output after restoring the primary screen.
|
|
143
|
+
|
|
144
|
+
Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout).
|
|
145
|
+
|
|
146
|
+
Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance.
|
|
147
|
+
|
|
148
|
+
@default false
|
|
149
|
+
*/
|
|
150
|
+
alternateScreen?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type Instance = {
|
|
154
|
+
/**
|
|
155
|
+
Replace the previous root node with a new one or update props of the current root node.
|
|
156
|
+
*/
|
|
157
|
+
rerender: Ink["render"];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
Manually unmount the whole Ink app.
|
|
161
|
+
*/
|
|
162
|
+
unmount: Ink["unmount"];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
Returns a promise that settles when the app is unmounted.
|
|
166
|
+
|
|
167
|
+
It resolves with the value passed to `exit(value)` and rejects with the error passed to `exit(error)`.
|
|
168
|
+
When `unmount()` is called manually, it settles after unmount-related stdout writes complete.
|
|
169
|
+
|
|
170
|
+
@example
|
|
171
|
+
```jsx
|
|
172
|
+
const {unmount, waitUntilExit} = render(<MyApp />);
|
|
173
|
+
|
|
174
|
+
setTimeout(unmount, 1000);
|
|
175
|
+
|
|
176
|
+
await waitUntilExit(); // resolves after `unmount()` is called
|
|
177
|
+
```
|
|
178
|
+
*/
|
|
179
|
+
waitUntilExit: Ink["waitUntilExit"];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
Returns a promise that settles after pending render output is flushed to stdout.
|
|
183
|
+
|
|
184
|
+
This can be used after `rerender()` when you need to run code only after the frame is written.
|
|
185
|
+
|
|
186
|
+
@example
|
|
187
|
+
```jsx
|
|
188
|
+
const {rerender, waitUntilRenderFlush} = render(<MyApp step="loading" />);
|
|
189
|
+
|
|
190
|
+
rerender(<MyApp step="ready" />);
|
|
191
|
+
await waitUntilRenderFlush(); // output for "ready" is flushed
|
|
192
|
+
|
|
193
|
+
runNextCommand();
|
|
194
|
+
```
|
|
195
|
+
*/
|
|
196
|
+
waitUntilRenderFlush: Ink["waitUntilRenderFlush"];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
Unmount the current app and remove the internal Ink instance for this stdout.
|
|
200
|
+
|
|
201
|
+
This is mostly useful for advanced cases where you need `render()` to create a fresh instance for the same stream without leaving terminal state such as the alternate screen behind.
|
|
202
|
+
*/
|
|
203
|
+
cleanup: () => void;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
Clear output.
|
|
207
|
+
*/
|
|
208
|
+
clear: () => void;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
Mount a component and render the output.
|
|
213
|
+
*/
|
|
214
|
+
export const render = (node: ReactNode, options?: Writable | RenderOptions): Instance => {
|
|
215
|
+
const inkOptions: InkOptions = {
|
|
216
|
+
stdout: process.stdout,
|
|
217
|
+
stdin: process.stdin,
|
|
218
|
+
stderr: process.stderr,
|
|
219
|
+
debug: false,
|
|
220
|
+
exitOnCtrlC: true,
|
|
221
|
+
patchConsole: true,
|
|
222
|
+
maxFps: 30,
|
|
223
|
+
incrementalRendering: false,
|
|
224
|
+
concurrent: false,
|
|
225
|
+
alternateScreen: false,
|
|
226
|
+
...getOptions(options),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const instance: Ink = getInstance(inkOptions.stdout, () => new Ink(inkOptions));
|
|
230
|
+
instance.render(node);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
rerender: instance.render.bind(instance),
|
|
234
|
+
unmount() {
|
|
235
|
+
instance.unmount();
|
|
236
|
+
},
|
|
237
|
+
waitUntilExit: instance.waitUntilExit.bind(instance),
|
|
238
|
+
waitUntilRenderFlush: instance.waitUntilRenderFlush.bind(instance),
|
|
239
|
+
cleanup() {
|
|
240
|
+
instance.unmount();
|
|
241
|
+
},
|
|
242
|
+
clear: instance.clear.bind(instance),
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const getOptions = (stdout: Writable | RenderOptions | undefined = {}): RenderOptions => {
|
|
247
|
+
if (stdout instanceof Stream) {
|
|
248
|
+
return {
|
|
249
|
+
stdout,
|
|
250
|
+
stdin: process.stdin,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return stdout;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const getInstance = (stdout: NodeJS.WritableStream, createInstance: () => Ink): Ink => {
|
|
258
|
+
const instance = instances.get(stdout);
|
|
259
|
+
|
|
260
|
+
if (instance === undefined) {
|
|
261
|
+
const newInstance = createInstance();
|
|
262
|
+
instances.set(stdout, newInstance);
|
|
263
|
+
return newInstance;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Ink keeps one live renderer per stdout. Reusing the same stream without
|
|
267
|
+
// unmounting is unsupported, but return the existing instance so we don't
|
|
268
|
+
// create two renderers that compete for the same output. Write the warning
|
|
269
|
+
// directly to native stderr so an existing alternate-screen renderer cannot
|
|
270
|
+
// swallow it via patchConsole.
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
"Warning: render() was called again for the same stdout before the previous Ink instance was unmounted. Reusing stdout across multiple render() calls is unsupported. Call unmount() first.\n",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return instance;
|
|
276
|
+
};
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type DOMElement } from "./dom.ts";
|
|
2
|
+
import { Output } from "./output.ts";
|
|
3
|
+
import { renderNodeToOutput, renderNodeToScreenReaderOutput } from "./render-node-to-output.ts";
|
|
4
|
+
|
|
5
|
+
type Result = {
|
|
6
|
+
output: string;
|
|
7
|
+
outputHeight: number;
|
|
8
|
+
staticOutput: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const renderer = (node: DOMElement, isScreenReaderEnabled: boolean): Result => {
|
|
12
|
+
if (node.yogaNode) {
|
|
13
|
+
if (isScreenReaderEnabled) {
|
|
14
|
+
const output = renderNodeToScreenReaderOutput(node, {
|
|
15
|
+
skipStaticElements: true,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const outputHeight = output === "" ? 0 : output.split("\n").length;
|
|
19
|
+
|
|
20
|
+
let staticOutput = "";
|
|
21
|
+
|
|
22
|
+
if (node.staticNode) {
|
|
23
|
+
staticOutput = renderNodeToScreenReaderOutput(node.staticNode, {
|
|
24
|
+
skipStaticElements: false,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
output,
|
|
30
|
+
outputHeight,
|
|
31
|
+
staticOutput: staticOutput ? `${staticOutput}\n` : "",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const output = new Output({
|
|
36
|
+
width: node.yogaNode.getComputedWidth(),
|
|
37
|
+
height: node.yogaNode.getComputedHeight(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
renderNodeToOutput(node, output, {
|
|
41
|
+
skipStaticElements: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let staticOutput;
|
|
45
|
+
|
|
46
|
+
if (node.staticNode?.yogaNode) {
|
|
47
|
+
staticOutput = new Output({
|
|
48
|
+
width: node.staticNode.yogaNode.getComputedWidth(),
|
|
49
|
+
height: node.staticNode.yogaNode.getComputedHeight(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
renderNodeToOutput(node.staticNode, staticOutput, {
|
|
53
|
+
skipStaticElements: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { output: generatedOutput, height: outputHeight } = output.get();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
output: generatedOutput,
|
|
61
|
+
outputHeight,
|
|
62
|
+
// Newline at the end is needed, because static output doesn't have one, so
|
|
63
|
+
// interactive output will override last line of static output
|
|
64
|
+
staticOutput: staticOutput ? `${staticOutput.get().output}\n` : "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
output: "",
|
|
70
|
+
outputHeight: 0,
|
|
71
|
+
staticOutput: "",
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hasAnsiControlCharacters, tokenizeAnsi } from "./ansi-tokenizer.ts";
|
|
2
|
+
|
|
3
|
+
const sgrParametersRegex = /^[\d:;]*$/;
|
|
4
|
+
|
|
5
|
+
// Strip ANSI escape sequences that would conflict with Ink's layout.
|
|
6
|
+
// Preserved: SGR sequences (colors, bold, etc. - end with 'm') and
|
|
7
|
+
// OSC sequences (hyperlinks, etc. - ESC ] or C1 OSC).
|
|
8
|
+
// Stripped: cursor movement, screen clearing, and other control sequences.
|
|
9
|
+
export const sanitizeAnsi = (text: string): string => {
|
|
10
|
+
if (!hasAnsiControlCharacters(text)) {
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let output = "";
|
|
15
|
+
|
|
16
|
+
for (const token of tokenizeAnsi(text)) {
|
|
17
|
+
if (token.type === "text" || token.type === "osc") {
|
|
18
|
+
output += token.value;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
token.type === "csi" &&
|
|
24
|
+
token.finalCharacter === "m" &&
|
|
25
|
+
token.intermediateString === "" &&
|
|
26
|
+
sgrParametersRegex.test(token.parameterString)
|
|
27
|
+
) {
|
|
28
|
+
output += token.value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return output;
|
|
33
|
+
};
|