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.
Files changed (2) hide show
  1. package/dist/brandrail.mjs +322 -28
  2. package/package.json +1 -1
@@ -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: `import { ${comp.name} } from "${comp.importPath}"`
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: ["jsx"],
1042
- check({ code, config }) {
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.3.2";
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("Always use these components. Never re-create them with raw HTML elements:");
2006
- for (const c of r.components) {
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 = 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)) {
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.2",
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",