mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Harden CLI detection for framework files, multi-file projects, and dev servers
Tier 1: Add Vue/Svelte <style> block extraction and CSS-in-JS template literal detection (styled-components, emotion) so anti-patterns inside framework-specific syntax are caught. Enable multi-line context for CSS files so cross-line patterns like gradient-text are detected. Tier 2: Build a lightweight import graph when scanning directories. Findings are annotated with importedBy context (e.g. "imported by App.tsx") in both human and JSON output. Tier 3: Detect framework config files (Next.js, Vite, SvelteKit, Nuxt, Astro, Angular, Remix), probe the dev server port with HTTP fingerprinting to distinguish the expected framework from unrelated services, and suggest URL-based scanning for more accurate results. Adds realistic Next.js project fixtures (Tailwind, CSS Modules, styled-components) plus Vue, Svelte, JSX, and CSS-in-JS unit fixtures. 158 tests, 356 assertions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
dd559cd1e9
commit
8d05411c3e
@@ -1945,31 +1945,108 @@ const REGEX_ANALYZERS = [
|
||||
},
|
||||
];
|
||||
|
||||
function detectText(content, filePath) {
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style block extraction (Vue/Svelte <style> blocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractStyleBlocks(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext !== '.vue' && ext !== '.svelte') return [];
|
||||
const blocks = [];
|
||||
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length + 1;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS-in-JS extraction (styled-components, emotion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
||||
|
||||
function extractCSSinJS(content, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
||||
const blocks = [];
|
||||
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const before = content.substring(0, m.index);
|
||||
const startLine = before.split('\n').length;
|
||||
blocks.push({ content: m[1], startLine });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
|
||||
const findings = [];
|
||||
for (const matcher of REGEX_MATCHERS) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
matcher.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = matcher.regex.exec(line)) !== null) {
|
||||
if (matcher.test(m, line)) {
|
||||
findings.push(finding(matcher.id, filePath, matcher.fmt(m, line), i + 1));
|
||||
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
||||
const context = blockContext
|
||||
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
||||
: line;
|
||||
if (matcher.test(m, context)) {
|
||||
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function detectText(content, filePath) {
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
|
||||
|
||||
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
||||
const styleBlocks = extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
const cssJsBlocks = extractCSSinJS(content, ext);
|
||||
for (const block of cssJsBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
||||
}
|
||||
|
||||
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
||||
const deduped = [];
|
||||
for (const f of findings) {
|
||||
const isDupe = deduped.some(d =>
|
||||
d.antipattern === f.antipattern &&
|
||||
d.snippet === f.snippet &&
|
||||
Math.abs(d.line - f.line) <= 2
|
||||
);
|
||||
if (!isDupe) deduped.push(f);
|
||||
}
|
||||
|
||||
// Page-level analyzers only run on full pages
|
||||
if (isFullPage(content)) {
|
||||
for (const analyzer of REGEX_ANALYZERS) {
|
||||
findings.push(...analyzer(content, filePath));
|
||||
deduped.push(...analyzer(content, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
return deduped;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2016,7 +2093,8 @@ function formatFindings(findings, jsonMode) {
|
||||
}
|
||||
const out = [];
|
||||
for (const [file, items] of Object.entries(grouped)) {
|
||||
out.push(`\n${file}`);
|
||||
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
|
||||
out.push(`\n${file}${importNote}`);
|
||||
for (const item of items) {
|
||||
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
|
||||
out.push(` → ${item.description}`);
|
||||
@@ -2045,6 +2123,159 @@ async function handleStdin() {
|
||||
return detectText(input, '<stdin>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import graph (multi-file awareness)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveImport(specifier, fromDir, fileSet) {
|
||||
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
|
||||
const base = path.resolve(fromDir, specifier);
|
||||
if (fileSet.has(base)) return base;
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const withExt = base + ext;
|
||||
if (fileSet.has(withExt)) return withExt;
|
||||
}
|
||||
// index file convention
|
||||
for (const ext of SCANNABLE_EXTENSIONS) {
|
||||
const indexFile = path.join(base, 'index' + ext);
|
||||
if (fileSet.has(indexFile)) return indexFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildImportGraph(files) {
|
||||
const fileSet = new Set(files);
|
||||
const graph = new Map();
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const dir = path.dirname(file);
|
||||
const imports = new Set();
|
||||
|
||||
// ES imports: import ... from '...' and import '...'
|
||||
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = esRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// CSS @import
|
||||
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
||||
while ((m = cssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
// SCSS @use / @forward
|
||||
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
||||
while ((m = scssRe.exec(content)) !== null) {
|
||||
const resolved = resolveImport(m[1], dir, fileSet);
|
||||
if (resolved) imports.add(resolved);
|
||||
}
|
||||
|
||||
graph.set(file, imports);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framework dev server detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FRAMEWORK_CONFIGS = [
|
||||
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /next/i } },
|
||||
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-sveltekit-page', value: null } },
|
||||
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
||||
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /@vite\/client/ } },
|
||||
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { body: /astro/i } },
|
||||
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
|
||||
portRe: /"port"\s*:\s*(\d+)/,
|
||||
fingerprint: { body: /ng-version/i } },
|
||||
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
|
||||
portRe: /port\s*[:=]\s*(\d+)/,
|
||||
fingerprint: { header: 'x-powered-by', value: /remix/i } },
|
||||
];
|
||||
|
||||
function detectFrameworkConfig(dir) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { return null; }
|
||||
const entrySet = new Set(entries);
|
||||
|
||||
for (const cfg of FRAMEWORK_CONFIGS) {
|
||||
const match = cfg.files.find(f => entrySet.has(f));
|
||||
if (!match) continue;
|
||||
|
||||
const configPath = path.join(dir, match);
|
||||
let port = cfg.defaultPort;
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const portMatch = content.match(cfg.portRe);
|
||||
if (portMatch) port = parseInt(portMatch[1], 10);
|
||||
} catch { /* use default */ }
|
||||
|
||||
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is listening and optionally verify it matches the expected framework.
|
||||
* Returns { listening: true, matched: true/false } or { listening: false }.
|
||||
*/
|
||||
async function isPortListening(port, fingerprint = null) {
|
||||
if (!fingerprint) {
|
||||
// Simple TCP probe fallback
|
||||
const net = await import('node:net');
|
||||
return new Promise((resolve) => {
|
||||
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
|
||||
sock.setTimeout(500);
|
||||
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
|
||||
sock.on('error', () => resolve({ listening: false }));
|
||||
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP probe with fingerprint matching
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Check header fingerprint
|
||||
if (fingerprint.header) {
|
||||
const val = res.headers.get(fingerprint.header);
|
||||
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Check body fingerprint
|
||||
if (fingerprint.body) {
|
||||
const body = await res.text();
|
||||
if (fingerprint.body.test(body)) {
|
||||
return { listening: true, matched: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Port is listening but doesn't match the expected framework
|
||||
return { listening: true, matched: false };
|
||||
} catch {
|
||||
return { listening: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2101,14 +2332,63 @@ async function main() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
for (const file of walkDir(resolved)) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
allFindings.push(...await detectHtml(file));
|
||||
} else {
|
||||
allFindings.push(...detectText(fs.readFileSync(file, 'utf-8'), file));
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
if (probe.listening && probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
|
||||
`For more accurate results, scan the running site:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
} else if (probe.listening && !probe.matched) {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
||||
`Start the dev server and scan via URL for best results:\n` +
|
||||
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = walkDir(resolved);
|
||||
|
||||
// Build import graph for multi-file awareness
|
||||
const graph = buildImportGraph(files);
|
||||
// Build reverse map: file -> set of files that import it
|
||||
const importedByMap = new Map();
|
||||
for (const [importer, imports] of graph) {
|
||||
for (const imported of imports) {
|
||||
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
|
||||
importedByMap.get(imported).add(importer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
let fileFindings;
|
||||
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
fileFindings = await detectHtml(file);
|
||||
} else {
|
||||
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
|
||||
}
|
||||
// Annotate findings with import context
|
||||
const importers = importedByMap.get(file);
|
||||
if (importers && importers.size > 0) {
|
||||
const importerNames = [...importers].map(f => path.basename(f));
|
||||
for (const f of fileFindings) {
|
||||
f.importedBy = importerNames;
|
||||
}
|
||||
}
|
||||
allFindings.push(...fileFindings);
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
||||
@@ -2148,6 +2428,9 @@ export {
|
||||
checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
||||
detectHtml, detectUrl, detectText,
|
||||
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
||||
extractStyleBlocks, extractCSSinJS,
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
main as detectCli,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
|
||||
detectText,
|
||||
detectText, extractStyleBlocks, extractCSSinJS,
|
||||
walkDir, SCANNABLE_EXTENSIONS,
|
||||
buildImportGraph, resolveImport,
|
||||
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
||||
} from '../source/skills/critique/scripts/detect-antipatterns.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
@@ -588,3 +590,642 @@ describe('CLI', () => {
|
||||
expect(stderr).toContain('Warning');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: Vue/Svelte <style> block extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractStyleBlocks', () => {
|
||||
test('extracts single <style> block from Vue SFC', () => {
|
||||
const vue = `<template><div>hi</div></template>
|
||||
<style scoped>
|
||||
.card { border-left: 4px solid blue; }
|
||||
</style>`;
|
||||
const blocks = extractStyleBlocks(vue, '.vue');
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].content).toContain('border-left: 4px solid blue');
|
||||
expect(blocks[0].startLine).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('extracts multiple <style> blocks', () => {
|
||||
const vue = `<template><div>hi</div></template>
|
||||
<style>
|
||||
.a { color: red; }
|
||||
</style>
|
||||
<style scoped>
|
||||
.b { color: blue; }
|
||||
</style>`;
|
||||
const blocks = extractStyleBlocks(vue, '.vue');
|
||||
expect(blocks.length).toBe(2);
|
||||
});
|
||||
|
||||
test('extracts <style> from Svelte', () => {
|
||||
const svelte = `<div>hi</div>
|
||||
<style>
|
||||
.sidebar { border-right: 4px solid #8b5cf6; }
|
||||
</style>`;
|
||||
const blocks = extractStyleBlocks(svelte, '.svelte');
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].content).toContain('border-right: 4px solid');
|
||||
});
|
||||
|
||||
test('returns empty for non-Vue/Svelte files', () => {
|
||||
const jsx = 'export function Card() { return <div>hi</div>; }';
|
||||
expect(extractStyleBlocks(jsx, '.jsx')).toHaveLength(0);
|
||||
expect(extractStyleBlocks(jsx, '.tsx')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty when no <style> blocks exist', () => {
|
||||
const vue = '<template><div>hi</div></template><script>export default {}</script>';
|
||||
expect(extractStyleBlocks(vue, '.vue')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: CSS-in-JS extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractCSSinJS', () => {
|
||||
test('extracts styled-components template literal', () => {
|
||||
const tsx = "const Card = styled.div`\n border-left: 4px solid blue;\n padding: 16px;\n`;";
|
||||
const blocks = extractCSSinJS(tsx, '.tsx');
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(blocks.some(b => b.content.includes('border-left: 4px solid'))).toBe(true);
|
||||
});
|
||||
|
||||
test('extracts styled(Component) template literal', () => {
|
||||
const tsx = "const Box = styled(BaseBox)`\n border-right: 5px solid #8b5cf6;\n`;";
|
||||
const blocks = extractCSSinJS(tsx, '.tsx');
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(blocks.some(b => b.content.includes('border-right: 5px solid'))).toBe(true);
|
||||
});
|
||||
|
||||
test('extracts emotion css template literal', () => {
|
||||
const tsx = "const style = css`\n animation: bounce 1s infinite;\n`;";
|
||||
const blocks = extractCSSinJS(tsx, '.tsx');
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(blocks.some(b => b.content.includes('animation: bounce'))).toBe(true);
|
||||
});
|
||||
|
||||
test('returns empty for non-JS files', () => {
|
||||
expect(extractCSSinJS('.card { color: red; }', '.css')).toHaveLength(0);
|
||||
expect(extractCSSinJS('<div>hi</div>', '.html')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty when no CSS-in-JS patterns exist', () => {
|
||||
const tsx = "function Card() { return <div className='p-4'>hi</div>; }";
|
||||
expect(extractCSSinJS(tsx, '.tsx')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: detectText on Vue/Svelte files (style blocks + template classes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectText -- Vue SFC', () => {
|
||||
test('detects side-tab in <style> block', () => {
|
||||
const vue = `<template><div class="card">hi</div></template>
|
||||
<style scoped>
|
||||
.card { border-left: 4px solid #3b82f6; border-radius: 12px; }
|
||||
</style>`;
|
||||
const f = detectText(vue, 'Card.vue');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects overused font in <style> block', () => {
|
||||
const vue = `<template><div>hi</div></template>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
</style>`;
|
||||
const f = detectText(vue, 'App.vue');
|
||||
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects bounce animation in <style> block', () => {
|
||||
const vue = `<template><div>hi</div></template>
|
||||
<style>
|
||||
.item { animation: bounce 1s infinite; }
|
||||
</style>`;
|
||||
const f = detectText(vue, 'Card.vue');
|
||||
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects gradient-text in <style> block', () => {
|
||||
const vue = `<template><div>hi</div></template>
|
||||
<style>
|
||||
h1 { background: linear-gradient(to right, purple, cyan); -webkit-background-clip: text; background-clip: text; }
|
||||
</style>`;
|
||||
const f = detectText(vue, 'Hero.vue');
|
||||
expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects Tailwind anti-patterns in <template>', () => {
|
||||
const vue = `<template>
|
||||
<div class="border-l-4 border-blue-500 rounded-lg">card</div>
|
||||
</template>`;
|
||||
const f = detectText(vue, 'Card.vue');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectText -- Svelte', () => {
|
||||
test('detects side-tab in <style> block', () => {
|
||||
const svelte = `<div>hi</div>
|
||||
<style>
|
||||
.sidebar { border-right: 4px solid #8b5cf6; border-radius: 16px; }
|
||||
</style>`;
|
||||
const f = detectText(svelte, 'Sidebar.svelte');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects overused font in <style> block', () => {
|
||||
const svelte = `<div>hi</div>
|
||||
<style>
|
||||
.app { font-family: 'Roboto', sans-serif; }
|
||||
</style>`;
|
||||
const f = detectText(svelte, 'App.svelte');
|
||||
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects layout transition in <style> block', () => {
|
||||
const svelte = `<div>hi</div>
|
||||
<style>
|
||||
.panel { transition: height 0.4s ease; }
|
||||
</style>`;
|
||||
const f = detectText(svelte, 'Panel.svelte');
|
||||
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: detectText on CSS-in-JS files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectText -- CSS-in-JS', () => {
|
||||
test('detects side-tab in styled-components', () => {
|
||||
const tsx = "const Card = styled.div`\n border-left: 4px solid #3b82f6;\n border-radius: 12px;\n`;";
|
||||
const f = detectText(tsx, 'Card.tsx');
|
||||
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects bounce in emotion css', () => {
|
||||
const tsx = "const style = css`\n animation: bounce 1s infinite;\n`;";
|
||||
const f = detectText(tsx, 'anim.ts');
|
||||
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects overused font in styled-components', () => {
|
||||
const tsx = "const Wrapper = styled.main`\n font-family: 'Inter', sans-serif;\n`;";
|
||||
const f = detectText(tsx, 'Layout.tsx');
|
||||
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects gradient-text in styled-components', () => {
|
||||
const tsx = "const Title = styled.h1`\n background: linear-gradient(to right, purple, cyan);\n -webkit-background-clip: text;\n background-clip: text;\n`;";
|
||||
const f = detectText(tsx, 'Hero.tsx');
|
||||
expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects pure-black-white in styled-components', () => {
|
||||
const tsx = "const Dark = styled.section`\n background-color: #000000;\n`;";
|
||||
const f = detectText(tsx, 'Dark.tsx');
|
||||
expect(f.some(r => r.antipattern === 'pure-black-white')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not false-positive on clean CSS-in-JS', () => {
|
||||
const tsx = "const Card = styled.div`\n border-radius: 12px;\n padding: 24px;\n`;";
|
||||
const f = detectText(tsx, 'Card.tsx');
|
||||
expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 1: Fixture file integration tests (CLI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CLI -- framework fixtures', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
test('jsx-should-flag catches anti-patterns', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'jsx-should-flag.jsx'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('jsx-should-pass is clean', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'jsx-should-pass.jsx'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('vue-should-flag catches anti-patterns', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'vue-should-flag.vue'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('vue-should-pass is clean', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'vue-should-pass.vue'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('svelte-should-flag catches anti-patterns', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'svelte-should-flag.svelte'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('svelte-should-pass is clean', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'svelte-should-pass.svelte'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test('cssinjs-should-flag catches anti-patterns', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'cssinjs-should-flag.tsx'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('cssinjs-should-pass is clean', () => {
|
||||
const { code } = run(path.join(FIXTURES, 'cssinjs-should-pass.tsx'));
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Realistic Next.js project fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CLI -- Next.js + Tailwind project', () => {
|
||||
const dir = path.join(FIXTURES, 'framework-next-tailwind');
|
||||
let stderr;
|
||||
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
test('finds all expected anti-pattern types', () => {
|
||||
const result = run(dir);
|
||||
stderr = result.stderr;
|
||||
expect(result.code).toBe(2);
|
||||
for (const ap of ['side-tab', 'gradient-text', 'ai-color-palette', 'overused-font', 'bounce-easing', 'pure-black-white']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
|
||||
test('FeatureCard: side-tab + ai-color-palette + bounce-easing', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'FeatureCard.tsx'));
|
||||
expect(stderr).toContain('side-tab');
|
||||
expect(stderr).toContain('border-l-4');
|
||||
expect(stderr).toContain('ai-color-palette');
|
||||
expect(stderr).toContain('text-purple-600');
|
||||
expect(stderr).toContain('bounce-easing');
|
||||
expect(stderr).toContain('animate-bounce');
|
||||
});
|
||||
|
||||
test('PricingCard: pure-black-white + gradient-text + ai-color-palette', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'PricingCard.tsx'));
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('bg-black');
|
||||
expect(stderr).toContain('gradient-text');
|
||||
expect(stderr).toContain('bg-clip-text');
|
||||
expect(stderr).toContain('ai-color-palette');
|
||||
});
|
||||
|
||||
test('globals.css: overused Inter font', () => {
|
||||
const { stderr } = run(path.join(dir, 'app', 'globals.css'));
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Inter');
|
||||
});
|
||||
|
||||
test('page.tsx: gradient-text + ai-color-palette', () => {
|
||||
const { stderr } = run(path.join(dir, 'app', 'page.tsx'));
|
||||
expect(stderr).toContain('gradient-text');
|
||||
expect(stderr).toContain('ai-color-palette');
|
||||
});
|
||||
|
||||
test('directory scan shows import context for components', () => {
|
||||
const { stderr } = run(dir);
|
||||
expect(stderr).toContain('imported by page.tsx');
|
||||
});
|
||||
|
||||
test('--json produces clean JSON without framework message', () => {
|
||||
const { stderr, code } = run('--json', dir);
|
||||
expect(code).toBe(2);
|
||||
const parsed = JSON.parse(stderr.trim());
|
||||
expect(parsed).toBeArray();
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI -- Next.js + CSS Modules project', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
const dir = path.join(FIXTURES, 'framework-next-modules');
|
||||
|
||||
test('finds all expected anti-pattern types', () => {
|
||||
const { code, stderr } = run(dir);
|
||||
expect(code).toBe(2);
|
||||
for (const ap of ['side-tab', 'overused-font', 'pure-black-white', 'layout-transition', 'gradient-text']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
|
||||
test('StatsCard.module.css: side-tab + overused-font + layout-transition', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'StatsCard.module.css'));
|
||||
expect(stderr).toContain('side-tab');
|
||||
expect(stderr).toContain('border-left: 4px solid #6366f1');
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Inter');
|
||||
expect(stderr).toContain('layout-transition');
|
||||
expect(stderr).toContain('transition: width');
|
||||
});
|
||||
|
||||
test('Sidebar.module.css: side-tab border accent', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'Sidebar.module.css'));
|
||||
expect(stderr).toContain('side-tab');
|
||||
expect(stderr).toContain('border-right: 3px solid');
|
||||
});
|
||||
|
||||
test('globals.css: overused Roboto + pure-black-white', () => {
|
||||
const { stderr } = run(path.join(dir, 'app', 'globals.css'));
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Roboto');
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('#000000');
|
||||
});
|
||||
|
||||
test('page.module.css: gradient-text across lines', () => {
|
||||
const { stderr } = run(path.join(dir, 'app', 'page.module.css'));
|
||||
expect(stderr).toContain('gradient-text');
|
||||
expect(stderr).toContain('background-clip: text');
|
||||
});
|
||||
|
||||
test('directory scan shows import context for CSS modules', () => {
|
||||
const { stderr } = run(dir);
|
||||
expect(stderr).toContain('imported by StatsCard.tsx');
|
||||
expect(stderr).toContain('imported by Sidebar.tsx');
|
||||
expect(stderr).toContain('imported by layout.tsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI -- Next.js + CSS-in-JS (styled-components) project', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
const dir = path.join(FIXTURES, 'framework-next-cssinjs');
|
||||
|
||||
test('finds all expected anti-pattern types', () => {
|
||||
const { code, stderr } = run(dir);
|
||||
expect(code).toBe(2);
|
||||
for (const ap of ['side-tab', 'gradient-text', 'overused-font', 'bounce-easing', 'pure-black-white', 'layout-transition']) {
|
||||
expect(stderr).toContain(ap);
|
||||
}
|
||||
});
|
||||
|
||||
test('FeatureGrid.tsx: side-tab + bounce-easing + layout-transition', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'FeatureGrid.tsx'));
|
||||
expect(stderr).toContain('side-tab');
|
||||
expect(stderr).toContain('border-left: 4px solid');
|
||||
expect(stderr).toContain('bounce-easing');
|
||||
expect(stderr).toContain('animation: bounce');
|
||||
expect(stderr).toContain('layout-transition');
|
||||
expect(stderr).toContain('transition: width');
|
||||
});
|
||||
|
||||
test('Hero.tsx: gradient-text + overused Montserrat font', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'Hero.tsx'));
|
||||
expect(stderr).toContain('gradient-text');
|
||||
expect(stderr).toContain('background-clip: text');
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Montserrat');
|
||||
});
|
||||
|
||||
test('GlobalStyle.tsx: overused Inter + pure-black-white', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'GlobalStyle.tsx'));
|
||||
expect(stderr).toContain('overused-font');
|
||||
expect(stderr).toContain('Inter');
|
||||
expect(stderr).toContain('pure-black-white');
|
||||
expect(stderr).toContain('#000000');
|
||||
});
|
||||
|
||||
test('Testimonials.tsx: side-tab + gradient-text in styled blockquote', () => {
|
||||
const { stderr } = run(path.join(dir, 'components', 'Testimonials.tsx'));
|
||||
expect(stderr).toContain('side-tab');
|
||||
expect(stderr).toContain('border-left: 4px solid');
|
||||
expect(stderr).toContain('gradient-text');
|
||||
});
|
||||
|
||||
test('directory scan shows import context for components', () => {
|
||||
const { stderr } = run(dir);
|
||||
expect(stderr).toContain('imported by index.tsx');
|
||||
expect(stderr).toContain('imported by _app.tsx');
|
||||
});
|
||||
|
||||
test('--json produces clean JSON without framework message', () => {
|
||||
const { stderr, code } = run('--json', dir);
|
||||
expect(code).toBe(2);
|
||||
const parsed = JSON.parse(stderr.trim());
|
||||
expect(parsed).toBeArray();
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(6);
|
||||
// Verify importedBy is present in JSON
|
||||
const featureGridFindings = parsed.filter(f => f.file?.includes('FeatureGrid'));
|
||||
expect(featureGridFindings.length).toBeGreaterThan(0);
|
||||
expect(featureGridFindings[0].importedBy).toContain('index.tsx');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 2: Import graph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildImportGraph', () => {
|
||||
const MF = path.join(FIXTURES, 'multifile');
|
||||
|
||||
test('resolves ES import from tsx to tsx', () => {
|
||||
const graph = buildImportGraph([
|
||||
path.join(MF, 'App.tsx'),
|
||||
path.join(MF, 'Card.tsx'),
|
||||
path.join(MF, 'styles.css'),
|
||||
]);
|
||||
const appImports = graph.get(path.join(MF, 'App.tsx'));
|
||||
expect(appImports).toBeDefined();
|
||||
expect(appImports.has(path.join(MF, 'Card.tsx'))).toBe(true);
|
||||
expect(appImports.has(path.join(MF, 'styles.css'))).toBe(true);
|
||||
});
|
||||
|
||||
test('resolves extensionless imports', () => {
|
||||
const graph = buildImportGraph([
|
||||
path.join(MF, 'App.tsx'),
|
||||
path.join(MF, 'Card.tsx'),
|
||||
]);
|
||||
const appImports = graph.get(path.join(MF, 'App.tsx'));
|
||||
expect(appImports.has(path.join(MF, 'Card.tsx'))).toBe(true);
|
||||
});
|
||||
|
||||
test('resolves CSS @import', () => {
|
||||
const graph = buildImportGraph([
|
||||
path.join(MF, 'theme.scss'),
|
||||
path.join(MF, 'variables.scss'),
|
||||
]);
|
||||
const themeImports = graph.get(path.join(MF, 'theme.scss'));
|
||||
expect(themeImports).toBeDefined();
|
||||
expect(themeImports.has(path.join(MF, 'variables.scss'))).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores bare/node_modules imports', () => {
|
||||
const graph = buildImportGraph([
|
||||
path.join(MF, 'App.tsx'),
|
||||
]);
|
||||
const appImports = graph.get(path.join(MF, 'App.tsx'));
|
||||
// Should not contain 'react' or 'styled-components'
|
||||
for (const imp of appImports) {
|
||||
expect(imp).toContain(MF);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveImport', () => {
|
||||
const MF = path.join(FIXTURES, 'multifile');
|
||||
|
||||
test('resolves relative path with extension', () => {
|
||||
const fileSet = new Set([path.join(MF, 'Card.tsx')]);
|
||||
const result = resolveImport('./Card.tsx', MF, fileSet);
|
||||
expect(result).toBe(path.join(MF, 'Card.tsx'));
|
||||
});
|
||||
|
||||
test('resolves extensionless import by trying extensions', () => {
|
||||
const fileSet = new Set([path.join(MF, 'Card.tsx')]);
|
||||
const result = resolveImport('./Card', MF, fileSet);
|
||||
expect(result).toBe(path.join(MF, 'Card.tsx'));
|
||||
});
|
||||
|
||||
test('returns null for bare specifiers', () => {
|
||||
const fileSet = new Set([path.join(MF, 'Card.tsx')]);
|
||||
expect(resolveImport('react', MF, fileSet)).toBeNull();
|
||||
expect(resolveImport('styled-components', MF, fileSet)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unresolvable imports', () => {
|
||||
const fileSet = new Set([path.join(MF, 'Card.tsx')]);
|
||||
expect(resolveImport('./Unknown', MF, fileSet)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 2: Multi-file directory scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CLI -- multi-file scan', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
test('scanning multifile/ directory finds findings across files', () => {
|
||||
const { code, stderr } = run(path.join(FIXTURES, 'multifile'));
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('side-tab');
|
||||
});
|
||||
|
||||
test('--json multi-file scan includes import context', () => {
|
||||
const { stderr, code } = run('--json', path.join(FIXTURES, 'multifile'));
|
||||
expect(code).toBe(2);
|
||||
const parsed = JSON.parse(stderr.trim());
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
// Findings from Card.tsx should mention being imported by App.tsx
|
||||
const cardFindings = parsed.filter(f => f.file?.includes('Card.tsx'));
|
||||
expect(cardFindings.length).toBeGreaterThan(0);
|
||||
expect(cardFindings.some(f => f.importedBy?.includes('App.tsx'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier 3: Framework config detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectFrameworkConfig', () => {
|
||||
test('detects next.config.mjs and returns Next.js with default port', () => {
|
||||
const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-next-tailwind'));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.name).toBe('Next.js');
|
||||
expect(result.port).toBe(3000);
|
||||
});
|
||||
|
||||
test('detects next.config.js (pages router)', () => {
|
||||
const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-next-cssinjs'));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.name).toBe('Next.js');
|
||||
});
|
||||
|
||||
test('parses custom port from vite.config.ts', () => {
|
||||
const result = detectFrameworkConfig(path.join(FIXTURES, 'framework-vite'));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.name).toBe('Vite');
|
||||
expect(result.port).toBe(8080);
|
||||
});
|
||||
|
||||
test('returns null for directory without framework config', () => {
|
||||
const result = detectFrameworkConfig(path.join(FIXTURES, 'multifile'));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for nonexistent directory', () => {
|
||||
const result = detectFrameworkConfig('/nonexistent/path/12345');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPortListening', () => {
|
||||
test('returns { listening: false } for unlikely port', async () => {
|
||||
const result = await isPortListening(59999);
|
||||
expect(result.listening).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FRAMEWORK_CONFIGS', () => {
|
||||
test('covers major frameworks', () => {
|
||||
const names = FRAMEWORK_CONFIGS.map(c => c.name);
|
||||
expect(names).toContain('Next.js');
|
||||
expect(names).toContain('Vite');
|
||||
expect(names).toContain('SvelteKit');
|
||||
expect(names).toContain('Nuxt');
|
||||
expect(names).toContain('Astro');
|
||||
});
|
||||
|
||||
test('each config has required fields', () => {
|
||||
for (const cfg of FRAMEWORK_CONFIGS) {
|
||||
expect(cfg.name).toBeTypeOf('string');
|
||||
expect(cfg.defaultPort).toBeTypeOf('number');
|
||||
expect(cfg.files).toBeArray();
|
||||
expect(cfg.files.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI -- dev server suggestion', () => {
|
||||
function run(...args) {
|
||||
const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
|
||||
}
|
||||
|
||||
test('suggests URL scan when Next.js config found', () => {
|
||||
const { stderr } = run(path.join(FIXTURES, 'framework-next-tailwind'));
|
||||
expect(stderr).toContain('Next.js');
|
||||
expect(stderr).toContain('3000');
|
||||
});
|
||||
|
||||
test('suggests URL scan when Vite config found', () => {
|
||||
const { stderr } = run(path.join(FIXTURES, 'framework-vite'));
|
||||
expect(stderr).toContain('Vite');
|
||||
expect(stderr).toContain('8080');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// CSS-in-JS patterns with anti-patterns (styled-components + emotion)
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
// styled-components: side-tab + border-accent-on-rounded
|
||||
export const Card = styled.div`
|
||||
border-left: 4px solid #3b82f6;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
`;
|
||||
|
||||
// styled-components: pure black background + gradient text
|
||||
export const Hero = styled.section`
|
||||
background-color: #000000;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
background: linear-gradient(to right, #a855f7, #06b6d4);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
// emotion css: bounce animation + layout transition
|
||||
export const animatedPanel = css`
|
||||
animation: bounce 1s infinite;
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
// styled with parenthesized component
|
||||
export const AccentBox = styled(Box)`
|
||||
border-right: 5px solid #8b5cf6;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
// Object style pattern
|
||||
export const inlineStyles = {
|
||||
borderLeft: '4px solid #6366f1',
|
||||
borderRadius: '12px',
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Clean CSS-in-JS patterns -- no anti-patterns
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
export const Card = styled.div`
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
font-family: 'Geist', system-ui, sans-serif;
|
||||
`;
|
||||
|
||||
export const Hero = styled.section`
|
||||
background-color: #0f172a;
|
||||
padding: 80px 20px;
|
||||
|
||||
h1 {
|
||||
color: #f8fafc;
|
||||
font-size: 3rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const smoothPanel = css`
|
||||
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||
`;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 80px 24px;
|
||||
`;
|
||||
|
||||
const Card = styled.div`
|
||||
border-left: 4px solid #8b5cf6;
|
||||
border-radius: 16px;
|
||||
background: #1a1a2e;
|
||||
padding: 32px;
|
||||
box-shadow: 0 0 25px rgba(139, 92, 246, 0.2);
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
const CardIcon = styled.div`
|
||||
font-size: 40px;
|
||||
margin-bottom: 16px;
|
||||
animation: bounce 2s infinite;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h3`
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #a855f7;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const CardDescription = styled.p`
|
||||
font-size: 15px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const features = [
|
||||
{ icon: "⚡", title: "Blazing Fast", description: "Optimized for speed with edge-first architecture." },
|
||||
{ icon: "🔒", title: "Secure by Default", description: "Enterprise-grade security with zero configuration." },
|
||||
{ icon: "📦", title: "Modular Design", description: "Pick and choose only what you need. No bloat." },
|
||||
];
|
||||
|
||||
export function FeatureGrid() {
|
||||
return (
|
||||
<Grid>
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title}>
|
||||
<CardIcon>{feature.icon}</CardIcon>
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
|
||||
export const GlobalStyle = createGlobalStyle`
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Section = styled.section`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background: linear-gradient(135deg, #a855f7, #06b6d4);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.1;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 20px;
|
||||
color: #6b7280;
|
||||
max-width: 600px;
|
||||
margin-bottom: 48px;
|
||||
`;
|
||||
|
||||
const CTAButton = styled.button`
|
||||
padding: 16px 48px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 40px rgba(139, 92, 246, 0.4);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 60px rgba(139, 92, 246, 0.6);
|
||||
}
|
||||
`;
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<Section>
|
||||
<Title>Build the Future</Title>
|
||||
<Subtitle>
|
||||
The most powerful platform for modern web development.
|
||||
Ship faster, scale easier.
|
||||
</Subtitle>
|
||||
<CTAButton>Get Started Free</CTAButton>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Section = styled.section`
|
||||
padding: 80px 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
background: linear-gradient(to right, #a855f7, #ec4899);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
`;
|
||||
|
||||
const Quote = styled.blockquote`
|
||||
border-left: 4px solid #6366f1;
|
||||
border-radius: 12px;
|
||||
background: #1a1a2e;
|
||||
padding: 24px 32px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 16px;
|
||||
color: #d1d5db;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
const Author = styled.p`
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 12px;
|
||||
font-style: normal;
|
||||
`;
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle>What People Say</SectionTitle>
|
||||
<Quote>
|
||||
"This platform completely transformed our workflow. Deployment went from
|
||||
hours to minutes."
|
||||
<Author>-- Jane Smith, CTO at TechCorp</Author>
|
||||
</Quote>
|
||||
<Quote>
|
||||
"The developer experience is unmatched. I can't imagine going back."
|
||||
<Author>-- Alex Chen, Senior Engineer</Author>
|
||||
</Quote>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
compiler: {
|
||||
styledComponents: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { GlobalStyle } from "../components/GlobalStyle";
|
||||
|
||||
const theme = {
|
||||
colors: {
|
||||
primary: "#8b5cf6",
|
||||
secondary: "#06b6d4",
|
||||
background: "#000000",
|
||||
surface: "#1a1a2e",
|
||||
text: "#ffffff",
|
||||
muted: "#6b7280",
|
||||
},
|
||||
fonts: {
|
||||
body: "'Inter', sans-serif",
|
||||
heading: "'Montserrat', sans-serif",
|
||||
},
|
||||
};
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyle />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Hero } from "../components/Hero";
|
||||
import { FeatureGrid } from "../components/FeatureGrid";
|
||||
import { Testimonials } from "../components/Testimonials";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<FeatureGrid />
|
||||
<Testimonials />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: #000000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
description: "Analytics dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 32px;
|
||||
background: linear-gradient(to right, #a855f7, #06b6d4);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { StatsCard } from "../components/StatsCard";
|
||||
import { Sidebar } from "../components/Sidebar";
|
||||
import styles from "./page.module.css";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Sidebar />
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>Dashboard</h1>
|
||||
<div className={styles.grid}>
|
||||
<StatsCard label="Revenue" value="$48,290" change="+12.5%" />
|
||||
<StatsCard label="Users" value="2,847" change="+8.1%" />
|
||||
<StatsCard label="Orders" value="1,024" change="-2.3%" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: #111827;
|
||||
border-right: 3px solid #4f46e5;
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from "./Sidebar.module.css";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Overview", icon: "📊" },
|
||||
{ label: "Analytics", icon: "📈" },
|
||||
{ label: "Customers", icon: "👥" },
|
||||
{ label: "Settings", icon: "⚙️" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.logo}>Dashboard</div>
|
||||
<nav className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<a key={item.label} href="#" className={styles.navItem}>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
.card {
|
||||
border-left: 4px solid #6366f1;
|
||||
border-radius: 12px;
|
||||
background: #1e1e2e;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-shadow: 0 0 30px rgba(99, 102, 241, 0.3);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.changeUp {
|
||||
color: #22c55e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.changeDown {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import styles from "./StatsCard.module.css";
|
||||
|
||||
interface StatsCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
}
|
||||
|
||||
export function StatsCard({ label, value, change }: StatsCardProps) {
|
||||
const isPositive = change.startsWith("+");
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.value}>{value}</span>
|
||||
<span className={isPositive ? styles.changeUp : styles.changeDown}>
|
||||
{change}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FeatureCard } from "../components/FeatureCard";
|
||||
import { PricingCard } from "../components/PricingCard";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4">
|
||||
Get started by editing
|
||||
<code className="font-mono font-bold">app/page.tsx</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-32 text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:text-left">
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent mb-8">
|
||||
Welcome to Our Platform
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg mb-12">
|
||||
The next generation of web development
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<FeatureCard
|
||||
title="Fast"
|
||||
description="Lightning-fast performance out of the box"
|
||||
icon="⚡"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Scalable"
|
||||
description="Grows with your business needs"
|
||||
icon="📈"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Secure"
|
||||
description="Enterprise-grade security built in"
|
||||
icon="🔒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<h2 className="text-purple-500 text-3xl font-bold text-center mb-8">Pricing</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<PricingCard name="Starter" price="$9" features={["5 projects", "Basic support"]} />
|
||||
<PricingCard name="Pro" price="$29" features={["Unlimited projects", "Priority support"]} highlighted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
interface FeatureCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function FeatureCard({ title, description, icon }: FeatureCardProps) {
|
||||
return (
|
||||
<div className="group border-l-4 border-blue-500 rounded-xl bg-white p-6 shadow-lg hover:shadow-xl transition-all">
|
||||
<div className="text-4xl mb-4 animate-bounce">{icon}</div>
|
||||
<h3 className="text-purple-600 text-xl font-bold mb-2">{title}</h3>
|
||||
<p className="text-gray-400">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
interface PricingCardProps {
|
||||
name: string;
|
||||
price: string;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export function PricingCard({ name, price, features, highlighted }: PricingCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl p-8 ${
|
||||
highlighted
|
||||
? "bg-black border-t-4 border-violet-500 text-white"
|
||||
: "bg-white border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-2">{name}</h3>
|
||||
<div className="text-4xl font-bold mb-6 bg-gradient-to-r from-violet-500 to-fuchsia-500 bg-clip-text text-transparent">
|
||||
{price}
|
||||
<span className="text-sm text-gray-400">/mo</span>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<span className="text-violet-500">✓</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="mt-8 w-full py-3 rounded-lg bg-gradient-to-r from-purple-500 to-indigo-500 text-white font-semibold transition-all hover:scale-105">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="border-l-4 border-indigo-500 rounded-xl p-6">
|
||||
<h1>Hello Vite</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 8080,
|
||||
},
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// A typical Next.js/React component with anti-patterns
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export function FeatureCard({ title, description, icon }) {
|
||||
return (
|
||||
<div className="border-l-4 border-blue-500 rounded-lg bg-white p-6 shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-purple-500 text-3xl">{icon}</div>
|
||||
<h3 className="text-xl font-bold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-2">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="bg-black py-20 text-center">
|
||||
<h1
|
||||
className="text-5xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent"
|
||||
>
|
||||
Welcome to the Future
|
||||
</h1>
|
||||
<p className="mt-4 text-gray-400">Build something amazing today</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCard({ value, label }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderLeft: '4px solid #3b82f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '32px', fontFamily: "'Inter', sans-serif" }}>{value}</span>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedPanel({ children }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="animate-bounce"
|
||||
style={{ transition: 'width 0.3s ease' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
// Clean React component -- no anti-patterns
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export function FeatureCard({ title, description, icon }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm ring-1 ring-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-teal-600 text-2xl">{icon}</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-2">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="bg-gray-950 py-20">
|
||||
<h1 className="text-5xl font-bold text-white">Welcome</h1>
|
||||
<p className="mt-4 text-gray-300">Build something amazing today</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCard({ value, label }) {
|
||||
return (
|
||||
<div className="rounded-lg p-6 ring-1 ring-gray-200">
|
||||
<span className="text-3xl font-bold tabular-nums">{value}</span>
|
||||
<p className="text-gray-600 mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// App that imports components -- anti-patterns are in the imported files
|
||||
import React from 'react';
|
||||
import { Card } from './Card';
|
||||
import './styles.css';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<Card title="Revenue" value="$12,345" />
|
||||
<Card title="Users" value="1,234" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Component with anti-patterns
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function Card({ title, value }: CardProps) {
|
||||
return (
|
||||
<div className="border-l-4 border-blue-500 rounded-lg p-4 bg-white shadow">
|
||||
<h3 className="text-purple-500 text-xl font-bold">{title}</h3>
|
||||
<p className="text-2xl tabular-nums">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Global styles with anti-patterns */
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
animation: bounce 0.5s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import './variables';
|
||||
|
||||
.card {
|
||||
border-left: 4px solid $primary;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
$primary: #3b82f6;
|
||||
$radius: 12px;
|
||||
$spacing: 16px;
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
export let title = '';
|
||||
export let items = [];
|
||||
</script>
|
||||
|
||||
<div class="sidebar border-r-4 border-violet-500 rounded-lg">
|
||||
<h3 class="text-purple-500 text-3xl font-bold">{title}</h3>
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li class="animate-bounce">{item.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
border-right: 4px solid #8b5cf6;
|
||||
border-radius: 16px;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
background: linear-gradient(135deg, #a855f7, #06b6d4);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.animated-item {
|
||||
animation: elastic 0.6s ease-out;
|
||||
transition: height 0.4s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
export let title = '';
|
||||
export let items = [];
|
||||
</script>
|
||||
|
||||
<div class="sidebar rounded-lg ring-1 ring-gray-200 p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li class="py-2">{item.name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
font-family: 'Geist', system-ui, sans-serif;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="card border-l-4 border-indigo-500 rounded-xl p-6">
|
||||
<h2 class="text-purple-600 text-2xl font-bold">{{ title }}</h2>
|
||||
<p class="text-gray-400">{{ description }}</p>
|
||||
<button class="animate-bounce mt-4 px-4 py-2 bg-indigo-500 text-white rounded">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-left: 4px solid #6366f1;
|
||||
border-radius: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
background: linear-gradient(to right, #a855f7, #06b6d4);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
animation: bounce 1s infinite;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="card rounded-xl p-6 ring-1 ring-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">{{ title }}</h2>
|
||||
<p class="text-gray-600 mt-2">{{ description }}</p>
|
||||
<button class="mt-4 px-4 py-2 bg-teal-600 text-white rounded-md">
|
||||
Get Started
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
description: String,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
font-family: 'Geist', system-ui, sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user