@x12i/kit-cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,763 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'fs';
3
+ import { writeFile, access, readFile, stat, readdir } from 'fs/promises';
4
+ import path3 from 'path';
5
+ import { parseArgs } from 'util';
6
+ import { themeNames, densityNames } from '@x12i/tokens';
7
+ import { createDesignMd } from '@x12i/tokens/design-md';
8
+
9
+ var CONFIG_FILE = "kit.config.json";
10
+ var CONFIG_VERSION = 1;
11
+ var RULE_SETTINGS = ["off", "warn", "error"];
12
+ function isThemeName(value) {
13
+ return themeNames.includes(value);
14
+ }
15
+ function isDensityName(value) {
16
+ return densityNames.includes(value);
17
+ }
18
+ function createConfig(options = {}) {
19
+ return {
20
+ version: CONFIG_VERSION,
21
+ theme: options.theme ?? "light",
22
+ density: options.density ?? "default",
23
+ doctor: { ignore: [] }
24
+ };
25
+ }
26
+ async function loadConfig(cwd) {
27
+ let text;
28
+ try {
29
+ text = await readFile(path3.join(cwd, CONFIG_FILE), "utf8");
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") return null;
32
+ throw error;
33
+ }
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(text);
37
+ } catch {
38
+ throw new Error(`${CONFIG_FILE} is not valid JSON.`);
39
+ }
40
+ const input = typeof raw === "object" && raw !== null ? raw : {};
41
+ const defaults = createConfig();
42
+ const ignore = input.doctor?.ignore;
43
+ const rawRules = input.doctor?.rules;
44
+ const rules = {};
45
+ if (typeof rawRules === "object" && rawRules !== null) {
46
+ for (const [id, setting] of Object.entries(rawRules)) {
47
+ if (typeof setting !== "string" || !RULE_SETTINGS.includes(setting)) {
48
+ throw new Error(`${CONFIG_FILE}: doctor.rules.${id} must be "off", "warn" or "error".`);
49
+ }
50
+ rules[id] = setting;
51
+ }
52
+ }
53
+ return {
54
+ version: CONFIG_VERSION,
55
+ theme: typeof input.theme === "string" && isThemeName(input.theme) ? input.theme : defaults.theme,
56
+ density: typeof input.density === "string" && isDensityName(input.density) ? input.density : defaults.density,
57
+ doctor: {
58
+ ignore: Array.isArray(ignore) ? ignore.filter((item) => typeof item === "string") : [],
59
+ rules
60
+ }
61
+ };
62
+ }
63
+ var RULES = [
64
+ { id: "hardcoded-color", severity: "error", description: "A #hex color. Use a --kit-color-* token." },
65
+ {
66
+ id: "hardcoded-color-function",
67
+ severity: "warn",
68
+ description: "An rgb(), hsl(), oklch() or similar color with literal numbers. Use a --kit-color-* token."
69
+ },
70
+ { id: "hardcoded-font-size", severity: "warn", description: "A literal font size. Use a --kit-text-*-size token." },
71
+ { id: "hardcoded-font-family", severity: "warn", description: "A literal font stack. Use --kit-font-family-sans or -mono." },
72
+ { id: "hardcoded-spacing", severity: "warn", description: "A literal padding, margin or gap. Use a --kit-space-* token." },
73
+ { id: "hardcoded-radius", severity: "warn", description: "A literal border radius. Use a --kit-radius-* token." },
74
+ { id: "hardcoded-duration", severity: "warn", description: "A literal animation time. Use a --kit-duration-* token." },
75
+ { id: "hardcoded-easing", severity: "warn", description: "A literal cubic-bezier curve. Use a --kit-easing-* token." },
76
+ { id: "bounce-easing", severity: "warn", description: "An easing curve that overshoots. Application UI should be crisp, not bouncy." },
77
+ { id: "transition-all", severity: "warn", description: "`transition: all`. List the properties you animate." },
78
+ {
79
+ id: "layout-animation",
80
+ severity: "warn",
81
+ description: "A transition on width, height, margin, padding or position. Animate transform and opacity instead."
82
+ },
83
+ {
84
+ id: "missing-reduced-motion",
85
+ severity: "warn",
86
+ description: "The project animates but nothing handles prefers-reduced-motion."
87
+ },
88
+ {
89
+ id: "physical-property",
90
+ severity: "warn",
91
+ description: "A left or right property. Use the logical one (inline-start, inline-end) so right to left works."
92
+ },
93
+ { id: "outline-none", severity: "warn", description: "The focus outline is removed with no replacement style." },
94
+ { id: "important", severity: "warn", description: "`!important`. Kit styles have zero specificity, so plain selectors win." }
95
+ ];
96
+ var RULE_IDS = new Set(RULES.map((rule) => rule.id));
97
+ var CSS_FILES = /* @__PURE__ */ new Set([".css", ".scss", ".less"]);
98
+ var SCRIPT_FILES = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
99
+ var MARKUP_FILES = /* @__PURE__ */ new Set([".html", ".vue", ".svelte"]);
100
+ var toKebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
101
+ function blankComments(text, lineComments) {
102
+ const blank = (match) => match.replace(/[^\n]/g, " ");
103
+ let result = text.replace(/\/\*[\s\S]*?\*\//g, blank);
104
+ if (lineComments) result = result.replace(/(?<![:"'`\w])\/\/[^\n]*/g, blank);
105
+ return result;
106
+ }
107
+ function styleRegions(text, extension) {
108
+ if (CSS_FILES.has(extension)) return [{ start: 0, end: text.length }];
109
+ const patterns = [];
110
+ if (SCRIPT_FILES.has(extension)) patterns.push(/`((?:[^`\\]|\\[\s\S])*)`/gd);
111
+ if (MARKUP_FILES.has(extension)) {
112
+ patterns.push(/<style\b[^>]*>([\s\S]*?)<\/style>/gid, /\sstyle\s*=\s*"([^"]*)"/gd, /\sstyle\s*=\s*'([^']*)'/gd);
113
+ }
114
+ const regions = [];
115
+ for (const pattern of patterns) {
116
+ for (const match of text.matchAll(pattern)) {
117
+ const span = match.indices?.[1];
118
+ if (span) regions.push({ start: span[0], end: span[1] });
119
+ }
120
+ }
121
+ return regions;
122
+ }
123
+ function cssDeclarations(text, region) {
124
+ const found = [];
125
+ const slice = text.slice(region.start, region.end);
126
+ const pattern = /(?<![\w$@#.:-])(-?[a-zA-Z][\w-]*)[ \t]*:[ \t\r\n]*([^;{}]*?)[ \t\r\n]*(?=;|}|$)/g;
127
+ for (const match of slice.matchAll(pattern)) {
128
+ const [, prop, raw] = match;
129
+ if (prop === void 0 || raw === void 0 || raw === "") continue;
130
+ const important = /!\s*important\s*$/i.test(raw);
131
+ const value = important ? raw.replace(/!\s*important\s*$/i, "").trim() : raw;
132
+ found.push({ prop: prop.toLowerCase(), value, important, index: region.start + (match.index ?? 0), source: "css" });
133
+ }
134
+ return found;
135
+ }
136
+ function jsDeclarations(text) {
137
+ const found = [];
138
+ const pattern = /(?<![\w$.])([a-z][A-Za-z]*)\s*:\s*(["'`])((?:(?!\2)[^\\\n]|\\.)*)\2/g;
139
+ for (const match of text.matchAll(pattern)) {
140
+ const [, prop, , value] = match;
141
+ if (prop === void 0 || value === void 0) continue;
142
+ found.push({ prop: toKebab(prop), value, important: false, index: match.index ?? 0, source: "js" });
143
+ }
144
+ return found;
145
+ }
146
+ var hasVar = (value) => /\bvar\(/.test(value);
147
+ function closingParen(text, open) {
148
+ let depth = 0;
149
+ for (let i = open; i < text.length; i += 1) {
150
+ if (text[i] === "(") depth += 1;
151
+ else if (text[i] === ")") {
152
+ depth -= 1;
153
+ if (depth === 0) return i;
154
+ }
155
+ }
156
+ return text.length - 1;
157
+ }
158
+ function literalPart(value) {
159
+ let result = "";
160
+ let cursor = 0;
161
+ for (const match of value.matchAll(/\b(var|calc|clamp|min|max)\(/g)) {
162
+ const start = match.index ?? 0;
163
+ if (start < cursor) continue;
164
+ const end = closingParen(value, start + match[0].length - 1);
165
+ const group = value.slice(start, end + 1);
166
+ if (match[1] === "var" || hasVar(group)) {
167
+ result += `${value.slice(cursor, start)} `;
168
+ cursor = end + 1;
169
+ }
170
+ }
171
+ return result + value.slice(cursor);
172
+ }
173
+ function lengths(value) {
174
+ return [...value.matchAll(/(?<![\w.#-])(-?\d*\.?\d+)(px|rem|em)\b/g)].map(
175
+ (match) => Number.parseFloat(match[1] ?? "0") * (match[2] === "px" ? 1 : 16)
176
+ );
177
+ }
178
+ function times(value) {
179
+ return [...value.matchAll(/(?<![\w.#-])(\d*\.?\d+)(ms|s)\b/g)].map(
180
+ (match) => Number.parseFloat(match[1] ?? "0") * (match[2] === "s" ? 1e3 : 1)
181
+ );
182
+ }
183
+ var SPACING = /^(?:(?:padding|margin)(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?|(?:row-|column-)?gap)$/;
184
+ var RADIUS = /^border-(?:(?:top|bottom)-(?:left|right)-|(?:start|end)-(?:start|end)-)?radius$/;
185
+ var MOTION = /^(?:transition(?:-duration|-delay|-timing-function|-property)?|animation(?:-duration|-delay|-timing-function|-name)?)$/;
186
+ var TRANSITION = /^transition(?:-property)?$/;
187
+ var LAYOUT_PROPERTY = /^(?:width|height|min-width|min-height|max-width|max-height|top|right|bottom|left|inset(?:-[a-z-]+)?|margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?|border-width|font-size|flex-basis|gap)$/;
188
+ var splitTopLevel = (value) => value.split(/,(?![^(]*\))/).map((part) => part.trim());
189
+ var PHYSICAL = /* @__PURE__ */ new Map([
190
+ ["margin-left", "margin-inline-start"],
191
+ ["margin-right", "margin-inline-end"],
192
+ ["padding-left", "padding-inline-start"],
193
+ ["padding-right", "padding-inline-end"],
194
+ ["border-left", "border-inline-start"],
195
+ ["border-right", "border-inline-end"],
196
+ ["border-left-width", "border-inline-start-width"],
197
+ ["border-right-width", "border-inline-end-width"],
198
+ ["border-left-color", "border-inline-start-color"],
199
+ ["border-right-color", "border-inline-end-color"],
200
+ ["border-left-style", "border-inline-start-style"],
201
+ ["border-right-style", "border-inline-end-style"],
202
+ ["border-top-left-radius", "border-start-start-radius"],
203
+ ["border-top-right-radius", "border-start-end-radius"],
204
+ ["border-bottom-left-radius", "border-end-start-radius"],
205
+ ["border-bottom-right-radius", "border-end-end-radius"]
206
+ ]);
207
+ var PHYSICAL_SIDE_SELECTOR = /(?:^|[^a-z0-9])(?:left|right)(?![a-z0-9])/i;
208
+ var PHYSICAL_STYLESHEET_ONLY = /* @__PURE__ */ new Map([
209
+ ["left", "inset-inline-start"],
210
+ ["right", "inset-inline-end"]
211
+ ]);
212
+ var FOCUS_REPLACEMENT = /\b(?:box-shadow|border[\w-]*|background[\w-]*|text-decoration[\w-]*)\s*:/i;
213
+ function enclosing(text, index) {
214
+ const blocks = [];
215
+ let depth = 0;
216
+ for (let i = index - 1; i >= 0; i -= 1) {
217
+ const char = text[i];
218
+ if (char === "}") depth += 1;
219
+ else if (char === "{") {
220
+ if (depth > 0) {
221
+ depth -= 1;
222
+ continue;
223
+ }
224
+ let start = i;
225
+ while (start > 0 && !";{}".includes(text[start - 1] ?? "")) start -= 1;
226
+ blocks.push({ header: text.slice(start, i).trim(), open: i });
227
+ }
228
+ }
229
+ return blocks;
230
+ }
231
+ function bodyOf(text, open) {
232
+ let depth = 0;
233
+ for (let i = open + 1; i < text.length; i += 1) {
234
+ const char = text[i];
235
+ if (char === "{") depth += 1;
236
+ else if (char === "}") {
237
+ if (depth === 0) return text.slice(open + 1, i);
238
+ depth -= 1;
239
+ }
240
+ }
241
+ return text.slice(open + 1);
242
+ }
243
+ function snippet(declaration) {
244
+ const text = `${declaration.prop}: ${declaration.value}${declaration.important ? " !important" : ""}`;
245
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
246
+ }
247
+ function checkDeclaration(declaration, text, report) {
248
+ const { prop, value } = declaration;
249
+ const literal = literalPart(value);
250
+ if (prop === "font-size" && lengths(literal).some((px) => px !== 0)) {
251
+ report("hardcoded-font-size", declaration, "Use a --kit-text-*-size token.");
252
+ }
253
+ if (prop === "font-family" && !hasVar(value) && !/^(?:inherit|initial|unset|revert(?:-layer)?)$/i.test(value)) {
254
+ const isFontFace = declaration.source === "css" && enclosing(text, declaration.index).some((block) => /^@font-face\b/i.test(block.header));
255
+ if (!isFontFace) report("hardcoded-font-family", declaration, "Use --kit-font-family-sans or --kit-font-family-mono.");
256
+ }
257
+ if (SPACING.test(prop) && lengths(literal).some((px) => Math.abs(px) > 1)) {
258
+ report("hardcoded-spacing", declaration, "Use a --kit-space-* token.");
259
+ }
260
+ if (RADIUS.test(prop) && lengths(literal).some((px) => px > 0)) {
261
+ report("hardcoded-radius", declaration, "Use a --kit-radius-* token.");
262
+ }
263
+ if (MOTION.test(prop)) {
264
+ if (times(literal).some((ms) => ms > 0)) {
265
+ report("hardcoded-duration", declaration, "Use a --kit-duration-* token.");
266
+ }
267
+ const curves = [...literal.matchAll(/cubic-bezier\(([^)]*)\)/gi)];
268
+ if (curves.length > 0) {
269
+ report("hardcoded-easing", declaration, "Use a --kit-easing-* token.");
270
+ }
271
+ const overshoots = curves.some((curve) => {
272
+ const numbers = (curve[1] ?? "").split(",").map((part) => Number.parseFloat(part));
273
+ return [numbers[1], numbers[3]].some((y) => y !== void 0 && !Number.isNaN(y) && (y < 0 || y > 1));
274
+ });
275
+ if (overshoots) {
276
+ report("bounce-easing", declaration, "This curve overshoots, so the motion bounces. Use a --kit-easing-* token.");
277
+ }
278
+ }
279
+ if (TRANSITION.test(prop)) {
280
+ const properties = prop === "transition-property" ? value.split(/[\s,]+/).filter(Boolean) : splitTopLevel(value).map((part) => part.split(/\s+/)[0] ?? "");
281
+ if (properties.some((name) => name.toLowerCase() === "all")) {
282
+ report("transition-all", declaration, "List the properties you animate. `all` also animates layout and anything added later.");
283
+ }
284
+ const layout = properties.find((name) => LAYOUT_PROPERTY.test(name.toLowerCase()));
285
+ if (layout !== void 0) {
286
+ report("layout-animation", declaration, `Animating ${layout} runs layout and paint on every frame. Animate transform and opacity.`);
287
+ }
288
+ }
289
+ const namesSide = declaration.source === "css" && PHYSICAL_SIDE_SELECTOR.test(enclosing(text, declaration.index)[0]?.header ?? "");
290
+ const bareInset = declaration.source === "css" && !value.includes("%") ? PHYSICAL_STYLESHEET_ONLY.get(prop) : void 0;
291
+ const logical = PHYSICAL.get(prop) ?? bareInset;
292
+ if (namesSide) ; else if (logical !== void 0) {
293
+ report("physical-property", declaration, `Use ${logical}, so the layout mirrors in right to left languages.`);
294
+ } else if (/^(?:text-align|float|clear)$/.test(prop) && /^(?:left|right)$/i.test(value)) {
295
+ const side = value.toLowerCase() === "left" ? "start" : "end";
296
+ const replacement = prop === "text-align" ? side : `inline-${side}`;
297
+ report("physical-property", declaration, `Use ${prop}: ${replacement}, so the layout mirrors in right to left languages.`);
298
+ }
299
+ if (declaration.source !== "css") return;
300
+ const removesOutline = prop === "outline" && /^(?:none|0|0px)$/i.test(value) || prop === "outline-style" && /^none$/i.test(value) || prop === "outline-width" && /^0(?:px)?$/i.test(value);
301
+ if (removesOutline) {
302
+ const [block] = enclosing(text, declaration.index);
303
+ const replaced = block !== void 0 && (FOCUS_REPLACEMENT.test(bodyOf(text, block.open)) || /:not\(\s*:focus-visible\s*\)/i.test(block.header));
304
+ if (!replaced) {
305
+ report(
306
+ "outline-none",
307
+ declaration,
308
+ "Removing the outline hides keyboard focus. Add a visible focus style, such as --kit-color-border-focus."
309
+ );
310
+ }
311
+ }
312
+ if (declaration.important) {
313
+ const allowed = enclosing(text, declaration.index).some((block) => /prefers-reduced-motion|@media\s+print/i.test(block.header));
314
+ if (!allowed) report("important", declaration, "Kit styles have zero specificity, so a plain selector wins. Remove !important.");
315
+ }
316
+ }
317
+ function lineStarts(text) {
318
+ const starts = [0];
319
+ for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") starts.push(i + 1);
320
+ return starts;
321
+ }
322
+ function positionOf(starts, index) {
323
+ let low = 0;
324
+ let high = starts.length - 1;
325
+ while (low < high) {
326
+ const middle = Math.ceil((low + high) / 2);
327
+ if ((starts[middle] ?? 0) <= index) low = middle;
328
+ else high = middle - 1;
329
+ }
330
+ return { line: low + 1, column: index - (starts[low] ?? 0) + 1 };
331
+ }
332
+ var FUNCTION_COLOR = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[-+]?[\d.][^)]*\)/gi;
333
+ var REDUCED_MOTION = /prefers-reduced-motion|useReducedMotion|reducedMotion/i;
334
+ var NO_MOTION = /^(?:none|initial|unset)\b/i;
335
+ function checkText(text, file, ignoreMarker) {
336
+ const extension = path3.extname(file).toLowerCase();
337
+ const stripped = blankComments(text, extension !== ".css");
338
+ const starts = lineStarts(text);
339
+ const rawLines = text.split(/\r?\n/);
340
+ const hits = [];
341
+ const seen = /* @__PURE__ */ new Set();
342
+ const push = (rule, index, match, message) => {
343
+ const { line, column } = positionOf(starts, index);
344
+ if ((rawLines[line - 1] ?? "").includes(ignoreMarker)) return;
345
+ const key = `${rule}:${line}:${column}`;
346
+ if (seen.has(key)) return;
347
+ seen.add(key);
348
+ hits.push({ rule, line, column, match, message });
349
+ };
350
+ for (const match of stripped.matchAll(FUNCTION_COLOR)) {
351
+ push("hardcoded-color-function", match.index ?? 0, match[0], "Use a --kit-color-* token.");
352
+ }
353
+ const declarations = [
354
+ ...styleRegions(stripped, extension).flatMap((region) => cssDeclarations(stripped, region)),
355
+ ...SCRIPT_FILES.has(extension) ? jsDeclarations(stripped) : []
356
+ ];
357
+ let firstMotion = null;
358
+ const sawMotion = (index) => {
359
+ firstMotion = firstMotion === null ? index : Math.min(firstMotion, index);
360
+ };
361
+ for (const declaration of declarations) {
362
+ checkDeclaration(declaration, stripped, (rule, source, message) => push(rule, source.index, snippet(source), message));
363
+ const { prop, value } = declaration;
364
+ const animates = (/^animation(?:-name)?$/.test(prop) || TRANSITION.test(prop)) && !NO_MOTION.test(value) || /^(?:animation|transition)-duration$/.test(prop) && times(value).some((ms) => ms > 0);
365
+ if (animates) sawMotion(declaration.index);
366
+ }
367
+ if (CSS_FILES.has(extension)) {
368
+ for (const match of stripped.matchAll(/@keyframes\b/gi)) sawMotion(match.index ?? 0);
369
+ }
370
+ hits.sort((a, b) => a.line - b.line || a.column - b.column);
371
+ return {
372
+ hits,
373
+ motion: {
374
+ first: firstMotion === null ? null : positionOf(starts, firstMotion),
375
+ handlesReducedMotion: REDUCED_MOTION.test(stripped)
376
+ }
377
+ };
378
+ }
379
+
380
+ // src/doctor.ts
381
+ var SCANNED_EXTENSIONS = /* @__PURE__ */ new Set([
382
+ ".css",
383
+ ".scss",
384
+ ".sass",
385
+ ".less",
386
+ ".ts",
387
+ ".tsx",
388
+ ".js",
389
+ ".jsx",
390
+ ".mjs",
391
+ ".cjs",
392
+ ".html",
393
+ ".vue",
394
+ ".svelte",
395
+ ".mdx"
396
+ ]);
397
+ var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
398
+ "node_modules",
399
+ "dist",
400
+ "build",
401
+ "out",
402
+ "coverage",
403
+ ".git",
404
+ ".next",
405
+ ".turbo",
406
+ ".svelte-kit"
407
+ ]);
408
+ var MAX_FILE_BYTES = 1e6;
409
+ var HEX_COLOR = /(?<![\w&#/-])(?<!\bhref=["'])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/g;
410
+ var IGNORE_MARKER = "kit-ignore";
411
+ var toPosix = (value) => value.split(path3.sep).join("/");
412
+ function isIgnored(relativePath, ignore) {
413
+ return ignore.some((entry) => {
414
+ const prefix = entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
415
+ return prefix !== "" && (relativePath === prefix || relativePath.startsWith(`${prefix}/`));
416
+ });
417
+ }
418
+ function findHexColors(text) {
419
+ const findings = [];
420
+ const lines = text.split(/\r?\n/);
421
+ lines.forEach((line, index) => {
422
+ if (line.includes(IGNORE_MARKER)) return;
423
+ for (const found of line.matchAll(HEX_COLOR)) {
424
+ findings.push({ line: index + 1, column: (found.index ?? 0) + 1, match: found[0] });
425
+ }
426
+ });
427
+ return findings;
428
+ }
429
+ async function* walk(directory) {
430
+ const entries = await readdir(directory, { withFileTypes: true });
431
+ entries.sort((a, b) => a.name.localeCompare(b.name));
432
+ for (const entry of entries) {
433
+ if (entry.isSymbolicLink()) continue;
434
+ const full = path3.join(directory, entry.name);
435
+ if (entry.isDirectory()) {
436
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) yield* walk(full);
437
+ } else if (entry.isFile()) {
438
+ yield full;
439
+ }
440
+ }
441
+ }
442
+ function isScannable(file) {
443
+ const name = path3.basename(file);
444
+ if (name.includes(".min.") || name.endsWith(".d.ts")) return false;
445
+ return SCANNED_EXTENSIONS.has(path3.extname(name));
446
+ }
447
+ async function* sourceFiles(cwd, targets, ignore) {
448
+ const seen = /* @__PURE__ */ new Set();
449
+ for (const target of targets) {
450
+ const root = path3.resolve(cwd, target);
451
+ const info = await stat(root);
452
+ const files = info.isDirectory() ? walk(root) : (async function* () {
453
+ yield root;
454
+ })();
455
+ for await (const file of files) {
456
+ if (seen.has(file)) continue;
457
+ seen.add(file);
458
+ const relative = toPosix(path3.relative(cwd, file));
459
+ if (isIgnored(relative, ignore) || !isScannable(file)) continue;
460
+ const { size } = await stat(file);
461
+ if (size > MAX_FILE_BYTES) continue;
462
+ yield { relative, text: await readFile(file, "utf8") };
463
+ }
464
+ }
465
+ }
466
+ function severityOf(rule, overrides) {
467
+ const setting = overrides[rule] ?? RULES.find((item) => item.id === rule)?.severity ?? "warn";
468
+ return setting === "off" ? null : setting;
469
+ }
470
+ async function scanProject(cwd, targets = ["."], { ignore = [], rules = {} } = {}) {
471
+ const findings = [];
472
+ const animated = [];
473
+ let handlesReducedMotion = false;
474
+ const add2 = (finding) => {
475
+ const severity = severityOf(finding.rule, rules);
476
+ if (severity !== null) findings.push({ ...finding, severity });
477
+ };
478
+ for await (const { relative, text } of sourceFiles(cwd, targets, ignore)) {
479
+ const found = [];
480
+ for (const hex of findHexColors(text)) {
481
+ found.push({ file: relative, ...hex, rule: "hardcoded-color", message: "Use a --kit-color-* token." });
482
+ }
483
+ const { hits, motion } = checkText(text, relative, IGNORE_MARKER);
484
+ for (const hit of hits) found.push({ file: relative, ...hit });
485
+ found.sort((a, b) => a.line - b.line || a.column - b.column);
486
+ for (const finding of found) add2(finding);
487
+ if (motion.first) animated.push({ file: relative, ...motion.first });
488
+ if (motion.handlesReducedMotion) handlesReducedMotion = true;
489
+ }
490
+ const [first] = animated;
491
+ if (first && !handlesReducedMotion) {
492
+ const files = animated.length === 1 ? "1 file animates" : `${animated.length} files animate`;
493
+ add2({
494
+ ...first,
495
+ match: "prefers-reduced-motion",
496
+ rule: "missing-reduced-motion",
497
+ message: `${files}, and nothing scanned handles prefers-reduced-motion. Add a @media (prefers-reduced-motion: reduce) rule that drops movement.`
498
+ });
499
+ }
500
+ return findings;
501
+ }
502
+
503
+ // src/main.ts
504
+ var HELP = `kit: command line for the Unified UI Kit
505
+
506
+ Usage
507
+ kit <command> [options]
508
+
509
+ Commands
510
+ init [--theme <name>] [--density <name>] [--force]
511
+ Write ${CONFIG_FILE} in the current folder.
512
+ add <component...>
513
+ Add a component to your project. Planned.
514
+ theme [name]
515
+ List the themes and densities. Generating a theme is planned.
516
+ doctor [paths...] [--json] [--strict] [--list-rules]
517
+ Check code against the kit's conventions: colors, sizes and motion that
518
+ should be --kit- tokens, left and right properties that will not mirror,
519
+ removed focus outlines, and more. Hex colors are errors and the rest are
520
+ warnings. Exits with 1 on an error, or on any finding with --strict.
521
+ Set a rule to off, warn or error in ${CONFIG_FILE} under doctor.rules.
522
+ design [--theme <name>] [--out <file>] [--force]
523
+ Write the kit's DESIGN.md, a description of the design system that coding
524
+ agents read. Defaults to DESIGN.md. Use --out - to print it.
525
+
526
+ Options
527
+ -h, --help Show this help.
528
+ -v, --version Show the version.
529
+ `;
530
+ function readVersion() {
531
+ const file = new URL("../package.json", import.meta.url);
532
+ return JSON.parse(readFileSync(file, "utf8")).version;
533
+ }
534
+ async function exists(file) {
535
+ try {
536
+ await access(file);
537
+ return true;
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
542
+ async function init(values, io) {
543
+ if (values.theme !== void 0 && !isThemeName(values.theme)) {
544
+ io.err(`Unknown theme "${values.theme}". Choose one of: ${themeNames.join(", ")}.
545
+ `);
546
+ return 2;
547
+ }
548
+ if (values.density !== void 0 && !isDensityName(values.density)) {
549
+ io.err(`Unknown density "${values.density}". Choose one of: ${densityNames.join(", ")}.
550
+ `);
551
+ return 2;
552
+ }
553
+ const target = path3.join(io.cwd, CONFIG_FILE);
554
+ if (!values.force && await exists(target)) {
555
+ io.err(`${CONFIG_FILE} already exists. Use --force to replace it.
556
+ `);
557
+ return 1;
558
+ }
559
+ const config = createConfig({ theme: values.theme, density: values.density });
560
+ await writeFile(target, `${JSON.stringify(config, null, 2)}
561
+ `, "utf8");
562
+ io.out(`Created ${CONFIG_FILE}
563
+ `);
564
+ return 0;
565
+ }
566
+ function add(components, io) {
567
+ const wanted = components.length > 0 ? ` (${components.join(", ")})` : "";
568
+ io.err(
569
+ `kit add${wanted} is not available yet. The component registry is planned. Until then, import components from @x12i/ui.
570
+ `
571
+ );
572
+ return 1;
573
+ }
574
+ function theme(name, io) {
575
+ if (name === void 0) {
576
+ io.out(`Themes: ${themeNames.join(", ")}
577
+ `);
578
+ io.out(`Densities: ${densityNames.join(", ")}
579
+ `);
580
+ return 0;
581
+ }
582
+ if (isThemeName(name)) {
583
+ io.out(`"${name}" is built in. Import @x12i/themes/${name}.css, or set data-theme="${name}" on <html>.
584
+ `);
585
+ return 0;
586
+ }
587
+ io.err(
588
+ `Generating a custom theme is planned and is not available yet. Built-in themes: ${themeNames.join(", ")}.
589
+ `
590
+ );
591
+ return 1;
592
+ }
593
+ async function readConfig(io) {
594
+ try {
595
+ return await loadConfig(io.cwd);
596
+ } catch (error) {
597
+ io.err(`${error.message}
598
+ `);
599
+ return void 0;
600
+ }
601
+ }
602
+ var plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
603
+ function listRules(io) {
604
+ const width = Math.max(...RULES.map((rule) => rule.id.length));
605
+ for (const rule of RULES) {
606
+ io.out(`${rule.id.padEnd(width)} ${rule.severity.padEnd(5)} ${rule.description}
607
+ `);
608
+ }
609
+ return 0;
610
+ }
611
+ async function doctor(paths, options, io) {
612
+ if (options.listRules) return listRules(io);
613
+ const config = await readConfig(io);
614
+ if (config === void 0) return 2;
615
+ const unknown = Object.keys(config?.doctor.rules ?? {}).filter((id) => !RULE_IDS.has(id));
616
+ if (unknown.length > 0) {
617
+ io.err(
618
+ `${CONFIG_FILE}: unknown doctor rule ${unknown.map((id) => `"${id}"`).join(", ")}. Run "kit doctor --list-rules" for the rule names.
619
+ `
620
+ );
621
+ return 2;
622
+ }
623
+ const targets = paths.length > 0 ? paths : ["."];
624
+ let findings;
625
+ try {
626
+ findings = await scanProject(io.cwd, targets, {
627
+ ignore: config?.doctor.ignore,
628
+ rules: config?.doctor.rules
629
+ });
630
+ } catch (error) {
631
+ if (error.code === "ENOENT") {
632
+ io.err(`Path not found: ${error.path ?? targets.join(", ")}
633
+ `);
634
+ return 2;
635
+ }
636
+ throw error;
637
+ }
638
+ const errors = findings.filter((finding) => finding.severity === "error").length;
639
+ const failed = errors > 0 || options.strict && findings.length > 0;
640
+ if (options.json) {
641
+ io.out(`${JSON.stringify(findings, null, 2)}
642
+ `);
643
+ return failed ? 1 : 0;
644
+ }
645
+ if (findings.length === 0) {
646
+ io.out("No problems found.\n");
647
+ return 0;
648
+ }
649
+ for (const finding of findings) {
650
+ io.out(
651
+ `${finding.file}:${finding.line}:${finding.column} ${finding.match} ${finding.message} (${finding.severity} ${finding.rule})
652
+ `
653
+ );
654
+ }
655
+ const files = new Set(findings.map((finding) => finding.file)).size;
656
+ io.out(
657
+ `
658
+ ${plural(errors, "error", "errors")} and ${plural(findings.length - errors, "warning", "warnings")} in ${plural(files, "file", "files")}.
659
+ `
660
+ );
661
+ return failed ? 1 : 0;
662
+ }
663
+ async function design(values, io) {
664
+ if (values.theme !== void 0 && !isThemeName(values.theme)) {
665
+ io.err(`Unknown theme "${values.theme}". Choose one of: ${themeNames.join(", ")}.
666
+ `);
667
+ return 2;
668
+ }
669
+ const config = await readConfig(io);
670
+ if (config === void 0) return 2;
671
+ const theme2 = values.theme !== void 0 && isThemeName(values.theme) ? values.theme : config?.theme ?? "light";
672
+ const markdown = createDesignMd({ theme: theme2 });
673
+ const out = values.out ?? "DESIGN.md";
674
+ if (out === "-") {
675
+ io.out(markdown);
676
+ return 0;
677
+ }
678
+ const target = path3.resolve(io.cwd, out);
679
+ if (!values.force && await exists(target)) {
680
+ io.err(`${out} already exists. Use --force to replace it, or --out to write somewhere else.
681
+ `);
682
+ return 1;
683
+ }
684
+ try {
685
+ await writeFile(target, markdown, "utf8");
686
+ } catch (error) {
687
+ if (error.code === "ENOENT") {
688
+ io.err(`Cannot write ${out}: the folder does not exist.
689
+ `);
690
+ return 2;
691
+ }
692
+ throw error;
693
+ }
694
+ io.out(`Created ${out} (${theme2} theme)
695
+ `);
696
+ return 0;
697
+ }
698
+ async function run(argv, io) {
699
+ let parsed;
700
+ try {
701
+ parsed = parseArgs({
702
+ args: argv,
703
+ allowPositionals: true,
704
+ options: {
705
+ help: { type: "boolean", short: "h" },
706
+ version: { type: "boolean", short: "v" },
707
+ force: { type: "boolean" },
708
+ json: { type: "boolean" },
709
+ strict: { type: "boolean" },
710
+ "list-rules": { type: "boolean" },
711
+ out: { type: "string" },
712
+ theme: { type: "string" },
713
+ density: { type: "string" }
714
+ }
715
+ });
716
+ } catch (error) {
717
+ io.err(`${error.message}
718
+
719
+ ${HELP}`);
720
+ return 2;
721
+ }
722
+ const { values, positionals } = parsed;
723
+ const [command, ...rest] = positionals;
724
+ if (values.version) {
725
+ io.out(`${readVersion()}
726
+ `);
727
+ return 0;
728
+ }
729
+ if (values.help || command === void 0 || command === "help") {
730
+ io.out(HELP);
731
+ return 0;
732
+ }
733
+ switch (command) {
734
+ case "init":
735
+ return init(values, io);
736
+ case "add":
737
+ return add(rest, io);
738
+ case "theme":
739
+ return theme(rest[0], io);
740
+ case "doctor":
741
+ return doctor(
742
+ rest,
743
+ { json: values.json ?? false, strict: values.strict ?? false, listRules: values["list-rules"] ?? false },
744
+ io
745
+ );
746
+ case "design":
747
+ return design(values, io);
748
+ default:
749
+ io.err(`Unknown command "${command}".
750
+
751
+ ${HELP}`);
752
+ return 2;
753
+ }
754
+ }
755
+
756
+ // src/cli.ts
757
+ process.exitCode = await run(process.argv.slice(2), {
758
+ cwd: process.cwd(),
759
+ out: (text) => process.stdout.write(text),
760
+ err: (text) => process.stderr.write(text)
761
+ });
762
+ //# sourceMappingURL=cli.js.map
763
+ //# sourceMappingURL=cli.js.map