@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/README.md +84 -14
- package/dist/cli.js +478 -31
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +49 -2
- package/dist/index.js +486 -32
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
-
import { writeFile, readFile, stat,
|
|
4
|
-
import
|
|
3
|
+
import { writeFile, access, readFile, stat, readdir } from 'fs/promises';
|
|
4
|
+
import path3 from 'path';
|
|
5
5
|
import { parseArgs } from 'util';
|
|
6
6
|
import { themeNames, densityNames } from '@x12i/tokens';
|
|
7
|
+
import { createDesignMd } from '@x12i/tokens/design-md';
|
|
7
8
|
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
443
|
+
const name = path3.basename(file);
|
|
113
444
|
if (name.includes(".min.") || name.endsWith(".d.ts")) return false;
|
|
114
|
-
return SCANNED_EXTENSIONS.has(
|
|
445
|
+
return SCANNED_EXTENSIONS.has(path3.extname(name));
|
|
115
446
|
}
|
|
116
|
-
async function
|
|
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 =
|
|
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,47 @@ 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(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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." });
|
|
136
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
|
+
});
|
|
137
499
|
}
|
|
138
500
|
return findings;
|
|
139
501
|
}
|
|
@@ -151,9 +513,15 @@ Commands
|
|
|
151
513
|
Add a component to your project. Planned.
|
|
152
514
|
theme [name]
|
|
153
515
|
List the themes and densities. Generating a theme is planned.
|
|
154
|
-
doctor [paths...] [--json]
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
157
525
|
|
|
158
526
|
Options
|
|
159
527
|
-h, --help Show this help.
|
|
@@ -182,7 +550,7 @@ async function init(values, io) {
|
|
|
182
550
|
`);
|
|
183
551
|
return 2;
|
|
184
552
|
}
|
|
185
|
-
const target =
|
|
553
|
+
const target = path3.join(io.cwd, CONFIG_FILE);
|
|
186
554
|
if (!values.force && await exists(target)) {
|
|
187
555
|
io.err(`${CONFIG_FILE} already exists. Use --force to replace it.
|
|
188
556
|
`);
|
|
@@ -222,12 +590,43 @@ function theme(name, io) {
|
|
|
222
590
|
);
|
|
223
591
|
return 1;
|
|
224
592
|
}
|
|
225
|
-
async function
|
|
226
|
-
|
|
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
|
+
}
|
|
227
623
|
const targets = paths.length > 0 ? paths : ["."];
|
|
228
624
|
let findings;
|
|
229
625
|
try {
|
|
230
|
-
findings = await
|
|
626
|
+
findings = await scanProject(io.cwd, targets, {
|
|
627
|
+
ignore: config?.doctor.ignore,
|
|
628
|
+
rules: config?.doctor.rules
|
|
629
|
+
});
|
|
231
630
|
} catch (error) {
|
|
232
631
|
if (error.code === "ENOENT") {
|
|
233
632
|
io.err(`Path not found: ${error.path ?? targets.join(", ")}
|
|
@@ -236,26 +635,65 @@ async function doctor(paths, json, io) {
|
|
|
236
635
|
}
|
|
237
636
|
throw error;
|
|
238
637
|
}
|
|
239
|
-
|
|
638
|
+
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
639
|
+
const failed = errors > 0 || options.strict && findings.length > 0;
|
|
640
|
+
if (options.json) {
|
|
240
641
|
io.out(`${JSON.stringify(findings, null, 2)}
|
|
241
642
|
`);
|
|
242
|
-
return
|
|
643
|
+
return failed ? 1 : 0;
|
|
243
644
|
}
|
|
244
645
|
if (findings.length === 0) {
|
|
245
|
-
io.out("No
|
|
646
|
+
io.out("No problems found.\n");
|
|
246
647
|
return 0;
|
|
247
648
|
}
|
|
248
649
|
for (const finding of findings) {
|
|
249
|
-
io.out(
|
|
250
|
-
|
|
650
|
+
io.out(
|
|
651
|
+
`${finding.file}:${finding.line}:${finding.column} ${finding.match} ${finding.message} (${finding.severity} ${finding.rule})
|
|
652
|
+
`
|
|
653
|
+
);
|
|
251
654
|
}
|
|
252
655
|
const files = new Set(findings.map((finding) => finding.file)).size;
|
|
253
656
|
io.out(
|
|
254
657
|
`
|
|
255
|
-
${
|
|
658
|
+
${plural(errors, "error", "errors")} and ${plural(findings.length - errors, "warning", "warnings")} in ${plural(files, "file", "files")}.
|
|
256
659
|
`
|
|
257
660
|
);
|
|
258
|
-
return 1;
|
|
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;
|
|
259
697
|
}
|
|
260
698
|
async function run(argv, io) {
|
|
261
699
|
let parsed;
|
|
@@ -268,6 +706,9 @@ async function run(argv, io) {
|
|
|
268
706
|
version: { type: "boolean", short: "v" },
|
|
269
707
|
force: { type: "boolean" },
|
|
270
708
|
json: { type: "boolean" },
|
|
709
|
+
strict: { type: "boolean" },
|
|
710
|
+
"list-rules": { type: "boolean" },
|
|
711
|
+
out: { type: "string" },
|
|
271
712
|
theme: { type: "string" },
|
|
272
713
|
density: { type: "string" }
|
|
273
714
|
}
|
|
@@ -297,7 +738,13 @@ ${HELP}`);
|
|
|
297
738
|
case "theme":
|
|
298
739
|
return theme(rest[0], io);
|
|
299
740
|
case "doctor":
|
|
300
|
-
return 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);
|
|
301
748
|
default:
|
|
302
749
|
io.err(`Unknown command "${command}".
|
|
303
750
|
|