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", () => {
|
describe("integration: aria props via render", () => {
|
||||||
test("no unknown prop warnings for aria props", async () => {
|
test("no unknown prop warnings for aria props", async () => {
|
||||||
// This test verifies that aria props don't trigger the "[vue-tui] unknown prop" warning
|
// 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 Yoga from "yoga-layout";
|
||||||
import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts";
|
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,
|
* 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.
|
// React's absent childNodes, must NOT advance the transform index.
|
||||||
if (child.type !== "comment") 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);
|
squashed += squashChildSR(childNode, index);
|
||||||
if (childNode.type !== "comment") 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
|
// Add accessibility annotations
|
||||||
|
|||||||
Reference in New Issue
Block a user