Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | 61x 61x 61x 61x 61x 61x 61x 61x 5114x 5114x 5114x 5114x 5024x 5490x 5114x 61x 61x 60x 60x 60x 60x 60x 2x 2x 2x 2x 60x 60x 60x 60x 60x 192842x 60x 60x 192842x 192842x 13289x 13289x 179553x 337x 337x 60x 69629x 60x 7898x 7898x 7839x 2902x 2902x 4937x 64692x 4937x 4937x 4937x 4937x 4937x 1x 1x 4936x 4936x 4936x 4936x 42089x 42089x 189x 189x 42089x 4936x 7898x 7898x 60x 4935x 1x 60x 58x 58x 58x 58x 58x 4x 4x 4x 2x 4x 4x 4x 4x 1x 4x 4x 1x 3x 3x 4x | // SPDX-License-Identifier: MIT
/**
* Site-native theme token validation (#944).
*
* A "site-native theme" is a theme token file maintained in a consuming
* repo and loaded as `[data-theme="…"] { --turbo-*: … }` CSS (or an
* equivalent JSON map). This module validates such files against the
* contract generated from the CSS generator itself
* (generated/native-contract.json), so a missing or misspelled token fails
* loudly in CI instead of silently falling back in shipped components.
*
* The audited contrast pairs behind most of these tokens live separately
* in schema/contrast-pairs.json (#922); the WCAG AA normalizer guarantees
* those inks for published flavors.
*/
import contract from "./generated/native-contract.json" with { type: "json" };
/** CSS variable names a native theme must declare (emitted for every flavor). */
export const REQUIRED_NATIVE_TOKENS: readonly string[] = contract.required;
/** CSS variable names a native theme may declare (emitted for some flavors). */
export const OPTIONAL_NATIVE_TOKENS: readonly string[] = contract.optional;
export interface NativeThemeValidation {
/** true when no required token is missing and nothing unknown/empty is declared. */
valid: boolean;
requiredCount: number;
declaredCount: number;
/** Required tokens the file does not declare. */
missing: string[];
/** `--turbo-*` declarations that are not part of the contract (likely typos). */
unknown: string[];
/** `--turbo-*` tokens declared more than once; last declaration wins in CSS. */
duplicates: string[];
/** Declarations whose value is empty. */
empty: string[];
/** Declarations whose value is the scaffold placeholder keyword `initial`. */
placeholders: string[];
/** Token map values that are not strings (JSON token maps only). */
nonStringValues: string[];
}
/**
* Validate a token map (`--turbo-x` name → value).
*/
export function validateNativeThemeTokens(tokens: Record<string, unknown>): NativeThemeValidation {
const required = new Set(REQUIRED_NATIVE_TOKENS);
const known = new Set([...REQUIRED_NATIVE_TOKENS, ...OPTIONAL_NATIVE_TOKENS]);
const declared = new Set<string>();
const duplicates = new Set<string>();
const empty: string[] = [];
const placeholders: string[] = [];
const nonStringValues: string[] = [];
for (const [name, value] of Object.entries(tokens)) {
Iif (declared.has(name)) duplicates.add(name);
declared.add(name);
Iif (typeof value !== "string") {
nonStringValues.push(name);
continue;
}
if (value.trim() === "") empty.push(name);
// The scaffold keyword is a placeholder, not a usable theme value.
else if (value.trim() === "initial") placeholders.push(name);
}
const missing = [...required].filter((name) => !declared.has(name)).sort();
const unknown = [...declared].filter((name) => !known.has(name)).sort();
const valid =
missing.length === 0 &&
unknown.length === 0 &&
duplicates.size === 0 &&
empty.length === 0 &&
placeholders.length === 0 &&
nonStringValues.length === 0;
return {
valid,
requiredCount: required.size,
declaredCount: declared.size,
missing,
unknown,
duplicates: [...duplicates].sort(),
empty: empty.sort(),
placeholders: placeholders.sort(),
nonStringValues: nonStringValues.sort(),
};
}
/**
* Parse `--turbo-*` custom property declarations out of CSS.
*
* Comments are stripped first so commented-out declarations are not
* counted, and values inside `var()` fallbacks are never mistaken for
* declarations (only `name: value;` shapes count).
*
* @returns The token map and any names declared more than once.
*/
/**
* Strip CSS block comments with a linear scan. The regex equivalent - a
* lazy match between the two comment delimiters - backtracks quadratically
* on inputs that repeat a comment opening with no closing delimiter, and
* this validator runs on consumer-provided theme files.
*/
function stripCssComments(css: string): string {
let out = "";
let from = 0;
while (from < css.length) {
const open = css.indexOf("/*", from);
if (open === -1) break;
out += css.slice(from, open);
const close = css.indexOf("*/", open + 2);
Iif (close === -1) {
from = css.length;
break;
}
from = close + 2;
}
out += css.slice(from);
return out;
}
export function parseNativeThemeCss(css: string): {
tokens: Record<string, string>;
duplicates: string[];
} {
const source = stripCssComments(css);
const tokens: Record<string, string> = {};
const occurrences = new Map<string, number>();
// Positions inside single/double-quoted strings are not declarations: a
// `content: "--turbo-bg-base: …"` string must not satisfy the contract.
const quoted: boolean[] = Array.from({ length: source.length }, () => false);
let quote: string | null = null;
for (let q = 0; q < source.length; q++) {
const ch = source[q];
if (quote) {
quoted[q] = true;
if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
quoted[q] = true;
}
}
// Linear scan for "--turbo-<name> : <value>" declarations. A regex with an
// open-ended name pattern is quadratic on adversarial input (many repeated
// prefixes with no colon), and this validator runs on consumer-provided
// theme files - so every step below is an indexOf/char loop.
let i = 0;
// CSS custom property names are case-sensitive and may contain uppercase
// and underscores; anything outside the contract surfaces as unknown.
const isNameChar = (ch: string): boolean => /[a-zA-Z0-9_-]/.test(ch);
while (i < source.length) {
const at = source.indexOf("--turbo-", i);
if (at === -1) break;
if (quoted[at]) {
i++;
continue;
}
let j = at + "--turbo-".length;
while (j < source.length && isNameChar(source[j] ?? "")) j++;
// Names are case-sensitive in CSS: --turbo-BG-base is a different token
// and surfaces as unknown rather than aliasing --turbo-bg-base.
const name = source.slice(at, j);
Iif (name === "--turbo-") {
i = j;
continue;
}
let k = j;
while (k < source.length && /\s/.test(source[k] ?? "")) k++;
if (source[k] !== ":") {
i = j;
continue;
}
k++;
while (k < source.length && /\s/.test(source[k] ?? "")) k++;
let valueEnd = k;
while (valueEnd < source.length && source[valueEnd] !== ";" && source[valueEnd] !== "}") {
const ch = source[valueEnd] ?? "";
if (ch === '"' || ch === "'") {
const close = source.indexOf(ch, valueEnd + 1);
valueEnd = close === -1 ? source.length : close;
}
valueEnd++;
}
occurrences.set(name, (occurrences.get(name) ?? 0) + 1);
tokens[name] = source.slice(k, valueEnd).trim();
i = valueEnd;
}
const duplicates = [...occurrences.entries()]
.filter(([, count]) => count > 1)
.map(([name]) => name)
.sort();
return { tokens, duplicates };
}
/**
* Validate a native theme given as CSS source.
*/
export function validateNativeThemeCss(css: string): NativeThemeValidation {
const { tokens, duplicates } = parseNativeThemeCss(css);
const result = validateNativeThemeTokens(tokens);
result.duplicates = duplicates;
result.valid = result.valid && duplicates.length === 0;
return result;
}
/**
* Format a validation result as a human-readable report (used by the CLI
* and the assert helper).
*/
export function formatNativeThemeValidation(
result: NativeThemeValidation,
source = "native theme",
): string {
const lines: string[] = [];
const problems: string[] = [];
if (result.missing.length > 0) {
problems.push(
`missing ${result.missing.length} required token(s):\n ${result.missing.join("\n ")}`,
);
}
Iif (result.unknown.length > 0) {
problems.push(
`unknown --turbo-* token(s) (${result.unknown.length}; typo or version mismatch?):\n ` +
`${result.unknown.join("\n ")}`,
);
}
Iif (result.duplicates.length > 0) {
problems.push(`duplicate declaration(s): ${result.duplicates.join(", ")}`);
}
Iif (result.empty.length > 0) {
problems.push(`empty value(s): ${result.empty.join(", ")}`);
}
if (result.placeholders.length > 0) {
problems.push(`placeholder value(s) still "initial": ${result.placeholders.join(", ")}`);
}
Iif (result.nonStringValues.length > 0) {
problems.push(`non-string value(s): ${result.nonStringValues.join(", ")}`);
}
if (problems.length === 0) {
lines.push(
`✅ ${source}: ${result.declaredCount}/${result.requiredCount} contract tokens declared, all good.`,
);
} else {
lines.push(
`❌ ${source}: ${result.missing.length} missing, ${result.unknown.length} unknown, ` +
`${result.duplicates.length} duplicate, ${result.empty.length} empty, ` +
`${result.placeholders.length} placeholder, ${result.nonStringValues.length} non-string ` +
`(${result.declaredCount} declared of ${result.requiredCount} required).`,
);
lines.push(...problems);
}
return lines.join("\n");
}
|