brandrail 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brandrail.mjs +322 -28
- package/package.json +1 -1
package/dist/brandrail.mjs
CHANGED
|
@@ -701,13 +701,100 @@ function resolveConfig(config) {
|
|
|
701
701
|
rules: config.rules ?? {},
|
|
702
702
|
ignore: [...DEFAULT_IGNORE, ...config.ignore ?? []],
|
|
703
703
|
forbiddenImports: config.forbiddenImports ?? DEFAULT_FORBIDDEN_IMPORTS,
|
|
704
|
-
colorTolerance: config.colorTolerance ?? 2.5
|
|
704
|
+
colorTolerance: config.colorTolerance ?? 2.5,
|
|
705
|
+
framework: config.framework
|
|
705
706
|
};
|
|
706
707
|
}
|
|
707
708
|
|
|
709
|
+
// ../engine/src/frameworks.ts
|
|
710
|
+
var FRAMEWORKS = ["react", "angular", "vue", "web-components", "html"];
|
|
711
|
+
var FRAMEWORK_LABEL = {
|
|
712
|
+
react: "React",
|
|
713
|
+
angular: "Angular",
|
|
714
|
+
vue: "Vue",
|
|
715
|
+
"web-components": "Web Components",
|
|
716
|
+
html: "Plain HTML"
|
|
717
|
+
};
|
|
718
|
+
var LANG = { react: "tsx", angular: "html", vue: "vue", "web-components": "html", html: "html" };
|
|
719
|
+
var isFramework = (x2) => typeof x2 === "string" && FRAMEWORKS.includes(x2);
|
|
720
|
+
var kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/\./g, "-").toLowerCase();
|
|
721
|
+
var prefixOf = (tag) => tag.includes("-") ? `${tag.split("-")[0]}-` : tag;
|
|
722
|
+
function webComponentSetup(target, u, tag) {
|
|
723
|
+
const load = u.importPath ? `Load it once with \`import "${u.importPath}";\` at the app entry.` : "";
|
|
724
|
+
switch (target) {
|
|
725
|
+
case "angular":
|
|
726
|
+
return `Web component: add \`CUSTOM_ELEMENTS_SCHEMA\` to the component's \`schemas\`. ${load}`.trim();
|
|
727
|
+
case "vue":
|
|
728
|
+
return `Web component: set \`compilerOptions.isCustomElement = (tag) => tag.startsWith("${prefixOf(tag)}")\` in the Vue config. ${load}`.trim();
|
|
729
|
+
case "html":
|
|
730
|
+
return u.importPath ? `Web component: load \`${u.importPath}\` once (a bundler import or \`<script type="module">\`).` : void 0;
|
|
731
|
+
default:
|
|
732
|
+
return u.setup ?? (load || void 0);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function resolveUsage(c, framework) {
|
|
736
|
+
if (framework === "react") {
|
|
737
|
+
return {
|
|
738
|
+
framework,
|
|
739
|
+
importLine: `import { ${c.name} } from "${c.importPath}";`,
|
|
740
|
+
tag: c.name,
|
|
741
|
+
example: c.example,
|
|
742
|
+
lang: LANG.react,
|
|
743
|
+
props: c.props
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
const own = c.frameworks?.[framework];
|
|
747
|
+
if (own) return fromUsage(c, framework, framework, own);
|
|
748
|
+
const wc = c.frameworks?.["web-components"];
|
|
749
|
+
if (wc) {
|
|
750
|
+
const r = fromUsage(c, framework, "web-components", wc);
|
|
751
|
+
return { ...r, via: "web-components", setup: webComponentSetup(framework, wc, r.tag), importLine: framework === "html" ? void 0 : r.importLine };
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
function fromUsage(c, framework, kind, u) {
|
|
756
|
+
const path = u.importPath?.trim();
|
|
757
|
+
let tag = u.tag?.trim();
|
|
758
|
+
let importLine;
|
|
759
|
+
let setup = u.setup?.trim() || void 0;
|
|
760
|
+
switch (kind) {
|
|
761
|
+
case "angular":
|
|
762
|
+
tag ||= kebab(c.name);
|
|
763
|
+
if (path && u.importName) {
|
|
764
|
+
importLine = `import { ${u.importName} } from "${path}";`;
|
|
765
|
+
setup ??= `Add \`${u.importName}\` to the standalone component's \`imports\` (or the NgModule's).`;
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
case "vue":
|
|
769
|
+
tag ||= u.importName || c.name;
|
|
770
|
+
if (path) importLine = `import { ${u.importName || tag} } from "${path}";`;
|
|
771
|
+
break;
|
|
772
|
+
case "web-components":
|
|
773
|
+
tag ||= kebab(c.name);
|
|
774
|
+
if (path) importLine = `import "${path}";`;
|
|
775
|
+
break;
|
|
776
|
+
case "html":
|
|
777
|
+
tag ||= c.replaces?.[0]?.replace(/\[.*$/, "") || "div";
|
|
778
|
+
if (path && !setup) setup = /\.css(\?|$)/.test(path) ? `Include \`<link rel="stylesheet" href="${path}">\` once.` : /\.m?js(\?|$)/.test(path) ? `Include \`<script type="module" src="${path}"></script>\` once.` : void 0;
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
return { framework, importLine, tag, setup, example: u.example?.trim() || void 0, lang: LANG[kind], props: u.props?.length ? u.props : void 0 };
|
|
782
|
+
}
|
|
783
|
+
var markup = (u) => `<${u.tag}>`;
|
|
784
|
+
function detectFramework(pkg) {
|
|
785
|
+
if (!pkg) return "html";
|
|
786
|
+
const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
787
|
+
const has = (name) => name in deps;
|
|
788
|
+
if (has("@angular/core")) return "angular";
|
|
789
|
+
if (has("vue") || has("nuxt")) return "vue";
|
|
790
|
+
if (has("react") || has("next") || has("preact")) return "react";
|
|
791
|
+
if (has("svelte") || has("lit") || has("@stencil/core") || has("@sveltejs/kit")) return "web-components";
|
|
792
|
+
return "html";
|
|
793
|
+
}
|
|
794
|
+
|
|
708
795
|
// ../engine/src/rules.ts
|
|
709
|
-
var MARKUP = ["jsx", "vue", "svelte", "html"];
|
|
710
|
-
var ALL = ["jsx", "vue", "svelte", "html", "css"];
|
|
796
|
+
var MARKUP = ["jsx", "vue", "svelte", "html", "template"];
|
|
797
|
+
var ALL = ["jsx", "vue", "svelte", "html", "template", "css"];
|
|
711
798
|
function* matches(re, text) {
|
|
712
799
|
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
713
800
|
let m;
|
|
@@ -740,6 +827,38 @@ function pxValues(value) {
|
|
|
740
827
|
}
|
|
741
828
|
return out;
|
|
742
829
|
}
|
|
830
|
+
var kebab2 = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
831
|
+
function styleBindings(code) {
|
|
832
|
+
const out = [];
|
|
833
|
+
const withUnit = (v, unit) => unit && /^-?\d*\.?\d+$/.test(v.trim()) ? `${v.trim()}${unit === "%" ? "%" : unit}` : v;
|
|
834
|
+
for (const m of matches(/(['"])([a-zA-Z-]+)(?:\.(px|rem|em|%))?\1\s*:\s*(?:(['"`])([^'"`\n]*)\4|(-?\d*\.?\d+)(?![\w.]))/g, code)) {
|
|
835
|
+
const value = m[5] ?? m[6];
|
|
836
|
+
const offset = m.index + m[0].length - value.length - (m[5] !== void 0 ? 1 : 0);
|
|
837
|
+
out.push({ prop: kebab2(m[2]), value: withUnit(value, m[3]), offset });
|
|
838
|
+
}
|
|
839
|
+
for (const m of matches(/(?<![\w.'"-])((?:padding|margin)(?:Top|Right|Bottom|Left|Inline|Block)(?:Start|End)?|rowGap|columnGap|border(?:Top|Bottom)?(?:Left|Right)?Radius)\s*:\s*(['"`])([^'"`\n]*\d(?:px|rem)[^'"`\n]*)\2/g, code)) {
|
|
840
|
+
out.push({ prop: kebab2(m[1]), value: m[3], offset: m.index + m[0].length - m[3].length - 1 });
|
|
841
|
+
}
|
|
842
|
+
for (const m of matches(/\[style\.([a-zA-Z-]+)(?:\.(px|rem|em|%))?\]\s*=\s*(["'])([\s\S]*?)\3/g, code)) {
|
|
843
|
+
const inner = m[4].trim();
|
|
844
|
+
const lit = /^(['"`])(.*)\1$/.exec(inner)?.[2] ?? (/^-?\d*\.?\d+$/.test(inner) ? inner : null);
|
|
845
|
+
if (lit === null) continue;
|
|
846
|
+
out.push({ prop: kebab2(m[1]), value: withUnit(lit, m[2]), offset: m.index + m[0].length - 1 - m[4].length + m[4].indexOf(inner) });
|
|
847
|
+
}
|
|
848
|
+
return out;
|
|
849
|
+
}
|
|
850
|
+
function declValue(v, kind) {
|
|
851
|
+
if (kind === "css") return v;
|
|
852
|
+
const q = v[0];
|
|
853
|
+
if (q === '"' || q === "'" || q === "`") {
|
|
854
|
+
const end = v.indexOf(q, 1);
|
|
855
|
+
return end === -1 ? v : v.slice(0, end + 1);
|
|
856
|
+
}
|
|
857
|
+
const cut = v.search(/["'`>]/);
|
|
858
|
+
return cut === -1 ? v : v.slice(0, cut);
|
|
859
|
+
}
|
|
860
|
+
var SPACING_PROP = /^(?:(?:padding|margin)(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?|gap|row-gap|column-gap)$/;
|
|
861
|
+
var RADIUS_PROP = /^border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius$/;
|
|
743
862
|
var noHardcodedColor = {
|
|
744
863
|
id: "no-hardcoded-color",
|
|
745
864
|
title: "Hard-coded colour",
|
|
@@ -825,7 +944,7 @@ var spacingScale = {
|
|
|
825
944
|
const ok = (n) => scale.some((s) => Math.abs(s - n) < 0.01);
|
|
826
945
|
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)) {
|
|
827
946
|
const valueStart = m.index + m[0].length - m[2].length;
|
|
828
|
-
for (const v of pxValues(m[2])) {
|
|
947
|
+
for (const v of pxValues(declValue(m[2], kind))) {
|
|
829
948
|
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)}` });
|
|
830
949
|
}
|
|
831
950
|
}
|
|
@@ -838,6 +957,12 @@ var spacingScale = {
|
|
|
838
957
|
const n = Math.abs(parseFloat(m[2])) * (m[3] === "rem" ? 16 : 1);
|
|
839
958
|
if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
|
|
840
959
|
}
|
|
960
|
+
for (const b of styleBindings(code)) {
|
|
961
|
+
if (!SPACING_PROP.test(b.prop)) continue;
|
|
962
|
+
for (const v of pxValues(b.value)) {
|
|
963
|
+
if (!ok(v.n)) out.push({ offset: b.offset + Math.min(v.index, b.value.length), message: `${b.prop} ${fmt(v.n)} is off the spacing scale`, suggestion: `Use ${neighbours(scale, v.n)}` });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
841
966
|
}
|
|
842
967
|
return out;
|
|
843
968
|
}
|
|
@@ -853,7 +978,7 @@ var radiusScale = {
|
|
|
853
978
|
const ok = (n) => n >= 999 || config.radii.some((r) => Math.abs(r - n) < 0.01);
|
|
854
979
|
for (const m of matches(/(?<![\w-])border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius\s*:\s*([^;{}\n]+)/g, code)) {
|
|
855
980
|
const valueStart = m.index + m[0].length - m[1].length;
|
|
856
|
-
for (const v of pxValues(m[1])) {
|
|
981
|
+
for (const v of pxValues(declValue(m[1], kind))) {
|
|
857
982
|
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)}` });
|
|
858
983
|
}
|
|
859
984
|
}
|
|
@@ -866,6 +991,12 @@ var radiusScale = {
|
|
|
866
991
|
const n = parseFloat(m[1]);
|
|
867
992
|
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)}` });
|
|
868
993
|
}
|
|
994
|
+
for (const b of styleBindings(code)) {
|
|
995
|
+
if (!RADIUS_PROP.test(b.prop)) continue;
|
|
996
|
+
for (const v of pxValues(b.value)) {
|
|
997
|
+
if (!ok(v.n)) out.push({ offset: b.offset, message: `${b.prop} ${fmt(v.n)} is not a radius token`, suggestion: `Use ${neighbours(config.radii.filter((r) => r < 999), v.n)}` });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
869
1000
|
}
|
|
870
1001
|
return out;
|
|
871
1002
|
}
|
|
@@ -889,10 +1020,11 @@ var fontFamily = {
|
|
|
889
1020
|
suggestion: `Use var(--font-family-sans) (${allowed[0]})`
|
|
890
1021
|
});
|
|
891
1022
|
};
|
|
892
|
-
for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(m[1], m.index);
|
|
1023
|
+
for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(declValue(m[1], kind), m.index);
|
|
893
1024
|
if (kind !== "css") {
|
|
894
1025
|
for (const m of matches(/(?<![\w-])fontFamily\s*:\s*(['"`])([^'"`]+)\1/g, code)) test(m[2], m.index);
|
|
895
1026
|
for (const m of matches(/(?<![\w-])font-\[([^\]]+)\]/g, code)) test(m[1].replace(/_/g, " "), m.index);
|
|
1027
|
+
for (const b of styleBindings(code)) if (b.prop === "font-family") test(b.value, b.offset);
|
|
896
1028
|
}
|
|
897
1029
|
return out;
|
|
898
1030
|
}
|
|
@@ -904,7 +1036,7 @@ var preferOfficialComponent = {
|
|
|
904
1036
|
description: "Use the design system component instead of a raw HTML element.",
|
|
905
1037
|
defaultSeverity: "error",
|
|
906
1038
|
kinds: MARKUP,
|
|
907
|
-
check({ code, config }) {
|
|
1039
|
+
check({ code, config, framework }) {
|
|
908
1040
|
if (config.replaces.size === 0) return [];
|
|
909
1041
|
const out = [];
|
|
910
1042
|
const baseTags = [...new Set([...config.replaces.keys()].map((k) => k.replace(/\[.*$/, "").replace(/[^a-z0-9-]/g, "")))].filter(Boolean);
|
|
@@ -919,16 +1051,28 @@ var preferOfficialComponent = {
|
|
|
919
1051
|
else if (tag === "input" && !TEXT_INPUT_TYPES.has(type)) comp = type === "submit" || type === "button" ? config.replaces.get("button") : void 0;
|
|
920
1052
|
}
|
|
921
1053
|
if (!comp) continue;
|
|
1054
|
+
const usage = resolveUsage(comp, framework);
|
|
1055
|
+
if (!usage) continue;
|
|
922
1056
|
if (new RegExp(`(?:function|const|class)\\s+${comp.name}\\b`).test(code)) continue;
|
|
1057
|
+
if (framework !== "react" && implementsTag(code, usage.tag)) continue;
|
|
923
1058
|
out.push({
|
|
924
1059
|
offset: m.index,
|
|
925
1060
|
message: `Raw <${tag}${tag === "input" && /type=/.test(attrs) ? ` type="${attrs.match(/type\s*=\s*\{?\s*["'`]([\w-]+)/)?.[1]}"` : ""}> re-implements ${comp.name}`,
|
|
926
|
-
suggestion:
|
|
1061
|
+
suggestion: useSuggestion(comp, usage)
|
|
927
1062
|
});
|
|
928
1063
|
}
|
|
929
1064
|
return out;
|
|
930
1065
|
}
|
|
931
1066
|
};
|
|
1067
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1068
|
+
var implementsTag = (code, tag) => new RegExp(`selector\\s*:\\s*['"\`]${escapeRe(tag)}['"\`]|customElements\\.define\\(\\s*['"\`]${escapeRe(tag)}['"\`]|@customElement\\(\\s*['"\`]${escapeRe(tag)}['"\`]`).test(code);
|
|
1069
|
+
function useSuggestion(comp, u) {
|
|
1070
|
+
if (u.framework === "react") return `import { ${comp.name} } from "${comp.importPath}"`;
|
|
1071
|
+
const parts = [`Use <${u.tag}>`];
|
|
1072
|
+
if (u.importLine) parts.push(`(${u.importLine})`);
|
|
1073
|
+
if (u.setup && u.via) parts.push(`\u2014 ${u.setup}`);
|
|
1074
|
+
return parts.join(" ");
|
|
1075
|
+
}
|
|
932
1076
|
var forbiddenImport = {
|
|
933
1077
|
id: "forbidden-import",
|
|
934
1078
|
title: "Other UI library",
|
|
@@ -1033,13 +1177,96 @@ function parseAttributes(code, start) {
|
|
|
1033
1177
|
}
|
|
1034
1178
|
return null;
|
|
1035
1179
|
}
|
|
1180
|
+
function parseHtmlAttributes(code, start) {
|
|
1181
|
+
const attrs = [];
|
|
1182
|
+
let i2 = start;
|
|
1183
|
+
while (i2 < code.length) {
|
|
1184
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1185
|
+
const c = code[i2];
|
|
1186
|
+
if (c === void 0) return null;
|
|
1187
|
+
if (c === ">" || c === "/" && code[i2 + 1] === ">") return { attrs, end: i2 };
|
|
1188
|
+
if (c === "{" || c === "<" && code[i2 + 1] === "?") {
|
|
1189
|
+
const close = c === "{" ? code[i2 + 1] === "%" ? "%}" : "}}" : "?>";
|
|
1190
|
+
const e = code.indexOf(close, i2 + 2);
|
|
1191
|
+
i2 = e === -1 ? code.length : e + close.length;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const name = /^[^\s=>/"'{]+/.exec(code.slice(i2, i2 + 120))?.[0];
|
|
1195
|
+
if (!name) return null;
|
|
1196
|
+
attrs.push({ name, offset: i2 });
|
|
1197
|
+
i2 += name.length;
|
|
1198
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1199
|
+
if (code[i2] === "=") {
|
|
1200
|
+
i2++;
|
|
1201
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1202
|
+
const q = code[i2];
|
|
1203
|
+
if (q === '"' || q === "'") {
|
|
1204
|
+
const e = code.indexOf(q, i2 + 1);
|
|
1205
|
+
i2 = e === -1 ? code.length : e + 1;
|
|
1206
|
+
} else while (i2 < code.length && !/[\s>]/.test(code[i2])) i2++;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
var HTML_GLOBAL = /^(?:id|class|style|title|role|hidden|tabindex|lang|dir|slot|part|is|key|ref|name|for|form|aria-.+|data-.+)$/;
|
|
1212
|
+
function propName(raw) {
|
|
1213
|
+
let n = raw;
|
|
1214
|
+
if (/^(?:\(|@|on-|v-on:|\*|#|let-|ref-|v-(?!bind:|model:))/.test(n)) return null;
|
|
1215
|
+
n = n.replace(/^v-bind:|^v-model:|^bind-|^:/, "").replace(/^\[\(?|\)?\]$/g, "");
|
|
1216
|
+
if (/^(?:attr|class|style)\./.test(n) || /^ng[A-Z]/.test(n) || /^(?:formControl|formControlName|formGroup|routerLink|routerLinkActive)$/.test(n)) return null;
|
|
1217
|
+
n = n.replace(/\.(?:px|rem|em|%)$/, "").replace(/\..*$/, "");
|
|
1218
|
+
if (HTML_GLOBAL.test(n.toLowerCase())) return null;
|
|
1219
|
+
return n;
|
|
1220
|
+
}
|
|
1221
|
+
var sameName = (a, b) => a.replace(/-/g, "").toLowerCase() === b.replace(/-/g, "").toLowerCase();
|
|
1222
|
+
function templateProps({ code, config, framework }) {
|
|
1223
|
+
const tagged = [];
|
|
1224
|
+
for (const comp of config.components) {
|
|
1225
|
+
const u = resolveUsage(comp, framework);
|
|
1226
|
+
const props = u?.props;
|
|
1227
|
+
if (u && props?.length) tagged.push({ comp, tag: u.tag, props });
|
|
1228
|
+
}
|
|
1229
|
+
if (!tagged.length) return [];
|
|
1230
|
+
const out = [];
|
|
1231
|
+
const forms = /* @__PURE__ */ new Map();
|
|
1232
|
+
for (const t of tagged) {
|
|
1233
|
+
forms.set(t.tag, t);
|
|
1234
|
+
if (framework === "vue") forms.set(t.tag.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), t);
|
|
1235
|
+
}
|
|
1236
|
+
const names = [...forms.keys()].sort((a, b) => b.length - a.length).map(escapeRe).join("|");
|
|
1237
|
+
for (const m of matches(new RegExp(`<(${names})(?=[\\s/>])`, "g"), code)) {
|
|
1238
|
+
const t = forms.get(m[1]);
|
|
1239
|
+
const parsed = parseHtmlAttributes(code, m.index + m[0].length);
|
|
1240
|
+
if (!parsed) continue;
|
|
1241
|
+
const list = t.props.map((p) => p.required ? `${p.name} (required)` : p.name).join(", ");
|
|
1242
|
+
const passed = [];
|
|
1243
|
+
for (const a of parsed.attrs) {
|
|
1244
|
+
const raw = a.name;
|
|
1245
|
+
if (/^v-model$/.test(raw)) passed.push("modelValue", "value");
|
|
1246
|
+
if (/^\[\(ngModel\)\]$/.test(raw)) passed.push("value");
|
|
1247
|
+
const n = propName(raw);
|
|
1248
|
+
if (!n) continue;
|
|
1249
|
+
passed.push(n);
|
|
1250
|
+
if (t.props.some((p) => sameName(p.name, n))) continue;
|
|
1251
|
+
out.push({ offset: a.offset, message: `<${t.tag}> has no "${n}" input`, suggestion: `Remove it. ${t.comp.name} takes: ${list}` });
|
|
1252
|
+
}
|
|
1253
|
+
for (const p of t.props) {
|
|
1254
|
+
if (!p.required || passed.some((n) => sameName(n, p.name))) continue;
|
|
1255
|
+
if (p.name === "children" || p.name === "content") continue;
|
|
1256
|
+
out.push({ offset: m.index, message: `<${t.tag}> is missing the required "${p.name}" input`, suggestion: `Add ${p.name}${p.type ? ` (${p.type})` : ""}` });
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return out;
|
|
1260
|
+
}
|
|
1036
1261
|
var componentProps = {
|
|
1037
1262
|
id: "component-props",
|
|
1038
1263
|
title: "Unknown or missing prop",
|
|
1039
1264
|
description: "Design system components only accept their documented props, and required props must be passed.",
|
|
1040
1265
|
defaultSeverity: "error",
|
|
1041
|
-
kinds:
|
|
1042
|
-
check(
|
|
1266
|
+
kinds: MARKUP,
|
|
1267
|
+
check(ctx) {
|
|
1268
|
+
if (ctx.framework !== "react" || ctx.kind !== "jsx") return templateProps(ctx);
|
|
1269
|
+
const { code, config } = ctx;
|
|
1043
1270
|
if (!config.components.some((c) => c.props?.length)) return [];
|
|
1044
1271
|
const imported = importedComponents(code, config.components);
|
|
1045
1272
|
if (imported.size === 0) return [];
|
|
@@ -1089,8 +1316,10 @@ var RULES = [
|
|
|
1089
1316
|
var RULE_IDS = RULES.map((r) => r.id);
|
|
1090
1317
|
|
|
1091
1318
|
// ../engine/src/source.ts
|
|
1319
|
+
var TEMPLATE_EXTENSIONS = ["mustache", "hbs", "handlebars", "php", "phtml", "twig", "njk", "liquid"];
|
|
1092
1320
|
function fileKind(path) {
|
|
1093
1321
|
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
1322
|
+
if (TEMPLATE_EXTENSIONS.includes(ext)) return "template";
|
|
1094
1323
|
if (["tsx", "jsx", "ts", "js", "mjs", "cjs", "mdx"].includes(ext)) return "jsx";
|
|
1095
1324
|
if (ext === "vue") return "vue";
|
|
1096
1325
|
if (ext === "svelte") return "svelte";
|
|
@@ -1098,13 +1327,41 @@ function fileKind(path) {
|
|
|
1098
1327
|
if (["css", "scss", "sass", "less", "pcss"].includes(ext)) return "css";
|
|
1099
1328
|
return "other";
|
|
1100
1329
|
}
|
|
1101
|
-
var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "css"]);
|
|
1330
|
+
var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "template", "css"]);
|
|
1331
|
+
var ANGULAR_COMPONENT = /@Component\s*\(\s*\{/;
|
|
1332
|
+
var ANGULAR_TEMPLATE = /\s(?:\[[\w.()-]+\]|\([\w.]+\)|\*ng[A-Z]\w*)\s*=|@(?:if|for|switch)\s*\(/;
|
|
1333
|
+
function fileFramework(path, kind, code, project) {
|
|
1334
|
+
const p = path.toLowerCase();
|
|
1335
|
+
switch (kind) {
|
|
1336
|
+
case "vue":
|
|
1337
|
+
return "vue";
|
|
1338
|
+
case "svelte":
|
|
1339
|
+
return "web-components";
|
|
1340
|
+
case "template":
|
|
1341
|
+
return "html";
|
|
1342
|
+
case "css":
|
|
1343
|
+
return project ?? "react";
|
|
1344
|
+
case "html":
|
|
1345
|
+
if (p.endsWith(".component.html") || project === "angular" || ANGULAR_TEMPLATE.test(code)) return "angular";
|
|
1346
|
+
return project === "vue" || project === "web-components" ? project : "html";
|
|
1347
|
+
case "jsx":
|
|
1348
|
+
if (ANGULAR_COMPONENT.test(code)) return "angular";
|
|
1349
|
+
if (/\.(tsx|jsx|mdx)$/.test(p)) return "react";
|
|
1350
|
+
return project ?? "react";
|
|
1351
|
+
default:
|
|
1352
|
+
return project ?? "react";
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1102
1355
|
var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
1103
1356
|
function stripComments(code, kind) {
|
|
1104
1357
|
let out = code;
|
|
1105
|
-
if (kind === "html" || kind === "vue" || kind === "svelte") {
|
|
1358
|
+
if (kind === "html" || kind === "vue" || kind === "svelte" || kind === "template") {
|
|
1106
1359
|
out = out.replace(/<!--[\s\S]*?-->/g, blank);
|
|
1107
1360
|
}
|
|
1361
|
+
if (kind === "template") {
|
|
1362
|
+
out = out.replace(/\{\{!--[\s\S]*?--\}\}|\{\{![\s\S]*?\}\}|\{\{--[\s\S]*?--\}\}|\{#[\s\S]*?#\}|\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}/g, blank).replace(/<\?(?:php|=)?[\s\S]*?(?:\?>|$)/g, (block) => block.replace(/\/\*[\s\S]*?\*\/|(?<![:"'])\/\/[^\n]*?(?=\?>|\n|$)|(?<=\s)#[^\n]*?(?=\?>|\n|$)/g, blank));
|
|
1363
|
+
return out;
|
|
1364
|
+
}
|
|
1108
1365
|
if (kind === "css") {
|
|
1109
1366
|
return out.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + blank(m.slice(p1.length)));
|
|
1110
1367
|
}
|
|
@@ -1196,7 +1453,7 @@ function makeIgnore(globs) {
|
|
|
1196
1453
|
}
|
|
1197
1454
|
|
|
1198
1455
|
// ../engine/src/check.ts
|
|
1199
|
-
var ENGINE_VERSION = "0.
|
|
1456
|
+
var ENGINE_VERSION = "0.4.0";
|
|
1200
1457
|
var WEIGHT = { error: 4, warning: 1.5, info: 0.25 };
|
|
1201
1458
|
function computeScore(counts, lines) {
|
|
1202
1459
|
const penalty = counts.error * WEIGHT.error + counts.warning * WEIGHT.warning + counts.info * WEIGHT.info;
|
|
@@ -1221,11 +1478,12 @@ function check(files, configInput, opts = {}) {
|
|
|
1221
1478
|
const idx = new LineIndex(file.content);
|
|
1222
1479
|
linesScanned += only ? only.size : file.content.split("\n").filter((l) => l.trim()).length;
|
|
1223
1480
|
const code = stripComments(file.content, kind);
|
|
1481
|
+
const framework = fileFramework(path, kind, code, config.framework);
|
|
1224
1482
|
const seen = /* @__PURE__ */ new Set();
|
|
1225
1483
|
for (const rule of RULES) {
|
|
1226
1484
|
const setting = config.rules[rule.id];
|
|
1227
1485
|
if (setting === "off" || !rule.kinds.includes(kind)) continue;
|
|
1228
|
-
for (const f of rule.check({ path, kind, code, config })) {
|
|
1486
|
+
for (const f of rule.check({ path, kind, code, config, framework })) {
|
|
1229
1487
|
const { line, column } = idx.position(f.offset);
|
|
1230
1488
|
if (only && !only.has(line)) continue;
|
|
1231
1489
|
const key = `${rule.id}:${line}:${column}`;
|
|
@@ -1982,6 +2240,7 @@ var RULES_START = "<!-- brandrail:start -->";
|
|
|
1982
2240
|
var RULES_END = "<!-- brandrail:end -->";
|
|
1983
2241
|
function generateInstructions(config, opts = {}) {
|
|
1984
2242
|
const r = resolveConfig(config);
|
|
2243
|
+
const fw = opts.framework ?? config.framework ?? "react";
|
|
1985
2244
|
const lines = [];
|
|
1986
2245
|
lines.push(`# Design system rules${opts.org ? ` \u2014 ${opts.org}` : ""}`);
|
|
1987
2246
|
lines.push("");
|
|
@@ -1989,7 +2248,7 @@ function generateInstructions(config, opts = {}) {
|
|
|
1989
2248
|
`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.`
|
|
1990
2249
|
);
|
|
1991
2250
|
lines.push("");
|
|
1992
|
-
const usesKit = r.components.some((c) => c.importPath === KIT_PACKAGE);
|
|
2251
|
+
const usesKit = fw === "react" && r.components.some((c) => c.importPath === KIT_PACKAGE);
|
|
1993
2252
|
if (opts.tokensCss || usesKit) {
|
|
1994
2253
|
lines.push("## Setup");
|
|
1995
2254
|
if (opts.tokensCss) {
|
|
@@ -2001,17 +2260,29 @@ function generateInstructions(config, opts = {}) {
|
|
|
2001
2260
|
lines.push("");
|
|
2002
2261
|
}
|
|
2003
2262
|
if (r.components.length) {
|
|
2263
|
+
const usable = r.components.map((c) => ({ c, u: resolveUsage(c, fw) }));
|
|
2264
|
+
const here = usable.filter((x2) => x2.u);
|
|
2265
|
+
const missing = usable.filter((x2) => !x2.u).map((x2) => x2.c.name);
|
|
2004
2266
|
lines.push("## Components");
|
|
2005
|
-
lines.push("
|
|
2006
|
-
|
|
2267
|
+
if (fw !== "react") lines.push(`This is a${fw === "angular" ? "n" : ""} ${FRAMEWORK_LABEL[fw]} project: use the ${FRAMEWORK_LABEL[fw]} form of each component shown here.`);
|
|
2268
|
+
if (here.length) lines.push("Always use these components. Never re-create them with raw HTML elements:");
|
|
2269
|
+
for (const { c, u } of here) {
|
|
2007
2270
|
const tags = [...r.replaces].filter(([, v]) => v === c).map(([t]) => `<${t}>`);
|
|
2008
|
-
const props =
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2271
|
+
const props = u.props?.length ? ` \u2014 props: ${u.props.map((p) => p.required ? `${p.name}*` : p.name).join(", ")}` : "";
|
|
2272
|
+
const how = fw === "react" ? `\`${c.name}\` from \`${c.importPath}\`` : `\`${markup(u)}\`${u.importLine ? ` (\`${u.importLine}\`)` : ""}`;
|
|
2273
|
+
lines.push(`- ${how}${fw === "react" ? "" : ` \u2014 ${c.name}`}${tags.length ? ` (instead of ${tags.join(", ")})` : ""}${props}`);
|
|
2274
|
+
}
|
|
2275
|
+
const setups = [...new Set(here.map((x2) => x2.u.setup).filter(Boolean))];
|
|
2276
|
+
if (setups.length && setups.length <= 3) for (const s of setups) lines.push(`- Setup: ${s}`);
|
|
2277
|
+
else if (setups.length) lines.push("- Each component's setup is in its docs (MCP `get_component_docs`).");
|
|
2278
|
+
if (here.some((x2) => x2.u.props?.length)) {
|
|
2012
2279
|
lines.push("");
|
|
2013
2280
|
lines.push("Pass only the props listed for each component (* = required). Any other prop is a type error and fails the check: if you need something a component doesn't take, look for a related component (often one with a longer name, like AvatarLabel next to Avatar) or compose it with design tokens.");
|
|
2014
2281
|
}
|
|
2282
|
+
if (missing.length) {
|
|
2283
|
+
lines.push("");
|
|
2284
|
+
lines.push(`No ${FRAMEWORK_LABEL[fw]} version yet: ${missing.join(", ")}. Build these with semantic HTML and the design tokens below; don't install another UI library.`);
|
|
2285
|
+
}
|
|
2015
2286
|
lines.push("");
|
|
2016
2287
|
}
|
|
2017
2288
|
if (r.colors.length) {
|
|
@@ -2073,7 +2344,7 @@ function mcpClientConfigs(url) {
|
|
|
2073
2344
|
file: ".mcp.json",
|
|
2074
2345
|
projectFile: true,
|
|
2075
2346
|
content: json({ mcpServers: { brandrail: { type: "http", url, headers: { Authorization: "Bearer ${BRANDRAIL_KEY}" } } } }),
|
|
2076
|
-
command: `claude mcp add --transport http brandrail ${url} --header "Authorization: Bearer $BRANDRAIL_KEY"`
|
|
2347
|
+
command: `claude mcp add --transport http brandrail "${url}" --header "Authorization: Bearer $BRANDRAIL_KEY"`
|
|
2077
2348
|
},
|
|
2078
2349
|
{
|
|
2079
2350
|
id: "codex",
|
|
@@ -2084,7 +2355,7 @@ function mcpClientConfigs(url) {
|
|
|
2084
2355
|
url = "${url}"
|
|
2085
2356
|
bearer_token_env_var = "BRANDRAIL_KEY"
|
|
2086
2357
|
`,
|
|
2087
|
-
command: `codex mcp add brandrail --url ${url} --bearer-token-env-var BRANDRAIL_KEY`
|
|
2358
|
+
command: `codex mcp add brandrail --url "${url}" --bearer-token-env-var BRANDRAIL_KEY`
|
|
2088
2359
|
},
|
|
2089
2360
|
{
|
|
2090
2361
|
id: "cursor",
|
|
@@ -2880,6 +3151,7 @@ Options for init
|
|
|
2880
3151
|
--no-rules Don't write AGENTS.md (Codex), CLAUDE.md, Cursor, Copilot, Windsurf, Gemini rule files
|
|
2881
3152
|
--mcp Also add the Brandrail MCP server to .mcp.json, .cursor/mcp.json, .vscode/mcp.json
|
|
2882
3153
|
and print the Codex / Windsurf config
|
|
3154
|
+
--framework <name> react | angular | vue | web-components | html (default: detected from package.json)
|
|
2883
3155
|
--tokens-css <file> Where to write the CSS variables (default src/brandrail.tokens.css); --no-tokens-css to skip
|
|
2884
3156
|
--force Overwrite an existing brandrail.config.json
|
|
2885
3157
|
|
|
@@ -2910,7 +3182,7 @@ function parseArgs(argv) {
|
|
|
2910
3182
|
const [k, v] = a.slice(2).split("=", 2);
|
|
2911
3183
|
const next = rest[i2 + 1];
|
|
2912
3184
|
if (v !== void 0) flags[k] = v;
|
|
2913
|
-
else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width", "tokens-css"].includes(k)) {
|
|
3185
|
+
else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width", "tokens-css", "framework"].includes(k)) {
|
|
2914
3186
|
flags[k] = next;
|
|
2915
3187
|
i2++;
|
|
2916
3188
|
} else flags[k] = true;
|
|
@@ -3012,7 +3284,7 @@ function writeRuleFiles(root, config, org, tokensCss) {
|
|
|
3012
3284
|
if (file.endsWith(".mdc")) {
|
|
3013
3285
|
write(file, () => `---
|
|
3014
3286
|
description: Design system rules from Brandrail
|
|
3015
|
-
globs: ["**/*.{tsx,jsx,vue,svelte,html,css,scss}"]
|
|
3287
|
+
globs: ["**/*.{tsx,jsx,ts,vue,svelte,html,css,scss,php,twig,hbs,mustache}"]
|
|
3016
3288
|
alwaysApply: true
|
|
3017
3289
|
---
|
|
3018
3290
|
|
|
@@ -3032,8 +3304,8 @@ ${md}
|
|
|
3032
3304
|
}
|
|
3033
3305
|
return written;
|
|
3034
3306
|
}
|
|
3035
|
-
function writeMcpConfigs(root) {
|
|
3036
|
-
const url = `${apiBase()}/api/mcp`;
|
|
3307
|
+
function writeMcpConfigs(root, framework) {
|
|
3308
|
+
const url = `${apiBase()}/api/mcp${framework === "react" ? "" : `?framework=${framework}`}`;
|
|
3037
3309
|
for (const c of mcpClientConfigs(url)) {
|
|
3038
3310
|
if (!c.projectFile) continue;
|
|
3039
3311
|
const abs = join3(root, c.file);
|
|
@@ -3068,6 +3340,21 @@ ${c.content.replace(/^/gm, " ")}`);
|
|
|
3068
3340
|
}
|
|
3069
3341
|
log("\nSet BRANDRAIL_KEY in your shell (an API key from Dashboard \u2192 Connect) before starting the AI tool.");
|
|
3070
3342
|
}
|
|
3343
|
+
function projectFramework(cwd, config, flags) {
|
|
3344
|
+
const asked = flags.framework;
|
|
3345
|
+
if (typeof asked === "string") {
|
|
3346
|
+
if (!isFramework(asked)) throw new Error(`Unknown --framework "${asked}". Use one of: ${FRAMEWORKS.join(", ")}.`);
|
|
3347
|
+
return asked;
|
|
3348
|
+
}
|
|
3349
|
+
if (config.framework && isFramework(config.framework)) return config.framework;
|
|
3350
|
+
const pkg = join3(cwd, "package.json");
|
|
3351
|
+
if (!existsSync3(pkg)) return detectFramework(null);
|
|
3352
|
+
try {
|
|
3353
|
+
return detectFramework(JSON.parse(readFileSync2(pkg, "utf8")));
|
|
3354
|
+
} catch {
|
|
3355
|
+
return "react";
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3071
3358
|
async function cmdInit(cwd, flags) {
|
|
3072
3359
|
const target = join3(cwd, CONFIG_FILE);
|
|
3073
3360
|
let config = starterConfig;
|
|
@@ -3092,13 +3379,20 @@ async function cmdInit(cwd, flags) {
|
|
|
3092
3379
|
`);
|
|
3093
3380
|
log(`\u2714 wrote ${CONFIG_FILE}${org ? ` from ${org}'s Brand Kit` : " (starter tokens \u2014 edit them or use --from-api)"}`);
|
|
3094
3381
|
}
|
|
3382
|
+
const framework = projectFramework(cwd, config, flags);
|
|
3383
|
+
if (config.framework !== framework) {
|
|
3384
|
+
config = { ...config, framework };
|
|
3385
|
+
writeFileSync(target, `${JSON.stringify(config, null, 2)}
|
|
3386
|
+
`);
|
|
3387
|
+
}
|
|
3388
|
+
log(`\u2714 framework: ${FRAMEWORK_LABEL[framework]}${flags.framework ? "" : " (detected; change with --framework)"}`);
|
|
3095
3389
|
const css = flags["tokens-css"] === false ? void 0 : tokensCssPath(cwd, config, flags);
|
|
3096
3390
|
if (css) {
|
|
3097
3391
|
const kit = writeTokensCss(cwd, css, config, org);
|
|
3098
3392
|
log(`\u2714 wrote ${css} (the Brand Kit as CSS variables${kit ? ", and @brandrail/kit themed from it" : ""} \u2014 import it once at your app entry)`);
|
|
3099
3393
|
}
|
|
3100
3394
|
if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org, css)) log(`\u2714 updated ${f}`);
|
|
3101
|
-
if (flags.mcp) writeMcpConfigs(cwd);
|
|
3395
|
+
if (flags.mcp) writeMcpConfigs(cwd, framework);
|
|
3102
3396
|
log("\nNext: npx brandrail check");
|
|
3103
3397
|
return 0;
|
|
3104
3398
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brandrail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Keep AI-generated UI on-brand: checks code and live pages against your design system, and wires your Brand Kit into Claude Code, Cursor, Copilot and Codex.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|