cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
343078557e96d87a7f9d14c2bcefdcd68cccac15

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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
21import { readFileSync, readdirSync, statSync } from "node:fs";
22import { dirname, resolve, relative, join } from "node:path";
23import { fileURLToPath } from "node:url";
24
25const __dirname = dirname(fileURLToPath(import.meta.url));
26
27// ============================================================================
28// Token validation logic (synced with no-primitive-colors.js)
29// ============================================================================
30
31const 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
34const 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
58const BUILTIN_COLORS = new Set(["white", "black"]);
59
60const 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
105const NON_COLOR_PATTERNS = [
106 /^linear-to-[trbl]{1,2}$/,
107 /^[trblxy]-\d+$/,
108 /^offset-\d+$/,
109 /^\d+$/,
110 /^clip-.+$/,
111];
112
113const 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
131const TEXT_COLOR_PREFIXES = new Set(["text"]);
132
133function 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
171const {
172 colorTokens: VALID_COLOR_TOKENS,
173 textColorTokens: VALID_TEXT_COLOR_TOKENS,
174} = parseKumoSemanticColors();
175
176const VALID_KUMO_SEMANTIC_COLORS = new Set([
177 ...VALID_COLOR_TOKENS,
178 ...VALID_TEXT_COLOR_TOKENS,
179]);
180
181function isNonColorUtility(tokenName) {
182 if (NON_COLOR_UTILITIES.has(tokenName)) return true;
183 return NON_COLOR_PATTERNS.some((pattern) => pattern.test(tokenName));
184}
185
186function 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
212function 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 */
268function 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 */
283function 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 */
304function 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 */
340function 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
366function 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
423main();
424