brandrail 0.3.2 → 0.4.1
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 +397 -29
- package/package.json +1 -1
package/dist/brandrail.mjs
CHANGED
|
@@ -701,13 +701,140 @@ 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 \`schemas\` of every NgModule or standalone component whose template uses it (with module federation, each remote's modules too). ${load} Importing it again in another bundle is harmless.`.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 {
|
|
782
|
+
framework,
|
|
783
|
+
importLine,
|
|
784
|
+
tag,
|
|
785
|
+
setup,
|
|
786
|
+
example: u.example?.trim() || void 0,
|
|
787
|
+
lang: LANG[kind],
|
|
788
|
+
props: u.props?.length ? u.props : void 0,
|
|
789
|
+
events: u.events?.length ? u.events : void 0,
|
|
790
|
+
customElement: kind === "web-components" || kind === "html" && tag.includes("-")
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
var VALUE_PROPS = ["value", "checked", "selected", "selected-date", "selectedDate"];
|
|
794
|
+
function bindingHowTo(u) {
|
|
795
|
+
const ev = u.events?.[0];
|
|
796
|
+
if (!ev) return [];
|
|
797
|
+
const camel = (n) => n.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
798
|
+
const vp = u.props?.find((p) => VALUE_PROPS.includes(p.name))?.name;
|
|
799
|
+
const valueProp = vp ? camel(vp) : void 0;
|
|
800
|
+
const dashed = (u.props ?? []).find((p) => p.name.includes("-"))?.name;
|
|
801
|
+
const caseNote = dashed ? ` Static attributes are kebab-case (\`${dashed}="\u2026"\`); bindings use the camelCase property (\`${u.framework === "vue" ? ":" : "["}${camel(dashed)}${u.framework === "vue" ? "" : "]"}="expr"\`), never \`${u.framework === "vue" ? ":" : "["}${dashed}${u.framework === "vue" ? "" : "]"}\`.` : "";
|
|
802
|
+
const attrs = (u.props ?? []).filter((p) => p.required).map((p) => `${p.name}="\u2026"`).join(" ");
|
|
803
|
+
const open = (extra) => `<${u.tag}${attrs ? ` ${attrs}` : ""}${extra}></${u.tag}>`;
|
|
804
|
+
const ce = u.customElement || u.via === "web-components";
|
|
805
|
+
switch (u.framework) {
|
|
806
|
+
case "angular":
|
|
807
|
+
if (!ce) return [`Outputs: bind with (${ev.name})="on${pascal(ev.name)}($event)".`];
|
|
808
|
+
return [
|
|
809
|
+
`Inputs: \`[prop]="expr"\` (objects and arrays work too). Events: \`(${ev.name})\` with the value in \`$any($event).detail\` (Angular types \`$event\` on custom elements as a plain Event, so \`$event.detail\` doesn't compile).${caseNote}`,
|
|
810
|
+
`Example: \`${open(`${valueProp ? ` [${valueProp}]="model"` : ""} (${ev.name})="model = $any($event).detail"`)}\``
|
|
811
|
+
];
|
|
812
|
+
case "vue":
|
|
813
|
+
return [
|
|
814
|
+
ce ? `Props: \`:prop="expr"\`. Events: \`@${ev.name}\` with the value in \`$event.detail\`.${caseNote} Example: \`${open(`${valueProp ? ` :${valueProp}="model"` : ""} @${ev.name}="model = $event.detail"`)}\`` : `Events: \`@${ev.name}="handler"\`.`
|
|
815
|
+
];
|
|
816
|
+
case "react":
|
|
817
|
+
return [];
|
|
818
|
+
default:
|
|
819
|
+
return [`Events: \`el.addEventListener("${ev.name}", (e) => e.detail)\`. Set lists and objects as properties (\`el.items = [\u2026]\`) or as JSON attributes.`];
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
var pascal = (s) => s.replace(/(^|[-_:])(\w)/g, (_, __, c) => c.toUpperCase());
|
|
823
|
+
var markup = (u) => `<${u.tag}>`;
|
|
824
|
+
function detectFramework(pkg) {
|
|
825
|
+
if (!pkg) return "html";
|
|
826
|
+
const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
|
|
827
|
+
const has = (name) => name in deps;
|
|
828
|
+
if (has("@angular/core")) return "angular";
|
|
829
|
+
if (has("vue") || has("nuxt")) return "vue";
|
|
830
|
+
if (has("react") || has("next") || has("preact")) return "react";
|
|
831
|
+
if (has("svelte") || has("lit") || has("@stencil/core") || has("@sveltejs/kit")) return "web-components";
|
|
832
|
+
return "html";
|
|
833
|
+
}
|
|
834
|
+
|
|
708
835
|
// ../engine/src/rules.ts
|
|
709
|
-
var MARKUP = ["jsx", "vue", "svelte", "html"];
|
|
710
|
-
var ALL = ["jsx", "vue", "svelte", "html", "css"];
|
|
836
|
+
var MARKUP = ["jsx", "vue", "svelte", "html", "template"];
|
|
837
|
+
var ALL = ["jsx", "vue", "svelte", "html", "template", "css"];
|
|
711
838
|
function* matches(re, text) {
|
|
712
839
|
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
|
|
713
840
|
let m;
|
|
@@ -740,6 +867,38 @@ function pxValues(value) {
|
|
|
740
867
|
}
|
|
741
868
|
return out;
|
|
742
869
|
}
|
|
870
|
+
var kebab2 = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
871
|
+
function styleBindings(code) {
|
|
872
|
+
const out = [];
|
|
873
|
+
const withUnit = (v, unit) => unit && /^-?\d*\.?\d+$/.test(v.trim()) ? `${v.trim()}${unit === "%" ? "%" : unit}` : v;
|
|
874
|
+
for (const m of matches(/(['"])([a-zA-Z-]+)(?:\.(px|rem|em|%))?\1\s*:\s*(?:(['"`])([^'"`\n]*)\4|(-?\d*\.?\d+)(?![\w.]))/g, code)) {
|
|
875
|
+
const value = m[5] ?? m[6];
|
|
876
|
+
const offset = m.index + m[0].length - value.length - (m[5] !== void 0 ? 1 : 0);
|
|
877
|
+
out.push({ prop: kebab2(m[2]), value: withUnit(value, m[3]), offset });
|
|
878
|
+
}
|
|
879
|
+
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)) {
|
|
880
|
+
out.push({ prop: kebab2(m[1]), value: m[3], offset: m.index + m[0].length - m[3].length - 1 });
|
|
881
|
+
}
|
|
882
|
+
for (const m of matches(/\[style\.([a-zA-Z-]+)(?:\.(px|rem|em|%))?\]\s*=\s*(["'])([\s\S]*?)\3/g, code)) {
|
|
883
|
+
const inner = m[4].trim();
|
|
884
|
+
const lit = /^(['"`])(.*)\1$/.exec(inner)?.[2] ?? (/^-?\d*\.?\d+$/.test(inner) ? inner : null);
|
|
885
|
+
if (lit === null) continue;
|
|
886
|
+
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) });
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
function declValue(v, kind) {
|
|
891
|
+
if (kind === "css") return v;
|
|
892
|
+
const q = v[0];
|
|
893
|
+
if (q === '"' || q === "'" || q === "`") {
|
|
894
|
+
const end = v.indexOf(q, 1);
|
|
895
|
+
return end === -1 ? v : v.slice(0, end + 1);
|
|
896
|
+
}
|
|
897
|
+
const cut = v.search(/["'`>]/);
|
|
898
|
+
return cut === -1 ? v : v.slice(0, cut);
|
|
899
|
+
}
|
|
900
|
+
var SPACING_PROP = /^(?:(?:padding|margin)(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?|gap|row-gap|column-gap)$/;
|
|
901
|
+
var RADIUS_PROP = /^border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius$/;
|
|
743
902
|
var noHardcodedColor = {
|
|
744
903
|
id: "no-hardcoded-color",
|
|
745
904
|
title: "Hard-coded colour",
|
|
@@ -825,7 +984,7 @@ var spacingScale = {
|
|
|
825
984
|
const ok = (n) => scale.some((s) => Math.abs(s - n) < 0.01);
|
|
826
985
|
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
986
|
const valueStart = m.index + m[0].length - m[2].length;
|
|
828
|
-
for (const v of pxValues(m[2])) {
|
|
987
|
+
for (const v of pxValues(declValue(m[2], kind))) {
|
|
829
988
|
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
989
|
}
|
|
831
990
|
}
|
|
@@ -838,6 +997,12 @@ var spacingScale = {
|
|
|
838
997
|
const n = Math.abs(parseFloat(m[2])) * (m[3] === "rem" ? 16 : 1);
|
|
839
998
|
if (!ok(n)) out.push({ offset: m.index, message: `${m[0]} is off the spacing scale`, suggestion: `Use ${neighbours(scale, n)}` });
|
|
840
999
|
}
|
|
1000
|
+
for (const b of styleBindings(code)) {
|
|
1001
|
+
if (!SPACING_PROP.test(b.prop)) continue;
|
|
1002
|
+
for (const v of pxValues(b.value)) {
|
|
1003
|
+
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)}` });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
841
1006
|
}
|
|
842
1007
|
return out;
|
|
843
1008
|
}
|
|
@@ -853,7 +1018,7 @@ var radiusScale = {
|
|
|
853
1018
|
const ok = (n) => n >= 999 || config.radii.some((r) => Math.abs(r - n) < 0.01);
|
|
854
1019
|
for (const m of matches(/(?<![\w-])border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius\s*:\s*([^;{}\n]+)/g, code)) {
|
|
855
1020
|
const valueStart = m.index + m[0].length - m[1].length;
|
|
856
|
-
for (const v of pxValues(m[1])) {
|
|
1021
|
+
for (const v of pxValues(declValue(m[1], kind))) {
|
|
857
1022
|
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
1023
|
}
|
|
859
1024
|
}
|
|
@@ -866,6 +1031,12 @@ var radiusScale = {
|
|
|
866
1031
|
const n = parseFloat(m[1]);
|
|
867
1032
|
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
1033
|
}
|
|
1034
|
+
for (const b of styleBindings(code)) {
|
|
1035
|
+
if (!RADIUS_PROP.test(b.prop)) continue;
|
|
1036
|
+
for (const v of pxValues(b.value)) {
|
|
1037
|
+
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)}` });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
869
1040
|
}
|
|
870
1041
|
return out;
|
|
871
1042
|
}
|
|
@@ -889,10 +1060,11 @@ var fontFamily = {
|
|
|
889
1060
|
suggestion: `Use var(--font-family-sans) (${allowed[0]})`
|
|
890
1061
|
});
|
|
891
1062
|
};
|
|
892
|
-
for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(m[1], m.index);
|
|
1063
|
+
for (const m of matches(/(?<![\w-])font-family\s*:\s*([^;{}\n]+)/g, code)) test(declValue(m[1], kind), m.index);
|
|
893
1064
|
if (kind !== "css") {
|
|
894
1065
|
for (const m of matches(/(?<![\w-])fontFamily\s*:\s*(['"`])([^'"`]+)\1/g, code)) test(m[2], m.index);
|
|
895
1066
|
for (const m of matches(/(?<![\w-])font-\[([^\]]+)\]/g, code)) test(m[1].replace(/_/g, " "), m.index);
|
|
1067
|
+
for (const b of styleBindings(code)) if (b.prop === "font-family") test(b.value, b.offset);
|
|
896
1068
|
}
|
|
897
1069
|
return out;
|
|
898
1070
|
}
|
|
@@ -904,7 +1076,7 @@ var preferOfficialComponent = {
|
|
|
904
1076
|
description: "Use the design system component instead of a raw HTML element.",
|
|
905
1077
|
defaultSeverity: "error",
|
|
906
1078
|
kinds: MARKUP,
|
|
907
|
-
check({ code, config }) {
|
|
1079
|
+
check({ code, config, framework }) {
|
|
908
1080
|
if (config.replaces.size === 0) return [];
|
|
909
1081
|
const out = [];
|
|
910
1082
|
const baseTags = [...new Set([...config.replaces.keys()].map((k) => k.replace(/\[.*$/, "").replace(/[^a-z0-9-]/g, "")))].filter(Boolean);
|
|
@@ -919,16 +1091,28 @@ var preferOfficialComponent = {
|
|
|
919
1091
|
else if (tag === "input" && !TEXT_INPUT_TYPES.has(type)) comp = type === "submit" || type === "button" ? config.replaces.get("button") : void 0;
|
|
920
1092
|
}
|
|
921
1093
|
if (!comp) continue;
|
|
1094
|
+
const usage = resolveUsage(comp, framework);
|
|
1095
|
+
if (!usage) continue;
|
|
922
1096
|
if (new RegExp(`(?:function|const|class)\\s+${comp.name}\\b`).test(code)) continue;
|
|
1097
|
+
if (framework !== "react" && implementsTag(code, usage.tag)) continue;
|
|
923
1098
|
out.push({
|
|
924
1099
|
offset: m.index,
|
|
925
1100
|
message: `Raw <${tag}${tag === "input" && /type=/.test(attrs) ? ` type="${attrs.match(/type\s*=\s*\{?\s*["'`]([\w-]+)/)?.[1]}"` : ""}> re-implements ${comp.name}`,
|
|
926
|
-
suggestion:
|
|
1101
|
+
suggestion: useSuggestion(comp, usage)
|
|
927
1102
|
});
|
|
928
1103
|
}
|
|
929
1104
|
return out;
|
|
930
1105
|
}
|
|
931
1106
|
};
|
|
1107
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1108
|
+
var implementsTag = (code, tag) => new RegExp(`selector\\s*:\\s*['"\`]${escapeRe(tag)}['"\`]|customElements\\.define\\(\\s*['"\`]${escapeRe(tag)}['"\`]|@customElement\\(\\s*['"\`]${escapeRe(tag)}['"\`]`).test(code);
|
|
1109
|
+
function useSuggestion(comp, u) {
|
|
1110
|
+
if (u.framework === "react") return `import { ${comp.name} } from "${comp.importPath}"`;
|
|
1111
|
+
const parts = [`Use <${u.tag}>`];
|
|
1112
|
+
if (u.importLine) parts.push(`(${u.importLine})`);
|
|
1113
|
+
if (u.setup && u.via) parts.push(`\u2014 ${u.setup}`);
|
|
1114
|
+
return parts.join(" ");
|
|
1115
|
+
}
|
|
932
1116
|
var forbiddenImport = {
|
|
933
1117
|
id: "forbidden-import",
|
|
934
1118
|
title: "Other UI library",
|
|
@@ -1033,13 +1217,120 @@ function parseAttributes(code, start) {
|
|
|
1033
1217
|
}
|
|
1034
1218
|
return null;
|
|
1035
1219
|
}
|
|
1220
|
+
function parseHtmlAttributes(code, start) {
|
|
1221
|
+
const attrs = [];
|
|
1222
|
+
let i2 = start;
|
|
1223
|
+
while (i2 < code.length) {
|
|
1224
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1225
|
+
const c = code[i2];
|
|
1226
|
+
if (c === void 0) return null;
|
|
1227
|
+
if (c === ">" || c === "/" && code[i2 + 1] === ">") return { attrs, end: i2 };
|
|
1228
|
+
if (c === "{" || c === "<" && code[i2 + 1] === "?") {
|
|
1229
|
+
const close = c === "{" ? code[i2 + 1] === "%" ? "%}" : "}}" : "?>";
|
|
1230
|
+
const e = code.indexOf(close, i2 + 2);
|
|
1231
|
+
i2 = e === -1 ? code.length : e + close.length;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
const name = /^[^\s=>/"'{]+/.exec(code.slice(i2, i2 + 120))?.[0];
|
|
1235
|
+
if (!name) return null;
|
|
1236
|
+
attrs.push({ name, offset: i2 });
|
|
1237
|
+
i2 += name.length;
|
|
1238
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1239
|
+
if (code[i2] === "=") {
|
|
1240
|
+
i2++;
|
|
1241
|
+
while (/\s/.test(code[i2] ?? "")) i2++;
|
|
1242
|
+
const q = code[i2];
|
|
1243
|
+
if (q === '"' || q === "'") {
|
|
1244
|
+
const e = code.indexOf(q, i2 + 1);
|
|
1245
|
+
i2 = e === -1 ? code.length : e + 1;
|
|
1246
|
+
} else while (i2 < code.length && !/[\s>]/.test(code[i2])) i2++;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
var HTML_GLOBAL = /^(?:id|class|style|title|role|hidden|tabindex|lang|dir|slot|part|is|key|ref|name|for|form|aria-.+|data-.+)$/;
|
|
1252
|
+
function propName(raw) {
|
|
1253
|
+
let n = raw;
|
|
1254
|
+
if (/^(?:\(|@|on-|v-on:|\*|#|let-|ref-|v-(?!bind:|model:))/.test(n)) return null;
|
|
1255
|
+
n = n.replace(/^v-bind:|^v-model:|^bind-|^:/, "").replace(/^\[\(?|\)?\]$/g, "");
|
|
1256
|
+
if (/^(?:attr|class|style)\./.test(n) || /^ng[A-Z]/.test(n) || /^(?:formControl|formControlName|formGroup|routerLink|routerLinkActive)$/.test(n)) return null;
|
|
1257
|
+
n = n.replace(/\.(?:px|rem|em|%)$/, "").replace(/\..*$/, "");
|
|
1258
|
+
if (HTML_GLOBAL.test(n.toLowerCase())) return null;
|
|
1259
|
+
return n;
|
|
1260
|
+
}
|
|
1261
|
+
var sameName = (a, b) => a.replace(/-/g, "").toLowerCase() === b.replace(/-/g, "").toLowerCase();
|
|
1262
|
+
var NATIVE_EVENT = /^(?:click|dblclick|auxclick|contextmenu|focus|blur|focusin|focusout|key(?:down|up|press)|mouse\w+|pointer\w+|touch\w+|wheel|drag\w*|drop|scroll|animation\w+|transition\w+)$/;
|
|
1263
|
+
function eventName(raw) {
|
|
1264
|
+
const m = /^\(([^)]+)\)$|^on-(.+)$|^@([^.]+)|^v-on:([^.]+)/.exec(raw);
|
|
1265
|
+
return m ? (m[1] ?? m[2] ?? m[3] ?? m[4]).replace(/\..*$/, "") : null;
|
|
1266
|
+
}
|
|
1267
|
+
function templateProps({ code, config, framework }) {
|
|
1268
|
+
const tagged = [];
|
|
1269
|
+
for (const comp of config.components) {
|
|
1270
|
+
const u = resolveUsage(comp, framework);
|
|
1271
|
+
if (u && (u.props?.length || u.events?.length)) tagged.push({ comp, tag: u.tag, props: u.props, events: u.events, ce: Boolean(u.customElement || u.via) });
|
|
1272
|
+
}
|
|
1273
|
+
if (!tagged.length) return [];
|
|
1274
|
+
const out = [];
|
|
1275
|
+
const forms = /* @__PURE__ */ new Map();
|
|
1276
|
+
for (const t of tagged) {
|
|
1277
|
+
forms.set(t.tag, t);
|
|
1278
|
+
if (framework === "vue") forms.set(t.tag.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), t);
|
|
1279
|
+
}
|
|
1280
|
+
const names = [...forms.keys()].sort((a, b) => b.length - a.length).map(escapeRe).join("|");
|
|
1281
|
+
for (const m of matches(new RegExp(`<(${names})(?=[\\s/>])`, "g"), code)) {
|
|
1282
|
+
const t = forms.get(m[1]);
|
|
1283
|
+
const parsed = parseHtmlAttributes(code, m.index + m[0].length);
|
|
1284
|
+
if (!parsed) continue;
|
|
1285
|
+
const list = (t.props ?? []).map((p) => p.required ? `${p.name} (required)` : p.name).join(", ");
|
|
1286
|
+
const passed = [];
|
|
1287
|
+
for (const a of parsed.attrs) {
|
|
1288
|
+
const raw = a.name;
|
|
1289
|
+
const ev = eventName(raw);
|
|
1290
|
+
if (ev) {
|
|
1291
|
+
if (!t.events?.length || NATIVE_EVENT.test(ev) || t.events.some((e) => e.name === ev)) continue;
|
|
1292
|
+
const like = t.events.find((e) => e.name.endsWith(`-${ev}`) || e.name.endsWith(ev)) ?? (t.events.length === 1 ? t.events[0] : void 0);
|
|
1293
|
+
out.push({
|
|
1294
|
+
offset: a.offset,
|
|
1295
|
+
message: `<${t.tag}> has no "${ev}" event`,
|
|
1296
|
+
suggestion: `${like ? `Listen to ${framework === "angular" ? `(${like.name})` : framework === "vue" ? `@${like.name}` : `"${like.name}"`}${like.detail ? ` and read ${framework === "angular" ? "$any($event).detail" : framework === "vue" ? "$event.detail" : "event.detail"} (${like.detail})` : ""}. ` : ""}Events: ${t.events.map((e) => e.name).join(", ")}`
|
|
1297
|
+
});
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (/^v-model$/.test(raw)) passed.push("modelValue", "value");
|
|
1301
|
+
if (/^\[\(ngModel\)\]$/.test(raw)) passed.push("value");
|
|
1302
|
+
const n = propName(raw);
|
|
1303
|
+
if (!n || !t.props?.length) continue;
|
|
1304
|
+
passed.push(n);
|
|
1305
|
+
if (t.ce && n.includes("-") && /^(?:\[(?!attr\.)|:|v-bind:|bind-)/.test(raw)) {
|
|
1306
|
+
const camel = n.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
1307
|
+
out.push({
|
|
1308
|
+
offset: a.offset,
|
|
1309
|
+
message: `${raw} binds a property that doesn't exist on <${t.tag}>`,
|
|
1310
|
+
suggestion: `Bind the camelCase property: ${framework === "vue" ? `:${camel}` : `[${camel}]`}="\u2026" (kebab-case is only for static attributes: ${n}="\u2026")`
|
|
1311
|
+
});
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (t.props.some((p) => sameName(p.name, n))) continue;
|
|
1315
|
+
out.push({ offset: a.offset, message: `<${t.tag}> has no "${n}" input`, suggestion: `Remove it. ${t.comp.name} takes: ${list}` });
|
|
1316
|
+
}
|
|
1317
|
+
for (const p of t.props ?? []) {
|
|
1318
|
+
if (!p.required || passed.some((n) => sameName(n, p.name))) continue;
|
|
1319
|
+
if (p.name === "children" || p.name === "content") continue;
|
|
1320
|
+
out.push({ offset: m.index, message: `<${t.tag}> is missing the required "${p.name}" input`, suggestion: `Add ${p.name}${p.type ? ` (${p.type})` : ""}` });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return out;
|
|
1324
|
+
}
|
|
1036
1325
|
var componentProps = {
|
|
1037
1326
|
id: "component-props",
|
|
1038
1327
|
title: "Unknown or missing prop",
|
|
1039
1328
|
description: "Design system components only accept their documented props, and required props must be passed.",
|
|
1040
1329
|
defaultSeverity: "error",
|
|
1041
|
-
kinds:
|
|
1042
|
-
check(
|
|
1330
|
+
kinds: MARKUP,
|
|
1331
|
+
check(ctx) {
|
|
1332
|
+
if (ctx.framework !== "react" || ctx.kind !== "jsx") return templateProps(ctx);
|
|
1333
|
+
const { code, config } = ctx;
|
|
1043
1334
|
if (!config.components.some((c) => c.props?.length)) return [];
|
|
1044
1335
|
const imported = importedComponents(code, config.components);
|
|
1045
1336
|
if (imported.size === 0) return [];
|
|
@@ -1089,8 +1380,10 @@ var RULES = [
|
|
|
1089
1380
|
var RULE_IDS = RULES.map((r) => r.id);
|
|
1090
1381
|
|
|
1091
1382
|
// ../engine/src/source.ts
|
|
1383
|
+
var TEMPLATE_EXTENSIONS = ["mustache", "hbs", "handlebars", "php", "phtml", "twig", "njk", "liquid"];
|
|
1092
1384
|
function fileKind(path) {
|
|
1093
1385
|
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
1386
|
+
if (TEMPLATE_EXTENSIONS.includes(ext)) return "template";
|
|
1094
1387
|
if (["tsx", "jsx", "ts", "js", "mjs", "cjs", "mdx"].includes(ext)) return "jsx";
|
|
1095
1388
|
if (ext === "vue") return "vue";
|
|
1096
1389
|
if (ext === "svelte") return "svelte";
|
|
@@ -1098,13 +1391,41 @@ function fileKind(path) {
|
|
|
1098
1391
|
if (["css", "scss", "sass", "less", "pcss"].includes(ext)) return "css";
|
|
1099
1392
|
return "other";
|
|
1100
1393
|
}
|
|
1101
|
-
var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "css"]);
|
|
1394
|
+
var SCANNABLE = /* @__PURE__ */ new Set(["jsx", "vue", "svelte", "html", "template", "css"]);
|
|
1395
|
+
var ANGULAR_COMPONENT = /@Component\s*\(\s*\{/;
|
|
1396
|
+
var ANGULAR_TEMPLATE = /\s(?:\[[\w.()-]+\]|\([\w.]+\)|\*ng[A-Z]\w*)\s*=|@(?:if|for|switch)\s*\(/;
|
|
1397
|
+
function fileFramework(path, kind, code, project) {
|
|
1398
|
+
const p = path.toLowerCase();
|
|
1399
|
+
switch (kind) {
|
|
1400
|
+
case "vue":
|
|
1401
|
+
return "vue";
|
|
1402
|
+
case "svelte":
|
|
1403
|
+
return "web-components";
|
|
1404
|
+
case "template":
|
|
1405
|
+
return "html";
|
|
1406
|
+
case "css":
|
|
1407
|
+
return project ?? "react";
|
|
1408
|
+
case "html":
|
|
1409
|
+
if (p.endsWith(".component.html") || project === "angular" || ANGULAR_TEMPLATE.test(code)) return "angular";
|
|
1410
|
+
return project === "vue" || project === "web-components" ? project : "html";
|
|
1411
|
+
case "jsx":
|
|
1412
|
+
if (ANGULAR_COMPONENT.test(code)) return "angular";
|
|
1413
|
+
if (/\.(tsx|jsx|mdx)$/.test(p)) return "react";
|
|
1414
|
+
return project ?? "react";
|
|
1415
|
+
default:
|
|
1416
|
+
return project ?? "react";
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1102
1419
|
var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
1103
1420
|
function stripComments(code, kind) {
|
|
1104
1421
|
let out = code;
|
|
1105
|
-
if (kind === "html" || kind === "vue" || kind === "svelte") {
|
|
1422
|
+
if (kind === "html" || kind === "vue" || kind === "svelte" || kind === "template") {
|
|
1106
1423
|
out = out.replace(/<!--[\s\S]*?-->/g, blank);
|
|
1107
1424
|
}
|
|
1425
|
+
if (kind === "template") {
|
|
1426
|
+
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));
|
|
1427
|
+
return out;
|
|
1428
|
+
}
|
|
1108
1429
|
if (kind === "css") {
|
|
1109
1430
|
return out.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + blank(m.slice(p1.length)));
|
|
1110
1431
|
}
|
|
@@ -1196,7 +1517,7 @@ function makeIgnore(globs) {
|
|
|
1196
1517
|
}
|
|
1197
1518
|
|
|
1198
1519
|
// ../engine/src/check.ts
|
|
1199
|
-
var ENGINE_VERSION = "0.
|
|
1520
|
+
var ENGINE_VERSION = "0.4.1";
|
|
1200
1521
|
var WEIGHT = { error: 4, warning: 1.5, info: 0.25 };
|
|
1201
1522
|
function computeScore(counts, lines) {
|
|
1202
1523
|
const penalty = counts.error * WEIGHT.error + counts.warning * WEIGHT.warning + counts.info * WEIGHT.info;
|
|
@@ -1221,11 +1542,12 @@ function check(files, configInput, opts = {}) {
|
|
|
1221
1542
|
const idx = new LineIndex(file.content);
|
|
1222
1543
|
linesScanned += only ? only.size : file.content.split("\n").filter((l) => l.trim()).length;
|
|
1223
1544
|
const code = stripComments(file.content, kind);
|
|
1545
|
+
const framework = fileFramework(path, kind, code, config.framework);
|
|
1224
1546
|
const seen = /* @__PURE__ */ new Set();
|
|
1225
1547
|
for (const rule of RULES) {
|
|
1226
1548
|
const setting = config.rules[rule.id];
|
|
1227
1549
|
if (setting === "off" || !rule.kinds.includes(kind)) continue;
|
|
1228
|
-
for (const f of rule.check({ path, kind, code, config })) {
|
|
1550
|
+
for (const f of rule.check({ path, kind, code, config, framework })) {
|
|
1229
1551
|
const { line, column } = idx.position(f.offset);
|
|
1230
1552
|
if (only && !only.has(line)) continue;
|
|
1231
1553
|
const key = `${rule.id}:${line}:${column}`;
|
|
@@ -1982,6 +2304,7 @@ var RULES_START = "<!-- brandrail:start -->";
|
|
|
1982
2304
|
var RULES_END = "<!-- brandrail:end -->";
|
|
1983
2305
|
function generateInstructions(config, opts = {}) {
|
|
1984
2306
|
const r = resolveConfig(config);
|
|
2307
|
+
const fw = opts.framework ?? config.framework ?? "react";
|
|
1985
2308
|
const lines = [];
|
|
1986
2309
|
lines.push(`# Design system rules${opts.org ? ` \u2014 ${opts.org}` : ""}`);
|
|
1987
2310
|
lines.push("");
|
|
@@ -1989,9 +2312,14 @@ function generateInstructions(config, opts = {}) {
|
|
|
1989
2312
|
`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
2313
|
);
|
|
1991
2314
|
lines.push("");
|
|
1992
|
-
const usesKit = r.components.some((c) => c.importPath === KIT_PACKAGE);
|
|
1993
|
-
|
|
2315
|
+
const usesKit = fw === "react" && r.components.some((c) => c.importPath === KIT_PACKAGE);
|
|
2316
|
+
const usesKitElements = fw !== "react" && r.components.some((c) => resolveUsage(c, fw)?.importLine?.includes(`"${KIT_PACKAGE}/`));
|
|
2317
|
+
if (opts.tokensCss || usesKit || usesKitElements) {
|
|
1994
2318
|
lines.push("## Setup");
|
|
2319
|
+
if (usesKit || usesKitElements) lines.push(`- The kit comes from npm: \`npm i ${KIT_PACKAGE}\` (once per app, if it isn't in package.json yet).`);
|
|
2320
|
+
if (usesKitElements) {
|
|
2321
|
+
lines.push(`- \`import "${KIT_PACKAGE}/elements";\` once at the app entry registers every \`<br-*>\` element. Don't import the React components from \`${KIT_PACKAGE}\` in this project.`);
|
|
2322
|
+
}
|
|
1995
2323
|
if (opts.tokensCss) {
|
|
1996
2324
|
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.`);
|
|
1997
2325
|
}
|
|
@@ -2001,17 +2329,34 @@ function generateInstructions(config, opts = {}) {
|
|
|
2001
2329
|
lines.push("");
|
|
2002
2330
|
}
|
|
2003
2331
|
if (r.components.length) {
|
|
2332
|
+
const usable = r.components.map((c) => ({ c, u: resolveUsage(c, fw) }));
|
|
2333
|
+
const here = usable.filter((x2) => x2.u);
|
|
2334
|
+
const missing = usable.filter((x2) => !x2.u).map((x2) => x2.c.name);
|
|
2004
2335
|
lines.push("## Components");
|
|
2005
|
-
lines.push("
|
|
2006
|
-
|
|
2336
|
+
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.`);
|
|
2337
|
+
if (here.length) lines.push("Always use these components. Never re-create them with raw HTML elements:");
|
|
2338
|
+
for (const { c, u } of here) {
|
|
2007
2339
|
const tags = [...r.replaces].filter(([, v]) => v === c).map(([t]) => `<${t}>`);
|
|
2008
|
-
const props =
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2340
|
+
const props = u.props?.length ? ` \u2014 props: ${u.props.map((p) => p.required ? `${p.name}*` : p.name).join(", ")}` : "";
|
|
2341
|
+
const how = fw === "react" ? `\`${c.name}\` from \`${c.importPath}\`` : `\`${markup(u)}\`${u.importLine ? ` (\`${u.importLine}\`)` : ""}`;
|
|
2342
|
+
lines.push(`- ${how}${fw === "react" ? "" : ` \u2014 ${c.name}`}${tags.length ? ` (instead of ${tags.join(", ")})` : ""}${props}`);
|
|
2343
|
+
}
|
|
2344
|
+
const evented = here.find((x2) => x2.u.events?.length && (x2.u.customElement || x2.u.via));
|
|
2345
|
+
if (evented && fw !== "react") {
|
|
2346
|
+
lines.push(`- Events: ${here.filter((x2) => x2.u.events?.length).slice(0, 12).map((x2) => `\`<${x2.u.tag}>\` ${x2.u.events.map((e) => `\`${e.name}\``).join(", ")}`).join("; ")}.`);
|
|
2347
|
+
for (const b of bindingHowTo(evented.u)) lines.push(`- ${b}`);
|
|
2348
|
+
}
|
|
2349
|
+
const setups = [...new Set(here.map((x2) => x2.u.setup).filter(Boolean))];
|
|
2350
|
+
if (setups.length && setups.length <= 3) for (const s of setups) lines.push(`- Setup: ${s}`);
|
|
2351
|
+
else if (setups.length) lines.push("- Each component's setup is in its docs (MCP `get_component_docs`).");
|
|
2352
|
+
if (here.some((x2) => x2.u.props?.length)) {
|
|
2012
2353
|
lines.push("");
|
|
2013
2354
|
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
2355
|
}
|
|
2356
|
+
if (missing.length) {
|
|
2357
|
+
lines.push("");
|
|
2358
|
+
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.`);
|
|
2359
|
+
}
|
|
2015
2360
|
lines.push("");
|
|
2016
2361
|
}
|
|
2017
2362
|
if (r.colors.length) {
|
|
@@ -2073,7 +2418,7 @@ function mcpClientConfigs(url) {
|
|
|
2073
2418
|
file: ".mcp.json",
|
|
2074
2419
|
projectFile: true,
|
|
2075
2420
|
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"`
|
|
2421
|
+
command: `claude mcp add --transport http brandrail "${url}" --header "Authorization: Bearer $BRANDRAIL_KEY"`
|
|
2077
2422
|
},
|
|
2078
2423
|
{
|
|
2079
2424
|
id: "codex",
|
|
@@ -2084,7 +2429,7 @@ function mcpClientConfigs(url) {
|
|
|
2084
2429
|
url = "${url}"
|
|
2085
2430
|
bearer_token_env_var = "BRANDRAIL_KEY"
|
|
2086
2431
|
`,
|
|
2087
|
-
command: `codex mcp add brandrail --url ${url} --bearer-token-env-var BRANDRAIL_KEY`
|
|
2432
|
+
command: `codex mcp add brandrail --url "${url}" --bearer-token-env-var BRANDRAIL_KEY`
|
|
2088
2433
|
},
|
|
2089
2434
|
{
|
|
2090
2435
|
id: "cursor",
|
|
@@ -2880,6 +3225,7 @@ Options for init
|
|
|
2880
3225
|
--no-rules Don't write AGENTS.md (Codex), CLAUDE.md, Cursor, Copilot, Windsurf, Gemini rule files
|
|
2881
3226
|
--mcp Also add the Brandrail MCP server to .mcp.json, .cursor/mcp.json, .vscode/mcp.json
|
|
2882
3227
|
and print the Codex / Windsurf config
|
|
3228
|
+
--framework <name> react | angular | vue | web-components | html (default: detected from package.json)
|
|
2883
3229
|
--tokens-css <file> Where to write the CSS variables (default src/brandrail.tokens.css); --no-tokens-css to skip
|
|
2884
3230
|
--force Overwrite an existing brandrail.config.json
|
|
2885
3231
|
|
|
@@ -2910,7 +3256,7 @@ function parseArgs(argv) {
|
|
|
2910
3256
|
const [k, v] = a.slice(2).split("=", 2);
|
|
2911
3257
|
const next = rest[i2 + 1];
|
|
2912
3258
|
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)) {
|
|
3259
|
+
else if (next !== void 0 && !next.startsWith("-") && ["config", "format", "fail-below", "changed", "pages", "storage-state", "wait", "width", "tokens-css", "framework"].includes(k)) {
|
|
2914
3260
|
flags[k] = next;
|
|
2915
3261
|
i2++;
|
|
2916
3262
|
} else flags[k] = true;
|
|
@@ -3012,7 +3358,7 @@ function writeRuleFiles(root, config, org, tokensCss) {
|
|
|
3012
3358
|
if (file.endsWith(".mdc")) {
|
|
3013
3359
|
write(file, () => `---
|
|
3014
3360
|
description: Design system rules from Brandrail
|
|
3015
|
-
globs: ["**/*.{tsx,jsx,vue,svelte,html,css,scss}"]
|
|
3361
|
+
globs: ["**/*.{tsx,jsx,ts,vue,svelte,html,css,scss,php,twig,hbs,mustache}"]
|
|
3016
3362
|
alwaysApply: true
|
|
3017
3363
|
---
|
|
3018
3364
|
|
|
@@ -3032,8 +3378,8 @@ ${md}
|
|
|
3032
3378
|
}
|
|
3033
3379
|
return written;
|
|
3034
3380
|
}
|
|
3035
|
-
function writeMcpConfigs(root) {
|
|
3036
|
-
const url = `${apiBase()}/api/mcp`;
|
|
3381
|
+
function writeMcpConfigs(root, framework) {
|
|
3382
|
+
const url = `${apiBase()}/api/mcp${framework === "react" ? "" : `?framework=${framework}`}`;
|
|
3037
3383
|
for (const c of mcpClientConfigs(url)) {
|
|
3038
3384
|
if (!c.projectFile) continue;
|
|
3039
3385
|
const abs = join3(root, c.file);
|
|
@@ -3068,6 +3414,21 @@ ${c.content.replace(/^/gm, " ")}`);
|
|
|
3068
3414
|
}
|
|
3069
3415
|
log("\nSet BRANDRAIL_KEY in your shell (an API key from Dashboard \u2192 Connect) before starting the AI tool.");
|
|
3070
3416
|
}
|
|
3417
|
+
function projectFramework(cwd, config, flags) {
|
|
3418
|
+
const asked = flags.framework;
|
|
3419
|
+
if (typeof asked === "string") {
|
|
3420
|
+
if (!isFramework(asked)) throw new Error(`Unknown --framework "${asked}". Use one of: ${FRAMEWORKS.join(", ")}.`);
|
|
3421
|
+
return asked;
|
|
3422
|
+
}
|
|
3423
|
+
if (config.framework && isFramework(config.framework)) return config.framework;
|
|
3424
|
+
const pkg = join3(cwd, "package.json");
|
|
3425
|
+
if (!existsSync3(pkg)) return detectFramework(null);
|
|
3426
|
+
try {
|
|
3427
|
+
return detectFramework(JSON.parse(readFileSync2(pkg, "utf8")));
|
|
3428
|
+
} catch {
|
|
3429
|
+
return "react";
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3071
3432
|
async function cmdInit(cwd, flags) {
|
|
3072
3433
|
const target = join3(cwd, CONFIG_FILE);
|
|
3073
3434
|
let config = starterConfig;
|
|
@@ -3092,13 +3453,20 @@ async function cmdInit(cwd, flags) {
|
|
|
3092
3453
|
`);
|
|
3093
3454
|
log(`\u2714 wrote ${CONFIG_FILE}${org ? ` from ${org}'s Brand Kit` : " (starter tokens \u2014 edit them or use --from-api)"}`);
|
|
3094
3455
|
}
|
|
3456
|
+
const framework = projectFramework(cwd, config, flags);
|
|
3457
|
+
if (config.framework !== framework) {
|
|
3458
|
+
config = { ...config, framework };
|
|
3459
|
+
writeFileSync(target, `${JSON.stringify(config, null, 2)}
|
|
3460
|
+
`);
|
|
3461
|
+
}
|
|
3462
|
+
log(`\u2714 framework: ${FRAMEWORK_LABEL[framework]}${flags.framework ? "" : " (detected; change with --framework)"}`);
|
|
3095
3463
|
const css = flags["tokens-css"] === false ? void 0 : tokensCssPath(cwd, config, flags);
|
|
3096
3464
|
if (css) {
|
|
3097
3465
|
const kit = writeTokensCss(cwd, css, config, org);
|
|
3098
3466
|
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
3467
|
}
|
|
3100
3468
|
if (flags.rules !== false) for (const f of writeRuleFiles(cwd, config, org, css)) log(`\u2714 updated ${f}`);
|
|
3101
|
-
if (flags.mcp) writeMcpConfigs(cwd);
|
|
3469
|
+
if (flags.mcp) writeMcpConfigs(cwd, framework);
|
|
3102
3470
|
log("\nNext: npx brandrail check");
|
|
3103
3471
|
return 0;
|
|
3104
3472
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brandrail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|