cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
lint/lint-astro-colors.js
423lines · modecode
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Lint .astro template sections for invalid color tokens. |
| 4 | * |
| 5 | * Oxlint only processes <script> blocks in .astro files, not the template HTML. |
| 6 | * This script fills that gap by checking class attributes in the template section. |
| 7 | * |
| 8 | * Checks for: |
| 9 | * 1. Tailwind primitive colors (e.g., bg-blue-500, text-gray-900) |
| 10 | * 2. Invalid/unknown semantic tokens not defined in theme-kumo.css |
| 11 | * |
| 12 | * Usage: |
| 13 | * node lint/lint-astro-colors.js [directory] |
| 14 | * node lint/lint-astro-colors.js packages/kumo-docs-astro/src |
| 15 | * |
| 16 | * Exit codes: |
| 17 | * 0 - No issues found |
| 18 | * 1 - Issues found |
| 19 | */ |
| 20 | |
| 21 | import { readFileSync, readdirSync, statSync } from "node:fs"; |
| 22 | import { dirname, resolve, relative, join } from "node:path"; |
| 23 | import { fileURLToPath } from "node:url"; |
| 24 | |
| 25 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 26 | |
| 27 | // ============================================================================ |
| 28 | // Token validation logic (synced with no-primitive-colors.js) |
| 29 | // ============================================================================ |
| 30 | |
| 31 | const TOKEN_RE = |
| 32 | /(?:^|[^a-zA-Z0-9-])(((?:[a-z-]+:)*)?(?:bg|border|text|ring(?:-offset)?|fill|stroke|placeholder|caret|accent|decoration|divide|outline|from|via|to)-([a-z][a-z0-9-]*)(?:-\d{2,3})?(?:\/[0-9]{1,3})?)/gim; |
| 33 | |
| 34 | const TAILWIND_COLOR_FAMILIES = new Set([ |
| 35 | "red", |
| 36 | "orange", |
| 37 | "amber", |
| 38 | "yellow", |
| 39 | "lime", |
| 40 | "green", |
| 41 | "emerald", |
| 42 | "teal", |
| 43 | "cyan", |
| 44 | "sky", |
| 45 | "blue", |
| 46 | "indigo", |
| 47 | "violet", |
| 48 | "purple", |
| 49 | "fuchsia", |
| 50 | "pink", |
| 51 | "slate", |
| 52 | "gray", |
| 53 | "zinc", |
| 54 | "neutral", |
| 55 | "stone", |
| 56 | ]); |
| 57 | |
| 58 | const BUILTIN_COLORS = new Set(["white", "black"]); |
| 59 | |
| 60 | const NON_COLOR_UTILITIES = new Set([ |
| 61 | "xs", |
| 62 | "sm", |
| 63 | "base", |
| 64 | "lg", |
| 65 | "xl", |
| 66 | "2xl", |
| 67 | "3xl", |
| 68 | "4xl", |
| 69 | "left", |
| 70 | "center", |
| 71 | "right", |
| 72 | "justify", |
| 73 | "wrap", |
| 74 | "nowrap", |
| 75 | "balance", |
| 76 | "pretty", |
| 77 | "ellipsis", |
| 78 | "clip", |
| 79 | "transparent", |
| 80 | "current", |
| 81 | "inherit", |
| 82 | "none", |
| 83 | "0", |
| 84 | "2", |
| 85 | "4", |
| 86 | "8", |
| 87 | "t", |
| 88 | "r", |
| 89 | "b", |
| 90 | "l", |
| 91 | "x", |
| 92 | "y", |
| 93 | "solid", |
| 94 | "dashed", |
| 95 | "dotted", |
| 96 | "double", |
| 97 | "hidden", |
| 98 | "collapse", |
| 99 | "separate", |
| 100 | "1", |
| 101 | "inset", |
| 102 | "inner", |
| 103 | ]); |
| 104 | |
| 105 | const NON_COLOR_PATTERNS = [ |
| 106 | /^linear-to-[trbl]{1,2}$/, |
| 107 | /^[trblxy]-\d+$/, |
| 108 | /^offset-\d+$/, |
| 109 | /^\d+$/, |
| 110 | /^clip-.+$/, |
| 111 | ]; |
| 112 | |
| 113 | const COLOR_PREFIXES = new Set([ |
| 114 | "bg", |
| 115 | "border", |
| 116 | "ring", |
| 117 | "ring-offset", |
| 118 | "fill", |
| 119 | "stroke", |
| 120 | "placeholder", |
| 121 | "caret", |
| 122 | "accent", |
| 123 | "decoration", |
| 124 | "divide", |
| 125 | "outline", |
| 126 | "from", |
| 127 | "via", |
| 128 | "to", |
| 129 | ]); |
| 130 | |
| 131 | const TEXT_COLOR_PREFIXES = new Set(["text"]); |
| 132 | |
| 133 | function parseKumoSemanticColors() { |
| 134 | const themeFiles = [ |
| 135 | resolve(__dirname, "../packages/kumo/src/styles/theme-kumo.css"), |
| 136 | resolve(__dirname, "../packages/kumo/src/styles/theme-fedramp.css"), |
| 137 | ]; |
| 138 | |
| 139 | const colorTokens = new Set(); |
| 140 | const textColorTokens = new Set(); |
| 141 | |
| 142 | for (const themePath of themeFiles) { |
| 143 | try { |
| 144 | const css = readFileSync(themePath, "utf-8"); |
| 145 | |
| 146 | const colorPropRe = /--color-([a-z][a-z0-9-]*)(?=\s*:)/gi; |
| 147 | let match; |
| 148 | while ((match = colorPropRe.exec(css))) { |
| 149 | const name = match[1]; |
| 150 | if (/^[a-z]+-\d{2,3}$/.test(name)) continue; |
| 151 | colorTokens.add(name); |
| 152 | } |
| 153 | |
| 154 | const textColorPropRe = /--text-color-([a-z][a-z0-9-]*)(?=\s*:)/gi; |
| 155 | while ((match = textColorPropRe.exec(css))) { |
| 156 | textColorTokens.add(match[1]); |
| 157 | } |
| 158 | } catch { |
| 159 | // File doesn't exist, skip |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | for (const color of BUILTIN_COLORS) { |
| 164 | colorTokens.add(color); |
| 165 | textColorTokens.add(color); |
| 166 | } |
| 167 | |
| 168 | return { colorTokens, textColorTokens }; |
| 169 | } |
| 170 | |
| 171 | const { |
| 172 | colorTokens: VALID_COLOR_TOKENS, |
| 173 | textColorTokens: VALID_TEXT_COLOR_TOKENS, |
| 174 | } = parseKumoSemanticColors(); |
| 175 | |
| 176 | const VALID_KUMO_SEMANTIC_COLORS = new Set([ |
| 177 | ...VALID_COLOR_TOKENS, |
| 178 | ...VALID_TEXT_COLOR_TOKENS, |
| 179 | ]); |
| 180 | |
| 181 | function isNonColorUtility(tokenName) { |
| 182 | if (NON_COLOR_UTILITIES.has(tokenName)) return true; |
| 183 | return NON_COLOR_PATTERNS.some((pattern) => pattern.test(tokenName)); |
| 184 | } |
| 185 | |
| 186 | function findPrimitiveColor(str) { |
| 187 | if (!str) return null; |
| 188 | |
| 189 | TOKEN_RE.lastIndex = 0; |
| 190 | let match; |
| 191 | while ((match = TOKEN_RE.exec(str))) { |
| 192 | const fullToken = match[1]; |
| 193 | const colorFamily = match[3]; |
| 194 | |
| 195 | if (!fullToken || !colorFamily) continue; |
| 196 | if (VALID_KUMO_SEMANTIC_COLORS.has(colorFamily)) continue; |
| 197 | if (colorFamily.startsWith("kumo-")) |
| 198 | return { type: "primitive", token: fullToken }; |
| 199 | |
| 200 | const primitiveFamily = colorFamily.replace(/-\d+$/, ""); |
| 201 | if ( |
| 202 | TAILWIND_COLOR_FAMILIES.has(primitiveFamily) && |
| 203 | !VALID_KUMO_SEMANTIC_COLORS.has(colorFamily) |
| 204 | ) { |
| 205 | return { type: "primitive", token: fullToken }; |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | return null; |
| 210 | } |
| 211 | |
| 212 | function findInvalidToken(str) { |
| 213 | if (!str) return null; |
| 214 | |
| 215 | TOKEN_RE.lastIndex = 0; |
| 216 | let match; |
| 217 | while ((match = TOKEN_RE.exec(str))) { |
| 218 | const fullToken = match[1]; |
| 219 | const colorFamily = match[3]; |
| 220 | |
| 221 | if (!fullToken || !colorFamily) continue; |
| 222 | if (isNonColorUtility(colorFamily)) continue; |
| 223 | if (colorFamily.startsWith("[")) continue; |
| 224 | |
| 225 | const prefixMatch = fullToken.match( |
| 226 | /^(?:[a-z-]+:)*(bg|border|text|ring(?:-offset)?|fill|stroke|placeholder|caret|accent|decoration|divide|outline|from|via|to)-/i, |
| 227 | ); |
| 228 | if (!prefixMatch) continue; |
| 229 | |
| 230 | const prefix = prefixMatch[1].toLowerCase(); |
| 231 | const tokenName = colorFamily.replace(/\/\d+$/, ""); |
| 232 | |
| 233 | if (isNonColorUtility(tokenName)) continue; |
| 234 | |
| 235 | if (TEXT_COLOR_PREFIXES.has(prefix)) { |
| 236 | if ( |
| 237 | !VALID_TEXT_COLOR_TOKENS.has(tokenName) && |
| 238 | !BUILTIN_COLORS.has(tokenName) |
| 239 | ) { |
| 240 | const primitiveFamily = tokenName.replace(/-\d+$/, ""); |
| 241 | if (!TAILWIND_COLOR_FAMILIES.has(primitiveFamily)) { |
| 242 | return { type: "invalid", token: fullToken, tokenName }; |
| 243 | } |
| 244 | } |
| 245 | } else if (COLOR_PREFIXES.has(prefix)) { |
| 246 | if ( |
| 247 | !VALID_COLOR_TOKENS.has(tokenName) && |
| 248 | !BUILTIN_COLORS.has(tokenName) |
| 249 | ) { |
| 250 | const primitiveFamily = tokenName.replace(/-\d+$/, ""); |
| 251 | if (!TAILWIND_COLOR_FAMILIES.has(primitiveFamily)) { |
| 252 | return { type: "invalid", token: fullToken, tokenName }; |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | return null; |
| 259 | } |
| 260 | |
| 261 | // ============================================================================ |
| 262 | // Astro file processing |
| 263 | // ============================================================================ |
| 264 | |
| 265 | /** |
| 266 | * Extract template section from astro file (everything after the --- frontmatter) |
| 267 | */ |
| 268 | function extractTemplateSection(content) { |
| 269 | // Astro frontmatter is between --- markers at the start |
| 270 | const frontmatterEnd = content.indexOf("---", 3); |
| 271 | if (frontmatterEnd === -1) return { template: content, offset: 0 }; |
| 272 | |
| 273 | const templateStart = frontmatterEnd + 3; |
| 274 | return { |
| 275 | template: content.slice(templateStart), |
| 276 | offset: content.slice(0, templateStart).split("\n").length - 1, |
| 277 | }; |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Extract class attribute values from template content |
| 282 | */ |
| 283 | function extractClassAttributes(template, lineOffset) { |
| 284 | const results = []; |
| 285 | const classAttrRe = /(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; |
| 286 | let match; |
| 287 | |
| 288 | while ((match = classAttrRe.exec(template))) { |
| 289 | const value = match[1] || match[2]; |
| 290 | if (value) { |
| 291 | const beforeMatch = template.slice(0, match.index); |
| 292 | const lineNumber = |
| 293 | (beforeMatch.match(/\n/g) || []).length + 1 + lineOffset; |
| 294 | results.push({ value, line: lineNumber }); |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | return results; |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Lint a single file for color token issues |
| 303 | */ |
| 304 | function lintFile(filePath) { |
| 305 | const content = readFileSync(filePath, "utf-8"); |
| 306 | const { template, offset } = extractTemplateSection(content); |
| 307 | const classAttrs = extractClassAttributes(template, offset); |
| 308 | const issues = []; |
| 309 | |
| 310 | for (const { value, line } of classAttrs) { |
| 311 | const primitive = findPrimitiveColor(value); |
| 312 | if (primitive) { |
| 313 | issues.push({ |
| 314 | line, |
| 315 | type: "primitive", |
| 316 | token: primitive.token, |
| 317 | message: `Avoid Tailwind primitive color '${primitive.token}'. Use Kumo semantic tokens instead.`, |
| 318 | }); |
| 319 | continue; |
| 320 | } |
| 321 | |
| 322 | const invalid = findInvalidToken(value); |
| 323 | if (invalid) { |
| 324 | issues.push({ |
| 325 | line, |
| 326 | type: "invalid", |
| 327 | token: invalid.token, |
| 328 | tokenName: invalid.tokenName, |
| 329 | message: `Invalid color token '${invalid.token}'. Token '${invalid.tokenName}' is not defined in theme-kumo.css.`, |
| 330 | }); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | return issues; |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Recursively find all .astro files in a directory |
| 339 | */ |
| 340 | function findAstroFiles(dir) { |
| 341 | const files = []; |
| 342 | |
| 343 | function walk(currentDir) { |
| 344 | const entries = readdirSync(currentDir); |
| 345 | for (const entry of entries) { |
| 346 | const fullPath = join(currentDir, entry); |
| 347 | const stat = statSync(fullPath); |
| 348 | if (stat.isDirectory()) { |
| 349 | if (!entry.startsWith(".") && entry !== "node_modules") { |
| 350 | walk(fullPath); |
| 351 | } |
| 352 | } else if (entry.endsWith(".astro")) { |
| 353 | files.push(fullPath); |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | walk(dir); |
| 359 | return files; |
| 360 | } |
| 361 | |
| 362 | // ============================================================================ |
| 363 | // Main |
| 364 | // ============================================================================ |
| 365 | |
| 366 | function main() { |
| 367 | const args = process.argv.slice(2); |
| 368 | const targetDir = args[0] || "packages/kumo-docs-astro/src"; |
| 369 | const rootDir = resolve(__dirname, ".."); |
| 370 | // Resolve relative to cwd if provided, otherwise relative to repo root |
| 371 | const absoluteTarget = args[0] |
| 372 | ? resolve(process.cwd(), targetDir) |
| 373 | : resolve(rootDir, targetDir); |
| 374 | |
| 375 | console.log(`\nLinting .astro template sections in: ${targetDir}\n`); |
| 376 | |
| 377 | const astroFiles = findAstroFiles(absoluteTarget); |
| 378 | |
| 379 | if (astroFiles.length === 0) { |
| 380 | console.log("No .astro files found."); |
| 381 | process.exit(0); |
| 382 | } |
| 383 | |
| 384 | let totalIssues = 0; |
| 385 | const fileIssues = []; |
| 386 | |
| 387 | for (const filePath of astroFiles) { |
| 388 | const issues = lintFile(filePath); |
| 389 | if (issues.length > 0) { |
| 390 | const relPath = relative(rootDir, filePath); |
| 391 | fileIssues.push({ path: relPath, issues }); |
| 392 | totalIssues += issues.length; |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | if (totalIssues === 0) { |
| 397 | console.log( |
| 398 | `✓ ${astroFiles.length} .astro files checked. No color token issues found.\n`, |
| 399 | ); |
| 400 | process.exit(0); |
| 401 | } |
| 402 | |
| 403 | // Print issues in oxlint-like format |
| 404 | for (const { path, issues } of fileIssues) { |
| 405 | for (const issue of issues) { |
| 406 | const symbol = issue.type === "primitive" ? "×" : "×"; |
| 407 | console.log( |
| 408 | ` ${symbol} kumo/${issue.type === "primitive" ? "no-primitive-colors" : "invalid-color-token"}: ${issue.message}`, |
| 409 | ); |
| 410 | console.log(` ╭─[${path}:${issue.line}]`); |
| 411 | console.log(` ╰────`); |
| 412 | console.log(); |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | console.log( |
| 417 | `Found ${totalIssues} issue${totalIssues === 1 ? "" : "s"} in ${fileIssues.length} file${fileIssues.length === 1 ? "" : "s"}.\n`, |
| 418 | ); |
| 419 | |
| 420 | process.exit(1); |
| 421 | } |
| 422 | |
| 423 | main(); |
| 424 | |