frontend-guardian 0.1.2 → 0.1.4

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/cli.js CHANGED
@@ -40,8 +40,13 @@ function readDirRecursive(dir, base = "") {
40
40
  }
41
41
  function issueType(w) {
42
42
  const msg = w.message;
43
- if (msg.startsWith("Mixed spacing values:"))
44
- return { title: "Mixed Spacing", detected: msg.replace(/^Mixed spacing values:\s*/, "") };
43
+ if (msg.startsWith("Mixed spacing values")) {
44
+ const isCard = msg.includes("(cards/boxes)");
45
+ return {
46
+ title: isCard ? "Mixed Spacing (cards/boxes)" : "Mixed Spacing",
47
+ detected: msg.replace(/^Mixed spacing values[^:]*:\s*/, ""),
48
+ };
49
+ }
45
50
  if (msg.startsWith("Inconsistent border-radius:"))
46
51
  return { title: "Inconsistent Border Radius", detected: msg.replace(/^Inconsistent border-radius:\s*/, "") };
47
52
  if (msg.startsWith("Arbitrary color"))
@@ -66,7 +71,8 @@ function scoreColor(score) {
66
71
  return chalk.red;
67
72
  }
68
73
  const SHORT_SUGGESTION = {
69
- "Mixed Spacing": "standardize spacing scale",
74
+ "Mixed Spacing": "standardize spacing scale for layout/containers",
75
+ "Mixed Spacing (cards/boxes)": "use one padding for all cards/boxes",
70
76
  "Inconsistent Border Radius": "use one border radius token",
71
77
  "Arbitrary Colors": "use Tailwind theme colors",
72
78
  "Unused Imports": "remove unused imports",
@@ -81,22 +87,30 @@ function shortSuggestion(title, full) {
81
87
  }
82
88
  const WRAP_WIDTH = 72;
83
89
  const INDENT = " ";
84
- function wrapLine(prefix, text) {
90
+ const FILES_INDENT = INDENT + " ";
91
+ function wrapLine(prefix, text, continuationPrefix, firstLinePrefix) {
92
+ const cont = continuationPrefix ?? INDENT;
93
+ const firstPrefix = firstLinePrefix ?? prefix;
85
94
  const maxLen = WRAP_WIDTH - prefix.length;
86
95
  if (text.length <= maxLen) {
87
- console.log(prefix + text);
96
+ console.log(firstPrefix + text);
88
97
  return;
89
98
  }
90
99
  let rest = text;
91
100
  let first = true;
92
101
  while (rest.length > 0) {
93
- const use = first ? prefix : INDENT;
94
- const allowed = WRAP_WIDTH - use.length;
102
+ const use = first ? firstPrefix : cont;
103
+ const lenForBreak = first ? prefix.length : cont.length;
104
+ const allowed = WRAP_WIDTH - lenForBreak;
95
105
  if (rest.length <= allowed) {
96
106
  console.log(use + rest);
97
107
  break;
98
108
  }
99
- let breakAt = rest.slice(0, allowed).lastIndexOf(", ") + 1 || rest.slice(0, allowed).lastIndexOf("; ") + 1;
109
+ const slice = rest.slice(0, allowed);
110
+ const lineBreak = slice.lastIndexOf(", line ");
111
+ let breakAt = lineBreak >= 0 ? lineBreak + ", line ".length : 0;
112
+ if (breakAt <= 0)
113
+ breakAt = slice.lastIndexOf(", ") + 1 || slice.lastIndexOf("; ") + 1;
100
114
  if (breakAt <= 0)
101
115
  breakAt = allowed;
102
116
  const chunk = rest.slice(0, breakAt).trim();
@@ -157,23 +171,38 @@ function printResult(projectName, result) {
157
171
  suggestion: shortSuggestion(title, suggestion),
158
172
  fileLines: [...fileLines].sort(),
159
173
  }));
160
- const NO_FILES_TITLES = new Set([
161
- "Mixed Spacing", "Inconsistent Border Radius", "Mixed Button Padding", "Inconsistent Button Border Radius",
174
+ const STYLE_TITLES = new Set([
175
+ "Mixed Spacing", "Mixed Spacing (cards/boxes)", "Inconsistent Border Radius", "Mixed Button Padding", "Inconsistent Button Border Radius",
162
176
  ]);
177
+ function groupFileLines(fileLines) {
178
+ const byFile = new Map();
179
+ for (const s of fileLines) {
180
+ const i = s.lastIndexOf(":");
181
+ const file = i >= 0 ? s.slice(0, i) : s;
182
+ const line = i >= 0 ? s.slice(i + 1) : "";
183
+ const num = line ? parseInt(line, 10) : 0;
184
+ if (!byFile.has(file))
185
+ byFile.set(file, []);
186
+ if (num && !byFile.get(file).includes(num))
187
+ byFile.get(file).push(num);
188
+ }
189
+ return [...byFile.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([file, lines]) => lines.length > 0 ? `${file}: ${chalk.italic("line " + [...lines].sort((a, b) => a - b).join(", line "))}` : file);
190
+ }
163
191
  if (allIssues.length > 0) {
164
192
  console.log(chalk.yellow(" ⚠ ") + chalk.bold("Issues Found") + "\n");
165
193
  allIssues.forEach((issue, i) => {
166
- console.log(" " + (i + 1) + ". " + issue.title);
194
+ console.log(" " + (i + 1) + ". " + chalk.bold(issue.title));
167
195
  if (issue.title === "Unused Imports" || issue.title === "Unused Variables") {
168
196
  wrapLine(INDENT, issue.detected);
169
197
  }
170
198
  else {
171
- wrapLine(INDENT + "Detected: ", issue.detected);
172
- console.log(INDENT + "Suggestion: " + issue.suggestion);
199
+ wrapLine(INDENT + "Detected: ", issue.detected, INDENT, INDENT + chalk.bold("Detected: "));
200
+ console.log(INDENT + chalk.bold("Suggestion: ") + issue.suggestion);
173
201
  }
174
- if (issue.fileLines.length > 0 && !NO_FILES_TITLES.has(issue.title)) {
175
- console.log(INDENT + "Files:");
176
- issue.fileLines.forEach((f) => console.log(INDENT + " " + f));
202
+ if (issue.fileLines.length > 0) {
203
+ console.log(INDENT + chalk.bold("Files:"));
204
+ const raw = STYLE_TITLES.has(issue.title) ? groupFileLines(issue.fileLines) : issue.fileLines.map((f) => (f.includes(":") ? f.replace(/:(\s*\d+)$/, ": " + chalk.italic("line $1")) : f));
205
+ raw.forEach((f) => wrapLine(FILES_INDENT, f, FILES_INDENT));
177
206
  }
178
207
  if (i < allIssues.length - 1)
179
208
  console.log("");
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "frontend-guardian",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Scan frontend projects for Tailwind & component consistency (Phase 1 CLI)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "frontend-guardian": "./dist/cli.js"
8
8
  },
9
- "scripts": {
10
- "build": "tsc"
11
- },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
12
13
  "dependencies": {
13
- "@justinmoto/frontend-guardian-core": "workspace:*",
14
14
  "chalk": "^5.3.0",
15
- "ora": "^8.0.1"
15
+ "ora": "^8.0.1",
16
+ "@justinmoto/frontend-guardian-core": "0.1.5"
16
17
  },
17
18
  "devDependencies": {
18
19
  "typescript": "^5"
@@ -20,6 +21,15 @@
20
21
  "engines": {
21
22
  "node": ">=18"
22
23
  },
23
- "keywords": ["tailwind", "consistency", "frontend", "scan", "cli"],
24
- "license": "MIT"
25
- }
24
+ "keywords": [
25
+ "tailwind",
26
+ "consistency",
27
+ "frontend",
28
+ "scan",
29
+ "cli"
30
+ ],
31
+ "license": "MIT",
32
+ "scripts": {
33
+ "build": "tsc"
34
+ }
35
+ }
package/PUBLISH.md DELETED
@@ -1,81 +0,0 @@
1
- # Publish frontend-guardian to npm
2
-
3
- So anyone can run `npx frontend-guardian .` without your repo.
4
-
5
- ## 1. npm account
6
-
7
- - Sign up at https://www.npmjs.com/signup
8
- - Login in terminal: `npm login` (username, password, email, OTP if 2FA)
9
-
10
- ## 2. Check package name
11
-
12
- - If **frontend-guardian** is taken on npm, use a scoped name in `package.json`:
13
- `"name": "@YOUR_NPM_USERNAME/frontend-guardian"`
14
- Then users run: `npx @YOUR_NPM_USERNAME/frontend-guardian .`
15
-
16
- ## 3. Publish core first (CLI depends on it)
17
-
18
- ```bash
19
- cd C:/Users/ADMIN/Desktop/frontend-guardian
20
- pnpm run build:packages
21
- cd packages/core
22
- ```
23
-
24
- In `packages/core/package.json` temporarily remove the line `"private": true` (or set to `false`).
25
-
26
- ```bash
27
- npm publish --access public
28
- ```
29
-
30
- (Scoped packages like `@frontend-guardian/core` need `--access public`.)
31
-
32
- Put `"private": true` back in core's package.json if you want to keep the repo from publishing it again by mistake.
33
-
34
- ## 4. Point CLI to published core
35
-
36
- In `packages/cli/package.json`, change:
37
-
38
- ```json
39
- "dependencies": {
40
- "@frontend-guardian/core": "workspace:*"
41
- }
42
- ```
43
-
44
- to:
45
-
46
- ```json
47
- "dependencies": {
48
- "@frontend-guardian/core": "^0.1.0"
49
- }
50
- ```
51
-
52
- (Use the same version you published for core, e.g. `^0.1.0`.)
53
-
54
- ## 5. Publish CLI
55
-
56
- ```bash
57
- cd C:/Users/ADMIN/Desktop/frontend-guardian/packages/cli
58
- npm publish
59
- ```
60
-
61
- If the name is scoped (`@YOUR_NPM_USERNAME/frontend-guardian`):
62
-
63
- ```bash
64
- npm publish --access public
65
- ```
66
-
67
- ## 6. After publish
68
-
69
- Anyone can run:
70
-
71
- ```bash
72
- npx frontend-guardian .
73
- ```
74
-
75
- (or `npx @YOUR_NPM_USERNAME/frontend-guardian .` if you used a scoped name.)
76
-
77
- ## Updating later
78
-
79
- 1. Bump `version` in `packages/core/package.json` and `packages/cli/package.json`.
80
- 2. In core: `npm publish --access public`
81
- 3. In cli: set `"@frontend-guardian/core": "^0.1.1"` (or new version), then `npm publish` (or `--access public` if scoped).
package/src/cli.ts DELETED
@@ -1,245 +0,0 @@
1
- #!/usr/bin/env node
2
- import * as fs from "fs";
3
- import * as path from "path";
4
- import { fileURLToPath } from "url";
5
- import chalk from "chalk";
6
- import ora from "ora";
7
- import { scanFiles, scanZip, type ScanResult, type Warning } from "@justinmoto/frontend-guardian-core";
8
-
9
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
- const EXT = [".js", ".jsx", ".ts", ".tsx"];
11
- const IGNORE = ["node_modules", ".next", "dist", "build", ".git"];
12
-
13
- function isCodeFile(name: string): boolean {
14
- return EXT.some((e) => name.toLowerCase().endsWith(e));
15
- }
16
-
17
- function isSourcePath(relativePath: string): boolean {
18
- const normalized = relativePath.replace(/\\/g, "/");
19
- return !IGNORE.some((part) => normalized.includes(part));
20
- }
21
-
22
- function readDirRecursive(dir: string, base = ""): { path: string; code: string }[] {
23
- const out: { path: string; code: string }[] = [];
24
- const entries = fs.readdirSync(dir, { withFileTypes: true });
25
- for (const e of entries) {
26
- const rel = base ? `${base}/${e.name}` : e.name;
27
- const full = path.join(dir, e.name);
28
- if (e.isDirectory()) {
29
- if (IGNORE.includes(e.name)) continue;
30
- out.push(...readDirRecursive(full, rel));
31
- } else if (e.isFile() && isCodeFile(e.name) && isSourcePath(rel)) {
32
- try {
33
- const code = fs.readFileSync(full, "utf-8");
34
- out.push({ path: rel, code });
35
- } catch {
36
- // skip
37
- }
38
- }
39
- }
40
- return out;
41
- }
42
-
43
- function issueType(w: Warning): { title: string; detected: string } {
44
- const msg = w.message;
45
- if (msg.startsWith("Mixed spacing values:"))
46
- return { title: "Mixed Spacing", detected: msg.replace(/^Mixed spacing values:\s*/, "") };
47
- if (msg.startsWith("Inconsistent border-radius:"))
48
- return { title: "Inconsistent Border Radius", detected: msg.replace(/^Inconsistent border-radius:\s*/, "") };
49
- if (msg.startsWith("Arbitrary color"))
50
- return { title: "Arbitrary Colors", detected: msg };
51
- if (msg.startsWith("Unused import:"))
52
- return { title: "Unused Imports", detected: msg.replace(/^Unused import:\s*/, "") };
53
- if (msg.startsWith("Unused variable:"))
54
- return { title: "Unused Variables", detected: msg.replace(/^Unused variable:\s*/, "") };
55
- if (msg.startsWith("Parse error"))
56
- return { title: "Parse Error", detected: msg };
57
- if (msg.startsWith("Mixed button padding:"))
58
- return { title: "Mixed Button Padding", detected: msg.replace(/^Mixed button padding:\s*/, "") };
59
- if (msg.startsWith("Inconsistent button border-radius:"))
60
- return { title: "Inconsistent Button Border Radius", detected: msg.replace(/^Inconsistent button border-radius:\s*/, "") };
61
- return { title: "Other", detected: msg };
62
- }
63
-
64
- function scoreColor(score: number): (s: string) => string {
65
- if (score >= 70) return chalk.green;
66
- if (score >= 40) return chalk.yellow;
67
- return chalk.red;
68
- }
69
-
70
- const SHORT_SUGGESTION: Record<string, string> = {
71
- "Mixed Spacing": "standardize spacing scale",
72
- "Inconsistent Border Radius": "use one border radius token",
73
- "Arbitrary Colors": "use Tailwind theme colors",
74
- "Unused Imports": "remove unused imports",
75
- "Unused Variables": "remove or use variable; prefix with _ if intentional",
76
- "Parse Error": "fix syntax or remove file",
77
- "Mixed Button Padding": "use one button padding",
78
- "Inconsistent Button Border Radius": "use one radius for all buttons",
79
- "Duplicate Components": "extract shared component and reuse",
80
- };
81
-
82
- function shortSuggestion(title: string, full: string): string {
83
- return SHORT_SUGGESTION[title] ?? (full.slice(0, 55) + (full.length > 55 ? "…" : ""));
84
- }
85
-
86
- const WRAP_WIDTH = 72;
87
- const INDENT = " ";
88
-
89
- function wrapLine(prefix: string, text: string): void {
90
- const maxLen = WRAP_WIDTH - prefix.length;
91
- if (text.length <= maxLen) {
92
- console.log(prefix + text);
93
- return;
94
- }
95
- let rest = text;
96
- let first = true;
97
- while (rest.length > 0) {
98
- const use = first ? prefix : INDENT;
99
- const allowed = WRAP_WIDTH - use.length;
100
- if (rest.length <= allowed) {
101
- console.log(use + rest);
102
- break;
103
- }
104
- let breakAt = rest.slice(0, allowed).lastIndexOf(", ") + 1 || rest.slice(0, allowed).lastIndexOf("; ") + 1;
105
- if (breakAt <= 0) breakAt = allowed;
106
- const chunk = rest.slice(0, breakAt).trim();
107
- rest = rest.slice(breakAt).trim();
108
- if (chunk) console.log(use + chunk);
109
- first = false;
110
- }
111
- }
112
-
113
- function printResult(projectName: string, result: ScanResult) {
114
- const line = chalk.gray("━".repeat(28));
115
- console.log("\n" + line);
116
- console.log(chalk.bold(" 🛡 Frontend Guardian"));
117
- console.log(line + "\n");
118
-
119
- let filesScanned = "filesScanned" in result ? (result as ScanResult & { filesScanned?: number }).filesScanned : 0;
120
- if (filesScanned === 0 && (result.warnings.length > 0 || result.duplicates.length > 0)) {
121
- const files = new Set<string>();
122
- result.warnings.forEach((w) => files.add(w.file));
123
- result.duplicates.forEach((d) => d.files.forEach((f) => files.add(f)));
124
- filesScanned = files.size;
125
- }
126
- console.log(" Project: " + projectName);
127
- console.log(" Files scanned: " + String(filesScanned));
128
- const scoreColorFn = scoreColor(result.score);
129
- console.log(" Consistency Score: " + scoreColorFn(result.score + " / 100"));
130
- console.log(chalk.gray(" (each warning −5, each duplicate group −10, from 100)") + "\n");
131
-
132
- type WarnWithLoc = Warning & { line?: number; locations?: { file: string; line: number }[] };
133
- function locationStrings(w: WarnWithLoc): string[] {
134
- if (w.locations?.length) return w.locations.map((loc) => (loc.line != null && loc.line > 0 ? `${loc.file}:${loc.line}` : loc.file)).filter((s) => s.length > 0);
135
- if (w.file && w.file !== "(project)") return [w.line != null && w.line > 0 ? `${w.file}:${w.line}` : w.file];
136
- return [];
137
- }
138
-
139
- const byTitle = new Map<string, { detected: string[]; suggestion: string; fileLines: Set<string> }>();
140
- for (const w of result.warnings) {
141
- const wl = w as WarnWithLoc;
142
- const { title, detected } = issueType(w);
143
- const sug = w.suggestion ?? "";
144
- if (!byTitle.has(title)) byTitle.set(title, { detected: [], suggestion: sug, fileLines: new Set() });
145
- const entry = byTitle.get(title)!;
146
- if (!entry.detected.includes(detected)) entry.detected.push(detected);
147
- locationStrings(wl).forEach((s) => entry.fileLines.add(s));
148
- }
149
- for (const d of result.duplicates) {
150
- const title = "Duplicate Components";
151
- const detected = d.files.join(", ");
152
- if (!byTitle.has(title)) byTitle.set(title, { detected: [], suggestion: d.suggestion ?? "", fileLines: new Set() });
153
- const entry = byTitle.get(title)!;
154
- entry.detected.push(detected);
155
- d.files.forEach((f) => entry.fileLines.add(f));
156
- }
157
-
158
- const allIssues = [...byTitle.entries()].map(([title, { detected, suggestion, fileLines }]) => ({
159
- title,
160
- detected: detected.join("; "),
161
- suggestion: shortSuggestion(title, suggestion),
162
- fileLines: [...fileLines].sort(),
163
- }));
164
-
165
- const NO_FILES_TITLES = new Set([
166
- "Mixed Spacing", "Inconsistent Border Radius", "Mixed Button Padding", "Inconsistent Button Border Radius",
167
- ]);
168
- if (allIssues.length > 0) {
169
- console.log(chalk.yellow(" ⚠ ") + chalk.bold("Issues Found") + "\n");
170
- allIssues.forEach((issue, i) => {
171
- console.log(" " + (i + 1) + ". " + issue.title);
172
- if (issue.title === "Unused Imports" || issue.title === "Unused Variables") {
173
- wrapLine(INDENT, issue.detected);
174
- } else {
175
- wrapLine(INDENT + "Detected: ", issue.detected);
176
- console.log(INDENT + "Suggestion: " + issue.suggestion);
177
- }
178
- if (issue.fileLines.length > 0 && !NO_FILES_TITLES.has(issue.title)) {
179
- console.log(INDENT + "Files:");
180
- issue.fileLines.forEach((f) => console.log(INDENT + " " + f));
181
- }
182
- if (i < allIssues.length - 1) console.log("");
183
- });
184
- console.log("");
185
- }
186
-
187
- console.log(line);
188
- console.log(chalk.bold(" Scan Complete") + "\n");
189
- }
190
-
191
- async function main() {
192
- const arg = process.argv[2];
193
- if (!arg) {
194
- console.error("Usage: frontend-guardian <path>");
195
- console.error(" npx frontend-guardian .");
196
- console.error(" npx frontend-guardian ./src");
197
- console.error(" npx frontend-guardian project.zip");
198
- process.exit(1);
199
- }
200
- const resolved = path.resolve(process.cwd(), arg);
201
- if (!fs.existsSync(resolved)) {
202
- console.error("Path not found:", resolved);
203
- process.exit(1);
204
- }
205
- const projectName = path.basename(resolved).replace(/\.zip$/i, "") || "project";
206
-
207
- const stat = fs.statSync(resolved);
208
- let result: ScanResult;
209
-
210
- if (stat.isDirectory()) {
211
- const files = readDirRecursive(resolved);
212
- if (files.length === 0) {
213
- console.error("No .js/.jsx/.ts/.tsx files found (or all under node_modules/.next/dist/build).");
214
- process.exit(1);
215
- }
216
- const spinner = ora("Scanning " + files.length + " file(s)...").start();
217
- try {
218
- result = scanFiles(files);
219
- spinner.succeed("Scan complete.");
220
- } catch (e) {
221
- spinner.fail("Scan failed.");
222
- throw e;
223
- }
224
- } else if (resolved.toLowerCase().endsWith(".zip")) {
225
- const spinner = ora("Scanning ZIP...").start();
226
- try {
227
- const buf = fs.readFileSync(resolved);
228
- result = await scanZip(buf);
229
- spinner.succeed("Scan complete.");
230
- } catch (e) {
231
- spinner.fail("Scan failed.");
232
- throw e;
233
- }
234
- } else {
235
- console.error("Provide a folder path or a .zip file.");
236
- process.exit(1);
237
- }
238
-
239
- printResult(projectName, result);
240
- }
241
-
242
- main().catch((err) => {
243
- console.error(err);
244
- process.exit(1);
245
- });
package/tsconfig.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "strict": true,
9
- "skipLibCheck": true
10
- },
11
- "include": ["src/**/*.ts"],
12
- "exclude": ["node_modules", "dist"]
13
- }