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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user