File size: 5,091 Bytes
bc575bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | /**
* Theme JSON validation, kept out of `theme.ts` on purpose.
*
* Validating user-authored theme files needs typebox, which costs ~17 MB of module graph to import.
* Palette lookup does not, so a presentation that only uses built-in themes should never pay for it.
* `interactive-mode.ts` installs this validator; anything that does not simply skips validation, as
* built-in themes already do.
*/
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
const ColorValueSchema = Type.Union([
Type.String(), // hex "#ff0000", var ref "primary", or empty ""
Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
]);
const ThemeJsonSchema = Type.Object({
$schema: Type.Optional(Type.String()),
name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({
// Core UI (11 colors)
accent: ColorValueSchema,
border: ColorValueSchema,
borderAccent: ColorValueSchema,
borderMuted: ColorValueSchema,
success: ColorValueSchema,
error: ColorValueSchema,
warning: ColorValueSchema,
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
thinkingText: ColorValueSchema,
// Scrollbar (2 optional colors)
scrollbarTrack: Type.Optional(ColorValueSchema),
scrollbarThumb: Type.Optional(ColorValueSchema),
// Backgrounds & Content Text (11 required, 2 optional)
selectedBg: ColorValueSchema,
searchMatchBg: Type.Optional(ColorValueSchema),
searchMatchText: Type.Optional(ColorValueSchema),
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
customMessageBg: ColorValueSchema,
customMessageText: ColorValueSchema,
customMessageLabel: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
toolTitle: ColorValueSchema,
toolOutput: ColorValueSchema,
// Markdown (10 colors)
mdHeading: ColorValueSchema,
mdLink: ColorValueSchema,
mdLinkUrl: ColorValueSchema,
mdCode: ColorValueSchema,
mdCodeBlock: ColorValueSchema,
mdCodeBlockBorder: ColorValueSchema,
mdQuote: ColorValueSchema,
mdQuoteBorder: ColorValueSchema,
mdHr: ColorValueSchema,
mdListBullet: ColorValueSchema,
// Tool Diffs (3 colors)
toolDiffAdded: ColorValueSchema,
toolDiffRemoved: ColorValueSchema,
toolDiffContext: ColorValueSchema,
// Syntax Highlighting (9 colors)
syntaxComment: ColorValueSchema,
syntaxKeyword: ColorValueSchema,
syntaxFunction: ColorValueSchema,
syntaxVariable: ColorValueSchema,
syntaxString: ColorValueSchema,
syntaxNumber: ColorValueSchema,
syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema,
syntaxPunctuation: ColorValueSchema,
// Thinking Level Borders (6 colors)
thinkingOff: ColorValueSchema,
thinkingMinimal: ColorValueSchema,
thinkingLow: ColorValueSchema,
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
thinkingXhigh: ColorValueSchema,
thinkingMax: Type.Optional(ColorValueSchema),
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
export: Type.Optional(
Type.Object({
pageBg: Type.Optional(ColorValueSchema),
cardBg: Type.Optional(ColorValueSchema),
infoBg: Type.Optional(ColorValueSchema),
}),
),
});
const compiledThemeSchema = Compile(ThemeJsonSchema);
export type ThemeColorValue = Static<typeof ColorValueSchema>;
export type ValidatedThemeJson = Static<typeof ThemeJsonSchema>;
/** Validate one theme document, throwing a message that names the offending tokens. */
export function validateThemeJson(label: string, json: unknown): ValidatedThemeJson {
if (!compiledThemeSchema.Check(json)) {
const errors = Array.from(compiledThemeSchema.Errors(json));
const missingColors = new Set<string>();
const otherErrors: string[] = [];
for (const error of errors) {
if (error.keyword === "required" && error.instancePath === "/colors") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
for (const requiredProperty of requiredProperties ?? []) {
missingColors.add(requiredProperty);
}
continue;
}
const path = error.instancePath || "/";
otherErrors.push(` - ${path}: ${error.message}`);
}
let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.size > 0) {
errorMessage += "\nMissing required color tokens:\n";
errorMessage += Array.from(missingColors)
.sort()
.map((color) => ` - ${color}`)
.join("\n");
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
}
throw new Error(errorMessage);
}
const themeJson = json as ValidatedThemeJson;
if (themeJson.name.includes("/")) {
throw new Error(
`Invalid theme name "${themeJson.name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`,
);
}
return themeJson;
}
|