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.
Files changed (2) hide show
  1. package/dist/brandrail.mjs +397 -29
  2. package/package.json +1 -1
@@ -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: `import { ${comp.name} } from "${comp.importPath}"`
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: ["jsx"],
1042
- check({ code, config }) {
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.3.2";
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
- if (opts.tokensCss || usesKit) {
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("Always use these components. Never re-create them with raw HTML elements:");
2006
- for (const c of r.components) {
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 = c.props?.length ? ` \u2014 props: ${c.props.map((p) => p.required ? `${p.name}*` : p.name).join(", ")}` : "";
2009
- lines.push(`- \`${c.name}\` from \`${c.importPath}\`${tags.length ? ` (instead of ${tags.join(", ")})` : ""}${props}`);
2010
- }
2011
- if (r.components.some((c) => c.props?.length)) {
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.2",
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",