brandrail 0.0.0-stage → 0.2.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.
@@ -0,0 +1,2196 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
5
+ import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
6
+
7
+ // ../engine/src/color.ts
8
+ var clamp = (n, lo = 0, hi = 255) => Math.min(hi, Math.max(lo, n));
9
+ function parseHex(hex) {
10
+ let h = hex.replace(/^#/, "").toLowerCase();
11
+ if (!/^[0-9a-f]+$/.test(h)) return null;
12
+ if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
13
+ if (h.length !== 6 && h.length !== 8) return null;
14
+ const n = (i) => parseInt(h.slice(i, i + 2), 16);
15
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) / 255 : 1 };
16
+ }
17
+ function hslToRgb(h, s, l) {
18
+ h = (h % 360 + 360) % 360;
19
+ s /= 100;
20
+ l /= 100;
21
+ const k = (n) => (n + h / 30) % 12;
22
+ const a = s * Math.min(l, 1 - l);
23
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
24
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
25
+ }
26
+ function parseColor(input) {
27
+ const s = input.trim().toLowerCase();
28
+ if (s.startsWith("#")) return parseHex(s);
29
+ const m = s.match(/^(rgba?|hsla?)\(\s*([^)]*)\)$/);
30
+ if (!m) return null;
31
+ const parts = m[2].split(/[\s,/]+/).filter(Boolean);
32
+ if (parts.length < 3) return null;
33
+ const num = (p, max) => p.endsWith("%") ? parseFloat(p) / 100 * max : parseFloat(p);
34
+ const alpha = parts[3] !== void 0 ? num(parts[3], 1) : 1;
35
+ if (m[1].startsWith("rgb")) {
36
+ const [r2, g2, b2] = parts.slice(0, 3).map((p) => num(p, 255));
37
+ if ([r2, g2, b2].some((v) => Number.isNaN(v))) return null;
38
+ return { r: clamp(r2), g: clamp(g2), b: clamp(b2), a: alpha };
39
+ }
40
+ const [h, sat, l] = [parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])];
41
+ if ([h, sat, l].some((v) => Number.isNaN(v))) return null;
42
+ const [r, g, b] = hslToRgb(h, sat, l);
43
+ return { r: clamp(r), g: clamp(g), b: clamp(b), a: alpha };
44
+ }
45
+ function toHex({ r, g, b }) {
46
+ return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
47
+ }
48
+ function rgbToLab({ r, g, b }) {
49
+ const lin = (c) => {
50
+ const v = c / 255;
51
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
52
+ };
53
+ const [R, G, B] = [lin(r), lin(g), lin(b)];
54
+ const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047;
55
+ const y = R * 0.2126 + G * 0.7152 + B * 0.0722;
56
+ const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883;
57
+ const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
58
+ return { l: 116 * f(y) - 16, a: 500 * (f(x) - f(y)), b: 200 * (f(y) - f(z)) };
59
+ }
60
+ function deltaE(a, b) {
61
+ return Math.sqrt((a.l - b.l) ** 2 + (a.a - b.a) ** 2 + (a.b - b.b) ** 2);
62
+ }
63
+ function contrastRatio(a, b) {
64
+ const lum = ({ r, g, b: bl }) => {
65
+ const c = [r, g, bl].map((v) => {
66
+ const s = v / 255;
67
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
68
+ });
69
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
70
+ };
71
+ const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x);
72
+ return (l1 + 0.05) / (l2 + 0.05);
73
+ }
74
+
75
+ // ../engine/src/config.ts
76
+ var DEFAULT_IGNORE = [
77
+ "**/node_modules/**",
78
+ "**/.git/**",
79
+ "**/dist/**",
80
+ "**/build/**",
81
+ "**/out/**",
82
+ "**/.next/**",
83
+ "**/.nuxt/**",
84
+ "**/.turbo/**",
85
+ "**/coverage/**",
86
+ "**/*.min.*",
87
+ "**/*.map",
88
+ "**/*.d.ts",
89
+ "**/tailwind.config.*",
90
+ "**/*.test.*",
91
+ "**/*.spec.*",
92
+ "**/*.stories.*",
93
+ "**/__tests__/**",
94
+ "**/__mocks__/**"
95
+ ];
96
+ var DEFAULT_FORBIDDEN_IMPORTS = [
97
+ "@mui/material",
98
+ "@mui/joy",
99
+ "@material-ui/core",
100
+ "antd",
101
+ "@chakra-ui/react",
102
+ "react-bootstrap",
103
+ "bootstrap",
104
+ "@mantine/core",
105
+ "semantic-ui-react",
106
+ "primereact"
107
+ ];
108
+ var NAME_TO_TAGS = {
109
+ button: ["button"],
110
+ input: ["input"],
111
+ textinput: ["input"],
112
+ inputtext: ["input"],
113
+ textfield: ["input"],
114
+ select: ["select"],
115
+ dropdown: ["select"],
116
+ textarea: ["textarea"],
117
+ inputtextarea: ["textarea"],
118
+ link: ["a"],
119
+ table: ["table"],
120
+ checkbox: ["input[type=checkbox]"],
121
+ radio: ["input[type=radio]"]
122
+ };
123
+ var GENERIC_FONTS = /* @__PURE__ */ new Set([
124
+ "inherit",
125
+ "initial",
126
+ "unset",
127
+ "system-ui",
128
+ "-apple-system",
129
+ "blinkmacsystemfont",
130
+ "sans-serif",
131
+ "serif",
132
+ "monospace",
133
+ "ui-sans-serif",
134
+ "ui-serif",
135
+ "ui-monospace",
136
+ "cursive",
137
+ "fantasy"
138
+ ]);
139
+ function leaves(doc, prefix = "", out = []) {
140
+ if (!doc || typeof doc !== "object") return out;
141
+ const obj = doc;
142
+ if ("$value" in obj) {
143
+ const ext = obj.$extensions?.["dev.brandrail"];
144
+ out.push({ path: prefix, type: obj.$type, value: obj.$value, dark: ext?.modes?.dark });
145
+ return out;
146
+ }
147
+ for (const [k, v] of Object.entries(obj)) {
148
+ if (k.startsWith("$")) continue;
149
+ leaves(v, prefix ? `${prefix}.${k}` : k, out);
150
+ }
151
+ return out;
152
+ }
153
+ var px = (v) => {
154
+ if (typeof v === "number") return v;
155
+ if (typeof v !== "string") return null;
156
+ const m = v.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/);
157
+ if (!m) return null;
158
+ const n = parseFloat(m[1]);
159
+ return m[2] === "rem" ? n * 16 : n;
160
+ };
161
+ function normalizeFontFamily(value) {
162
+ const first = value.split(",")[0] ?? "";
163
+ return first.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
164
+ }
165
+ function isGenericFont(family) {
166
+ return GENERIC_FONTS.has(family) || family.startsWith("var(");
167
+ }
168
+ var uniqSorted = (xs) => [...new Set(xs.map((x) => Math.round(x * 100) / 100))].sort((a, b) => a - b);
169
+ function resolveConfig(config) {
170
+ const all = leaves(config.tokens);
171
+ const colors = [];
172
+ for (const l of all) {
173
+ if (typeof l.value !== "string") continue;
174
+ if (l.type && l.type !== "color") continue;
175
+ if (!l.type && !l.path.startsWith("color")) continue;
176
+ const rgb = parseColor(l.value);
177
+ if (!rgb) continue;
178
+ const cssVar2 = `--${l.path.replace(/\./g, "-")}`;
179
+ colors.push({ path: l.path, cssVar: cssVar2, hex: toHex(rgb), lab: rgbToLab(rgb), alpha: rgb.a });
180
+ const dark = typeof l.dark === "string" ? parseColor(l.dark) : null;
181
+ if (dark) colors.push({ path: l.path, cssVar: cssVar2, hex: toHex(dark), lab: rgbToLab(dark), alpha: dark.a, mode: "dark" });
182
+ }
183
+ const spaceLeaves = all.filter((l) => /^(space|spacing)\./.test(l.path));
184
+ const spacing = [0, 1, 2];
185
+ const unit = px(spaceLeaves.find((l) => /\.unit$/.test(l.path))?.value);
186
+ for (const l of spaceLeaves) {
187
+ const v = px(l.value);
188
+ if (v !== null && !/\.unit$/.test(l.path)) spacing.push(v);
189
+ }
190
+ if (unit) for (const m of [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24]) spacing.push(unit * m);
191
+ if (spacing.length === 3) for (const v of [4, 8, 12, 16, 20, 24, 32, 40, 48, 64]) spacing.push(v);
192
+ const radiusVals = all.filter((l) => /^(radius|radii|border-?radius)\./i.test(l.path)).map((l) => px(l.value)).filter((v) => v !== null);
193
+ const radii = [0, 9999, ...radiusVals];
194
+ if (radiusVals.length === 1) radii.push(Math.round(radiusVals[0] / 2), radiusVals[0] * 2);
195
+ if (radiusVals.length === 0) radii.push(2, 4, 6, 8, 12, 16);
196
+ const fontLeaves = all.filter((l) => (l.type === "fontFamily" || /^font\.family\./.test(l.path)) && typeof l.value === "string");
197
+ const fonts = fontLeaves.map((l) => normalizeFontFamily(l.value));
198
+ const fontVars = fontLeaves.map((l) => `--${l.path.replace(/\./g, "-")}`);
199
+ const components = config.components ?? [];
200
+ const replaces = /* @__PURE__ */ new Map();
201
+ for (const c of components) {
202
+ const tags = c.replaces ?? NAME_TO_TAGS[c.name.toLowerCase()] ?? [];
203
+ for (const t of tags) {
204
+ const key = t.toLowerCase().replace(/\s+/g, "").replace(/["']/g, "");
205
+ if (!replaces.has(key)) replaces.set(key, c);
206
+ }
207
+ }
208
+ return {
209
+ tokenVersion: config.tokenVersion,
210
+ colors,
211
+ spacing: uniqSorted(spacing),
212
+ radii: uniqSorted(radii),
213
+ fonts: [...new Set(fonts)],
214
+ fontVars: fontVars.length ? fontVars : ["--font-family-sans"],
215
+ fontSizes: uniqSorted(all.filter((l) => /^font\.size\./.test(l.path)).map((l) => px(l.value)).filter((v) => v !== null)),
216
+ components,
217
+ replaces,
218
+ rules: config.rules ?? {},
219
+ ignore: [...DEFAULT_IGNORE, ...config.ignore ?? []],
220
+ forbiddenImports: config.forbiddenImports ?? DEFAULT_FORBIDDEN_IMPORTS,
221
+ colorTolerance: config.colorTolerance ?? 2.5
222
+ };
223
+ }
224
+
225
+ // ../engine/src/rules.ts
226
+ var MARKUP = ["jsx", "vue", "svelte", "html"];
227
+ var ALL = ["jsx", "vue", "svelte", "html", "css"];
228
+ function* matches(re, text) {
229
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
230
+ let m;
231
+ while (m = g.exec(text)) {
232
+ yield m;
233
+ if (m[0].length === 0) g.lastIndex++;
234
+ }
235
+ }
236
+ var lineBefore = (code, offset, max = 80) => {
237
+ const start = Math.max(code.lastIndexOf("\n", offset - 1) + 1, offset - max);
238
+ return code.slice(start, offset);
239
+ };
240
+ var lineOf = (code, offset) => {
241
+ const start = code.lastIndexOf("\n", offset - 1) + 1;
242
+ const end = code.indexOf("\n", offset);
243
+ return code.slice(start, end === -1 ? void 0 : end);
244
+ };
245
+ var fmt = (n) => `${Math.round(n * 100) / 100}px`;
246
+ function neighbours(scale, v) {
247
+ const lower = [...scale].reverse().find((x) => x <= v);
248
+ const upper = scale.find((x) => x >= v);
249
+ const opts = [...new Set([lower, upper].filter((x) => x !== void 0))];
250
+ return opts.map(fmt).join(" or ");
251
+ }
252
+ function pxValues(value) {
253
+ const out = [];
254
+ for (const m of matches(/(-?\d*\.?\d+)(px|rem)\b/g, value)) {
255
+ const n = Math.abs(parseFloat(m[1])) * (m[2] === "rem" ? 16 : 1);
256
+ out.push({ n, index: m.index });
257
+ }
258
+ return out;
259
+ }
260
+ var noHardcodedColor = {
261
+ id: "no-hardcoded-color",
262
+ title: "Hard-coded colour",
263
+ description: "Colours must come from Brand Kit tokens, not hex / rgb / hsl literals.",
264
+ defaultSeverity: "error",
265
+ kinds: ALL,
266
+ check({ code, kind, config }) {
267
+ if (config.colors.length === 0) return [];
268
+ const out = [];
269
+ const re = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-zA-Z_-])|(?<![\w-])(?:rgba?|hsla?)\(\s*[^()]*\)/g;
270
+ for (const m of matches(re, code)) {
271
+ const raw = m[0];
272
+ const before = lineBefore(code, m.index);
273
+ const line = lineOf(code, m.index);
274
+ if (raw.startsWith("#")) {
275
+ const prev = code[m.index - 1] ?? "";
276
+ if (/[A-Za-z0-9_&/]/.test(prev)) continue;
277
+ if (/(?:href|to|id|xlink:href|for|aria-[a-z]+)\s*=\s*["'{`]?\s*$/i.test(before)) continue;
278
+ if (/url\(\s*["']?$/.test(before)) continue;
279
+ }
280
+ if (/--[\w-]+\s*:\s*[^;{}]*$/.test(before)) continue;
281
+ if (kind !== "css" && /^\s*(import|export)\s/.test(line) && !/style|color/i.test(line)) continue;
282
+ const rgb = parseColor(raw);
283
+ if (!rgb) continue;
284
+ const lab = rgbToLab(rgb);
285
+ let best = config.colors[0];
286
+ let bestD = Infinity;
287
+ for (const c of config.colors) {
288
+ const d = deltaE(lab, c.lab) + Math.abs(rgb.a - (c.alpha ?? 1)) * 100;
289
+ if (d < bestD) {
290
+ bestD = d;
291
+ best = c;
292
+ }
293
+ }
294
+ if (bestD <= config.colorTolerance) {
295
+ out.push({
296
+ offset: m.index,
297
+ severity: "warning",
298
+ message: `Hard-coded ${raw} matches the token ${best.path}`,
299
+ suggestion: `Use var(${best.cssVar}) so it follows Brand Kit updates`
300
+ });
301
+ } else {
302
+ out.push({
303
+ offset: m.index,
304
+ message: `${raw} is not in the Brand Kit`,
305
+ suggestion: `Closest token is ${best.path} (${best.hex}, \u0394E ${bestD.toFixed(1)}): use var(${best.cssVar})`
306
+ });
307
+ }
308
+ }
309
+ return out;
310
+ }
311
+ };
312
+ var PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
313
+ var UTIL = "bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|from|via|to|outline|divide|placeholder|accent|caret|decoration|shadow";
314
+ var noRawPaletteClass = {
315
+ id: "no-raw-palette-class",
316
+ title: "Raw Tailwind palette class",
317
+ description: "Use semantic token classes (bg-primary, text-muted) instead of Tailwind's default palette.",
318
+ defaultSeverity: "warning",
319
+ kinds: MARKUP,
320
+ check({ code }) {
321
+ const out = [];
322
+ const re = new RegExp(`(?<![\\w-])((?:[a-z0-9-]+:)*)(${UTIL})-(${PALETTE})-(50|[1-9]00|950)(?:/\\d{1,3})?(?![\\w-])`, "g");
323
+ for (const m of matches(re, code)) {
324
+ out.push({
325
+ offset: m.index + m[1].length,
326
+ message: `Tailwind palette class ${m[0].slice(m[1].length)} bypasses the Brand Kit`,
327
+ suggestion: `Use a semantic class mapped to a token, e.g. ${m[2]}-primary or ${m[2]}-muted`
328
+ });
329
+ }
330
+ return out;
331
+ }
332
+ };
333
+ var spacingScale = {
334
+ id: "spacing-scale",
335
+ title: "Off-scale spacing",
336
+ description: "Padding, margin and gap must use the spacing scale.",
337
+ defaultSeverity: "warning",
338
+ kinds: ALL,
339
+ check({ code, kind, config }) {
340
+ const out = [];
341
+ const scale = config.spacing;
342
+ const ok = (n) => scale.some((s) => Math.abs(s - n) < 0.01);
343
+ for (const m of matches(/(?<![\w-])(padding|margin|gap|row-gap|column-gap)(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?\s*:\s*([^;{}\n]+)/g, code)) {
344
+ const valueStart = m.index + m[0].length - m[2].length;
345
+ for (const v of pxValues(m[2])) {
346
+ if (!ok(v.n)) out.push({ offset: valueStart + v.index, message: `${m[1]} ${fmt(v.n)} is off the spacing scale`, suggestion: `Use ${neighbours(scale, v.n)}` });
347
+ }
348
+ }
349
+ if (kind !== "css") {
350
+ for (const m of matches(/(?<![\w-])(padding|margin|gap|rowGap|columnGap)(?:Top|Right|Bottom|Left|Inline|Block|X|Y)?\s*:\s*(['"`]?)(-?\d*\.?\d+)\2(?=\s*[,}\n])/g, code)) {
351
+ const n = Math.abs(parseFloat(m[3]));
352
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[1]} ${fmt(n)} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
353
+ }
354
+ for (const m of matches(/(?<![\w-])-?(p[xytrbl]?|m[xytrbl]?|gap(?:-[xy])?|space-[xy])-\[(-?\d*\.?\d+)(px|rem)\]/g, code)) {
355
+ const n = Math.abs(parseFloat(m[2])) * (m[3] === "rem" ? 16 : 1);
356
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+ };
362
+ var radiusScale = {
363
+ id: "radius-scale",
364
+ title: "Off-scale corner radius",
365
+ description: "Border radius must use the radius tokens.",
366
+ defaultSeverity: "info",
367
+ kinds: ALL,
368
+ check({ code, kind, config }) {
369
+ const out = [];
370
+ const ok = (n) => n >= 999 || config.radii.some((r) => Math.abs(r - n) < 0.01);
371
+ for (const m of matches(/(?<![\w-])border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius\s*:\s*([^;{}\n]+)/g, code)) {
372
+ const valueStart = m.index + m[0].length - m[1].length;
373
+ for (const v of pxValues(m[1])) {
374
+ if (!ok(v.n)) out.push({ offset: valueStart + v.index, message: `border-radius ${fmt(v.n)} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), v.n)}` });
375
+ }
376
+ }
377
+ if (kind !== "css") {
378
+ for (const m of matches(/(?<![\w-])borderRadius\s*:\s*(['"`]?)(\d*\.?\d+)\1(?=\s*[,}\n])/g, code)) {
379
+ const n = parseFloat(m[2]);
380
+ if (!ok(n)) out.push({ offset: m.index, message: `borderRadius ${fmt(n)} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), n)}` });
381
+ }
382
+ for (const m of matches(/(?<![\w-])rounded(?:-[a-z]{1,2})?-\[(\d*\.?\d+)px\]/g, code)) {
383
+ const n = parseFloat(m[1]);
384
+ if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), n)}` });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ };
390
+ var fontFamily = {
391
+ id: "font-family",
392
+ title: "Unapproved font",
393
+ description: "Only the Brand Kit's font families may be used.",
394
+ defaultSeverity: "warning",
395
+ kinds: ALL,
396
+ check({ code, kind, config }) {
397
+ if (config.fonts.length === 0) return [];
398
+ const out = [];
399
+ const allowed = config.fonts;
400
+ const test = (value, offset) => {
401
+ const fam = normalizeFontFamily(value);
402
+ if (!fam || isGenericFont(fam) || allowed.includes(fam)) return;
403
+ out.push({
404
+ offset,
405
+ message: `Font "${fam}" is not in the Brand Kit`,
406
+ suggestion: `Use var(--font-family-sans) (${allowed[0]})`
407
+ });
408
+ };
409
+ for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(m[1], m.index);
410
+ if (kind !== "css") {
411
+ for (const m of matches(/(?<![\w-])fontFamily\s*:\s*(['"`])([^'"`]+)\1/g, code)) test(m[2], m.index);
412
+ for (const m of matches(/(?<![\w-])font-\[([^\]]+)\]/g, code)) test(m[1].replace(/_/g, " "), m.index);
413
+ }
414
+ return out;
415
+ }
416
+ };
417
+ var TEXT_INPUT_TYPES = /* @__PURE__ */ new Set(["", "text", "email", "password", "number", "search", "tel", "url"]);
418
+ var preferOfficialComponent = {
419
+ id: "prefer-official-component",
420
+ title: "Re-invented component",
421
+ description: "Use the design system component instead of a raw HTML element.",
422
+ defaultSeverity: "error",
423
+ kinds: MARKUP,
424
+ check({ code, config }) {
425
+ if (config.replaces.size === 0) return [];
426
+ const out = [];
427
+ const baseTags = [...new Set([...config.replaces.keys()].map((k) => k.replace(/\[.*$/, "").replace(/[^a-z0-9-]/g, "")))].filter(Boolean);
428
+ for (const m of matches(new RegExp(`<(${baseTags.join("|")})(?=[\\s>/])([^>]*)>`, "g"), code)) {
429
+ const tag = m[1];
430
+ const attrs = m[2];
431
+ let comp = config.replaces.get(tag);
432
+ if (tag === "input" || tag === "button") {
433
+ const type = (attrs.match(/\btype\s*=\s*\{?\s*["'`]([\w-]+)["'`]/)?.[1] ?? "").toLowerCase();
434
+ const specific = config.replaces.get(`${tag}[type=${type}]`);
435
+ if (specific) comp = specific;
436
+ else if (tag === "input" && !TEXT_INPUT_TYPES.has(type)) comp = type === "submit" || type === "button" ? config.replaces.get("button") : void 0;
437
+ }
438
+ if (!comp) continue;
439
+ if (new RegExp(`(?:function|const|class)\\s+${comp.name}\\b`).test(code)) continue;
440
+ out.push({
441
+ offset: m.index,
442
+ message: `Raw <${tag}${tag === "input" && /type=/.test(attrs) ? ` type="${attrs.match(/type\s*=\s*\{?\s*["'`]([\w-]+)/)?.[1]}"` : ""}> re-implements ${comp.name}`,
443
+ suggestion: `import { ${comp.name} } from "${comp.importPath}"`
444
+ });
445
+ }
446
+ return out;
447
+ }
448
+ };
449
+ var forbiddenImport = {
450
+ id: "forbidden-import",
451
+ title: "Other UI library",
452
+ description: "Only the design system's component library may be imported.",
453
+ defaultSeverity: "error",
454
+ kinds: ALL,
455
+ check({ code, config }) {
456
+ const out = [];
457
+ const lib = config.components[0]?.importPath;
458
+ const re = /(?:\bimport\s[^'";]*?from\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\(\s*|@import\s+(?:url\(\s*)?)(['"])([^'"]+)\1/g;
459
+ for (const m of matches(re, code)) {
460
+ const spec = m[2];
461
+ const bad = config.forbiddenImports.find((p) => spec === p || spec.startsWith(`${p}/`));
462
+ if (!bad) continue;
463
+ out.push({
464
+ offset: m.index + m[0].indexOf(spec),
465
+ message: `Imports ${bad}, which isn't part of the design system`,
466
+ suggestion: lib ? `Use components from "${lib}"` : "Use the design system's components"
467
+ });
468
+ }
469
+ return out;
470
+ }
471
+ };
472
+ var imgAlt = {
473
+ id: "img-alt",
474
+ title: "Image without alt text",
475
+ description: "Every <img> needs an alt attribute (empty for decorative images).",
476
+ defaultSeverity: "warning",
477
+ kinds: MARKUP,
478
+ check({ code }) {
479
+ const out = [];
480
+ for (const m of matches(/<img(?=[\s/>])([^>]*)>/g, code)) {
481
+ if (/(?<![\w-])alt\s*=/.test(m[1]) || /\{\s*\.\.\./.test(m[1])) continue;
482
+ out.push({ offset: m.index, message: "<img> has no alt text", suggestion: 'Add alt="\u2026" describing the image, or alt="" if decorative' });
483
+ }
484
+ return out;
485
+ }
486
+ };
487
+ var RULES = [
488
+ noHardcodedColor,
489
+ noRawPaletteClass,
490
+ preferOfficialComponent,
491
+ forbiddenImport,
492
+ spacingScale,
493
+ fontFamily,
494
+ radiusScale,
495
+ imgAlt
496
+ ];
497
+ var RULE_IDS = RULES.map((r) => r.id);
498
+
499
+ // ../engine/src/source.ts
500
+ function fileKind(path) {
501
+ const ext = path.toLowerCase().split(".").pop() ?? "";
502
+ if (["tsx", "jsx", "ts", "js", "mjs", "cjs", "mdx"].includes(ext)) return "jsx";
503
+ if (ext === "vue") return "vue";
504
+ if (ext === "svelte") return "svelte";
505
+ if (["html", "htm", "astro"].includes(ext)) return "html";
506
+ if (["css", "scss", "sass", "less", "pcss"].includes(ext)) return "css";
507
+ return "other";
508
+ }
509
+ var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "css"]);
510
+ var blank = (s) => s.replace(/[^\n]/g, " ");
511
+ function stripComments(code, kind) {
512
+ let out = code;
513
+ if (kind === "html" || kind === "vue" || kind === "svelte") {
514
+ out = out.replace(/<!--[\s\S]*?-->/g, blank);
515
+ }
516
+ if (kind === "css") {
517
+ return out.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + blank(m.slice(p1.length)));
518
+ }
519
+ const chars = out.split("");
520
+ let i = 0;
521
+ let quote = null;
522
+ while (i < chars.length) {
523
+ const c = chars[i];
524
+ const n = chars[i + 1];
525
+ if (quote) {
526
+ if (c === "\\") i += 2;
527
+ else {
528
+ if (c === quote) quote = null;
529
+ i++;
530
+ }
531
+ continue;
532
+ }
533
+ if (c === '"' || c === "'" || c === "`") {
534
+ quote = c;
535
+ i++;
536
+ continue;
537
+ }
538
+ if (c === "/" && n === "*") {
539
+ const end = out.indexOf("*/", i + 2);
540
+ const stop = end === -1 ? chars.length : end + 2;
541
+ for (let j = i; j < stop; j++) if (chars[j] !== "\n") chars[j] = " ";
542
+ i = stop;
543
+ continue;
544
+ }
545
+ if (c === "/" && n === "/" && chars[i - 1] !== ":" && kind === "jsx") {
546
+ while (i < chars.length && chars[i] !== "\n") chars[i++] = " ";
547
+ continue;
548
+ }
549
+ i++;
550
+ }
551
+ return chars.join("");
552
+ }
553
+ var LineIndex = class {
554
+ starts = [0];
555
+ text;
556
+ constructor(text) {
557
+ this.text = text;
558
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) this.starts.push(i + 1);
559
+ }
560
+ position(offset) {
561
+ let lo = 0;
562
+ let hi = this.starts.length - 1;
563
+ while (lo < hi) {
564
+ const mid = lo + hi + 1 >> 1;
565
+ if (this.starts[mid] <= offset) lo = mid;
566
+ else hi = mid - 1;
567
+ }
568
+ return { line: lo + 1, column: offset - this.starts[lo] + 1 };
569
+ }
570
+ lineText(line) {
571
+ const start = this.starts[line - 1] ?? 0;
572
+ const end = this.starts[line] ?? this.text.length + 1;
573
+ return this.text.slice(start, end - 1);
574
+ }
575
+ get lineCount() {
576
+ return this.starts.length;
577
+ }
578
+ };
579
+ function globToRegExp(glob) {
580
+ let re = "";
581
+ for (let i = 0; i < glob.length; i++) {
582
+ const c = glob[i];
583
+ if (c === "*") {
584
+ if (glob[i + 1] === "*") {
585
+ const slash = glob[i + 2] === "/";
586
+ re += slash ? "(?:.*/)?" : ".*";
587
+ i += slash ? 2 : 1;
588
+ } else re += "[^/]*";
589
+ } else if (c === "?") re += "[^/]";
590
+ else if (c === "{") {
591
+ const end = glob.indexOf("}", i);
592
+ re += `(?:${glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`;
593
+ i = end;
594
+ } else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
595
+ }
596
+ return new RegExp(`^${re}$`);
597
+ }
598
+ function makeIgnore(globs) {
599
+ const res = globs.map(globToRegExp);
600
+ return (path) => {
601
+ const p = path.replace(/\\/g, "/").replace(/^\.\//, "");
602
+ return res.some((r) => r.test(p) || r.test(`/${p}`));
603
+ };
604
+ }
605
+
606
+ // ../engine/src/check.ts
607
+ var ENGINE_VERSION = "0.2.0";
608
+ var WEIGHT = { error: 4, warning: 1.5, info: 0.25 };
609
+ function computeScore(counts, lines) {
610
+ const penalty = counts.error * WEIGHT.error + counts.warning * WEIGHT.warning + counts.info * WEIGHT.info;
611
+ if (penalty === 0) return 100;
612
+ const perK = penalty / Math.max(1, lines / 1e3);
613
+ return Math.max(0, Math.min(99, Math.round(100 / (1 + perK / 25))));
614
+ }
615
+ function check(files, configInput, opts = {}) {
616
+ const t0 = Date.now();
617
+ const config = "replaces" in configInput ? configInput : resolveConfig(configInput);
618
+ const ignored = makeIgnore(config.ignore);
619
+ const violations = [];
620
+ let filesScanned = 0;
621
+ let linesScanned = 0;
622
+ for (const file of files) {
623
+ const path = file.path.replace(/\\/g, "/").replace(/^\.\//, "");
624
+ const kind = fileKind(path);
625
+ if (!SCANNABLE.has(kind) || ignored(path)) continue;
626
+ const only = opts.onlyLines?.get(path);
627
+ if (opts.onlyLines && (!only || only.size === 0)) continue;
628
+ filesScanned++;
629
+ const idx = new LineIndex(file.content);
630
+ linesScanned += only ? only.size : file.content.split("\n").filter((l) => l.trim()).length;
631
+ const code = stripComments(file.content, kind);
632
+ const seen = /* @__PURE__ */ new Set();
633
+ for (const rule of RULES) {
634
+ const setting = config.rules[rule.id];
635
+ if (setting === "off" || !rule.kinds.includes(kind)) continue;
636
+ for (const f of rule.check({ path, kind, code, config })) {
637
+ const { line, column } = idx.position(f.offset);
638
+ if (only && !only.has(line)) continue;
639
+ const key = `${rule.id}:${line}:${column}`;
640
+ if (seen.has(key)) continue;
641
+ seen.add(key);
642
+ violations.push({
643
+ ruleId: rule.id,
644
+ severity: setting ?? f.severity ?? rule.defaultSeverity,
645
+ file: path,
646
+ line,
647
+ column,
648
+ message: f.message,
649
+ suggestion: f.suggestion,
650
+ snippet: opts.snippets === false ? void 0 : idx.lineText(line).trim().slice(0, 160)
651
+ });
652
+ }
653
+ }
654
+ }
655
+ violations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
656
+ const counts = { error: 0, warning: 0, info: 0 };
657
+ const perRule = /* @__PURE__ */ new Map();
658
+ for (const v of violations) {
659
+ counts[v.severity]++;
660
+ perRule.set(v.ruleId, (perRule.get(v.ruleId) ?? 0) + 1);
661
+ }
662
+ return {
663
+ engineVersion: ENGINE_VERSION,
664
+ tokenVersion: config.tokenVersion,
665
+ score: computeScore(counts, linesScanned),
666
+ counts,
667
+ filesScanned,
668
+ linesScanned,
669
+ violations,
670
+ byRule: [...perRule].map(([ruleId, count]) => ({ ruleId, count })).sort((a, b) => b.count - a.count),
671
+ durationMs: Date.now() - t0
672
+ };
673
+ }
674
+
675
+ // ../engine/src/report.ts
676
+ var RENDERED_ONLY = { "text-contrast": "Low text contrast", "font-size-scale": "Off-scale font size", "page-rendered": "Blank page" };
677
+ var title = (id) => RULES.find((r) => r.id === id)?.title ?? RENDERED_ONLY[id] ?? id;
678
+ var ansi = (on) => {
679
+ const w = (code) => (s) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
680
+ return { red: w("31"), yellow: w("33"), cyan: w("36"), green: w("32"), dim: w("2"), bold: w("1") };
681
+ };
682
+ var rendered = (r) => r.violations.length > 0 && r.violations.every((v) => v.line === 0);
683
+ function formatPretty(report, { color = true, max = 200 } = {}) {
684
+ const c = ansi(color);
685
+ const sev = { error: c.red, warning: c.yellow, info: c.cyan };
686
+ const lines = [];
687
+ let lastFile = "";
688
+ for (const v of report.violations.slice(0, max)) {
689
+ if (v.file !== lastFile) {
690
+ lines.push("", c.bold(v.file));
691
+ lastFile = v.file;
692
+ }
693
+ const rendered2 = v.line === 0;
694
+ lines.push(` ${c.dim(rendered2 ? "".padEnd(8) : `${v.line}:${v.column}`.padEnd(8))} ${sev[v.severity](v.severity.padEnd(7))} ${v.message} ${c.dim(v.ruleId)}`);
695
+ if (v.suggestion) lines.push(` ${" ".repeat(16)} ${c.green("\u2192")} ${v.suggestion}`);
696
+ if (rendered2 && v.snippet) lines.push(` ${" ".repeat(16)} ${c.dim(`on ${v.snippet}`)}`);
697
+ }
698
+ if (report.violations.length > max) lines.push("", c.dim(`\u2026 and ${report.violations.length - max} more`));
699
+ const s = report.score;
700
+ const scoreStr = s >= 80 ? c.green(String(s)) : s >= 65 ? c.yellow(String(s)) : c.red(String(s));
701
+ lines.push(
702
+ "",
703
+ `${c.bold("Brand score")} ${scoreStr}/100 ${c.dim("\xB7")} ${c.red(`${report.counts.error} errors`)} ${c.dim("\xB7")} ${c.yellow(`${report.counts.warning} warnings`)} ${c.dim("\xB7")} ${c.cyan(`${report.counts.info} info`)}`,
704
+ c.dim(`${report.filesScanned} ${rendered(report) ? "pages" : "files"}, ${report.linesScanned} ${rendered(report) ? "elements" : "lines"} in ${report.durationMs}ms${report.tokenVersion ? ` \xB7 Brand Kit v${report.tokenVersion}` : ""}`)
705
+ );
706
+ return lines.join("\n");
707
+ }
708
+ var esc = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
709
+ var escProp = (s) => esc(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
710
+ function formatGithubAnnotations(report, max = 50) {
711
+ const level = { error: "error", warning: "warning", info: "notice" };
712
+ return report.violations.slice(0, max).map(
713
+ (v) => `::${level[v.severity]} file=${escProp(v.file)},line=${v.line},col=${v.column},title=${escProp(`Brandrail: ${title(v.ruleId)}`)}::${esc(
714
+ v.suggestion ? `${v.message}. ${v.suggestion}` : v.message
715
+ )}`
716
+ ).join("\n");
717
+ }
718
+ var COMMENT_MARKER = "<!-- brandrail-report -->";
719
+ function formatMarkdown(report, opts = {}) {
720
+ const { threshold, maxRows = 30 } = opts;
721
+ const pass = threshold === void 0 || report.score >= threshold;
722
+ const badge = report.violations.length === 0 ? "\u{1F7E2}" : pass ? "\u{1F7E1}" : "\u{1F534}";
723
+ const out = [COMMENT_MARKER];
724
+ out.push(`## ${badge} Brand score: **${report.score}**/100${threshold !== void 0 ? ` (threshold ${threshold})` : ""}`);
725
+ out.push("");
726
+ out.push(
727
+ `**${report.counts.error}** errors \xB7 **${report.counts.warning}** warnings \xB7 **${report.counts.info}** info \u2014 ${report.filesScanned} files scanned${report.tokenVersion ? ` against Brand Kit v${report.tokenVersion}` : ""}`
728
+ );
729
+ if (report.violations.length === 0) {
730
+ out.push("", "Everything uses approved tokens and components. \u2728");
731
+ } else {
732
+ if (report.byRule.length) {
733
+ out.push("", "| Rule | Count |", "| --- | ---: |");
734
+ for (const r of report.byRule) out.push(`| ${title(r.ruleId)} \`${r.ruleId}\` | ${r.count} |`);
735
+ }
736
+ out.push("", "<details><summary>Violations</summary>", "", "| | Location | Issue | Fix |", "| --- | --- | --- | --- |");
737
+ const icon = { error: "\u{1F534}", warning: "\u{1F7E1}", info: "\u{1F535}" };
738
+ for (const v of report.violations.slice(0, maxRows)) {
739
+ const loc = v.line === 0 ? `${v.file} ${v.snippet ?? ""}`.trim() : `${v.file}:${v.line}`;
740
+ const url = opts.fileUrl?.(v);
741
+ const cell = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
742
+ out.push(`| ${icon[v.severity]} | ${url ? `[\`${loc}\`](${url})` : `\`${loc}\``} | ${cell(v.message)} | ${cell(v.suggestion ?? "")} |`);
743
+ }
744
+ if (report.violations.length > maxRows) out.push("", `\u2026and ${report.violations.length - maxRows} more.`);
745
+ out.push("", "</details>");
746
+ }
747
+ out.push("", `<sub>Brandrail engine ${report.engineVersion}${opts.dashboardUrl ? ` \xB7 [Open in Brandrail](${opts.dashboardUrl})` : ""}</sub>`);
748
+ return out.join("\n");
749
+ }
750
+
751
+ // ../engine/src/defaults.ts
752
+ var starterConfig = {
753
+ $schema: "https://brandrail.dev/schema/config.json",
754
+ tokenVersion: "0.0.0-local",
755
+ tokens: {
756
+ color: {
757
+ action: { primary: { $type: "color", $value: "#1f883d", $description: "One primary action per view" } },
758
+ accent: { default: { $type: "color", $value: "#0969da", $description: "Links, focus, selection" } },
759
+ fg: {
760
+ default: { $type: "color", $value: "#1f2328", $description: "Body text" },
761
+ muted: { $type: "color", $value: "#59636e", $description: "Secondary text" }
762
+ },
763
+ canvas: {
764
+ default: { $type: "color", $value: "#ffffff", $description: "Page background" },
765
+ subtle: { $type: "color", $value: "#f6f8fa", $description: "Headers, wells" }
766
+ },
767
+ border: { default: { $type: "color", $value: "#d1d9e0", $description: "Borders and dividers" } },
768
+ danger: { default: { $type: "color", $value: "#cf222e", $description: "Destructive actions, errors" } }
769
+ },
770
+ font: {
771
+ family: { sans: { $type: "fontFamily", $value: "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif" } },
772
+ size: { base: { $type: "dimension", $value: "14px" } }
773
+ },
774
+ radius: { md: { $type: "dimension", $value: "6px" } },
775
+ space: { unit: { $type: "dimension", $value: "8px" } }
776
+ },
777
+ components: [
778
+ { name: "Button", importPath: "@acme/ui" },
779
+ { name: "TextInput", importPath: "@acme/ui" },
780
+ { name: "Select", importPath: "@acme/ui" },
781
+ { name: "Textarea", importPath: "@acme/ui" }
782
+ ],
783
+ rules: {},
784
+ ignore: []
785
+ };
786
+
787
+ // ../engine/src/tokens.ts
788
+ var EXT = "dev.brandrail";
789
+ function slug(s) {
790
+ return s.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/([A-Za-z])(\d)/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
791
+ }
792
+ var segs = (name) => name.split(/[/$.]+/).map(slug).filter(Boolean);
793
+ var stripPrefix = (s, ...prefixes) => {
794
+ for (const p of prefixes) if (s === p) return "";
795
+ for (const p of prefixes) if (s.startsWith(`${p}-`)) return s.slice(p.length + 1);
796
+ return s;
797
+ };
798
+ var WEIGHTS = {
799
+ thin: "100",
800
+ hairline: "100",
801
+ "extra-light": "200",
802
+ "ultra-light": "200",
803
+ light: "300",
804
+ regular: "400",
805
+ regulare: "400",
806
+ normal: "400",
807
+ book: "400",
808
+ medium: "500",
809
+ "semi-bold": "600",
810
+ semibold: "600",
811
+ "demi-bold": "600",
812
+ bold: "700",
813
+ "extra-bold": "800",
814
+ "ultra-bold": "800",
815
+ black: "900",
816
+ heavy: "900"
817
+ };
818
+ function fontWeight(v) {
819
+ if (typeof v === "number") return String(v);
820
+ return WEIGHTS[slug(v)] ?? (/^\d{3}$/.test(v.trim()) ? v.trim() : v);
821
+ }
822
+ function colorValue(v) {
823
+ if (v && typeof v === "object" && "r" in v) {
824
+ const c = v;
825
+ const hex2 = toHex({ r: c.r * 255, g: c.g * 255, b: c.b * 255, a: 1 });
826
+ const a = c.a ?? 1;
827
+ return a < 1 ? `${hex2}${Math.round(a * 255).toString(16).padStart(2, "0")}` : hex2;
828
+ }
829
+ if (typeof v !== "string") return null;
830
+ const s = v.trim();
831
+ if (/^#[0-9a-f]{3,8}$/i.test(s)) {
832
+ const h = s.slice(1).toLowerCase();
833
+ return h.length === 3 || h.length === 4 ? `#${[...h].map((c) => c + c).join("")}` : `#${h}`;
834
+ }
835
+ const rgb = parseColor(s);
836
+ if (!rgb) return null;
837
+ const hex = toHex(rgb);
838
+ return rgb.a < 1 ? `${hex}${Math.round(rgb.a * 255).toString(16).padStart(2, "0")}` : hex;
839
+ }
840
+ var dim = (v) => typeof v === "number" ? `${Math.round(v * 100) / 100}px` : String(v).trim();
841
+ function classify(name, kind, value, opts = {}) {
842
+ const parts = segs(name);
843
+ const hay = slug(`${opts.context ?? ""} ${name}`);
844
+ const scopes = opts.scopes ?? [];
845
+ const last = parts[parts.length - 1] ?? "";
846
+ const rest = parts.slice(0, -1);
847
+ if (kind === "boolean") return null;
848
+ if (kind === "color") {
849
+ const v2 = colorValue(value);
850
+ if (!v2) return null;
851
+ let p = parts[0] === "color" || parts[0] === "colors" ? parts.slice(1) : parts;
852
+ if (p.length === 1) p = [stripPrefix(p[0], "colors", "color") || p[0]];
853
+ const tail = p[p.length - 1];
854
+ const m = tail.match(/^(.*[a-z])-(\d+)$/);
855
+ if (m && p.length <= 2) p = [...p.slice(0, -1), m[1], m[2]];
856
+ return { path: ["color", ...p].join("."), type: "color", value: v2 };
857
+ }
858
+ if (kind === "string") {
859
+ const s = String(value);
860
+ if (scopes.includes("FONT_FAMILY") || /font-family|typeface|font-name/.test(hay) || last === "font") {
861
+ const n = stripPrefix(last, "font-family", "font", "family") || "sans";
862
+ return { path: `font.family.${/mono|code/.test(s.toLowerCase()) && n === "sans" ? "mono" : n}`, type: "fontFamily", value: s };
863
+ }
864
+ if (scopes.includes("FONT_STYLE") || scopes.includes("FONT_WEIGHT") || /font-weight|weight/.test(hay)) {
865
+ return { path: `font.weight.${stripPrefix(last, "font-weight", "weight") || last}`, type: "fontWeight", value: fontWeight(s) };
866
+ }
867
+ const c = colorValue(s);
868
+ if (c && /^#|^rgb|^hsl/i.test(s.trim())) return classify(name, "color", s, opts);
869
+ return null;
870
+ }
871
+ const v = dim(value);
872
+ const has = (...scope) => scope.some((x) => scopes.includes(x));
873
+ const nm = (...prefixes) => stripPrefix(last, ...prefixes) || last;
874
+ if (has("CORNER_RADIUS") || /radius|(^|-)rad(-|$)|corner|rounded/.test(hay)) return { path: `radius.${nm("radius", "rad", "corner", "rounded")}`, type: "dimension", value: v };
875
+ if (has("FONT_SIZE") || /font-size|text-size/.test(hay)) return { path: `font.size.${nm("font-size", "text-size", "size")}`, type: "dimension", value: v };
876
+ if (has("LINE_HEIGHT") || /line-height|leading/.test(hay)) return { path: `font.line-height.${nm("line-height", "leading")}`, type: "dimension", value: v };
877
+ if (has("LETTER_SPACING") || /letter-spacing|tracking/.test(hay)) return { path: `font.letter-spacing.${nm("letter-spacing", "tracking")}`, type: "dimension", value: v };
878
+ if (has("FONT_WEIGHT") || /font-weight/.test(hay)) return { path: `font.weight.${nm("font-weight", "weight")}`, type: "fontWeight", value: String(value) };
879
+ if (/icon/.test(last)) return { path: `size.icon.${nm("icon")}`, type: "dimension", value: v };
880
+ if (has("GAP") || /spac|gap|padding|margin|inset|(^|-)space(-|$)/.test(hay)) return { path: `space.${nm("spacing", "space", "gap")}`, type: "dimension", value: v };
881
+ if (has("STROKE_FLOAT") || /stroke|border-width/.test(hay)) return { path: `border.width.${nm("border-width", "stroke")}`, type: "dimension", value: v };
882
+ if (has("WIDTH_HEIGHT") || /size|width|height/.test(hay)) return { path: ["size", ...rest.filter((x) => x !== "size"), last].join("."), type: "dimension", value: v };
883
+ return { path: ["number", ...parts].join("."), type: "number", value: String(value) };
884
+ }
885
+ function rawLeaves(node, prefix, out, studio, inherited) {
886
+ if (!node || typeof node !== "object" || Array.isArray(node)) return;
887
+ const o = node;
888
+ const groupType = o.$type ?? inherited;
889
+ const isLeaf = "$value" in o || studio && "value" in o && (typeof o.value !== "object" || o.value === null || "type" in o);
890
+ if (isLeaf) {
891
+ const ext = o.$extensions?.[EXT];
892
+ out.push({
893
+ path: prefix,
894
+ type: o.$type ?? o.type ?? groupType,
895
+ value: "$value" in o ? o.$value : o.value,
896
+ description: o.$description ?? o.description,
897
+ dark: ext?.modes?.dark
898
+ });
899
+ return;
900
+ }
901
+ for (const [k, v] of Object.entries(o)) if (!k.startsWith("$")) rawLeaves(v, prefix ? `${prefix}.${k}` : k, out, studio, groupType);
902
+ }
903
+ var REF = /^\{([^}]+)\}$/;
904
+ function resolveRefs(raws) {
905
+ const byPath = new Map(raws.map((r) => [r.path, r]));
906
+ const deref = (v, mode, depth = 0) => {
907
+ if (typeof v !== "string" || depth > 10) return v;
908
+ const m = v.trim().match(REF);
909
+ if (!m) return v.replace(/\{([^}]+)\}/g, (all, p) => String(byPath.get(p)?.value ?? all));
910
+ const target = byPath.get(m[1]);
911
+ if (!target) return v;
912
+ return deref(mode === "dark" && target.dark !== void 0 ? target.dark : target.value, mode, depth + 1);
913
+ };
914
+ return raws.map((r) => ({ ...r, value: deref(r.value, "light"), dark: r.dark !== void 0 ? deref(r.dark, "dark") : void 0 }));
915
+ }
916
+ var TYPE_KIND = {
917
+ color: "color",
918
+ dimension: "number",
919
+ spacing: "number",
920
+ sizing: "number",
921
+ borderradius: "number",
922
+ fontsizes: "number",
923
+ fontsize: "number",
924
+ lineheights: "number",
925
+ lineheight: "number",
926
+ letterspacing: "number",
927
+ number: "number",
928
+ fontfamilies: "string",
929
+ fontfamily: "string",
930
+ fontweights: "string",
931
+ fontweight: "string"
932
+ };
933
+ function flattenTokens(doc) {
934
+ const raws = resolveRefs(collectRaw(doc).raws);
935
+ const out = [];
936
+ for (const r of raws) {
937
+ const t = (r.type ?? "").toLowerCase().replace(/[^a-z]/g, "");
938
+ if (t === "color" || !t && r.path.startsWith("color")) {
939
+ const v = colorValue(r.value);
940
+ if (!v) continue;
941
+ const d = r.dark !== void 0 ? colorValue(r.dark) : null;
942
+ out.push({ path: r.path, type: "color", value: v, ...d ? { dark: d } : {}, ...r.description ? { description: r.description } : {} });
943
+ } else if (typeof r.value === "string" || typeof r.value === "number") {
944
+ const type = t === "fontfamily" || t === "fontfamilies" ? "fontFamily" : t === "fontweight" || t === "fontweights" ? "fontWeight" : TYPE_KIND[t] === "number" ? "dimension" : t === "number" ? "number" : /^font\.family\./.test(r.path) ? "fontFamily" : typeof r.value === "number" ? "number" : /px$|rem$/.test(String(r.value)) ? "dimension" : "string";
945
+ out.push({ path: r.path, type, value: type === "dimension" ? dim(r.value) : String(r.value), ...r.description ? { description: r.description } : {} });
946
+ }
947
+ }
948
+ return out;
949
+ }
950
+ function collectRaw(doc) {
951
+ const o = doc ?? {};
952
+ const studio = !hasDollarValue(o) && hasStudioValue(o);
953
+ const order = o.$metadata?.tokenSetOrder;
954
+ if (!studio) {
955
+ const raws = [];
956
+ rawLeaves(o, "", raws, false);
957
+ return { raws, studio };
958
+ }
959
+ const setNames = (order ?? Object.keys(o).filter((k) => !k.startsWith("$"))).filter((k) => o[k] && typeof o[k] === "object");
960
+ const looksLikeSets = setNames.length > 0 && setNames.every((k) => !hasLeafAtTop(o[k]));
961
+ if (!looksLikeSets) {
962
+ const raws = [];
963
+ rawLeaves(o, "", raws, true);
964
+ return { raws, studio };
965
+ }
966
+ const merged = /* @__PURE__ */ new Map();
967
+ const darkSets = setNames.filter((n) => /dark/i.test(n));
968
+ for (const set of setNames.filter((n) => !/dark/i.test(n))) {
969
+ const raws = [];
970
+ rawLeaves(o[set], "", raws, true);
971
+ for (const r of raws) merged.set(r.path, r);
972
+ }
973
+ for (const set of darkSets) {
974
+ const raws = [];
975
+ rawLeaves(o[set], "", raws, true);
976
+ for (const r of raws) {
977
+ const base = merged.get(r.path);
978
+ if (base) base.dark = r.value;
979
+ else merged.set(r.path, r);
980
+ }
981
+ }
982
+ return { raws: [...merged.values()], studio };
983
+ }
984
+ function hasDollarValue(node, depth = 0) {
985
+ if (!node || typeof node !== "object" || depth > 12) return false;
986
+ if ("$value" in node) return true;
987
+ return Object.values(node).some((v) => hasDollarValue(v, depth + 1));
988
+ }
989
+ function hasStudioValue(node, depth = 0) {
990
+ if (!node || typeof node !== "object" || depth > 12) return false;
991
+ const o = node;
992
+ if ("value" in o && "type" in o) return true;
993
+ return Object.values(o).some((v) => hasStudioValue(v, depth + 1));
994
+ }
995
+ function hasLeafAtTop(node) {
996
+ return !!node && typeof node === "object" && ("value" in node || "$value" in node);
997
+ }
998
+ function buildDtcg(tokens) {
999
+ const tree = {};
1000
+ for (const t of tokens) {
1001
+ const parts = t.path.split(".");
1002
+ let node = tree;
1003
+ parts.forEach((p, i) => {
1004
+ if (i === parts.length - 1) {
1005
+ node[p] = {
1006
+ $type: t.type,
1007
+ $value: t.value,
1008
+ ...t.description ? { $description: t.description } : {},
1009
+ ...t.dark && t.dark !== t.value ? { $extensions: { [EXT]: { modes: { dark: t.dark } } } } : {}
1010
+ };
1011
+ } else {
1012
+ const next = node[p];
1013
+ if (next && typeof next === "object" && "$value" in next) node[p] = { default: next };
1014
+ node = node[p] ??= {};
1015
+ }
1016
+ });
1017
+ }
1018
+ return tree;
1019
+ }
1020
+ var isAlias = (v) => !!v && typeof v === "object" && v.type === "VARIABLE_ALIAS";
1021
+ var DARK = /dark|night/i;
1022
+ var LIGHT = /light|default|day|base/i;
1023
+ function finish(format, all, notes, opts) {
1024
+ const PALETTE2 = /^(color\.[a-z-]+)\.\d{1,3}$/;
1025
+ const usedFamilies = new Set(all.filter((t) => t.referenced).map((t) => t.path.match(PALETTE2)?.[1]).filter(Boolean));
1026
+ if (usedFamilies.size) {
1027
+ const steps = /* @__PURE__ */ new Map();
1028
+ for (const t of all) {
1029
+ const f = t.path.match(PALETTE2)?.[1];
1030
+ if (f) steps.set(f, (steps.get(f) ?? 0) + 1);
1031
+ }
1032
+ for (const t of all) {
1033
+ const f = t.path.match(PALETTE2)?.[1];
1034
+ if (f && (usedFamilies.has(f) || (steps.get(f) ?? 0) >= 3)) t.referenced = true;
1035
+ }
1036
+ }
1037
+ const hasSemantic = all.some((t) => !t.referenced) && all.some((t) => t.referenced);
1038
+ const keep = opts.includePrimitives || !hasSemantic ? all : all.filter((t) => !t.referenced);
1039
+ const primitives = hasSemantic ? all.filter((t) => t.referenced).length : 0;
1040
+ const seen = /* @__PURE__ */ new Set();
1041
+ const tokens = [];
1042
+ for (const { referenced: _r, ...t } of keep) {
1043
+ if (seen.has(t.path)) continue;
1044
+ seen.add(t.path);
1045
+ tokens.push(t);
1046
+ }
1047
+ tokens.sort((a, b) => a.path.localeCompare(b.path, "en", { numeric: true }));
1048
+ return { format, tokens: buildDtcg(tokens), count: tokens.length, primitives, notes };
1049
+ }
1050
+ function fromFigmaVariables(meta, opts = {}) {
1051
+ const vars = meta.variables ?? {};
1052
+ const cols = meta.variableCollections ?? {};
1053
+ const notes = [];
1054
+ const referenced = /* @__PURE__ */ new Set();
1055
+ const modeFor = (col, want, likeName) => {
1056
+ if (likeName) {
1057
+ const same = col.modes.find((m) => m.name.toLowerCase() === likeName.toLowerCase());
1058
+ if (same) return same.modeId;
1059
+ }
1060
+ if (want === "dark") return col.modes.find((m) => DARK.test(m.name))?.modeId;
1061
+ return col.defaultModeId ?? col.modes[0]?.modeId;
1062
+ };
1063
+ const resolve4 = (v, want, modeName, depth = 0) => {
1064
+ const col = cols[v.variableCollectionId];
1065
+ if (!col) return void 0;
1066
+ const modeId = modeFor(col, want, modeName) ?? col.defaultModeId;
1067
+ const raw = v.valuesByMode[modeId] ?? Object.values(v.valuesByMode)[0];
1068
+ if (!isAlias(raw)) return raw;
1069
+ referenced.add(raw.id);
1070
+ const target = vars[raw.id];
1071
+ if (!target || depth > 10) return void 0;
1072
+ const name = col.modes.find((m) => m.modeId === modeId)?.name;
1073
+ return resolve4(target, want, name, depth + 1);
1074
+ };
1075
+ for (const v of Object.values(vars)) for (const val of Object.values(v.valuesByMode)) if (isAlias(val)) referenced.add(val.id);
1076
+ for (const col of Object.values(cols)) {
1077
+ if (col.remote) continue;
1078
+ const dark = col.modes.find((m) => DARK.test(m.name));
1079
+ const others = col.modes.filter((m) => m.modeId !== col.defaultModeId && m !== dark);
1080
+ if (others.length) {
1081
+ const def = col.modes.find((m) => m.modeId === col.defaultModeId)?.name;
1082
+ notes.push(`\u201C${col.name}\u201D has modes ${col.modes.map((m) => m.name).join(", ")}; used \u201C${def}\u201D${dark ? ` (and \u201C${dark.name}\u201D as dark)` : ""}.`);
1083
+ }
1084
+ }
1085
+ const out = [];
1086
+ let hidden = 0;
1087
+ for (const v of Object.values(vars)) {
1088
+ const col = cols[v.variableCollectionId];
1089
+ if (!col || v.remote || col.remote) continue;
1090
+ if (v.hiddenFromPublishing) {
1091
+ hidden++;
1092
+ continue;
1093
+ }
1094
+ const kind = v.resolvedType === "COLOR" ? "color" : v.resolvedType === "FLOAT" ? "number" : v.resolvedType === "STRING" ? "string" : "boolean";
1095
+ const value = resolve4(v, "default");
1096
+ if (value === void 0) continue;
1097
+ const c = classify(v.name, kind, value, { scopes: v.scopes?.filter((s) => s !== "ALL_SCOPES"), context: col.name });
1098
+ if (!c) continue;
1099
+ let darkValue;
1100
+ if (c.type === "color") {
1101
+ const hasDarkSomewhere = Object.values(cols).some((x) => x.modes.some((m) => DARK.test(m.name)));
1102
+ if (hasDarkSomewhere) {
1103
+ const d = colorValue(resolve4(v, "dark"));
1104
+ if (d && d !== c.value) darkValue = d;
1105
+ }
1106
+ }
1107
+ out.push({ ...c, ...darkValue ? { dark: darkValue } : {}, ...v.description ? { description: v.description } : {}, referenced: referenced.has(v.id) });
1108
+ }
1109
+ if (hidden) notes.push(`${hidden} variable${hidden === 1 ? "" : "s"} hidden from publishing were skipped.`);
1110
+ return finish("figma-variables", out, notes, opts);
1111
+ }
1112
+ function fromFigmaStyles(styles, opts = {}) {
1113
+ const out = [];
1114
+ const families = /* @__PURE__ */ new Map();
1115
+ let skipped = 0;
1116
+ for (const s of styles) {
1117
+ if (s.styleType === "FILL") {
1118
+ const fill = s.node?.fills?.find((f) => f.type === "SOLID" && f.visible !== false && f.color);
1119
+ if (!fill?.color) {
1120
+ skipped++;
1121
+ continue;
1122
+ }
1123
+ const c = classify(s.name, "color", { ...fill.color, a: (fill.color.a ?? 1) * (fill.opacity ?? 1) });
1124
+ if (c) out.push({ ...c, ...s.description ? { description: s.description } : {} });
1125
+ } else if (s.styleType === "TEXT" && s.node?.style) {
1126
+ const st = s.node.style;
1127
+ const name = segs(s.name).join("-");
1128
+ if (st.fontSize) out.push({ path: `font.size.${name}`, type: "dimension", value: dim(st.fontSize) });
1129
+ if (st.lineHeightPx) out.push({ path: `font.line-height.${name}`, type: "dimension", value: dim(Math.round(st.lineHeightPx)) });
1130
+ if (st.fontWeight) out.push({ path: `font.weight.${name}`, type: "fontWeight", value: String(st.fontWeight) });
1131
+ if (st.fontFamily) families.set(st.fontFamily, (families.get(st.fontFamily) ?? 0) + 1);
1132
+ }
1133
+ }
1134
+ const fams = [...families.entries()].sort((a, b) => b[1] - a[1]);
1135
+ fams.forEach(([f], i) => out.push({ path: `font.family.${i === 0 ? "sans" : /mono|code/i.test(f) ? "mono" : slug(f)}`, type: "fontFamily", value: f }));
1136
+ const notes = skipped ? [`${skipped} fill style${skipped === 1 ? "" : "s"} (gradients or images) were skipped.`] : [];
1137
+ return finish("figma-styles", out, notes, opts);
1138
+ }
1139
+ function plainLeaves(node, prefix, out) {
1140
+ if (node === null || node === void 0) return;
1141
+ if (typeof node !== "object" || typeof node === "object" && "r" in node && "g" in node) {
1142
+ out.push({ key: prefix, value: node });
1143
+ return;
1144
+ }
1145
+ for (const [k, v] of Object.entries(node)) plainLeaves(v, [...prefix, k], out);
1146
+ }
1147
+ function fromModeObject(obj, opts = {}) {
1148
+ const notes = [];
1149
+ const modes = Object.entries(obj).filter(([, v]) => v && typeof v === "object");
1150
+ const leafSets = modes.map(([name, v]) => {
1151
+ const l = [];
1152
+ plainLeaves(v, [], l);
1153
+ return { name, leaves: l, keys: new Set(l.map((x) => x.key.join("/"))) };
1154
+ });
1155
+ const groups = [];
1156
+ for (const m of leafSets) {
1157
+ const g = groups.find((grp) => {
1158
+ const a = grp[0].keys;
1159
+ let same = 0;
1160
+ for (const k of m.keys) if (a.has(k)) same++;
1161
+ return same / Math.max(a.size, m.keys.size) >= 0.8;
1162
+ });
1163
+ if (g) g.push(m);
1164
+ else groups.push([m]);
1165
+ }
1166
+ const byLeaf = /* @__PURE__ */ new Map();
1167
+ groups.forEach((grp, gi) => {
1168
+ for (const m of grp) for (const l of m.leaves) {
1169
+ const k = l.key[l.key.length - 1];
1170
+ const e = byLeaf.get(k) ?? { group: gi, values: /* @__PURE__ */ new Map() };
1171
+ e.values.set(m.name, l.value);
1172
+ byLeaf.set(k, e);
1173
+ }
1174
+ });
1175
+ const pick = (grp, want) => want === "dark" ? grp.find((m) => DARK.test(m.name)) : grp.find((m) => LIGHT.test(m.name) && !DARK.test(m.name)) ?? grp.find((m) => !DARK.test(m.name)) ?? grp[0];
1176
+ const referenced = /* @__PURE__ */ new Set();
1177
+ for (const m of leafSets) for (const l of m.leaves) if (typeof l.value === "string" && byLeaf.has(l.value)) referenced.add(l.value);
1178
+ const deref = (v, want, depth = 0) => {
1179
+ if (typeof v !== "string" || depth > 10) return v;
1180
+ const ref = byLeaf.get(v) ?? byLeaf.get(v.replace(REF, "$1"));
1181
+ if (!ref) return v;
1182
+ referenced.add(v);
1183
+ const grp = groups[ref.group];
1184
+ const mode = pick(grp, want) ?? pick(grp, "default");
1185
+ return deref(ref.values.get(mode.name) ?? [...ref.values.values()][0], want, depth + 1);
1186
+ };
1187
+ const out = [];
1188
+ const pending = [];
1189
+ for (const grp of groups) {
1190
+ const def = pick(grp, "default");
1191
+ const dark = pick(grp, "dark");
1192
+ if (grp.length > 1) notes.push(`${grp.map((m) => m.name).join(" / ")} look like modes of one collection; used \u201C${def.name}\u201D${dark && dark !== def ? ` (and \u201C${dark.name}\u201D as dark)` : ""}.`);
1193
+ for (const l of def.leaves) {
1194
+ const key = l.key[l.key.length - 1];
1195
+ const container = l.key.length > 1 ? l.key[l.key.length - 2].toLowerCase() : "";
1196
+ const value = deref(l.value, "default");
1197
+ const kind = container === "color" || colorValue(value) && /^#|^rgb|^hsl/i.test(String(value)) ? "color" : container === "string" || typeof value === "string" && !/^-?[\d.]+(px|rem|%)?$/.test(value) ? "string" : "number";
1198
+ const c = classify(key, kind, kind === "number" && typeof value === "string" && /px$/.test(value) ? parseFloat(value) : value, { context: def.name });
1199
+ if (!c) continue;
1200
+ let d;
1201
+ if (c.type === "color") {
1202
+ const darkLeaf = dark && dark !== def ? dark.leaves.find((x) => x.key.join("/") === l.key.join("/")) : void 0;
1203
+ const dv = colorValue(deref(darkLeaf ? darkLeaf.value : l.value, "dark"));
1204
+ if (dv && dv !== c.value) d = dv;
1205
+ }
1206
+ pending.push({ key, t: { ...c, ...d ? { dark: d } : {} } });
1207
+ }
1208
+ }
1209
+ for (const p of pending) out.push({ ...p.t, referenced: referenced.has(p.key) });
1210
+ return finish("mode-object", out, notes, opts);
1211
+ }
1212
+ function importTokens(input, opts = {}) {
1213
+ if (!input || typeof input !== "object") throw new Error("Expected a JSON object.");
1214
+ const o = input;
1215
+ const meta = o.meta ?? o;
1216
+ if (meta.variables && meta.variableCollections) return fromFigmaVariables(meta, opts);
1217
+ if (Array.isArray(o.styles) && o.styles.every((s) => s && "styleType" in s)) return fromFigmaStyles(o.styles, opts);
1218
+ if (hasDollarValue(o) || hasStudioValue(o)) {
1219
+ const studio = !hasDollarValue(o);
1220
+ const flat = flattenTokens(o);
1221
+ if (!flat.length) throw new Error("No tokens found in that file.");
1222
+ const out = flat.map((t) => {
1223
+ if (t.type === "color" && !t.path.startsWith("color.")) return { ...t, path: `color.${t.path}` };
1224
+ return t;
1225
+ });
1226
+ const notes = studio ? [] : [];
1227
+ const res = finish(studio ? "tokens-studio" : "dtcg", out, notes, opts);
1228
+ return res;
1229
+ }
1230
+ const leaves2 = [];
1231
+ plainLeaves(o, [], leaves2);
1232
+ if (leaves2.length) return fromModeObject(o, opts);
1233
+ throw new Error("Couldn't find any tokens. Supported: DTCG, Tokens Studio, Figma variables JSON.");
1234
+ }
1235
+ function diffTokens(before, after) {
1236
+ const a = new Map(flattenTokens(before).map((t) => [t.path, t]));
1237
+ const b = new Map(flattenTokens(after).map((t) => [t.path, t]));
1238
+ const diff = { added: [], removed: [], changed: [], unchanged: 0 };
1239
+ for (const [p, t] of b) {
1240
+ const old = a.get(p);
1241
+ if (!old) diff.added.push(t);
1242
+ else if (old.value.toLowerCase() !== t.value.toLowerCase() || (old.dark ?? "").toLowerCase() !== (t.dark ?? "").toLowerCase()) diff.changed.push({ path: p, before: old, after: t });
1243
+ else diff.unchanged++;
1244
+ }
1245
+ for (const [p, t] of a) if (!b.has(p)) diff.removed.push(t);
1246
+ return diff;
1247
+ }
1248
+ function mergeTokens(base, incoming) {
1249
+ const m = new Map(flattenTokens(base).map((t) => [t.path, t]));
1250
+ for (const t of flattenTokens(incoming)) m.set(t.path, { ...m.get(t.path), ...t, description: t.description ?? m.get(t.path)?.description });
1251
+ return buildDtcg([...m.values()]);
1252
+ }
1253
+
1254
+ // ../engine/src/instructions.ts
1255
+ var RULES_START = "<!-- brandrail:start -->";
1256
+ var RULES_END = "<!-- brandrail:end -->";
1257
+ function generateInstructions(config, opts = {}) {
1258
+ const r = resolveConfig(config);
1259
+ const lines = [];
1260
+ lines.push(`# Design system rules${opts.org ? ` \u2014 ${opts.org}` : ""}`);
1261
+ lines.push("");
1262
+ lines.push(
1263
+ `All UI in this repo must follow the Brand Kit${r.tokenVersion ? ` (v${r.tokenVersion})` : ""}. These rules are checked on every pull request by Brandrail.`
1264
+ );
1265
+ lines.push("");
1266
+ if (opts.tokensCss) {
1267
+ lines.push("## Setup");
1268
+ lines.push(`- \`${opts.tokensCss}\` defines every CSS variable below (light and dark). Import it once at the app entry, before other styles. Don't edit it: it's generated from the Brand Kit.`);
1269
+ lines.push("");
1270
+ }
1271
+ if (r.components.length) {
1272
+ lines.push("## Components");
1273
+ lines.push("Always use these components. Never re-create them with raw HTML elements:");
1274
+ for (const c of r.components) {
1275
+ const tags = [...r.replaces].filter(([, v]) => v === c).map(([t]) => `<${t}>`);
1276
+ lines.push(`- \`${c.name}\` from \`${c.importPath}\`${tags.length ? ` (instead of ${tags.join(", ")})` : ""}`);
1277
+ }
1278
+ lines.push("");
1279
+ }
1280
+ if (r.colors.length) {
1281
+ lines.push("## Colours");
1282
+ lines.push("Use CSS variables only. Never write hex, rgb() or hsl() values, and never use Tailwind palette classes like `bg-blue-500`.");
1283
+ for (const c of r.colors) {
1284
+ if (c.mode) continue;
1285
+ const dark = r.colors.find((d) => d.mode === "dark" && d.path === c.path);
1286
+ lines.push(`- \`var(${c.cssVar})\` \u2014 ${c.path} (${c.hex}${dark ? `, dark ${dark.hex}` : ""})`);
1287
+ }
1288
+ lines.push("");
1289
+ }
1290
+ lines.push("## Spacing, radius and type");
1291
+ const named = (prefix) => flattenTokens(config.tokens).filter((t) => prefix.test(t.path)).map((t) => `\`var(--${t.path.replace(/\./g, "-")})\` ${t.value}`);
1292
+ const spaceVars = named(/^(space|spacing)\./);
1293
+ const radiusVars = named(/^radius\./);
1294
+ const sizeVars = named(/^font\.size\./);
1295
+ lines.push(`- Spacing (padding, margin, gap) must be one of: ${r.spacing.filter((s) => s > 2).map((s) => `${s}px`).join(", ")}.${spaceVars.length ? ` Prefer the variables: ${spaceVars.join(", ")}.` : ""}`);
1296
+ lines.push(`- Corner radius must be one of: ${r.radii.filter((x) => x > 0 && x < 999).map((x) => `${x}px`).join(", ")} (or fully rounded).${radiusVars.length ? ` Variables: ${radiusVars.join(", ")}.` : ""}`);
1297
+ if (sizeVars.length) lines.push(`- Font sizes come from the type scale: ${sizeVars.join(", ")}. Matching line heights are \`--font-line-height-*\`.`);
1298
+ if (r.fonts.length) lines.push(`- Fonts: use ${r.fontVars.map((v) => `\`var(${v})\``).join(" or ")}; never set another font-family.`);
1299
+ lines.push("");
1300
+ lines.push("## Before you finish");
1301
+ lines.push("- If the Brandrail MCP server is connected, call `validate_snippet` on the UI you wrote and fix every violation.");
1302
+ lines.push("- Otherwise run `npx brandrail check` and fix errors before committing.");
1303
+ lines.push("");
1304
+ lines.push("<details><summary>Checked rules</summary>");
1305
+ lines.push("");
1306
+ for (const rule of RULES) {
1307
+ if (r.rules[rule.id] === "off") continue;
1308
+ lines.push(`- **${rule.title}** (\`${rule.id}\`): ${rule.description}`);
1309
+ }
1310
+ lines.push("");
1311
+ lines.push("</details>");
1312
+ return lines.join("\n");
1313
+ }
1314
+ function upsertSection(existing, section) {
1315
+ const block = `${RULES_START}
1316
+ ${section}
1317
+ ${RULES_END}`;
1318
+ const start = existing.indexOf(RULES_START);
1319
+ const end = existing.indexOf(RULES_END);
1320
+ if (start !== -1 && end > start) return existing.slice(0, start) + block + existing.slice(end + RULES_END.length);
1321
+ return existing.trim() ? `${existing.trimEnd()}
1322
+
1323
+ ${block}
1324
+ ` : `${block}
1325
+ `;
1326
+ }
1327
+
1328
+ // ../engine/src/mcp-config.ts
1329
+ var json = (o) => `${JSON.stringify(o, null, 2)}
1330
+ `;
1331
+ function mcpClientConfigs(url) {
1332
+ return [
1333
+ {
1334
+ id: "claude-code",
1335
+ name: "Claude Code",
1336
+ file: ".mcp.json",
1337
+ projectFile: true,
1338
+ content: json({ mcpServers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${BRANDRAIL_KEY}" } } } }),
1339
+ command: `claude mcp add --transport http brandrail ${url} --header "Authorization: Bearer $BRANDRAIL_KEY"`
1340
+ },
1341
+ {
1342
+ id: "codex",
1343
+ name: "Codex",
1344
+ file: "~/.codex/config.toml",
1345
+ projectFile: false,
1346
+ content: `[mcp_servers.brandrail]
1347
+ url = "${url}"
1348
+ bearer_token_env_var = "BRANDRAIL_KEY"
1349
+ `,
1350
+ command: `codex mcp add brandrail --url ${url} --bearer-token-env-var BRANDRAIL_KEY`
1351
+ },
1352
+ {
1353
+ id: "cursor",
1354
+ name: "Cursor",
1355
+ file: ".cursor/mcp.json",
1356
+ projectFile: true,
1357
+ content: json({ mcpServers: { brandrail: { url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
1358
+ },
1359
+ {
1360
+ id: "vscode",
1361
+ name: "GitHub Copilot (VS Code)",
1362
+ file: ".vscode/mcp.json",
1363
+ projectFile: true,
1364
+ content: json({
1365
+ inputs: [{ type: "promptString", id: "brandrail-key", description: "Brandrail API key", password: true }],
1366
+ servers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${input:brandrail-key}" } } }
1367
+ })
1368
+ },
1369
+ {
1370
+ id: "windsurf",
1371
+ name: "Windsurf",
1372
+ file: "~/.codeium/windsurf/mcp_config.json",
1373
+ projectFile: false,
1374
+ content: json({ mcpServers: { brandrail: { serverUrl: url, headers: { Authorization: "Bearer ${env:BRANDRAIL_KEY}" } } } })
1375
+ }
1376
+ ];
1377
+ }
1378
+ var RULE_FILES = [
1379
+ { file: "AGENTS.md", readBy: "Codex, Jules, Amp, Cursor and other agents" },
1380
+ { file: "CLAUDE.md", readBy: "Claude Code" },
1381
+ { file: ".cursor/rules/brandrail.mdc", readBy: "Cursor" },
1382
+ { file: ".github/copilot-instructions.md", readBy: "GitHub Copilot" },
1383
+ { file: ".windsurf/rules/brandrail.md", readBy: "Windsurf" },
1384
+ { file: "GEMINI.md", readBy: "Gemini CLI" }
1385
+ ];
1386
+
1387
+ // ../engine/src/rendered.ts
1388
+ function collectRenderedNodes(maxNodes = 3e3) {
1389
+ const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "HEAD", "META", "LINK", "BR", "WBR", "SOURCE", "TRACK"]);
1390
+ const px2 = (v) => {
1391
+ const n = parseFloat(v);
1392
+ return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
1393
+ };
1394
+ const alphaOf = (c) => {
1395
+ const m = c.match(/rgba?\(([^)]+)\)/);
1396
+ if (!m) return 1;
1397
+ const parts = m[1].split(/[\s,/]+/).filter(Boolean);
1398
+ return parts[3] !== void 0 ? parseFloat(parts[3]) : 1;
1399
+ };
1400
+ const cssEsc = (s) => window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/[^\w-]/g, "\\$&");
1401
+ const part = (el) => {
1402
+ if (el.id && el.id.length < 40) return `${el.tagName.toLowerCase()}#${cssEsc(el.id)}`;
1403
+ const cls = Array.from(el.classList).filter((c) => c.length < 30).slice(0, 2).map((c) => `.${cssEsc(c)}`).join("");
1404
+ return el.tagName.toLowerCase() + cls;
1405
+ };
1406
+ const selectorOf = (el) => {
1407
+ const parts = [];
1408
+ let cur = el;
1409
+ while (cur && cur !== document.body && parts.length < 3) {
1410
+ parts.unshift(part(cur));
1411
+ if (cur.id) break;
1412
+ cur = cur.parentElement;
1413
+ }
1414
+ return parts.join(" > ");
1415
+ };
1416
+ const bgCache = /* @__PURE__ */ new Map();
1417
+ const effectiveBg = (el) => {
1418
+ if (!el) return "rgb(255, 255, 255)";
1419
+ if (bgCache.has(el)) return bgCache.get(el);
1420
+ const cs = getComputedStyle(el);
1421
+ let out;
1422
+ if (cs.backgroundImage && cs.backgroundImage !== "none") out = null;
1423
+ else if (alphaOf(cs.backgroundColor) >= 0.95) out = cs.backgroundColor;
1424
+ else out = effectiveBg(el.parentElement);
1425
+ bgCache.set(el, out);
1426
+ return out;
1427
+ };
1428
+ const nodes = [];
1429
+ const all = document.body ? document.body.querySelectorAll("*") : [];
1430
+ for (const el of Array.from(all)) {
1431
+ if (nodes.length >= maxNodes) break;
1432
+ if (SKIP.has(el.tagName) || el.closest("svg") !== null && el.tagName.toLowerCase() !== "svg") continue;
1433
+ const cs = getComputedStyle(el);
1434
+ if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) continue;
1435
+ const r = el.getBoundingClientRect();
1436
+ if (r.width < 1 || r.height < 1) continue;
1437
+ const text = Array.from(el.childNodes).filter((n) => n.nodeType === 3).map((n) => n.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
1438
+ const borderW = Math.max(px2(cs.borderTopWidth), px2(cs.borderRightWidth), px2(cs.borderBottomWidth), px2(cs.borderLeftWidth));
1439
+ const borderSide = px2(cs.borderTopWidth) > 0 ? cs.borderTopColor : px2(cs.borderBottomWidth) > 0 ? cs.borderBottomColor : px2(cs.borderLeftWidth) > 0 ? cs.borderLeftColor : cs.borderRightColor;
1440
+ nodes.push({
1441
+ selector: selectorOf(el),
1442
+ tag: el.tagName.toLowerCase(),
1443
+ text: text ? text.slice(0, 60) : void 0,
1444
+ color: cs.color,
1445
+ backgroundColor: cs.backgroundColor,
1446
+ borderColor: borderW > 0 && cs.borderTopStyle !== "none" ? borderSide : void 0,
1447
+ effectiveBackground: effectiveBg(el),
1448
+ fontFamily: cs.fontFamily,
1449
+ fontSize: px2(cs.fontSize),
1450
+ fontWeight: parseInt(cs.fontWeight, 10) || 400,
1451
+ radius: Math.max(px2(cs.borderTopLeftRadius), px2(cs.borderTopRightRadius), px2(cs.borderBottomLeftRadius), px2(cs.borderBottomRightRadius)),
1452
+ minSide: Math.round(Math.min(r.width, r.height)),
1453
+ padding: [px2(cs.paddingTop), px2(cs.paddingRight), px2(cs.paddingBottom), px2(cs.paddingLeft)],
1454
+ gap: cs.display.includes("flex") || cs.display.includes("grid") ? [px2(cs.rowGap), px2(cs.columnGap)] : []
1455
+ });
1456
+ }
1457
+ const links = Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((h) => {
1458
+ try {
1459
+ const u = new URL(h);
1460
+ return u.origin === location.origin && !/\.(pdf|zip|png|jpe?g|svg|gif|webp|mp4)$/i.test(u.pathname);
1461
+ } catch {
1462
+ return false;
1463
+ }
1464
+ });
1465
+ return { title: document.title, nodes, links: Array.from(new Set(links.map((h) => h.split("#")[0]))).slice(0, 50) };
1466
+ }
1467
+ var near = (scale, v, tol = 0.5) => scale.some((s) => Math.abs(s - v) <= tol);
1468
+ var neighbours2 = (scale, v) => {
1469
+ const lo = [...scale].reverse().find((x) => x <= v);
1470
+ const hi = scale.find((x) => x >= v);
1471
+ return [...new Set([lo, hi].filter((x) => x !== void 0))].map((x) => `${x}px`).join(" or ");
1472
+ };
1473
+ function closestToken(config, rgb) {
1474
+ const lab = rgbToLab(rgb);
1475
+ let best = config.colors[0];
1476
+ let bestD = Infinity;
1477
+ for (const c of config.colors) {
1478
+ const d = deltaE(lab, c.lab) + Math.abs(rgb.a - (c.alpha ?? 1)) * 100;
1479
+ if (d < bestD) {
1480
+ bestD = d;
1481
+ best = c;
1482
+ }
1483
+ }
1484
+ return { token: best, distance: bestD };
1485
+ }
1486
+ function colorLabel(rgb) {
1487
+ const hex = toHex(rgb);
1488
+ return rgb.a < 1 ? `${hex} at ${Math.round(rgb.a * 100)}%` : hex;
1489
+ }
1490
+ function checkRendered(pages, configInput, opts = {}) {
1491
+ const t0 = Date.now();
1492
+ const config = "replaces" in configInput ? configInput : resolveConfig(configInput);
1493
+ const examples = opts.examples ?? 3;
1494
+ const fontSizes = config.fontSizes;
1495
+ const violations = [];
1496
+ let nodesChecked = 0;
1497
+ const spacing = config.spacing;
1498
+ const radii = config.radii.filter((r) => r < 999);
1499
+ for (const page of pages) {
1500
+ const groups = /* @__PURE__ */ new Map();
1501
+ const add = (key, g, selector) => {
1502
+ const sev = config.rules[g.ruleId];
1503
+ if (sev === "off") return;
1504
+ const cur = groups.get(key) ?? { ...g, severity: sev ?? g.severity, selectors: [], count: 0 };
1505
+ cur.count++;
1506
+ if (cur.selectors.length < examples && !cur.selectors.includes(selector)) cur.selectors.push(selector);
1507
+ groups.set(key, cur);
1508
+ };
1509
+ const colorCheck = (raw, where, selector) => {
1510
+ if (!raw || !config.colors.length) return;
1511
+ const rgb = parseColor(raw);
1512
+ if (!rgb || rgb.a < 0.05) return;
1513
+ const { token, distance } = closestToken(config, rgb);
1514
+ if (distance <= config.colorTolerance) return;
1515
+ const label = colorLabel(rgb);
1516
+ add(
1517
+ `color:${label}`,
1518
+ {
1519
+ ruleId: "no-hardcoded-color",
1520
+ severity: "error",
1521
+ message: (n) => `${label} isn't in the Brand Kit (${where} on ${n} element${n === 1 ? "" : "s"})`,
1522
+ suggestion: token ? `Closest token is ${token.path} (${token.hex}, \u0394E ${distance.toFixed(1)}): use var(${token.cssVar})` : void 0
1523
+ },
1524
+ selector
1525
+ );
1526
+ };
1527
+ for (const n of page.nodes) {
1528
+ nodesChecked++;
1529
+ if (n.text) colorCheck(n.color, "text", n.selector);
1530
+ colorCheck(n.backgroundColor, "background", n.selector);
1531
+ colorCheck(n.borderColor, "border", n.selector);
1532
+ if (n.text) {
1533
+ const fg = parseColor(n.color);
1534
+ const bg = n.effectiveBackground ? parseColor(n.effectiveBackground) : null;
1535
+ if (fg && bg && fg.a > 0.05) {
1536
+ const blended = fg.a < 1 ? { r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 } : fg;
1537
+ const ratio = contrastRatio(blended, bg);
1538
+ const large = n.fontSize >= 24 || n.fontSize >= 18.66 && n.fontWeight >= 700;
1539
+ const min = large ? 3 : 4.5;
1540
+ if (ratio < min) {
1541
+ const pair = `${colorLabel(fg)} on ${toHex(bg)}`;
1542
+ add(
1543
+ `contrast:${pair}:${large}`,
1544
+ {
1545
+ ruleId: "text-contrast",
1546
+ severity: "error",
1547
+ message: (c) => `${pair} is ${ratio.toFixed(2)}:1, below ${min}:1${large ? " for large text" : ""} (${c} element${c === 1 ? "" : "s"})`,
1548
+ suggestion: "Use the Brand Kit's text colours on its background colours, e.g. fg.default on canvas.default",
1549
+ sample: n.text
1550
+ },
1551
+ n.selector
1552
+ );
1553
+ }
1554
+ }
1555
+ if (config.fonts.length) {
1556
+ const fam = normalizeFontFamily(n.fontFamily);
1557
+ if (fam && !isGenericFont(fam) && !config.fonts.includes(fam) && !/^(pre|code|kbd|samp)$/.test(n.tag)) {
1558
+ add(
1559
+ `font:${fam}`,
1560
+ {
1561
+ ruleId: "font-family",
1562
+ severity: "warning",
1563
+ message: (c) => `Font \u201C${n.fontFamily.split(",")[0].replace(/["']/g, "").trim()}\u201D isn't a Brand Kit font (${c} element${c === 1 ? "" : "s"})`,
1564
+ suggestion: `Use ${config.fontVars.map((v) => `var(${v})`).join(" or ")}`
1565
+ },
1566
+ n.selector
1567
+ );
1568
+ }
1569
+ }
1570
+ if (fontSizes.length >= 3 && !near(fontSizes, n.fontSize)) {
1571
+ add(
1572
+ `size:${n.fontSize}`,
1573
+ {
1574
+ ruleId: "font-size-scale",
1575
+ severity: "warning",
1576
+ message: (c) => `Font size ${n.fontSize}px is off the type scale (${c} element${c === 1 ? "" : "s"})`,
1577
+ suggestion: `Use ${neighbours2(fontSizes, n.fontSize)}`
1578
+ },
1579
+ n.selector
1580
+ );
1581
+ }
1582
+ }
1583
+ for (const v of [.../* @__PURE__ */ new Set([...n.padding, ...n.gap])]) {
1584
+ if (v <= 2 || near(spacing, v)) continue;
1585
+ add(
1586
+ `space:${v}`,
1587
+ { ruleId: "spacing-scale", severity: "warning", message: (c) => `Spacing ${v}px is off the scale (${c} element${c === 1 ? "" : "s"})`, suggestion: `Use ${neighbours2(spacing, v)}` },
1588
+ n.selector
1589
+ );
1590
+ }
1591
+ if (n.radius > 0 && n.radius < 999 && n.radius < n.minSide / 2 - 0.5 && radii.length && !near(radii, n.radius)) {
1592
+ add(
1593
+ `radius:${n.radius}`,
1594
+ { ruleId: "radius-scale", severity: "warning", message: (c) => `Corner radius ${n.radius}px is off the scale (${c} element${c === 1 ? "" : "s"})`, suggestion: `Use ${neighbours2(radii, n.radius)}` },
1595
+ n.selector
1596
+ );
1597
+ }
1598
+ }
1599
+ const path = pagePath(page.url);
1600
+ if (page.nodes.length < 5) {
1601
+ violations.push({
1602
+ ruleId: "page-rendered",
1603
+ severity: "error",
1604
+ file: path,
1605
+ line: 0,
1606
+ column: 0,
1607
+ message: `The page rendered ${page.nodes.length} visible element${page.nodes.length === 1 ? "" : "s"}, so there was nothing to check`,
1608
+ suggestion: "Open it in a browser: it may have crashed, need a login (use --storage-state), or need longer to load (--wait)."
1609
+ });
1610
+ }
1611
+ for (const g of groups.values()) {
1612
+ violations.push({
1613
+ ruleId: g.ruleId,
1614
+ severity: g.severity,
1615
+ file: path,
1616
+ line: 0,
1617
+ column: 0,
1618
+ message: g.message(g.count),
1619
+ suggestion: g.suggestion,
1620
+ snippet: g.selectors.join(", ") + (g.sample ? ` \u201C${g.sample}\u201D` : "")
1621
+ });
1622
+ }
1623
+ }
1624
+ const order = { error: 0, warning: 1, info: 2 };
1625
+ violations.sort((a, b) => a.file.localeCompare(b.file) || order[a.severity] - order[b.severity] || countOf(b) - countOf(a));
1626
+ const counts = { error: 0, warning: 0, info: 0 };
1627
+ const perRule = /* @__PURE__ */ new Map();
1628
+ for (const v of violations) {
1629
+ counts[v.severity]++;
1630
+ perRule.set(v.ruleId, (perRule.get(v.ruleId) ?? 0) + 1);
1631
+ }
1632
+ return {
1633
+ engineVersion: ENGINE_VERSION,
1634
+ tokenVersion: config.tokenVersion,
1635
+ // Findings are grouped per distinct value, so each one weighs more than a single code line.
1636
+ score: computeScore(counts, Math.max(2e3, nodesChecked * 3)),
1637
+ counts,
1638
+ filesScanned: pages.length,
1639
+ linesScanned: nodesChecked,
1640
+ violations,
1641
+ byRule: [...perRule].map(([ruleId, count]) => ({ ruleId, count })).sort((a, b) => b.count - a.count),
1642
+ durationMs: Date.now() - t0
1643
+ };
1644
+ }
1645
+ var countOf = (v) => Number(v.message.match(/\((?:\w+ on )?(\d+) element/)?.[1] ?? 0);
1646
+ function pagePath(url) {
1647
+ try {
1648
+ const u = new URL(url);
1649
+ return `${u.host}${u.pathname}${u.search}`;
1650
+ } catch {
1651
+ return url;
1652
+ }
1653
+ }
1654
+
1655
+ // ../engine/src/tokens-css.ts
1656
+ var cssVar = (path) => `--${path.replace(/\./g, "-")}`;
1657
+ function generateTokensCss(tokens, opts = {}) {
1658
+ const flat = flattenTokens(tokens);
1659
+ const head = `/* Generated by Brandrail from the ${opts.org ? `${opts.org} ` : ""}Brand Kit${opts.version ? ` v${opts.version}` : ""}. Do not edit: run \`npx brandrail init --from-api\` to update. */`;
1660
+ const light = flat.map((t) => ` ${cssVar(t.path)}: ${t.type === "fontFamily" ? t.value : t.value};`);
1661
+ const dark = flat.filter((t) => t.dark).map((t) => ` ${cssVar(t.path)}: ${t.dark};`);
1662
+ const out = [head, "", ":root {", ...light, "}"];
1663
+ if (dark.length) {
1664
+ out.push("", '[data-theme="dark"] {', ...dark, "}", "", "@media (prefers-color-scheme: dark) {", ' :root:not([data-theme="light"]) {', ...dark.map((l) => ` ${l}`), " }", "}");
1665
+ }
1666
+ return `${out.join("\n")}
1667
+ `;
1668
+ }
1669
+
1670
+ // src/api.ts
1671
+ var DEFAULT_API = "https://web-phi-nine-77.vercel.app";
1672
+ function apiBase() {
1673
+ return (process.env.BRANDRAIL_API_URL ?? DEFAULT_API).replace(/\/$/, "");
1674
+ }
1675
+ async function call(path, init) {
1676
+ const res = await fetch(`${apiBase()}${path}`, {
1677
+ ...init,
1678
+ headers: { "content-type": "application/json", authorization: `Bearer ${init.key}`, ...init.headers ?? {} }
1679
+ });
1680
+ const text = await res.text();
1681
+ let body = null;
1682
+ try {
1683
+ body = text ? JSON.parse(text) : null;
1684
+ } catch {
1685
+ }
1686
+ if (!res.ok) {
1687
+ const msg = body?.error ?? `${res.status} ${res.statusText}`;
1688
+ throw new Error(`Brandrail API ${path}: ${msg}`);
1689
+ }
1690
+ return body;
1691
+ }
1692
+ function fetchBrandKit(key) {
1693
+ return call("/api/v1/brand-kit", { method: "GET", key });
1694
+ }
1695
+ function uploadReport(key, payload) {
1696
+ const report = payload.uploadSnippets ? payload.report : { ...payload.report, violations: payload.report.violations.map(({ snippet: _snippet, ...v }) => v) };
1697
+ return call("/api/v1/scans", { method: "POST", key, body: JSON.stringify({ ...payload, report }) });
1698
+ }
1699
+
1700
+ // src/files.ts
1701
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1702
+ import { dirname, join, relative, resolve, sep } from "node:path";
1703
+ var CONFIG_FILE = "brandrail.config.json";
1704
+ var MAX_FILE_BYTES = 512 * 1024;
1705
+ function collectFiles(root, paths, ignoreGlobs) {
1706
+ const ignored = makeIgnore(ignoreGlobs);
1707
+ const out = [];
1708
+ const rel = (p) => relative(root, p).split(sep).join("/");
1709
+ const visit = (abs) => {
1710
+ const r = rel(abs);
1711
+ let st;
1712
+ try {
1713
+ st = statSync(abs);
1714
+ } catch {
1715
+ return;
1716
+ }
1717
+ if (st.isDirectory()) {
1718
+ const name = abs.split(sep).pop() ?? "";
1719
+ if (r && (ignored(`${r}/`) || ignored(`${r}/x`) || name.startsWith(".") && name !== "." && name !== ".github")) return;
1720
+ for (const entry of readdirSync(abs)) visit(join(abs, entry));
1721
+ return;
1722
+ }
1723
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return;
1724
+ if (fileKind(r) === "other" || ignored(r)) return;
1725
+ out.push({ path: r, content: readFileSync(abs, "utf8") });
1726
+ };
1727
+ for (const p of paths.length ? paths : ["."]) visit(resolve(root, p));
1728
+ return out;
1729
+ }
1730
+ function findConfig(start) {
1731
+ let dir = resolve(start);
1732
+ for (; ; ) {
1733
+ const candidate = join(dir, CONFIG_FILE);
1734
+ if (existsSync(candidate)) return candidate;
1735
+ const parent = dirname(dir);
1736
+ if (parent === dir) return null;
1737
+ dir = parent;
1738
+ }
1739
+ }
1740
+ function readConfig(path) {
1741
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1742
+ if (!parsed || typeof parsed !== "object" || !parsed.tokens) {
1743
+ throw new Error(`${path} has no "tokens". Run \`brandrail init\` to create one.`);
1744
+ }
1745
+ return parsed;
1746
+ }
1747
+
1748
+ // src/git.ts
1749
+ import { execFileSync } from "node:child_process";
1750
+ var git = (args, cwd) => {
1751
+ try {
1752
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1753
+ } catch {
1754
+ return null;
1755
+ }
1756
+ };
1757
+ function repoInfo(cwd) {
1758
+ const remote = git(["remote", "get-url", "origin"], cwd);
1759
+ const m = remote?.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
1760
+ const folder = cwd.split(/[\\/]/).pop() ?? "repo";
1761
+ return {
1762
+ slug: m?.[1] ?? remote ?? folder,
1763
+ branch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? void 0,
1764
+ sha: git(["rev-parse", "HEAD"], cwd) ?? void 0
1765
+ };
1766
+ }
1767
+ function parseChangedLines(diff) {
1768
+ const out = /* @__PURE__ */ new Map();
1769
+ let file = null;
1770
+ for (const line of diff.split("\n")) {
1771
+ if (line.startsWith("+++ ")) {
1772
+ const p = line.slice(4).trim();
1773
+ file = p === "/dev/null" ? null : p.replace(/^b\//, "");
1774
+ if (file && !out.has(file)) out.set(file, /* @__PURE__ */ new Set());
1775
+ continue;
1776
+ }
1777
+ const h = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
1778
+ if (h && file) {
1779
+ const start = Number(h[1]);
1780
+ const count = h[2] === void 0 ? 1 : Number(h[2]);
1781
+ const set = out.get(file);
1782
+ for (let i = 0; i < count; i++) set.add(start + i);
1783
+ }
1784
+ }
1785
+ return out;
1786
+ }
1787
+ function changedLines(cwd, base) {
1788
+ const mergeBase = git(["merge-base", base, "HEAD"], cwd) ?? base;
1789
+ const diff = git(["diff", "-U0", "--no-color", "--diff-filter=AMR", mergeBase], cwd);
1790
+ return diff === null ? null : parseChangedLines(diff);
1791
+ }
1792
+
1793
+ // src/scan.ts
1794
+ import { existsSync as existsSync2 } from "node:fs";
1795
+ import { createRequire } from "node:module";
1796
+ import { join as join2, resolve as resolve2 } from "node:path";
1797
+ function loadPlaywright(cwd) {
1798
+ for (const base of [join2(cwd, "package.json"), import.meta.url]) {
1799
+ for (const name of ["playwright", "playwright-core"]) {
1800
+ try {
1801
+ return createRequire(base)(name);
1802
+ } catch {
1803
+ }
1804
+ }
1805
+ }
1806
+ throw new Error("`brandrail scan` needs Playwright: npm i -D playwright && npx playwright install chromium");
1807
+ }
1808
+ async function scanPages(cwd, url, config, opts, log2) {
1809
+ const pw = loadPlaywright(cwd);
1810
+ const start = /^https?:\/\//i.test(url) ? url : `http://${url}`;
1811
+ const state = opts.storageState ? resolve2(cwd, opts.storageState) : void 0;
1812
+ if (state && !existsSync2(state)) throw new Error(`--storage-state file not found: ${state}`);
1813
+ const browser = await pw.chromium.launch({ headless: true });
1814
+ try {
1815
+ const context = await browser.newContext({ viewport: { width: opts.width, height: 900 }, storageState: state });
1816
+ const queue = [start];
1817
+ const seen = /* @__PURE__ */ new Set();
1818
+ const pages = [];
1819
+ while (queue.length && pages.length < opts.pages) {
1820
+ const next = queue.shift();
1821
+ const key = next.replace(/\/$/, "");
1822
+ if (seen.has(key)) continue;
1823
+ seen.add(key);
1824
+ const page = await context.newPage();
1825
+ try {
1826
+ const res = await page.goto(next, { waitUntil: "domcontentloaded", timeout: 3e4 });
1827
+ if (res && res.status() >= 400) {
1828
+ log2(` skipped ${next} (${res.status()})`);
1829
+ if (pages.length === 0) throw new Error(`${next} answered ${res.status()}`);
1830
+ continue;
1831
+ }
1832
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1833
+ });
1834
+ if (opts.waitMs) await page.waitForTimeout(opts.waitMs);
1835
+ const data = await page.evaluate(collectRenderedNodes, 4e3);
1836
+ pages.push({ url: page.url(), title: data.title, nodes: data.nodes });
1837
+ log2(` \u2714 ${page.url()} (${data.nodes.length} elements)`);
1838
+ if (pages.length === 1) queue.push(...data.links.filter((l) => l.startsWith(new URL(page.url()).origin)).sort((a, b) => a.length - b.length));
1839
+ } finally {
1840
+ await page.close().catch(() => {
1841
+ });
1842
+ }
1843
+ }
1844
+ return { report: checkRendered(pages, config), pages };
1845
+ } finally {
1846
+ await browser.close();
1847
+ }
1848
+ }
1849
+
1850
+ // src/index.ts
1851
+ var HELP = `brandrail ${ENGINE_VERSION} \u2014 check that UI follows your design system
1852
+
1853
+ Usage
1854
+ brandrail check [paths...] Scan files and print violations
1855
+ brandrail init Create brandrail.config.json and AI rule files
1856
+ brandrail scan <url> Check a running page (localhost, staging, behind a login) in a real browser
1857
+ brandrail import <file> Load tokens (DTCG, Tokens Studio, Figma variables JSON) into the config
1858
+ brandrail rules List the rules
1859
+
1860
+ Options for check
1861
+ --config <file> Config file (default: nearest brandrail.config.json)
1862
+ --format <fmt> pretty | json | markdown | github (default: pretty)
1863
+ --fail-below <score> Exit 1 when the brand score is lower (default: off)
1864
+ --changed [base] Only report lines changed vs base branch (default: origin/main)
1865
+ --upload Send the result to Brandrail (needs BRANDRAIL_KEY)
1866
+ --no-color Plain output
1867
+
1868
+ Options for init
1869
+ --from-api Pull the published Brand Kit (needs BRANDRAIL_KEY)
1870
+ --no-rules Don't write AGENTS.md (Codex), CLAUDE.md, Cursor, Copilot, Windsurf, Gemini rule files
1871
+ --mcp Also add the Brandrail MCP server to .mcp.json, .cursor/mcp.json, .vscode/mcp.json
1872
+ and print the Codex / Windsurf config
1873
+ --tokens-css <file> Where to write the CSS variables (default src/brandrail.tokens.css); --no-tokens-css to skip
1874
+ --force Overwrite an existing brandrail.config.json
1875
+
1876
+ Options for scan
1877
+ --pages <n> Also scan pages it links to, up to n in total (default 1, max 20)
1878
+ --storage-state <file> Playwright storage state with your login cookies
1879
+ --wait <ms> Extra wait after load, for client-rendered apps
1880
+ --width <px> Viewport width (default 1280)
1881
+ --format, --fail-below, --upload As for check
1882
+ Needs Playwright: npm i -D playwright && npx playwright install chromium
1883
+
1884
+ Options for import
1885
+ --replace Replace the config's tokens instead of merging
1886
+ --primitives Keep raw palette ramps (gray.100\u2026900) that semantic tokens point at
1887
+
1888
+ Environment
1889
+ BRANDRAIL_KEY API key from Dashboard \u2192 Connect
1890
+ BRANDRAIL_API_URL Brandrail URL (default ${apiBase()})
1891
+ `;
1892
+ function parseArgs(argv) {
1893
+ const [cmd = "help", ...rest] = argv;
1894
+ const positional = [];
1895
+ const flags = {};
1896
+ for (let i = 0; i < rest.length; i++) {
1897
+ const a = rest[i];
1898
+ if (a.startsWith("--no-")) flags[a.slice(5)] = false;
1899
+ else if (a.startsWith("--")) {
1900
+ const [k, v] = a.slice(2).split("=", 2);
1901
+ const next = rest[i + 1];
1902
+ if (v !== void 0) flags[k] = v;
1903
+ else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width", "tokens-css"].includes(k)) {
1904
+ flags[k] = next;
1905
+ i++;
1906
+ } else flags[k] = true;
1907
+ } else positional.push(a);
1908
+ }
1909
+ return { cmd, positional, flags };
1910
+ }
1911
+ var log = (s = "") => process.stderr.write(`${s}
1912
+ `);
1913
+ async function loadConfig(cwd, flags) {
1914
+ const explicit = typeof flags.config === "string" ? resolve3(cwd, flags.config) : null;
1915
+ const path = explicit ?? findConfig(cwd);
1916
+ if (path) return { config: readConfig(path), source: relative2(cwd, path) || CONFIG_FILE };
1917
+ const key = process.env.BRANDRAIL_KEY;
1918
+ if (key) {
1919
+ const { config } = await fetchBrandKit(key);
1920
+ return { config, source: `${apiBase()} (published Brand Kit)` };
1921
+ }
1922
+ throw new Error(`No ${CONFIG_FILE} found. Run \`npx brandrail init\` first, or set BRANDRAIL_KEY to use your published Brand Kit.`);
1923
+ }
1924
+ async function cmdCheck(cwd, positional, flags) {
1925
+ const { config, source } = await loadConfig(cwd, flags);
1926
+ const resolved = resolveConfig(config);
1927
+ const root = dirname2(typeof flags.config === "string" ? resolve3(cwd, flags.config) : findConfig(cwd) ?? join3(cwd, CONFIG_FILE));
1928
+ const files = collectFiles(root, positional.map((p) => relative2(root, resolve3(cwd, p))), resolved.ignore);
1929
+ let onlyLines;
1930
+ if (flags.changed) {
1931
+ const base = typeof flags.changed === "string" ? flags.changed : "origin/main";
1932
+ onlyLines = changedLines(root, base) ?? void 0;
1933
+ if (!onlyLines) log(`warning: couldn't read git diff against ${base}; checking everything`);
1934
+ }
1935
+ const report = check(files, resolved, { onlyLines });
1936
+ const format = typeof flags.format === "string" ? flags.format : "pretty";
1937
+ const threshold = flags["fail-below"] !== void 0 ? Number(flags["fail-below"]) : void 0;
1938
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}
1939
+ `);
1940
+ else if (format === "markdown") process.stdout.write(`${formatMarkdown(report, { threshold })}
1941
+ `);
1942
+ else if (format === "github") process.stdout.write(`${formatGithubAnnotations(report)}
1943
+ `);
1944
+ else {
1945
+ const color = flags.color !== false && process.stdout.isTTY !== false && !process.env.NO_COLOR;
1946
+ const kit = `Brand Kit: ${source}${config.tokenVersion ? ` v${config.tokenVersion}` : ""}`;
1947
+ log(color ? `\x1B[2m${kit}\x1B[0m` : kit);
1948
+ process.stdout.write(`${formatPretty(report, { color })}
1949
+ `);
1950
+ }
1951
+ if (flags.upload) {
1952
+ const key = process.env.BRANDRAIL_KEY;
1953
+ if (!key) log("warning: --upload needs BRANDRAIL_KEY; skipped");
1954
+ else {
1955
+ const repo = repoInfo(root);
1956
+ const res = await uploadReport(key, {
1957
+ source: process.env.CI ? "ci" : "cli",
1958
+ target: { kind: "repo", name: repo.slug.split("/").pop() ?? repo.slug, location: repo.slug },
1959
+ ref: repo.branch ?? repo.sha?.slice(0, 7),
1960
+ title: flags.changed ? `Changed lines on ${repo.branch ?? "HEAD"}` : `Local check on ${repo.branch ?? "HEAD"}`,
1961
+ threshold,
1962
+ report
1963
+ });
1964
+ log(`Uploaded \u2192 ${res.url ?? res.scanId}`);
1965
+ }
1966
+ }
1967
+ if (threshold !== void 0 && report.score < threshold) {
1968
+ log(`
1969
+ Brand score ${report.score} is below ${threshold}.`);
1970
+ return 1;
1971
+ }
1972
+ return 0;
1973
+ }
1974
+ function tokensCssPath(root, config, flags) {
1975
+ if (typeof flags["tokens-css"] === "string") return flags["tokens-css"];
1976
+ if (config.tokensCss) return config.tokensCss;
1977
+ return existsSync3(join3(root, "src")) ? "src/brandrail.tokens.css" : "brandrail.tokens.css";
1978
+ }
1979
+ function writeTokensCss(root, rel, config, org) {
1980
+ const abs = join3(root, rel);
1981
+ mkdirSync(dirname2(abs), { recursive: true });
1982
+ writeFileSync(abs, generateTokensCss(config.tokens, { version: config.tokenVersion, org }));
1983
+ }
1984
+ function writeRuleFiles(root, config, org, tokensCss) {
1985
+ const md = generateInstructions(config, { org, tokensCss });
1986
+ const written = [];
1987
+ const write = (rel, content) => {
1988
+ const abs = join3(root, rel);
1989
+ mkdirSync(dirname2(abs), { recursive: true });
1990
+ const before = existsSync3(abs) ? readFileSync2(abs, "utf8") : "";
1991
+ writeFileSync(abs, content(before));
1992
+ written.push(rel);
1993
+ };
1994
+ for (const { file, readBy } of RULE_FILES) {
1995
+ if (file.endsWith(".mdc")) {
1996
+ write(file, () => `---
1997
+ description: Design system rules from Brandrail
1998
+ globs: ["**/*.{tsx,jsx,vue,svelte,html,css,scss}"]
1999
+ alwaysApply: true
2000
+ ---
2001
+
2002
+ ${md}
2003
+ `);
2004
+ } else if (file.startsWith(".windsurf/")) {
2005
+ write(file, () => `---
2006
+ trigger: always_on
2007
+ ---
2008
+
2009
+ ${md}
2010
+ `);
2011
+ } else {
2012
+ write(file, (before) => upsertSection(before, md));
2013
+ }
2014
+ written[written.length - 1] = `${file} (${readBy})`;
2015
+ }
2016
+ return written;
2017
+ }
2018
+ function writeMcpConfigs(root) {
2019
+ const url = `${apiBase()}/api/mcp`;
2020
+ for (const c of mcpClientConfigs(url)) {
2021
+ if (!c.projectFile) continue;
2022
+ const abs = join3(root, c.file);
2023
+ mkdirSync(dirname2(abs), { recursive: true });
2024
+ const ours = JSON.parse(c.content);
2025
+ let merged = ours;
2026
+ if (existsSync3(abs)) {
2027
+ try {
2028
+ const existing = JSON.parse(readFileSync2(abs, "utf8"));
2029
+ merged = { ...existing };
2030
+ for (const [k, v] of Object.entries(ours)) {
2031
+ if (Array.isArray(v)) {
2032
+ const prev = Array.isArray(existing[k]) ? existing[k] : [];
2033
+ merged[k] = [...prev.filter((x) => !v.some((y) => y.id === x.id)), ...v];
2034
+ } else merged[k] = { ...existing[k] ?? {}, ...v };
2035
+ }
2036
+ } catch {
2037
+ log(`warning: ${c.file} isn't valid JSON; left unchanged`);
2038
+ continue;
2039
+ }
2040
+ }
2041
+ writeFileSync(abs, `${JSON.stringify(merged, null, 2)}
2042
+ `);
2043
+ log(`\u2714 added brandrail to ${c.file} (${c.name})`);
2044
+ }
2045
+ for (const c of mcpClientConfigs(url).filter((x) => !x.projectFile)) {
2046
+ log(`
2047
+ ${c.name}: ${c.command ? `run
2048
+ ${c.command}
2049
+ or add` : "add"} to ${c.file}:
2050
+ ${c.content.replace(/^/gm, " ")}`);
2051
+ }
2052
+ log("\nSet BRANDRAIL_KEY in your shell (an API key from Dashboard \u2192 Connect) before starting the AI tool.");
2053
+ }
2054
+ async function cmdInit(cwd, flags) {
2055
+ const target = join3(cwd, CONFIG_FILE);
2056
+ let config = starterConfig;
2057
+ let org;
2058
+ if (flags["from-api"]) {
2059
+ const key = process.env.BRANDRAIL_KEY;
2060
+ if (!key) throw new Error("--from-api needs BRANDRAIL_KEY (Dashboard \u2192 Connect \u2192 API keys).");
2061
+ const res = await fetchBrandKit(key);
2062
+ config = { $schema: starterConfig.$schema, ...res.config };
2063
+ org = res.org;
2064
+ }
2065
+ if (existsSync3(target) && !flags.force && !flags["from-api"]) {
2066
+ log(`${CONFIG_FILE} already exists (use --force to overwrite). Updating rule files from it.`);
2067
+ config = readConfig(target);
2068
+ org ??= config.org;
2069
+ } else {
2070
+ if (existsSync3(target)) {
2071
+ const prev = readConfig(target);
2072
+ config = { ...prev, ...config, rules: { ...prev.rules, ...config.rules }, ignore: prev.ignore ?? config.ignore, tokensCss: prev.tokensCss };
2073
+ }
2074
+ writeFileSync(target, `${JSON.stringify(config, null, 2)}
2075
+ `);
2076
+ log(`\u2714 wrote ${CONFIG_FILE}${org ? ` from ${org}'s Brand Kit` : " (starter tokens \u2014 edit them or use --from-api)"}`);
2077
+ }
2078
+ const css = flags["tokens-css"] === false ? void 0 : tokensCssPath(cwd, config, flags);
2079
+ if (css) {
2080
+ writeTokensCss(cwd, css, config, org);
2081
+ log(`\u2714 wrote ${css} (the Brand Kit as CSS variables \u2014 import it once at your app entry)`);
2082
+ }
2083
+ if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org, css)) log(`\u2714 updated ${f}`);
2084
+ if (flags.mcp) writeMcpConfigs(cwd);
2085
+ log("\nNext: npx brandrail check");
2086
+ return 0;
2087
+ }
2088
+ async function cmdScan(cwd, positional, flags) {
2089
+ const url = positional[0];
2090
+ if (!url) throw new Error("Usage: brandrail scan <url> [--pages 3] [--storage-state auth.json] [--upload]");
2091
+ const { config, source } = await loadConfig(cwd, flags);
2092
+ const num = (k, d, max) => typeof flags[k] === "string" ? Math.min(Math.max(Number(flags[k]) || d, 0), max) : d;
2093
+ log(`Scanning ${url} \u2026`);
2094
+ const { report, pages } = await scanPages(
2095
+ cwd,
2096
+ url,
2097
+ config,
2098
+ {
2099
+ pages: Math.max(1, num("pages", 1, 20)),
2100
+ storageState: typeof flags["storage-state"] === "string" ? flags["storage-state"] : void 0,
2101
+ waitMs: num("wait", 0, 3e4),
2102
+ width: num("width", 1280, 3840) || 1280
2103
+ },
2104
+ log
2105
+ );
2106
+ const format = typeof flags.format === "string" ? flags.format : "pretty";
2107
+ const threshold = flags["fail-below"] !== void 0 ? Number(flags["fail-below"]) : void 0;
2108
+ if (format === "json") process.stdout.write(`${JSON.stringify(report, null, 2)}
2109
+ `);
2110
+ else if (format === "markdown") process.stdout.write(`${formatMarkdown(report, { threshold })}
2111
+ `);
2112
+ else {
2113
+ const color = flags.color !== false && process.stdout.isTTY !== false && !process.env.NO_COLOR;
2114
+ log(color ? `\x1B[2mBrand Kit: ${source}\x1B[0m` : `Brand Kit: ${source}`);
2115
+ process.stdout.write(`${formatPretty(report, { color }).replace(/:0:0\b/g, "")}
2116
+ `);
2117
+ }
2118
+ if (flags.upload) {
2119
+ const key = process.env.BRANDRAIL_KEY;
2120
+ if (!key) log("warning: --upload needs BRANDRAIL_KEY; skipped");
2121
+ else {
2122
+ const first = new URL(pages[0].url);
2123
+ const res = await uploadReport(key, {
2124
+ source: process.env.CI ? "ci" : "cli",
2125
+ target: { kind: "url", name: first.host, location: `${first.origin}${first.pathname === "/" ? "" : first.pathname}` },
2126
+ ref: first.toString(),
2127
+ title: pages.length > 1 ? `${first.host} \xB7 ${pages.length} pages` : `${first.host}${first.pathname === "/" ? "" : first.pathname}`,
2128
+ threshold,
2129
+ report
2130
+ });
2131
+ log(`Uploaded \u2192 ${res.url ?? res.scanId}`);
2132
+ }
2133
+ }
2134
+ if (threshold !== void 0 && report.score < threshold) {
2135
+ log(`
2136
+ Brand score ${report.score} is below ${threshold}.`);
2137
+ return 1;
2138
+ }
2139
+ return 0;
2140
+ }
2141
+ function cmdImport(cwd, positional, flags) {
2142
+ const file = positional[0];
2143
+ if (!file) throw new Error("Usage: brandrail import <tokens.json> [--replace] [--primitives]");
2144
+ const raw = JSON.parse(readFileSync2(resolve3(cwd, file), "utf8"));
2145
+ const res = importTokens(raw, { includePrimitives: !!flags.primitives });
2146
+ const path = (typeof flags.config === "string" ? resolve3(cwd, flags.config) : findConfig(cwd)) ?? join3(cwd, CONFIG_FILE);
2147
+ const config = existsSync3(path) ? readConfig(path) : { ...starterConfig, tokens: {} };
2148
+ const next = flags.replace ? res.tokens : mergeTokens(config.tokens, res.tokens);
2149
+ const d = diffTokens(config.tokens, next);
2150
+ writeFileSync(path, `${JSON.stringify({ ...config, tokens: next }, null, 2)}
2151
+ `);
2152
+ log(`\u2714 ${res.count} tokens from ${file} (${res.format})${res.primitives ? `, ${res.primitives} palette primitives left out (--primitives to keep)` : ""}`);
2153
+ for (const n of res.notes) log(` \xB7 ${n}`);
2154
+ log(`\u2714 ${relative2(cwd, path) || CONFIG_FILE}: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed`);
2155
+ log("\nNext: npx brandrail init (to refresh AI rule files), then npx brandrail check");
2156
+ return 0;
2157
+ }
2158
+ function cmdRules() {
2159
+ for (const r of RULES) process.stdout.write(`${r.id.padEnd(28)} ${r.defaultSeverity.padEnd(8)} ${r.description}
2160
+ `);
2161
+ return 0;
2162
+ }
2163
+ async function main(argv = process.argv.slice(2)) {
2164
+ const { cmd, positional, flags } = parseArgs(argv);
2165
+ const cwd = process.cwd();
2166
+ if (flags.version || cmd === "--version" || cmd === "-v") {
2167
+ process.stdout.write(`${ENGINE_VERSION}
2168
+ `);
2169
+ return 0;
2170
+ }
2171
+ switch (cmd) {
2172
+ case "check":
2173
+ return cmdCheck(cwd, positional, flags);
2174
+ case "init":
2175
+ return cmdInit(cwd, flags);
2176
+ case "scan":
2177
+ return cmdScan(cwd, positional, flags);
2178
+ case "import":
2179
+ return cmdImport(cwd, positional, flags);
2180
+ case "rules":
2181
+ return cmdRules();
2182
+ default:
2183
+ process.stdout.write(HELP);
2184
+ return cmd === "help" || cmd === "--help" || cmd === "-h" ? 0 : 1;
2185
+ }
2186
+ }
2187
+ main().then(
2188
+ (code) => process.exit(code),
2189
+ (err) => {
2190
+ log(`brandrail: ${err.message}`);
2191
+ process.exit(2);
2192
+ }
2193
+ );
2194
+ export {
2195
+ main
2196
+ };