227 lines
6.7 KiB
TypeScript
227 lines
6.7 KiB
TypeScript
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<LimitLineOverride>;
|
|
};
|
|
|
|
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<AstNode>;
|
|
body: Array<AstNode>;
|
|
[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<ComponentInfo> {
|
|
const results: Array<ComponentInfo> = [];
|
|
|
|
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<ComponentInfo>): 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<LimitLineOptions> = {},
|
|
): Array<Plugin> {
|
|
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 };
|