2026-05-26 15:59:17 +08:00
|
|
|
import type { Component } from "vue";
|
|
|
|
|
import { shallowRef } from "vue";
|
|
|
|
|
import { createRenderer } from "@vue/runtime-core";
|
|
|
|
|
import { EventEmitter } from "node:events";
|
|
|
|
|
import Yoga from "yoga-layout";
|
|
|
|
|
import { createRoot, type TuiNode } from "./host/nodes.ts";
|
|
|
|
|
import { attachYoga, detachYoga } from "./host/yoga.ts";
|
|
|
|
|
import { buildNodeOps } from "./host/node-ops.ts";
|
|
|
|
|
import { paint, paintIsolated } from "./paint/paint.ts";
|
2026-05-27 20:42:54 +08:00
|
|
|
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
|
2026-05-26 15:59:17 +08:00
|
|
|
import { findStatics } from "./paint/static-channel.ts";
|
|
|
|
|
import {
|
|
|
|
|
AppContextKey,
|
|
|
|
|
FocusContextKey,
|
|
|
|
|
StdinContextKey,
|
|
|
|
|
type AppContext,
|
|
|
|
|
type FocusContext,
|
|
|
|
|
type StdinContext,
|
|
|
|
|
} from "./context.ts";
|
|
|
|
|
|
|
|
|
|
export interface RenderToStringOptions {
|
|
|
|
|
/**
|
|
|
|
|
* Width of the virtual terminal in columns.
|
|
|
|
|
*
|
|
|
|
|
* @default 80
|
|
|
|
|
*/
|
|
|
|
|
columns?: number;
|
2026-05-27 20:42:54 +08:00
|
|
|
/**
|
|
|
|
|
* Enable screen reader mode. When enabled, the output is plain text
|
|
|
|
|
* suitable for screen readers (no ANSI styling, with role/state annotations).
|
|
|
|
|
*
|
|
|
|
|
* @default false
|
|
|
|
|
*/
|
|
|
|
|
isScreenReaderEnabled?: boolean;
|
2026-05-26 15:59:17 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render a Vue component to a string synchronously. Unlike `createApp()`,
|
|
|
|
|
* this function does not write to stdout, does not set up any terminal event
|
|
|
|
|
* listeners, and returns the rendered output as a string.
|
|
|
|
|
*
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
|
|
|
|
* Terminal-specific composables (`useInput`, `useStdin`, `useStdout`,
|
|
|
|
|
* `useStderr`, `useExit`, `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.
|
|
|
|
|
*
|
|
|
|
|
* The `<Static>` component is supported --- its output is prepended to the
|
|
|
|
|
* dynamic output.
|
|
|
|
|
*
|
|
|
|
|
* If a component throws during rendering, the error is propagated to the
|
|
|
|
|
* caller after cleanup.
|
|
|
|
|
*/
|
|
|
|
|
export function renderToString(component: Component, options?: RenderToStringOptions): string {
|
|
|
|
|
const columns = options?.columns ?? 80;
|
2026-05-27 20:42:54 +08:00
|
|
|
const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false;
|
2026-05-26 15:59:17 +08:00
|
|
|
|
|
|
|
|
// Create a standalone root node --- no stdout, stdin, or terminal bindings.
|
2026-05-27 20:42:54 +08:00
|
|
|
const appContext = createNoOpAppContext(isScreenReaderEnabled);
|
2026-05-26 15:59:17 +08:00
|
|
|
const root = createRoot(appContext);
|
|
|
|
|
attachYoga(root);
|
|
|
|
|
root.yoga.setWidth(columns);
|
|
|
|
|
|
|
|
|
|
// Capture static output from intermediate renders.
|
|
|
|
|
// The <Static> component uses watchEffect / onMounted to clear its children
|
|
|
|
|
// after the first commit. The onCommit callback fires on each DOM mutation,
|
|
|
|
|
// giving us a chance to capture static content before it is cleared.
|
|
|
|
|
let capturedStaticOutput = "";
|
|
|
|
|
|
|
|
|
|
const renderer = createRenderer<TuiNode, TuiNode>(
|
|
|
|
|
buildNodeOps({
|
|
|
|
|
onCommit: () => {
|
|
|
|
|
root.yoga.calculateLayout(columns, undefined, Yoga.DIRECTION_LTR);
|
|
|
|
|
// Flush static output from intermediate renders
|
|
|
|
|
for (const stat of findStatics(root)) {
|
|
|
|
|
const fresh = stat.children.slice(stat.writtenCount);
|
|
|
|
|
if (fresh.length === 0) continue;
|
|
|
|
|
const staticFrame = paintIsolated(fresh, columns, stat);
|
|
|
|
|
if (staticFrame && staticFrame !== "\n") {
|
|
|
|
|
capturedStaticOutput += staticFrame + "\n";
|
|
|
|
|
}
|
|
|
|
|
stat.writtenCount = stat.children.length;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const app = renderer.createApp(component);
|
|
|
|
|
|
|
|
|
|
// Provide no-op contexts so composables don't throw when injecting.
|
|
|
|
|
app.provide(AppContextKey, appContext);
|
|
|
|
|
app.provide(FocusContextKey, createNoOpFocusContext());
|
|
|
|
|
app.provide(StdinContextKey, createNoOpStdinContext());
|
|
|
|
|
|
|
|
|
|
// Capture the first uncaught error so we can re-throw after cleanup.
|
|
|
|
|
// Vue's error handling catches component errors internally; for a
|
|
|
|
|
// synchronous utility like renderToString, callers expect errors to throw.
|
|
|
|
|
let uncaughtError: unknown;
|
|
|
|
|
app.config.errorHandler = (err) => {
|
|
|
|
|
uncaughtError ??= err;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let teardownSucceeded = false;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Synchronously render the Vue tree into the root.
|
|
|
|
|
app.mount(root);
|
|
|
|
|
|
|
|
|
|
// Calculate final layout (onCommit may have already done this, but
|
|
|
|
|
// ensure the final state is laid out).
|
|
|
|
|
root.yoga.calculateLayout(columns, undefined, Yoga.DIRECTION_LTR);
|
|
|
|
|
|
|
|
|
|
// Render the dynamic frame to a string.
|
2026-05-27 20:42:54 +08:00
|
|
|
const output = isScreenReaderEnabled
|
|
|
|
|
? renderScreenReaderOutput(root, { skipStaticElements: true })
|
|
|
|
|
: paint(root);
|
2026-05-26 15:59:17 +08:00
|
|
|
|
|
|
|
|
// Tear down: unmount the tree so Vue cleans up child nodes and runs
|
|
|
|
|
// effect cleanup functions. Child yoga nodes are freed by the node-ops
|
|
|
|
|
// remove handler.
|
|
|
|
|
app.unmount();
|
|
|
|
|
teardownSucceeded = true;
|
|
|
|
|
|
|
|
|
|
// Free the root yoga node itself (children already freed by unmount).
|
|
|
|
|
detachYoga(root);
|
|
|
|
|
|
|
|
|
|
// Re-throw after full cleanup so callers see the original error.
|
|
|
|
|
if (uncaughtError !== undefined) {
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
|
|
|
throw uncaughtError instanceof Error ? uncaughtError : new Error(String(uncaughtError));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:42:54 +08:00
|
|
|
// Screen reader mode returns plain text directly — no static channel.
|
|
|
|
|
if (isScreenReaderEnabled) {
|
|
|
|
|
return output;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 15:59:17 +08:00
|
|
|
// The static channel appends a trailing newline for terminal rendering
|
|
|
|
|
// (so dynamic output starts on a fresh line). Strip it here so
|
|
|
|
|
// renderToString returns clean output.
|
|
|
|
|
const normalizedStaticOutput = capturedStaticOutput.endsWith("\n")
|
|
|
|
|
? capturedStaticOutput.slice(0, -1)
|
|
|
|
|
: capturedStaticOutput;
|
|
|
|
|
|
|
|
|
|
if (normalizedStaticOutput && output) {
|
|
|
|
|
return normalizedStaticOutput + "\n" + output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalizedStaticOutput || output;
|
|
|
|
|
} finally {
|
|
|
|
|
// Ensure native yoga memory is freed even if rendering or teardown threw.
|
|
|
|
|
// Yoga nodes are WASM-backed and not garbage collected.
|
|
|
|
|
if (!teardownSucceeded) {
|
|
|
|
|
try {
|
|
|
|
|
// If unmount failed, some child nodes may not have been freed.
|
|
|
|
|
// Use freeRecursive to clean up the entire tree as best-effort.
|
|
|
|
|
root.yoga.freeRecursive();
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort: node may already be partially freed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 20:42:54 +08:00
|
|
|
function createNoOpAppContext(isScreenReaderEnabled = false): AppContext {
|
2026-05-26 15:59:17 +08:00
|
|
|
return {
|
|
|
|
|
exit: () => {},
|
|
|
|
|
stdout: process.stdout,
|
|
|
|
|
stderr: process.stderr,
|
|
|
|
|
stdin: process.stdin,
|
|
|
|
|
debug: false,
|
|
|
|
|
interactive: false,
|
2026-05-27 20:42:54 +08:00
|
|
|
isScreenReaderEnabled,
|
2026-05-26 15:59:17 +08:00
|
|
|
isRawModeSupported: false,
|
|
|
|
|
setRawMode: () => {},
|
|
|
|
|
writeToStdout: () => {},
|
|
|
|
|
writeToStderr: () => {},
|
|
|
|
|
cursorPosition: undefined,
|
|
|
|
|
setCursorPosition: () => {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createNoOpFocusContext(): FocusContext {
|
|
|
|
|
return {
|
|
|
|
|
activeId: null,
|
|
|
|
|
activeIdRef: shallowRef(null),
|
|
|
|
|
enabled: false,
|
|
|
|
|
enableFocus: () => {},
|
|
|
|
|
disableFocus: () => {},
|
|
|
|
|
focusNext: () => {},
|
|
|
|
|
focusPrevious: () => {},
|
|
|
|
|
focus: () => {},
|
|
|
|
|
blur: () => {},
|
|
|
|
|
add: () => {},
|
|
|
|
|
remove: () => {},
|
|
|
|
|
activate: () => {},
|
|
|
|
|
deactivate: () => {},
|
|
|
|
|
subscribe: () => () => {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createNoOpStdinContext(): StdinContext {
|
|
|
|
|
return {
|
|
|
|
|
stdin: process.stdin,
|
|
|
|
|
setRawMode: () => {},
|
|
|
|
|
isRawModeSupported: false,
|
|
|
|
|
internal_eventEmitter: new EventEmitter(),
|
|
|
|
|
internal_exitOnCtrlC: false,
|
|
|
|
|
acquireRawMode: () => {},
|
|
|
|
|
releaseRawMode: () => {},
|
|
|
|
|
setBracketedPasteMode: () => {},
|
|
|
|
|
};
|
|
|
|
|
}
|