@x12i/kit-cli 1.0.0 → 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/index.js CHANGED
@@ -1,12 +1,14 @@
1
- import { readFile, stat, readdir, writeFile, access } from 'fs/promises';
2
- import path2 from 'path';
1
+ import { readFile, stat, writeFile, readdir, access } from 'fs/promises';
2
+ import path3 from 'path';
3
3
  import { densityNames, themeNames } from '@x12i/tokens';
4
4
  import { readFileSync } from 'fs';
5
5
  import { parseArgs } from 'util';
6
+ import { createDesignMd } from '@x12i/tokens/design-md';
6
7
 
7
8
  // src/config.ts
8
9
  var CONFIG_FILE = "kit.config.json";
9
10
  var CONFIG_VERSION = 1;
11
+ var RULE_SETTINGS = ["off", "warn", "error"];
10
12
  function isThemeName(value) {
11
13
  return themeNames.includes(value);
12
14
  }
@@ -24,7 +26,7 @@ function createConfig(options = {}) {
24
26
  async function loadConfig(cwd) {
25
27
  let text;
26
28
  try {
27
- text = await readFile(path2.join(cwd, CONFIG_FILE), "utf8");
29
+ text = await readFile(path3.join(cwd, CONFIG_FILE), "utf8");
28
30
  } catch (error) {
29
31
  if (error.code === "ENOENT") return null;
30
32
  throw error;
@@ -38,15 +40,344 @@ async function loadConfig(cwd) {
38
40
  const input = typeof raw === "object" && raw !== null ? raw : {};
39
41
  const defaults = createConfig();
40
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
+ }
41
53
  return {
42
54
  version: CONFIG_VERSION,
43
55
  theme: typeof input.theme === "string" && isThemeName(input.theme) ? input.theme : defaults.theme,
44
56
  density: typeof input.density === "string" && isDensityName(input.density) ? input.density : defaults.density,
45
57
  doctor: {
46
- ignore: Array.isArray(ignore) ? ignore.filter((item) => typeof item === "string") : []
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)
47
376
  }
48
377
  };
49
378
  }
379
+
380
+ // src/doctor.ts
50
381
  var SCANNED_EXTENSIONS = /* @__PURE__ */ new Set([
51
382
  ".css",
52
383
  ".scss",
@@ -77,7 +408,7 @@ var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
77
408
  var MAX_FILE_BYTES = 1e6;
78
409
  var HEX_COLOR = /(?<![\w&#/-])(?<!\bhref=["'])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/g;
79
410
  var IGNORE_MARKER = "kit-ignore";
80
- var toPosix = (value) => value.split(path2.sep).join("/");
411
+ var toPosix = (value) => value.split(path3.sep).join("/");
81
412
  function isIgnored(relativePath, ignore) {
82
413
  return ignore.some((entry) => {
83
414
  const prefix = entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
@@ -100,7 +431,7 @@ async function* walk(directory) {
100
431
  entries.sort((a, b) => a.name.localeCompare(b.name));
101
432
  for (const entry of entries) {
102
433
  if (entry.isSymbolicLink()) continue;
103
- const full = path2.join(directory, entry.name);
434
+ const full = path3.join(directory, entry.name);
104
435
  if (entry.isDirectory()) {
105
436
  if (!SKIPPED_DIRECTORIES.has(entry.name)) yield* walk(full);
106
437
  } else if (entry.isFile()) {
@@ -109,15 +440,14 @@ async function* walk(directory) {
109
440
  }
110
441
  }
111
442
  function isScannable(file) {
112
- const name = path2.basename(file);
443
+ const name = path3.basename(file);
113
444
  if (name.includes(".min.") || name.endsWith(".d.ts")) return false;
114
- return SCANNED_EXTENSIONS.has(path2.extname(name));
445
+ return SCANNED_EXTENSIONS.has(path3.extname(name));
115
446
  }
116
- async function scanForHardcodedColors(cwd, targets = ["."], { ignore = [] } = {}) {
117
- const findings = [];
447
+ async function* sourceFiles(cwd, targets, ignore) {
118
448
  const seen = /* @__PURE__ */ new Set();
119
449
  for (const target of targets) {
120
- const root = path2.resolve(cwd, target);
450
+ const root = path3.resolve(cwd, target);
121
451
  const info = await stat(root);
122
452
  const files = info.isDirectory() ? walk(root) : (async function* () {
123
453
  yield root;
@@ -125,15 +455,54 @@ async function scanForHardcodedColors(cwd, targets = ["."], { ignore = [] } = {}
125
455
  for await (const file of files) {
126
456
  if (seen.has(file)) continue;
127
457
  seen.add(file);
128
- const relative = toPosix(path2.relative(cwd, file));
458
+ const relative = toPosix(path3.relative(cwd, file));
129
459
  if (isIgnored(relative, ignore) || !isScannable(file)) continue;
130
460
  const { size } = await stat(file);
131
461
  if (size > MAX_FILE_BYTES) continue;
132
- const text = await readFile(file, "utf8");
133
- for (const finding of findHexColors(text)) {
134
- findings.push({ file: relative, ...finding });
135
- }
462
+ yield { relative, text: await readFile(file, "utf8") };
463
+ }
464
+ }
465
+ }
466
+ async function scanForHardcodedColors(cwd, targets = ["."], { ignore = [] } = {}) {
467
+ const findings = [];
468
+ for await (const { relative, text } of sourceFiles(cwd, targets, ignore)) {
469
+ for (const finding of findHexColors(text)) findings.push({ file: relative, ...finding });
470
+ }
471
+ return findings;
472
+ }
473
+ function severityOf(rule, overrides) {
474
+ const setting = overrides[rule] ?? RULES.find((item) => item.id === rule)?.severity ?? "warn";
475
+ return setting === "off" ? null : setting;
476
+ }
477
+ async function scanProject(cwd, targets = ["."], { ignore = [], rules = {} } = {}) {
478
+ const findings = [];
479
+ const animated = [];
480
+ let handlesReducedMotion = false;
481
+ const add2 = (finding) => {
482
+ const severity = severityOf(finding.rule, rules);
483
+ if (severity !== null) findings.push({ ...finding, severity });
484
+ };
485
+ for await (const { relative, text } of sourceFiles(cwd, targets, ignore)) {
486
+ const found = [];
487
+ for (const hex of findHexColors(text)) {
488
+ found.push({ file: relative, ...hex, rule: "hardcoded-color", message: "Use a --kit-color-* token." });
136
489
  }
490
+ const { hits, motion } = checkText(text, relative, IGNORE_MARKER);
491
+ for (const hit of hits) found.push({ file: relative, ...hit });
492
+ found.sort((a, b) => a.line - b.line || a.column - b.column);
493
+ for (const finding of found) add2(finding);
494
+ if (motion.first) animated.push({ file: relative, ...motion.first });
495
+ if (motion.handlesReducedMotion) handlesReducedMotion = true;
496
+ }
497
+ const [first] = animated;
498
+ if (first && !handlesReducedMotion) {
499
+ const files = animated.length === 1 ? "1 file animates" : `${animated.length} files animate`;
500
+ add2({
501
+ ...first,
502
+ match: "prefers-reduced-motion",
503
+ rule: "missing-reduced-motion",
504
+ message: `${files}, and nothing scanned handles prefers-reduced-motion. Add a @media (prefers-reduced-motion: reduce) rule that drops movement.`
505
+ });
137
506
  }
138
507
  return findings;
139
508
  }
@@ -149,9 +518,15 @@ Commands
149
518
  Add a component to your project. Planned.
150
519
  theme [name]
151
520
  List the themes and densities. Generating a theme is planned.
152
- doctor [paths...] [--json]
153
- Find hard-coded hex colors that should be --kit- tokens.
154
- Exits with 1 when it finds any.
521
+ doctor [paths...] [--json] [--strict] [--list-rules]
522
+ Check code against the kit's conventions: colors, sizes and motion that
523
+ should be --kit- tokens, left and right properties that will not mirror,
524
+ removed focus outlines, and more. Hex colors are errors and the rest are
525
+ warnings. Exits with 1 on an error, or on any finding with --strict.
526
+ Set a rule to off, warn or error in ${CONFIG_FILE} under doctor.rules.
527
+ design [--theme <name>] [--out <file>] [--force]
528
+ Write the kit's DESIGN.md, a description of the design system that coding
529
+ agents read. Defaults to DESIGN.md. Use --out - to print it.
155
530
 
156
531
  Options
157
532
  -h, --help Show this help.
@@ -180,7 +555,7 @@ async function init(values, io) {
180
555
  `);
181
556
  return 2;
182
557
  }
183
- const target = path2.join(io.cwd, CONFIG_FILE);
558
+ const target = path3.join(io.cwd, CONFIG_FILE);
184
559
  if (!values.force && await exists(target)) {
185
560
  io.err(`${CONFIG_FILE} already exists. Use --force to replace it.
186
561
  `);
@@ -220,12 +595,43 @@ function theme(name, io) {
220
595
  );
221
596
  return 1;
222
597
  }
223
- async function doctor(paths, json, io) {
224
- const config = await loadConfig(io.cwd);
598
+ async function readConfig(io) {
599
+ try {
600
+ return await loadConfig(io.cwd);
601
+ } catch (error) {
602
+ io.err(`${error.message}
603
+ `);
604
+ return void 0;
605
+ }
606
+ }
607
+ var plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
608
+ function listRules(io) {
609
+ const width = Math.max(...RULES.map((rule) => rule.id.length));
610
+ for (const rule of RULES) {
611
+ io.out(`${rule.id.padEnd(width)} ${rule.severity.padEnd(5)} ${rule.description}
612
+ `);
613
+ }
614
+ return 0;
615
+ }
616
+ async function doctor(paths, options, io) {
617
+ if (options.listRules) return listRules(io);
618
+ const config = await readConfig(io);
619
+ if (config === void 0) return 2;
620
+ const unknown = Object.keys(config?.doctor.rules ?? {}).filter((id) => !RULE_IDS.has(id));
621
+ if (unknown.length > 0) {
622
+ io.err(
623
+ `${CONFIG_FILE}: unknown doctor rule ${unknown.map((id) => `"${id}"`).join(", ")}. Run "kit doctor --list-rules" for the rule names.
624
+ `
625
+ );
626
+ return 2;
627
+ }
225
628
  const targets = paths.length > 0 ? paths : ["."];
226
629
  let findings;
227
630
  try {
228
- findings = await scanForHardcodedColors(io.cwd, targets, { ignore: config?.doctor.ignore });
631
+ findings = await scanProject(io.cwd, targets, {
632
+ ignore: config?.doctor.ignore,
633
+ rules: config?.doctor.rules
634
+ });
229
635
  } catch (error) {
230
636
  if (error.code === "ENOENT") {
231
637
  io.err(`Path not found: ${error.path ?? targets.join(", ")}
@@ -234,26 +640,65 @@ async function doctor(paths, json, io) {
234
640
  }
235
641
  throw error;
236
642
  }
237
- if (json) {
643
+ const errors = findings.filter((finding) => finding.severity === "error").length;
644
+ const failed = errors > 0 || options.strict && findings.length > 0;
645
+ if (options.json) {
238
646
  io.out(`${JSON.stringify(findings, null, 2)}
239
647
  `);
240
- return findings.length > 0 ? 1 : 0;
648
+ return failed ? 1 : 0;
241
649
  }
242
650
  if (findings.length === 0) {
243
- io.out("No hard-coded colors found.\n");
651
+ io.out("No problems found.\n");
244
652
  return 0;
245
653
  }
246
654
  for (const finding of findings) {
247
- io.out(`${finding.file}:${finding.line}:${finding.column} ${finding.match} Use a --kit- color token.
248
- `);
655
+ io.out(
656
+ `${finding.file}:${finding.line}:${finding.column} ${finding.match} ${finding.message} (${finding.severity} ${finding.rule})
657
+ `
658
+ );
249
659
  }
250
660
  const files = new Set(findings.map((finding) => finding.file)).size;
251
661
  io.out(
252
662
  `
253
- ${findings.length} hard-coded ${findings.length === 1 ? "color" : "colors"} found in ${files} ${files === 1 ? "file" : "files"}.
663
+ ${plural(errors, "error", "errors")} and ${plural(findings.length - errors, "warning", "warnings")} in ${plural(files, "file", "files")}.
254
664
  `
255
665
  );
256
- return 1;
666
+ return failed ? 1 : 0;
667
+ }
668
+ async function design(values, io) {
669
+ if (values.theme !== void 0 && !isThemeName(values.theme)) {
670
+ io.err(`Unknown theme "${values.theme}". Choose one of: ${themeNames.join(", ")}.
671
+ `);
672
+ return 2;
673
+ }
674
+ const config = await readConfig(io);
675
+ if (config === void 0) return 2;
676
+ const theme2 = values.theme !== void 0 && isThemeName(values.theme) ? values.theme : config?.theme ?? "light";
677
+ const markdown = createDesignMd({ theme: theme2 });
678
+ const out = values.out ?? "DESIGN.md";
679
+ if (out === "-") {
680
+ io.out(markdown);
681
+ return 0;
682
+ }
683
+ const target = path3.resolve(io.cwd, out);
684
+ if (!values.force && await exists(target)) {
685
+ io.err(`${out} already exists. Use --force to replace it, or --out to write somewhere else.
686
+ `);
687
+ return 1;
688
+ }
689
+ try {
690
+ await writeFile(target, markdown, "utf8");
691
+ } catch (error) {
692
+ if (error.code === "ENOENT") {
693
+ io.err(`Cannot write ${out}: the folder does not exist.
694
+ `);
695
+ return 2;
696
+ }
697
+ throw error;
698
+ }
699
+ io.out(`Created ${out} (${theme2} theme)
700
+ `);
701
+ return 0;
257
702
  }
258
703
  async function run(argv, io) {
259
704
  let parsed;
@@ -266,6 +711,9 @@ async function run(argv, io) {
266
711
  version: { type: "boolean", short: "v" },
267
712
  force: { type: "boolean" },
268
713
  json: { type: "boolean" },
714
+ strict: { type: "boolean" },
715
+ "list-rules": { type: "boolean" },
716
+ out: { type: "string" },
269
717
  theme: { type: "string" },
270
718
  density: { type: "string" }
271
719
  }
@@ -295,7 +743,13 @@ ${HELP}`);
295
743
  case "theme":
296
744
  return theme(rest[0], io);
297
745
  case "doctor":
298
- return doctor(rest, values.json ?? false, io);
746
+ return doctor(
747
+ rest,
748
+ { json: values.json ?? false, strict: values.strict ?? false, listRules: values["list-rules"] ?? false },
749
+ io
750
+ );
751
+ case "design":
752
+ return design(values, io);
299
753
  default:
300
754
  io.err(`Unknown command "${command}".
301
755
 
@@ -304,6 +758,6 @@ ${HELP}`);
304
758
  }
305
759
  }
306
760
 
307
- export { CONFIG_FILE, CONFIG_VERSION, IGNORE_MARKER, createConfig, findHexColors, loadConfig, run, scanForHardcodedColors };
761
+ export { CONFIG_FILE, CONFIG_VERSION, IGNORE_MARKER, RULES, createConfig, findHexColors, loadConfig, run, scanForHardcodedColors, scanProject };
308
762
  //# sourceMappingURL=index.js.map
309
763
  //# sourceMappingURL=index.js.map