Files
vue-tui/packages/runtime/src/render-to-string.ts
T
Yunfei He 9227ddf696 test: add Ink component/composable test parity (+130) and fix layout listener bug
* test: add text ANSI sanitization parity tests from Ink (+15)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-animation parity tests from Ink (+43)

Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support

Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-box-metrics/measure parity tests from Ink (+16)

Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).

3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add screen-reader parity tests from Ink (+11)

Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.

Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).

Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add render-to-string parity tests from Ink (+18)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add cursor composable parity tests from Ink (+7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add 6 missing screen-reader Ink parity tests

Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix render-to-string missing Ink parity tests (+10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix cursor composable missing Ink parity tests (+6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix use-box-metrics missing Ink parity tests (+4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add layout listener so useBoxMetrics updates on resize and sibling changes

Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating

Enables 4 previously-skipped tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:42:54 +08:00

217 lines
7.2 KiB
TypeScript

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";
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
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;
/**
* 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;
}
/**
* 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;
const isScreenReaderEnabled = options?.isScreenReaderEnabled ?? false;
// Create a standalone root node --- no stdout, stdin, or terminal bindings.
const appContext = createNoOpAppContext(isScreenReaderEnabled);
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.
const output = isScreenReaderEnabled
? renderScreenReaderOutput(root, { skipStaticElements: true })
: paint(root);
// 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));
}
// Screen reader mode returns plain text directly — no static channel.
if (isScreenReaderEnabled) {
return output;
}
// 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
}
}
}
}
function createNoOpAppContext(isScreenReaderEnabled = false): AppContext {
return {
exit: () => {},
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin,
debug: false,
interactive: false,
isScreenReaderEnabled,
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: () => {},
};
}