fix(runtime): validate invalid foreground color props

This commit is contained in:
Yunfei He
2026-06-13 03:27:48 +08:00
committed by GitHub
parent d1fe39f96c
commit 5d29240df9
6 changed files with 124 additions and 20 deletions
@@ -875,6 +875,46 @@ test('foreground color="bold" (a chalk modifier) bolds, does NOT throw', async (
expect(lastFrame()).toContain("\x1b[1m");
});
// Foreground colors have their own invalid named-color edge: some chalk keys
// exist but are not callable color/modifier methods. Ink calls chalk[color] in
// that case and crashes; vue-tui validates during render so the app rejects
// through ErrorOverview instead of letting paint wedge the scheduler.
test('foreground color="level" throws (Ink parity, recoverable channel)', async ({ expect }) => {
await expect(
render(
defineComponent(() => () => (
<Box alignSelf="flex-start">
<Text color="level">Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/color/i);
});
for (const borderColorProp of [
"borderColor",
"borderTopColor",
"borderBottomColor",
"borderLeftColor",
"borderRightColor",
] as const) {
test(`<Box ${borderColorProp}="level"> throws (Ink parity, recoverable channel)`, async ({
expect,
}) => {
await expect(
render(
defineComponent(() => () => (
<Box borderStyle="single" {...{ [borderColorProp]: "level" }} alignSelf="flex-start">
<Text>Hi</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/color/i);
});
}
// --- A12 gating: vue must throw WHERE Ink colorizes, and NOT elsewhere ---
//
// Ink throws on a chalk-modifier-name bg LAZILY — only when it actually
+24 -7
View File
@@ -1,7 +1,7 @@
import { defineComponent, h, inject, type ExtractPublicPropTypes, type PropType } from "vue";
import cliBoxes from "cli-boxes";
import { AppContextKey } from "../context.ts";
import { assertValidBackgroundColor } from "../paint/text-style.ts";
import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts";
import type { WithChildren } from "./with-children.ts";
type Spacing = number;
@@ -216,29 +216,46 @@ const BoxImpl = defineComponent({
// width=0 with no left/right border) produces an empty string Ink skips.
// Matching that needs layout, unavailable at render. Niche + invalid-input-only.
if (props.borderStyle) {
const stringBg = (value: unknown) => (typeof value === "string" ? value : undefined);
const generalBg = stringBg(props.borderBackgroundColor);
const stringStyle = (value: unknown) => (typeof value === "string" ? value : undefined);
const generalBg = stringStyle(props.borderBackgroundColor);
const generalFg = stringStyle(props.borderColor);
if (props.borderTop !== false) {
assertValidForegroundColor(
stringStyle(props.borderTopColor) ?? generalFg,
"borderTopColor",
);
assertValidBackgroundColor(
stringBg(props.borderTopBackgroundColor) ?? generalBg,
stringStyle(props.borderTopBackgroundColor) ?? generalBg,
"borderTopBackgroundColor",
);
}
if (props.borderBottom !== false) {
assertValidForegroundColor(
stringStyle(props.borderBottomColor) ?? generalFg,
"borderBottomColor",
);
assertValidBackgroundColor(
stringBg(props.borderBottomBackgroundColor) ?? generalBg,
stringStyle(props.borderBottomBackgroundColor) ?? generalBg,
"borderBottomBackgroundColor",
);
}
if (props.borderLeft !== false) {
assertValidForegroundColor(
stringStyle(props.borderLeftColor) ?? generalFg,
"borderLeftColor",
);
assertValidBackgroundColor(
stringBg(props.borderLeftBackgroundColor) ?? generalBg,
stringStyle(props.borderLeftBackgroundColor) ?? generalBg,
"borderLeftBackgroundColor",
);
}
if (props.borderRight !== false) {
assertValidForegroundColor(
stringStyle(props.borderRightColor) ?? generalFg,
"borderRightColor",
);
assertValidBackgroundColor(
stringBg(props.borderRightBackgroundColor) ?? generalBg,
stringStyle(props.borderRightBackgroundColor) ?? generalBg,
"borderRightBackgroundColor",
);
}
+2 -1
View File
@@ -11,7 +11,7 @@ import {
type VNode,
} from "vue";
import { AppContextKey } from "../context.ts";
import { assertValidBackgroundColor } from "../paint/text-style.ts";
import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts";
import type { WithChildren } from "./with-children.ts";
type WrapMode =
@@ -73,6 +73,7 @@ const TextImpl = defineComponent({
// a group of only empty/inert children) gets past it, so we additionally
// skip validation when the content would render empty. (A12)
if (wouldRenderNonEmptyText(children)) {
assertValidForegroundColor(props.color);
assertValidBackgroundColor(props.backgroundColor);
}
+20 -1
View File
@@ -1,6 +1,12 @@
import chalk from "chalk";
import { expect, test } from "vite-plus/test";
import { applyChalk, assertValidBackgroundColor, isInvalidBackgroundColor } from "./text-style.ts";
import {
applyChalk,
assertValidBackgroundColor,
assertValidForegroundColor,
isInvalidBackgroundColor,
isInvalidForegroundColor,
} from "./text-style.ts";
test("named color applies chalk method", () => {
const prev = chalk.level;
@@ -208,6 +214,19 @@ test("assertValidBackgroundColor throws only for a modifier name, with the label
expect(() => assertValidBackgroundColor(undefined)).not.toThrow();
});
test("assertValidForegroundColor throws only for chalk keys that are not methods", () => {
expect(isInvalidForegroundColor("level")).toBe(true);
expect(() => assertValidForegroundColor("level")).toThrow(/color/i);
expect(() => assertValidForegroundColor("level", "borderTopColor")).toThrow(/borderTopColor/);
// Valid foreground forms and unknown strings keep Ink's bare-text fallback.
expect(isInvalidForegroundColor("bold")).toBe(false);
expect(isInvalidForegroundColor("red")).toBe(false);
expect(isInvalidForegroundColor("#abcdef")).toBe(false);
expect(isInvalidForegroundColor("not-a-real-color")).toBe(false);
expect(isInvalidForegroundColor(undefined)).toBe(false);
});
// Foreground is UNAFFECTED: `color="bold"` resolves `chalk.bold` (a real fn) and
// applies the modifier — Ink does NOT throw on a foreground modifier name, and
// neither does vue-tui. (Only the bg path has the missing-`bg*`-method problem.)
+25
View File
@@ -68,6 +68,19 @@ export function isInvalidBackgroundColor(color: unknown): boolean {
return typeof bgMethod !== "function";
}
/**
* Detect a foreground color value that Ink's `colorize` would THROW on.
*
* Ink's foreground path calls `chalk[color](str)` when `color in chalk`. That
* works for real colors and modifiers (`red`, `bold`) but throws for non-method
* chalk properties such as `level`.
*/
export function isInvalidForegroundColor(color: unknown): boolean {
if (typeof color !== "string" || color.length === 0) return false;
const method = (chalk as unknown as Record<string, unknown>)[color];
return color in (chalk as unknown as Record<string, unknown>) && typeof method !== "function";
}
/**
* Throw (during component render) if `color` is a chalk-modifier-name
* backgroundColor — the exact case Ink's colorize.ts throws on. No-op for every
@@ -82,6 +95,18 @@ export function assertValidBackgroundColor(color: unknown, label = "backgroundCo
}
}
/**
* Throw during component render for foreground color names that Ink's paint path
* would throw on. `label` names the offending prop in the message.
*/
export function assertValidForegroundColor(color: unknown, label = "color"): void {
if (isInvalidForegroundColor(color)) {
throw new Error(
`Invalid ${label}: ${JSON.stringify(color)} (chalk has this key but it is not a color method)`,
);
}
}
export function applyChalk(text: string, props: TextProps): string {
// Mirror Ink's Text.tsx `transform` (commit 40b3a75): apply each enabled
// style as its OWN nested chalk call, in the exact order