← vibesdk
worker/services/static-analysis/validators/HTMLCSSCrossValidator.ts
5.4 KB
Download file
import { Parser } from 'htmlparser2';
import type { CrossFileValidator, FileInput, CodeIssue } from '../types';
interface CSSUsage {
name: string;
type: 'class' | 'id';
filePath: string;
line: number;
}
const DYNAMIC_CLASS_PATTERNS = [
/^{{.*}}$/,
/^<%.*%>$/,
/^\$\{.*\}$/,
/^\[.*\]$/,
/^:.*$/,
/^v-/,
/^ng-/,
/^x-/,
];
const UTILITY_FRAMEWORK_PATTERNS = [
/^-?[mpwh][trblxy]?-/,
/^(flex|grid|block|inline|hidden|visible|absolute|relative|fixed|sticky)/,
/^(text|bg|border|rounded|shadow|opacity|z)-/,
/^(sm|md|lg|xl|2xl):/,
/^(hover|focus|active|disabled|dark):/,
/^(justify|items|content|self)-/,
/^(gap|space)-/,
/^(font|leading|tracking)-/,
/^(overflow|cursor|pointer|transition|duration|ease)-/,
];
function isLikelyDynamicOrFrameworkClass(className: string): boolean {
for (const pattern of DYNAMIC_CLASS_PATTERNS) {
if (pattern.test(className)) return true;
}
for (const pattern of UTILITY_FRAMEWORK_PATTERNS) {
if (pattern.test(className)) return true;
}
return false;
}
export class HTMLCSSCrossValidator implements CrossFileValidator {
validate(files: FileInput[]): CodeIssue[] {
const definedClasses = new Set<string>();
const definedIds = new Set<string>();
const usedSelectors: CSSUsage[] = [];
// Extract CSS definitions from .css files and inline <style> tags
for (const file of files) {
if (file.path.endsWith('.css')) {
this.extractCSSDefinitions(file.content, definedClasses, definedIds);
}
if (file.path.endsWith('.html') || file.path.endsWith('.htm')) {
this.extractInlineStyles(file.content, definedClasses, definedIds);
}
}
// Extract CSS usage from HTML files
for (const file of files) {
if (file.path.endsWith('.html') || file.path.endsWith('.htm')) {
const usage = this.extractHTMLUsage(file);
usedSelectors.push(...usage);
}
}
// Find undefined selectors
const issues: CodeIssue[] = [];
for (const usage of usedSelectors) {
if (usage.type === 'class' && !definedClasses.has(usage.name)) {
if (isLikelyDynamicOrFrameworkClass(usage.name)) continue;
issues.push({
message: `CSS class "${usage.name}" is used but not defined in any CSS file`,
filePath: usage.filePath,
line: usage.line,
column: 0,
severity: 'warning',
ruleId: 'CSS_CLASS_UNDEFINED',
source: 'html-css-validator',
});
}
if (usage.type === 'id' && !definedIds.has(usage.name)) {
issues.push({
message: `CSS ID "${usage.name}" is used but not defined in any CSS file`,
filePath: usage.filePath,
line: usage.line,
column: 0,
severity: 'warning',
ruleId: 'CSS_ID_UNDEFINED',
source: 'html-css-validator',
});
}
}
return issues;
}
private extractCSSDefinitions(
content: string,
classes: Set<string>,
ids: Set<string>
): void {
// Strip comments
let cleaned = content.replace(/\/\*[\s\S]*?\*\//g, '');
// Strip url(...) values to avoid matching dots in URLs
cleaned = cleaned.replace(/url\s*\([^)]*\)/gi, '');
// Extract only selector portions (text before each {)
const selectorBlocks: string[] = [];
let depth = 0;
let currentSelector = '';
for (let i = 0; i < cleaned.length; i++) {
const char = cleaned[i];
if (char === '{') {
if (depth === 0) {
selectorBlocks.push(currentSelector);
currentSelector = '';
}
depth++;
} else if (char === '}') {
depth--;
} else if (depth === 0) {
currentSelector += char;
}
}
// Parse selectors from the extracted selector blocks
const selectorText = selectorBlocks.join(' ');
// Match class selectors: .classname (must start with letter, underscore, or hyphen)
const classRegex = /\.([a-zA-Z_][a-zA-Z0-9_-]*)/g;
let match;
while ((match = classRegex.exec(selectorText)) !== null) {
classes.add(match[1]);
}
// Match ID selectors: #idname (must start with letter or underscore, not digit)
const idRegex = /#([a-zA-Z_][a-zA-Z0-9_-]*)/g;
while ((match = idRegex.exec(selectorText)) !== null) {
ids.add(match[1]);
}
}
private extractInlineStyles(
htmlContent: string,
classes: Set<string>,
ids: Set<string>
): void {
// Extract content from <style> tags
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let match;
while ((match = styleRegex.exec(htmlContent)) !== null) {
this.extractCSSDefinitions(match[1], classes, ids);
}
}
private extractHTMLUsage(file: FileInput): CSSUsage[] {
const usage: CSSUsage[] = [];
let currentLine = 1;
const parser = new Parser(
{
onopentag: (_name: string, attribs: Record<string, string>) => {
// Extract class usage
if (attribs.class) {
const classNames = attribs.class.split(/\s+/).filter(Boolean);
for (const className of classNames) {
usage.push({
name: className,
type: 'class',
filePath: file.path,
line: currentLine,
});
}
}
// Extract id usage
if (attribs.id) {
usage.push({
name: attribs.id,
type: 'id',
filePath: file.path,
line: currentLine,
});
}
},
ontext: (text: string) => {
currentLine += (text.match(/\n/g) || []).length;
},
},
{
lowerCaseTags: false,
lowerCaseAttributeNames: true,
}
);
try {
parser.write(file.content);
parser.end();
} catch {
// Ignore parse errors - HTMLAnalyzer will catch them
}
return usage;
}
}