fe-inspector-mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/design-review.d.ts +11 -0
- package/dist/design-review.js +133 -0
- package/dist/index.js +28 -1
- package/dist/inspector/a11y.js +3 -10
- package/dist/inspector/aesthetic.js +3 -10
- package/dist/inspector/index.js +8 -0
- package/dist/inspector/interaction.js +3 -10
- package/dist/inspector/performance.d.ts +8 -0
- package/dist/inspector/performance.js +157 -0
- package/dist/inspector/responsive.js +3 -10
- package/dist/types.d.ts +4 -1
- package/dist/utils/dom.d.ts +7 -0
- package/dist/utils/dom.js +13 -0
- package/dist/utils/parser.d.ts +31 -0
- package/dist/utils/parser.js +56 -0
- package/package.json +6 -2
- package/rules/community/README.md +46 -0
- package/skills/fe-inspect.md +3 -1
package/README.md
CHANGED
|
@@ -159,6 +159,40 @@ Same as `inspect_html` but returns structured Markdown fix suggestions that codi
|
|
|
159
159
|
|
|
160
160
|
List available categories and rule counts.
|
|
161
161
|
|
|
162
|
+
### `review_design` ✨ (hallmark can't do this)
|
|
163
|
+
|
|
164
|
+
**Pre-build design review.** Describe what you're about to build, and get a checklist of common frontend pitfalls BEFORE writing code.
|
|
165
|
+
|
|
166
|
+
**Parameters:**
|
|
167
|
+
- `description` (required): Describe the UI you're planning (e.g. "a login form with email and password, a navbar with dropdown menus, a pricing card grid")
|
|
168
|
+
|
|
169
|
+
**Example:**
|
|
170
|
+
```
|
|
171
|
+
Agent: review_design description="a landing page with hero section, feature cards, testimonial carousel, and footer form"
|
|
172
|
+
|
|
173
|
+
Result:
|
|
174
|
+
[!] [a11y] MUST: Alt text on all images
|
|
175
|
+
Every <img> needs alt. Decorative images get alt="".
|
|
176
|
+
|
|
177
|
+
[!] [responsive] MUST: Viewport meta tag
|
|
178
|
+
Every page needs <meta name="viewport">.
|
|
179
|
+
|
|
180
|
+
[~] [responsive] SHOULD: Responsive grid breakpoints
|
|
181
|
+
Use grid-template-columns with auto-fit/auto-fill or add @media breakpoints.
|
|
182
|
+
|
|
183
|
+
[~] [interaction] SHOULD: Loading states
|
|
184
|
+
Show skeleton screens or spinners during data fetch.
|
|
185
|
+
|
|
186
|
+
[~] [semantic] SHOULD: Semantic HTML structure
|
|
187
|
+
Use <header>, <main>, <section> instead of generic <div>s.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
3 must-fix, 4 should-fix, 7 total points to review.
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Why this matters:** hallmark only checks code after it's written. `review_design` checks your intent before you write a single line — catching issues at the design stage where they're cheapest to fix.
|
|
195
|
+
|
|
162
196
|
## Usage with agents
|
|
163
197
|
|
|
164
198
|
**Claude Code:**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface DesignReviewRequest {
|
|
2
|
+
description: string;
|
|
3
|
+
}
|
|
4
|
+
export interface DesignPoint {
|
|
5
|
+
category: 'a11y' | 'responsive' | 'interaction' | 'performance' | 'semantic';
|
|
6
|
+
severity: 'must' | 'should' | 'consider';
|
|
7
|
+
point: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function reviewDesign(request: DesignReviewRequest): DesignPoint[];
|
|
11
|
+
export declare function formatReview(points: DesignPoint[]): string;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ── Pre-build design review
|
|
2
|
+
// hallmark checks code AFTER it's written.
|
|
3
|
+
// This checks BEFORE — given a description, it predicts common pitfalls.
|
|
4
|
+
const designPatterns = [
|
|
5
|
+
// ── Forms ──
|
|
6
|
+
{
|
|
7
|
+
triggers: ['form', 'input', 'signup', 'login', 'register', 'contact', 'search box', 'enter', 'submit'],
|
|
8
|
+
points: [
|
|
9
|
+
{ category: 'a11y', severity: 'must', point: 'Label every input', detail: 'Use <label for="id"> or aria-label. Placeholder is not a label.' },
|
|
10
|
+
{ category: 'interaction', severity: 'must', point: 'Show validation inline', detail: 'Don\'t rely on alert() for errors. Show messages next to the field.' },
|
|
11
|
+
{ category: 'a11y', severity: 'must', point: 'Error messages need aria-live', detail: 'Use aria-live="polite" so screen readers announce errors.' },
|
|
12
|
+
{ category: 'responsive', severity: 'should', point: 'Inputs fill width on mobile', detail: 'Use width: 100% on small screens so inputs aren\'t tiny.' },
|
|
13
|
+
{ category: 'interaction', severity: 'should', point: 'Disable submit while loading', detail: 'Prevent double-submission with disabled state + spinner.' },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
// ── Navigation / menus ──
|
|
17
|
+
{
|
|
18
|
+
triggers: ['nav', 'menu', 'sidebar', 'header', 'navigation', 'navbar', 'tabs', 'breadcrumb'],
|
|
19
|
+
points: [
|
|
20
|
+
{ category: 'a11y', severity: 'must', point: 'Add skip-to-content link', detail: 'First focusable element should be "Skip to main content".' },
|
|
21
|
+
{ category: 'a11y', severity: 'must', point: 'Keyboard navigation for menus', detail: 'Use arrow keys for submenu navigation. Escape closes.' },
|
|
22
|
+
{ category: 'a11y', severity: 'should', point: 'Current page indicator', detail: 'Mark current page with aria-current="page" on the active link.' },
|
|
23
|
+
{ category: 'responsive', severity: 'must', point: 'Mobile hamburger menu', detail: 'On screens < 768px, collapse nav into a toggleable menu.' },
|
|
24
|
+
{ category: 'interaction', severity: 'should', point: 'Click outside closes menu', detail: 'Clicking outside an open dropdown/modal should close it.' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
// ── Cards / grids / lists ──
|
|
28
|
+
{
|
|
29
|
+
triggers: ['card', 'grid', 'list', 'feed', 'gallery', 'features', 'pricing', 'showcase'],
|
|
30
|
+
points: [
|
|
31
|
+
{ category: 'responsive', severity: 'must', point: 'Responsive grid breakpoints', detail: 'Use grid-template-columns with auto-fit/auto-fill or add @media breakpoints for mobile.' },
|
|
32
|
+
{ category: 'interaction', severity: 'should', point: 'Clickable cards need visual feedback', detail: 'Add :hover transform/scale and :focus outline to card interactions.' },
|
|
33
|
+
{ category: 'a11y', severity: 'should', point: 'Card text contrast', detail: 'Ensure text on colored card backgrounds meets WCAG AA (4.5:1).' },
|
|
34
|
+
{ category: 'performance', severity: 'consider', point: 'Lazy-load images', detail: 'Use loading="lazy" on images below the fold.' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
// ── Tables / data ──
|
|
38
|
+
{
|
|
39
|
+
triggers: ['table', 'data', 'spreadsheet', 'grid', 'report', 'chart'],
|
|
40
|
+
points: [
|
|
41
|
+
{ category: 'semantic', severity: 'must', point: 'Use <th> for headers', detail: 'Header cells must use <th> with scope="col" or scope="row".' },
|
|
42
|
+
{ category: 'a11y', severity: 'must', point: 'Add <caption> or aria-label', detail: 'Tables need a caption or aria-label describing their purpose.' },
|
|
43
|
+
{ category: 'responsive', severity: 'must', point: 'Horizontally scrollable on mobile', detail: 'Wrap wide tables in overflow-x: auto container.' },
|
|
44
|
+
{ category: 'interaction', severity: 'should', point: 'Row hover highlight', detail: 'Add :hover background to table rows for readability.' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
// ── Modals / dialogs ──
|
|
48
|
+
{
|
|
49
|
+
triggers: ['modal', 'dialog', 'popup', 'overlay', 'lightbox', 'drawer'],
|
|
50
|
+
points: [
|
|
51
|
+
{ category: 'a11y', severity: 'must', point: 'Trap focus inside modal', detail: 'When modal is open, Tab should cycle within it, not behind it.' },
|
|
52
|
+
{ category: 'a11y', severity: 'must', point: 'Escape key closes modal', detail: 'Add keydown listener for Escape to close.' },
|
|
53
|
+
{ category: 'interaction', severity: 'must', point: 'Backdrop click behavior', detail: 'Clicking the overlay should either close the modal or be clearly NOT clickable.' },
|
|
54
|
+
{ category: 'a11y', severity: 'must', point: 'Restore focus on close', detail: 'When modal closes, return focus to the element that opened it.' },
|
|
55
|
+
{ category: 'responsive', severity: 'should', point: 'Modal fits small screens', detail: 'On mobile, use full-screen or near-full-screen modal layout.' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
// ── Images / media ──
|
|
59
|
+
{
|
|
60
|
+
triggers: ['image', 'photo', 'picture', 'avatar', 'thumbnail', 'carousel', 'slider'],
|
|
61
|
+
points: [
|
|
62
|
+
{ category: 'a11y', severity: 'must', point: 'Alt text on all images', detail: 'Every <img> needs alt. Decorative images get alt="".' },
|
|
63
|
+
{ category: 'responsive', severity: 'must', point: 'Responsive images', detail: 'Use max-width: 100% or srcset for responsive images.' },
|
|
64
|
+
{ category: 'performance', severity: 'should', point: 'Optimize image loading', detail: 'Use width/height attributes to prevent layout shift.' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
// ── Buttons / CTAs ──
|
|
68
|
+
{
|
|
69
|
+
triggers: ['button', 'cta', 'action', 'call to action', 'submit', 'click'],
|
|
70
|
+
points: [
|
|
71
|
+
{ category: 'interaction', severity: 'must', point: 'Cursor pointer on buttons', detail: 'All clickable elements need cursor: pointer.' },
|
|
72
|
+
{ category: 'interaction', severity: 'must', point: 'Hover and focus states', detail: 'Every interactive element needs :hover and :focus visual feedback.' },
|
|
73
|
+
{ category: 'a11y', severity: 'must', point: 'Descriptive button text', detail: '"Click here" is bad. "Submit form" or "Open menu" is good.' },
|
|
74
|
+
{ category: 'responsive', severity: 'should', point: 'Touch-friendly tap targets', detail: 'Minimum 44x44px tap target size for mobile.' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
// ── Text content ──
|
|
78
|
+
{
|
|
79
|
+
triggers: ['article', 'blog', 'content', 'text', 'paragraph', 'typography', 'reading'],
|
|
80
|
+
points: [
|
|
81
|
+
{ category: 'a11y', severity: 'must', point: 'Heading hierarchy', detail: 'Use h1 → h2 → h3 in order. Don\'t skip levels.' },
|
|
82
|
+
{ category: 'responsive', severity: 'should', point: 'Readable line length', detail: 'Keep text containers at 60-75 characters max-width for readability.' },
|
|
83
|
+
{ category: 'a11y', severity: 'should', point: 'Font size minimum', detail: 'Body text should be at least 16px to prevent mobile zoom issues.' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
// ── Layout / page structure ──
|
|
87
|
+
{
|
|
88
|
+
triggers: ['landing', 'page', 'layout', 'homepage', 'dashboard', 'app'],
|
|
89
|
+
points: [
|
|
90
|
+
{ category: 'responsive', severity: 'must', point: 'Viewport meta tag', detail: 'Every page needs <meta name="viewport" content="width=device-width, initial-scale=1">.' },
|
|
91
|
+
{ category: 'semantic', severity: 'should', point: 'Semantic HTML structure', detail: 'Use <header>, <main>, <nav>, <section>, <article>, <footer> instead of generic <div>s.' },
|
|
92
|
+
{ category: 'a11y', severity: 'should', point: 'Landmark regions', detail: 'Add aria-label to <nav> and <section> elements when multiple exist.' },
|
|
93
|
+
{ category: 'interaction', severity: 'consider', point: 'Loading states', detail: 'Show skeleton screens or spinners during data fetch — don\'t leave users staring at blank space.' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
export function reviewDesign(request) {
|
|
98
|
+
const desc = request.description.toLowerCase();
|
|
99
|
+
const matchedPoints = new Set();
|
|
100
|
+
const results = [];
|
|
101
|
+
for (const pattern of designPatterns) {
|
|
102
|
+
const matches = pattern.triggers.some(t => desc.includes(t));
|
|
103
|
+
if (matches) {
|
|
104
|
+
for (const point of pattern.points) {
|
|
105
|
+
if (!matchedPoints.has(point.point)) {
|
|
106
|
+
matchedPoints.add(point.point);
|
|
107
|
+
results.push(point);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
export function formatReview(points) {
|
|
115
|
+
if (points.length === 0) {
|
|
116
|
+
return 'No pre-build review points matched. Describe more UI details for better suggestions.';
|
|
117
|
+
}
|
|
118
|
+
const lines = [];
|
|
119
|
+
lines.push('# Design Pre-Review\n');
|
|
120
|
+
const severityOrder = { must: 0, should: 1, consider: 2 };
|
|
121
|
+
const sorted = [...points].sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
122
|
+
for (const p of sorted) {
|
|
123
|
+
const icon = p.severity === 'must' ? '[!]' : p.severity === 'should' ? '[~]' : '[i]';
|
|
124
|
+
const tag = `[${p.category}]`;
|
|
125
|
+
lines.push(`${icon} ${tag} ${p.severity.toUpperCase()}: ${p.point}`);
|
|
126
|
+
lines.push(` ${p.detail}`);
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
129
|
+
const must = points.filter(p => p.severity === 'must').length;
|
|
130
|
+
const should = points.filter(p => p.severity === 'should').length;
|
|
131
|
+
lines.push(`---\n${must} must-fix, ${should} should-fix, ${points.length} total points to review.`);
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { createRequire } from 'module';
|
|
5
6
|
import { runInspection, getInspectorMeta } from './inspector/index.js';
|
|
6
7
|
import { generateTextReport, generateJsonReport } from './reporter/index.js';
|
|
7
|
-
|
|
8
|
+
import { reviewDesign, formatReview } from './design-review.js';
|
|
9
|
+
// 从 package.json 动态读取版本号
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
const { version, name } = { version: pkg.version, name: pkg.name };
|
|
8
13
|
const server = new Server({ name, version }, { capabilities: { tools: {} } });
|
|
9
14
|
// ── Tool definitions ──
|
|
10
15
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -70,6 +75,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
70
75
|
required: ['html'],
|
|
71
76
|
},
|
|
72
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: 'review_design',
|
|
80
|
+
description: 'Pre-build design review. Given a description of what you\'re about to build, returns a checklist of common frontend pitfalls to avoid. Use this BEFORE writing code to catch issues early — hallmark only checks after.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
description: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'Describe what you\'re about to build (e.g. "a login form with email and password, a navbar with dropdown menus, a pricing card grid")',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ['description'],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
73
92
|
],
|
|
74
93
|
};
|
|
75
94
|
});
|
|
@@ -124,6 +143,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
124
143
|
content: [{ type: 'text', text: fixLines.join('\n') }],
|
|
125
144
|
};
|
|
126
145
|
}
|
|
146
|
+
case 'review_design': {
|
|
147
|
+
const description = String(args?.description || '');
|
|
148
|
+
const points = reviewDesign({ description });
|
|
149
|
+
const output = formatReview(points);
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: 'text', text: output }],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
127
154
|
default:
|
|
128
155
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
129
156
|
}
|
package/dist/inspector/a11y.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'a11y';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -118,16 +119,8 @@ const rules = [
|
|
|
118
119
|
},
|
|
119
120
|
},
|
|
120
121
|
];
|
|
121
|
-
function buildSelector($el) {
|
|
122
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
123
|
-
const id = $el.attr?.('id');
|
|
124
|
-
if (id)
|
|
125
|
-
return `#${id}`;
|
|
126
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
127
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
128
|
-
}
|
|
129
122
|
export function inspectA11y(html) {
|
|
130
|
-
const $ =
|
|
123
|
+
const $ = parseHtml(html);
|
|
131
124
|
const results = [];
|
|
132
125
|
for (const rule of rules) {
|
|
133
126
|
results.push(...rule.check($));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'aesthetic';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -122,16 +123,8 @@ const rules = [
|
|
|
122
123
|
},
|
|
123
124
|
},
|
|
124
125
|
];
|
|
125
|
-
function buildSelector($el) {
|
|
126
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
127
|
-
const id = $el.attr?.('id');
|
|
128
|
-
if (id)
|
|
129
|
-
return `#${id}`;
|
|
130
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
131
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
132
|
-
}
|
|
133
126
|
export function inspectAesthetic(html) {
|
|
134
|
-
const $ =
|
|
127
|
+
const $ = parseHtml(html);
|
|
135
128
|
const results = [];
|
|
136
129
|
for (const rule of rules) {
|
|
137
130
|
results.push(...rule.check($));
|
package/dist/inspector/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
1
2
|
import { inspectAesthetic, aestheticMeta } from './aesthetic.js';
|
|
2
3
|
import { inspectA11y, a11yMeta } from './a11y.js';
|
|
3
4
|
import { inspectResponsive, responsiveMeta } from './responsive.js';
|
|
4
5
|
import { inspectInteraction, interactionMeta } from './interaction.js';
|
|
6
|
+
import { inspectPerformance, performanceMeta } from './performance.js';
|
|
5
7
|
const inspectors = {
|
|
6
8
|
aesthetic: {
|
|
7
9
|
...aestheticMeta,
|
|
@@ -19,10 +21,16 @@ const inspectors = {
|
|
|
19
21
|
...interactionMeta,
|
|
20
22
|
inspect: inspectInteraction,
|
|
21
23
|
},
|
|
24
|
+
performance: {
|
|
25
|
+
...performanceMeta,
|
|
26
|
+
inspect: inspectPerformance,
|
|
27
|
+
},
|
|
22
28
|
};
|
|
23
29
|
export function runInspection(input) {
|
|
24
30
|
const { html, url } = input;
|
|
25
31
|
const categories = input.categories || Object.keys(inspectors);
|
|
32
|
+
// 使用缓存解析 HTML,避免重复解析
|
|
33
|
+
const $ = parseHtml(html);
|
|
26
34
|
const categoryResults = {};
|
|
27
35
|
let total = 0;
|
|
28
36
|
let passed = 0;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'interaction';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -117,16 +118,8 @@ const rules = [
|
|
|
117
118
|
},
|
|
118
119
|
},
|
|
119
120
|
];
|
|
120
|
-
function buildSelector($el) {
|
|
121
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
122
|
-
const id = $el.attr?.('id');
|
|
123
|
-
if (id)
|
|
124
|
-
return `#${id}`;
|
|
125
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
126
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
127
|
-
}
|
|
128
121
|
export function inspectInteraction(html) {
|
|
129
|
-
const $ =
|
|
122
|
+
const $ = parseHtml(html);
|
|
130
123
|
const results = [];
|
|
131
124
|
for (const rule of rules) {
|
|
132
125
|
results.push(...rule.check($));
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
3
|
+
const category = 'performance';
|
|
4
|
+
const rules = [
|
|
5
|
+
{
|
|
6
|
+
id: 'pf-001',
|
|
7
|
+
title: 'Large image without width/height',
|
|
8
|
+
check: ($) => {
|
|
9
|
+
const results = [];
|
|
10
|
+
$('img').each((_i, el) => {
|
|
11
|
+
const width = $(el).attr('width');
|
|
12
|
+
const height = $(el).attr('height');
|
|
13
|
+
if (!width && !height) {
|
|
14
|
+
results.push({
|
|
15
|
+
ruleId: 'pf-001',
|
|
16
|
+
severity: 'warning',
|
|
17
|
+
title: 'Image missing dimensions',
|
|
18
|
+
message: 'Image has no width/height attributes — causes layout shift (CLS).',
|
|
19
|
+
element: buildSelector($(el)),
|
|
20
|
+
recommendation: 'Add explicit width and height attributes to prevent Cumulative Layout Shift.',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
return results;
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'pf-002',
|
|
29
|
+
title: 'Render-blocking external CSS/JS in <head>',
|
|
30
|
+
check: ($) => {
|
|
31
|
+
const results = [];
|
|
32
|
+
// Check for render-blocking stylesheets
|
|
33
|
+
$('head link[rel="stylesheet"]').each((_i, el) => {
|
|
34
|
+
const href = $(el).attr('href');
|
|
35
|
+
const media = $(el).attr('media');
|
|
36
|
+
if (href && !media) {
|
|
37
|
+
results.push({
|
|
38
|
+
ruleId: 'pf-002',
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
title: 'Render-blocking stylesheet',
|
|
41
|
+
message: `Stylesheet ${href} blocks rendering.`,
|
|
42
|
+
element: buildSelector($(el)),
|
|
43
|
+
recommendation: 'Use media="print" for non-critical CSS, or load asynchronously.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
// Check for render-blocking scripts
|
|
48
|
+
$('head script[src]').each((_i, el) => {
|
|
49
|
+
const defer = $(el).attr('defer');
|
|
50
|
+
const async = $(el).attr('async');
|
|
51
|
+
if (!defer && !async) {
|
|
52
|
+
results.push({
|
|
53
|
+
ruleId: 'pf-002',
|
|
54
|
+
severity: 'warning',
|
|
55
|
+
title: 'Render-blocking script',
|
|
56
|
+
message: 'Script in <head> without defer/async blocks rendering.',
|
|
57
|
+
element: buildSelector($(el)),
|
|
58
|
+
recommendation: 'Add defer or async attribute to non-critical scripts.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return results;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'pf-003',
|
|
67
|
+
title: 'Image not optimized (no loading=lazy below fold)',
|
|
68
|
+
check: ($) => {
|
|
69
|
+
const results = [];
|
|
70
|
+
const images = $('img').toArray();
|
|
71
|
+
// Skip first 2 images (likely above fold)
|
|
72
|
+
images.slice(2).forEach((el, index) => {
|
|
73
|
+
const loading = $(el).attr('loading');
|
|
74
|
+
if (!loading || loading !== 'lazy') {
|
|
75
|
+
results.push({
|
|
76
|
+
ruleId: 'pf-003',
|
|
77
|
+
severity: 'info',
|
|
78
|
+
title: 'Below-fold image missing lazy loading',
|
|
79
|
+
message: `Image #${index + 3} should use loading="lazy".`,
|
|
80
|
+
element: buildSelector($(el)),
|
|
81
|
+
recommendation: 'Add loading="lazy" to images below the fold for faster initial load.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return results;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'pf-004',
|
|
90
|
+
title: 'Too many HTTP requests (css/js/img count)',
|
|
91
|
+
check: ($) => {
|
|
92
|
+
const results = [];
|
|
93
|
+
const cssCount = $('link[href][rel="stylesheet"]').length;
|
|
94
|
+
const jsCount = $('script[src]').length;
|
|
95
|
+
const imgCount = $('img[src]').length;
|
|
96
|
+
const total = cssCount + jsCount + imgCount;
|
|
97
|
+
if (total > 30) {
|
|
98
|
+
results.push({
|
|
99
|
+
ruleId: 'pf-004',
|
|
100
|
+
severity: 'info',
|
|
101
|
+
title: 'Too many HTTP requests',
|
|
102
|
+
message: `Found ${total} external resources (${cssCount} CSS, ${jsCount} JS, ${imgCount} images).`,
|
|
103
|
+
element: 'head',
|
|
104
|
+
recommendation: 'Bundle CSS/JS files and use image sprites or lazy loading to reduce requests.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return results;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'pf-005',
|
|
112
|
+
title: 'DOM depth too deep (> 15 levels)',
|
|
113
|
+
check: ($) => {
|
|
114
|
+
const results = [];
|
|
115
|
+
function getMaxDepth(el, currentDepth) {
|
|
116
|
+
let maxDepth = currentDepth;
|
|
117
|
+
const children = $(el).children().toArray();
|
|
118
|
+
for (const child of children) {
|
|
119
|
+
const childDepth = getMaxDepth(child, currentDepth + 1);
|
|
120
|
+
if (childDepth > maxDepth) {
|
|
121
|
+
maxDepth = childDepth;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return maxDepth;
|
|
125
|
+
}
|
|
126
|
+
const body = $('body').get(0);
|
|
127
|
+
if (body) {
|
|
128
|
+
const depth = getMaxDepth(body, 0);
|
|
129
|
+
if (depth > 15) {
|
|
130
|
+
results.push({
|
|
131
|
+
ruleId: 'pf-005',
|
|
132
|
+
severity: 'info',
|
|
133
|
+
title: 'DOM depth too deep',
|
|
134
|
+
message: `DOM nesting depth is ${depth} levels (max recommended: 15).`,
|
|
135
|
+
element: 'body',
|
|
136
|
+
recommendation: 'Flatten HTML structure — deep nesting slows rendering and hurts maintainability.',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return results;
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
export function inspectPerformance(html) {
|
|
145
|
+
const $ = parseHtml(html);
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const rule of rules) {
|
|
148
|
+
results.push(...rule.check($));
|
|
149
|
+
}
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
export const performanceMeta = {
|
|
153
|
+
category,
|
|
154
|
+
label: 'Performance',
|
|
155
|
+
description: 'Page speed — image optimization, render blocking, DOM depth, HTTP requests',
|
|
156
|
+
ruleCount: rules.length,
|
|
157
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseHtml } from '../utils/parser.js';
|
|
2
|
+
import { buildSelector } from '../utils/dom.js';
|
|
2
3
|
const category = 'responsive';
|
|
3
4
|
const rules = [
|
|
4
5
|
{
|
|
@@ -125,16 +126,8 @@ const rules = [
|
|
|
125
126
|
},
|
|
126
127
|
},
|
|
127
128
|
];
|
|
128
|
-
function buildSelector($el) {
|
|
129
|
-
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
130
|
-
const id = $el.attr?.('id');
|
|
131
|
-
if (id)
|
|
132
|
-
return `#${id}`;
|
|
133
|
-
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
134
|
-
return cls ? `${tag}.${cls}` : tag;
|
|
135
|
-
}
|
|
136
129
|
export function inspectResponsive(html) {
|
|
137
|
-
const $ =
|
|
130
|
+
const $ = parseHtml(html);
|
|
138
131
|
const results = [];
|
|
139
132
|
for (const rule of rules) {
|
|
140
133
|
results.push(...rule.check($));
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export type Severity = 'error' | 'warning' | 'info';
|
|
2
|
+
export type CheerioElement = any;
|
|
3
|
+
export type CheerioSelection = any;
|
|
4
|
+
export type CheerioAPI = any;
|
|
2
5
|
export interface Detection {
|
|
3
6
|
ruleId: string;
|
|
4
7
|
severity: Severity;
|
|
@@ -7,7 +10,7 @@ export interface Detection {
|
|
|
7
10
|
element?: string;
|
|
8
11
|
recommendation: string;
|
|
9
12
|
}
|
|
10
|
-
export type InspectCategory = 'aesthetic' | 'a11y' | 'responsive' | 'interaction';
|
|
13
|
+
export type InspectCategory = 'aesthetic' | 'a11y' | 'responsive' | 'interaction' | 'performance';
|
|
11
14
|
export interface InspectReport {
|
|
12
15
|
url?: string;
|
|
13
16
|
totalElements: number;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建元素的 CSS 选择器
|
|
3
|
+
* @param $el Cheerio 元素对象
|
|
4
|
+
* @returns CSS 选择器字符串
|
|
5
|
+
*/
|
|
6
|
+
export function buildSelector($el) {
|
|
7
|
+
const tag = typeof $el.prop === 'function' ? ($el.prop('tagName')?.toLowerCase() || 'div') : 'div';
|
|
8
|
+
const id = $el.attr?.('id');
|
|
9
|
+
if (id)
|
|
10
|
+
return `#${id}`;
|
|
11
|
+
const cls = ($el.attr?.('class') || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
12
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML 解析缓存
|
|
3
|
+
* 避免同一个 HTML 字符串被多次解析
|
|
4
|
+
*/
|
|
5
|
+
declare class ParserCache {
|
|
6
|
+
private cache;
|
|
7
|
+
private maxSize;
|
|
8
|
+
constructor(maxSize?: number);
|
|
9
|
+
/**
|
|
10
|
+
* 获取解析后的 Cheerio 实例
|
|
11
|
+
* @param html HTML 字符串
|
|
12
|
+
* @returns CheerioAPI 实例
|
|
13
|
+
*/
|
|
14
|
+
parse(html: string): any;
|
|
15
|
+
/**
|
|
16
|
+
* 清空缓存
|
|
17
|
+
*/
|
|
18
|
+
clear(): void;
|
|
19
|
+
/**
|
|
20
|
+
* 获取缓存大小
|
|
21
|
+
*/
|
|
22
|
+
get size(): number;
|
|
23
|
+
}
|
|
24
|
+
export declare const parserCache: ParserCache;
|
|
25
|
+
/**
|
|
26
|
+
* 解析 HTML 并缓存结果
|
|
27
|
+
* @param html HTML 字符串
|
|
28
|
+
* @returns CheerioAPI 实例
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseHtml(html: string): any;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ── HTML 解析缓存模块 ──
|
|
2
|
+
import { load } from 'cheerio';
|
|
3
|
+
/**
|
|
4
|
+
* HTML 解析缓存
|
|
5
|
+
* 避免同一个 HTML 字符串被多次解析
|
|
6
|
+
*/
|
|
7
|
+
class ParserCache {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
maxSize;
|
|
10
|
+
constructor(maxSize = 10) {
|
|
11
|
+
this.maxSize = maxSize;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 获取解析后的 Cheerio 实例
|
|
15
|
+
* @param html HTML 字符串
|
|
16
|
+
* @returns CheerioAPI 实例
|
|
17
|
+
*/
|
|
18
|
+
parse(html) {
|
|
19
|
+
const cached = this.cache.get(html);
|
|
20
|
+
if (cached) {
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
// 如果缓存已满,删除最旧的条目
|
|
24
|
+
if (this.cache.size >= this.maxSize) {
|
|
25
|
+
const firstKey = this.cache.keys().next().value;
|
|
26
|
+
if (firstKey !== undefined) {
|
|
27
|
+
this.cache.delete(firstKey);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const $ = load(html);
|
|
31
|
+
this.cache.set(html, $);
|
|
32
|
+
return $;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 清空缓存
|
|
36
|
+
*/
|
|
37
|
+
clear() {
|
|
38
|
+
this.cache.clear();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 获取缓存大小
|
|
42
|
+
*/
|
|
43
|
+
get size() {
|
|
44
|
+
return this.cache.size;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// 导出全局缓存实例
|
|
48
|
+
export const parserCache = new ParserCache();
|
|
49
|
+
/**
|
|
50
|
+
* 解析 HTML 并缓存结果
|
|
51
|
+
* @param html HTML 字符串
|
|
52
|
+
* @returns CheerioAPI 实例
|
|
53
|
+
*/
|
|
54
|
+
export function parseHtml(html) {
|
|
55
|
+
return parserCache.parse(html);
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fe-inspector-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP Server for frontend quality inspection — catches aesthetic flaws, a11y issues, responsive breaks, and interaction bugs in AI-generated HTML.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"start": "node dist/index.js",
|
|
17
17
|
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:coverage": "vitest run --coverage",
|
|
18
21
|
"prepublishOnly": "npm run build"
|
|
19
22
|
},
|
|
20
23
|
"keywords": [
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
"@types/cheerio": "^0.22.35",
|
|
39
42
|
"@types/node": "^22.20.1",
|
|
40
43
|
"tsx": "^4.19.0",
|
|
41
|
-
"typescript": "^5.7.0"
|
|
44
|
+
"typescript": "^5.7.0",
|
|
45
|
+
"vitest": "^4.1.10"
|
|
42
46
|
}
|
|
43
47
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Community Rules
|
|
2
|
+
|
|
3
|
+
This directory contains community-contributed inspection rules for FE Inspector MCP.
|
|
4
|
+
|
|
5
|
+
## How to contribute
|
|
6
|
+
|
|
7
|
+
1. Create a new `.ts` file in this directory following the rule template below
|
|
8
|
+
2. Each file should export a `rules` array and a `meta` object
|
|
9
|
+
3. Submit a PR to the [GitHub repo](https://github.com/Erich956389473/fe-inspector-mcp)
|
|
10
|
+
|
|
11
|
+
## Rule template
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import type { Detection } from '../../src/types.js';
|
|
15
|
+
import { load } from 'cheerio';
|
|
16
|
+
|
|
17
|
+
export const meta = {
|
|
18
|
+
category: 'aesthetic', // aesthetic | a11y | responsive | interaction
|
|
19
|
+
label: 'Your Rule Category',
|
|
20
|
+
description: 'Short description',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const rules = [{
|
|
24
|
+
id: 'community-001', // community-NNN format
|
|
25
|
+
title: 'Your rule title',
|
|
26
|
+
severity: 'warning', // error | warning | info
|
|
27
|
+
check: ($: ReturnType<typeof load>): Detection[] => {
|
|
28
|
+
const results: Detection[] = [];
|
|
29
|
+
// Your detection logic here
|
|
30
|
+
return results;
|
|
31
|
+
},
|
|
32
|
+
}];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Guidelines
|
|
36
|
+
|
|
37
|
+
- Use `community-NNN` rule ID format (NNN = 3-digit number)
|
|
38
|
+
- Each rule must have a clear fix recommendation
|
|
39
|
+
- Test your rule against real HTML before submitting
|
|
40
|
+
- Keep rules focused — one check per rule
|
|
41
|
+
|
|
42
|
+
## Review process
|
|
43
|
+
|
|
44
|
+
1. PR received → automated test run
|
|
45
|
+
2. Manual review within 7 days
|
|
46
|
+
3. Approved rules merged into next release
|
package/skills/fe-inspect.md
CHANGED
|
@@ -35,9 +35,11 @@ Run fe-inspect on the most recent HTML output and report issues.
|
|
|
35
35
|
- `inspect_html` — run all checks on HTML, get a full report
|
|
36
36
|
- `inspect_html_fix` — run all checks, get structured fix suggestions the agent can apply
|
|
37
37
|
- `inspect_categories` — list available categories and rule counts
|
|
38
|
+
- `review_design` — **before writing code**, describe what you're building and get a checklist of common pitfalls. hallmark can't do this.
|
|
38
39
|
|
|
39
40
|
## Design principles
|
|
40
41
|
|
|
41
42
|
1. **hallmark covered aesthetic only.** This skill extends to behavior-level checks that hallmark ignores.
|
|
42
43
|
2. **Report is actionable.** Every detection includes a specific fix recommendation.
|
|
43
|
-
3. **
|
|
44
|
+
3. **Pre-check before code.** Use `review_design` before writing any HTML to catch issues at design stage.
|
|
45
|
+
4. **Not a style guide.** It detects anti-patterns, not taste choices.
|