fix(runtime): sanitize ANSI in screen-reader text, matching Ink (#96)

Ink squashes every ink-text via squashTextNodes, which always returns
sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI while
keeping SGR + OSC. vue's SR squash concatenated raw text-leaf values with no
sanitize, so an embedded control sequence (e.g. \x1b[2J) leaked into screen-reader
output. Wrap the squashed SR text in sanitizeAnsi at squashTextContent and the
standalone <Transform> branch — the SR twin of text-measure.ts:54. The double pass
on nested/transform text is idempotent (sanitizeAnsi is a fixed point), matching
Ink's recursive squashTextNodes.

Tests assert erase stripping in Text/Box/Transform AND that SGR is KEPT (so a
strip-everything regression is caught).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 21:16:51 +08:00
committed by GitHub
parent eea9fb6b72
commit 8091154785
2 changed files with 68 additions and 2 deletions
@@ -522,6 +522,62 @@ describe("Transform accessibility", () => {
});
});
describe("screen-reader ANSI sanitization (Ink parity)", () => {
// Ink squashes every ink-text via squashTextNodes, which ALWAYS returns
// sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI
// (e.g. `\x1b[2J`) while keeping SGR + OSC. vue-tui's SR squash previously
// concatenated raw text-leaf values with NO sanitize, so an embedded control
// sequence survived into screen-reader output. This is the SR twin of
// text-measure.ts:54 (`return sanitizeAnsi(out)`).
// eslint-disable-next-line no-control-regex -- ESC erase code is a control char by definition; testing it is the point
const ERASE_SCREEN = "\x1b[2J";
test("strips an erase CSI embedded in <Text> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => <Text>{`a${ERASE_SCREEN}b`}</Text>),
{ isScreenReaderEnabled: true },
);
// The erase sequence is stripped; visible chars survive.
expect(output).toBe("ab");
});
test("strips an erase CSI in <Text> nested under <Box> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
<Box>
<Text>{`x${ERASE_SCREEN}y`}</Text>
</Box>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("xy");
});
test("strips an erase CSI inside a standalone <Transform> in screen-reader mode", () => {
const output = renderToString(
defineComponent(() => () => (
<Transform transform={(s: string) => s}>{`p${ERASE_SCREEN}q`}</Transform>
)),
{ isScreenReaderEnabled: true },
);
expect(output).toBe("pq");
});
test("keeps SGR (color) sequences in screen-reader output, matching Ink's sanitizeAnsi", () => {
// sanitizeAnsi strips cursor/erase CSI but KEEPS SGR + OSC — so this would
// FAIL against a strip-everything replacement, proving we mirror Ink's
// sanitizeAnsi (sanitize-ansi.ts), not a blanket ANSI strip.
// eslint-disable-next-line no-control-regex -- SGR codes are control chars; asserting they survive is the point
const colored = "a\x1b[31mb\x1b[39mc";
const output = renderToString(
defineComponent(() => () => <Text>{`${colored}${ERASE_SCREEN}`}</Text>),
{ isScreenReaderEnabled: true },
);
// SGR kept, trailing erase stripped.
expect(output).toBe(colored);
});
});
describe("integration: aria props via render", () => {
test("no unknown prop warnings for aria props", async () => {
// This test verifies that aria props don't trigger the "[vue-tui] unknown prop" warning
+12 -2
View File
@@ -1,5 +1,6 @@
import Yoga from "yoga-layout";
import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts";
import { sanitizeAnsi } from "./sanitize-ansi.ts";
/**
* Squash a single child of a text/transform context into plain SR text,
@@ -61,7 +62,12 @@ function squashTextContent(node: TuiText | TuiVirtualText): string {
// React's absent childNodes, must NOT advance the transform index.
if (child.type !== "comment") index++;
}
return text;
// Strip cursor/erase control sequences (keep SGR/OSC) from the squashed text,
// exactly as Ink's squashTextNodes returns sanitizeAnsi(text)
// (squash-text-nodes.ts:45). This is the SR twin of host/text-measure.ts:54.
// Without it an embedded control sequence (e.g. `\x1b[2J`) survives into
// screen-reader output.
return sanitizeAnsi(text);
}
/**
@@ -192,7 +198,11 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt
squashed += squashChildSR(childNode, index);
if (childNode.type !== "comment") index++;
}
output = squashed;
// A standalone <Transform> is an `ink-text` node in Ink, squashed via
// squashTextNodes which returns sanitizeAnsi(text) (squash-text-nodes.ts:45).
// Strip cursor/erase control sequences (keep SGR/OSC) so an embedded control
// sequence does not survive into screen-reader output.
output = sanitizeAnsi(squashed);
}
// Add accessibility annotations