import { createFilter, type Plugin } from "vite"; type LimitLineOverride = { files: string; maxReactFCLines: number | null; maxFileLines: number | null; }; type LimitLineOptions = { maxReactFCLines: number; maxFileLines: number; include: RegExp; exclude: RegExp | null; overrides: Array; }; const DEFAULT_OPTIONS: LimitLineOptions = { maxReactFCLines: 300, maxFileLines: 600, include: /\.[tj]sx$/, exclude: null, overrides: [], }; type ResolvedLimits = { maxReactFCLines: number | null; maxFileLines: number | null; }; type ComponentInfo = { name: string; startLine: number; lineCount: number; }; const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/; type AstNode = { type: string; start: number; end: number; id: { name: string } | null; init: AstNode | null; declaration: AstNode | null; declarations: Array<{ id: { name: string }; init: AstNode | null }>; callee: { name: string } | null; arguments: Array; body: Array; [key: string]: unknown; }; function lineAt(code: string, offset: number): number { let line = 1; for (let i = 0; i < offset && i < code.length; i++) { if (code[i] === "\n") line++; } return line; } function lineSpan(code: string, start: number, end: number): { startLine: number; lineCount: number } { const startLine = lineAt(code, start); const endLine = lineAt(code, end); return { startLine, lineCount: endLine - startLine + 1 }; } function extractComponents(ast: AstNode, code: string): Array { const results: Array = []; for (const node of ast.body ?? []) { if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) { const span = lineSpan(code, node.start, node.end); results.push({ name: node.id.name, ...span }); continue; } if (node.type === "ExportNamedDeclaration" && node.declaration) { const decl = node.declaration; if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) { const span = lineSpan(code, node.start, node.end); results.push({ name: decl.id.name, ...span }); continue; } if (decl.type === "VariableDeclaration") { collectFromVarDeclaration(decl, code, results); continue; } } if (node.type === "VariableDeclaration") { collectFromVarDeclaration(node, code, results); } } return results; } function collectFromVarDeclaration(node: AstNode, code: string, results: Array): void { for (const declarator of node.declarations ?? []) { if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue; const init = declarator.init; if (isFunctionLike(init)) { const span = lineSpan(code, node.start, node.end); results.push({ name: declarator.id.name, ...span }); } else if (isWrapperCall(init) && init.arguments.length > 0 && isFunctionLike(init.arguments[0])) { const span = lineSpan(code, node.start, node.end); results.push({ name: declarator.id.name, ...span }); } } } function isFunctionLike(node: AstNode): boolean { return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; } const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]); function isWrapperCall(node: AstNode): boolean { return node.type === "CallExpression" && node.callee !== null && node.callee.name !== undefined && WRAPPER_NAMES.has(node.callee.name); } function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits { const matchers = options.overrides.map((override) => ({ match: createFilter(override.files), maxReactFCLines: override.maxReactFCLines, maxFileLines: override.maxFileLines, })); return (id: string): ResolvedLimits => { let maxReactFCLines: number | null = options.maxReactFCLines; let maxFileLines: number | null = options.maxFileLines; for (const matcher of matchers) { if (matcher.match(id)) { maxReactFCLines = matcher.maxReactFCLines; maxFileLines = matcher.maxFileLines; } } return { maxReactFCLines, maxFileLines }; }; } function shouldProcess(id: string, options: LimitLineOptions): boolean { return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id)); } function viteLimitLinePlugin( userOptions: Partial = {}, ): Array { const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] }; const resolve = createLimitResolver(options); return [ { name: "vite-plugin-file-line-limit", enforce: "pre", transform(code, id) { if (!shouldProcess(id, options)) return null; const limits = resolve(id); if (limits.maxFileLines === null) return null; const totalLines = code.split("\n").length; if (totalLines > limits.maxFileLines) { this.error( [ `[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`, ` file: ${id}`, "", "How to fix:", " Split this file into smaller modules — extract related types, helpers,", " or sub-components into separate files and re-export from an index.ts.", ].join("\n"), ); } return null; }, }, { name: "vite-plugin-react-fc-line-limit", transform(code, id) { if (!shouldProcess(id, options)) return null; const limits = resolve(id); if (limits.maxReactFCLines === null) return null; const ast = this.parse(code) as unknown as AstNode; const components = extractComponents(ast, code); const violations = components.filter((c) => c.lineCount > (limits.maxReactFCLines as number)); if (violations.length > 0) { const details = violations .map( (v) => ` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${limits.maxReactFCLines})`, ) .join("\n"); this.error( [ `[vite-limit-line] React component too long in ${id}:`, details, "", "How to fix:", " Break each oversized component into smaller ones. Extract reusable", " sections into child components, move complex logic into custom hooks,", " and keep each component focused on a single responsibility.", ].join("\n"), ); } return null; }, }, ]; } export { viteLimitLinePlugin }; export type { LimitLineOptions, LimitLineOverride };