@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/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { readFile, stat,
|
|
2
|
-
import
|
|
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(
|
|
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,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(
|
|
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
|
+
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
|
-
|
|
154
|
-
|
|
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 =
|
|
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
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
648
|
+
return failed ? 1 : 0;
|
|
241
649
|
}
|
|
242
650
|
if (findings.length === 0) {
|
|
243
|
-
io.out("No
|
|
651
|
+
io.out("No problems found.\n");
|
|
244
652
|
return 0;
|
|
245
653
|
}
|
|
246
654
|
for (const finding of findings) {
|
|
247
|
-
io.out(
|
|
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
|
-
${
|
|
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(
|
|
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
|