All files / packages/core/src/native-theme validator.ts

89.83% Statements 106/118
82.89% Branches 63/76
100% Functions 11/11
90.38% Lines 94/104

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");
}