refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
const readability = {
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
|
||||
function checkParameters(node) {
|
||||
const last = node.params.at(-1);
|
||||
if (
|
||||
last?.type === "Identifier"
|
||||
&& !last.optional
|
||||
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
|
||||
&& last.typeAnnotation.typeAnnotation.types.some(
|
||||
type => type.type === "TSUndefinedKeyword",
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "optional", node: last });
|
||||
}
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: checkParameters,
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === "MemberExpression"
|
||||
&& ["refine", "superRefine"].includes(node.callee.property.name)
|
||||
&& !filename.endsWith(".schema.ts")
|
||||
)
|
||||
context.report({ messageId: "schema", node });
|
||||
},
|
||||
FunctionDeclaration: checkParameters,
|
||||
FunctionExpression: checkParameters,
|
||||
ImportDeclaration(node) {
|
||||
if (
|
||||
node.source.value === "zod"
|
||||
&& node.importKind !== "type"
|
||||
&& !filename.endsWith(".schema.ts")
|
||||
)
|
||||
context.report({ messageId: "schema", node });
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
if (
|
||||
!filename.includes("/packages/icons/")
|
||||
&& node.name.type === "JSXIdentifier"
|
||||
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
|
||||
node.name.name,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "svg", node });
|
||||
},
|
||||
TSUnionType(node) {
|
||||
const complex
|
||||
= node.types.length > 2
|
||||
|| node.types.some(type => type.type === "TSTypeLiteral");
|
||||
|
||||
if (
|
||||
complex
|
||||
&& node.types.some(
|
||||
(type, index) =>
|
||||
index > 0
|
||||
&& type.loc.start.line === node.types[index - 1].loc.end.line,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "union", node });
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (
|
||||
!/^[A-Z]/.test(node.id.name ?? "")
|
||||
|| node.init?.type !== "ArrowFunctionExpression"
|
||||
|| node.init.body.type !== "BlockStatement"
|
||||
)
|
||||
return;
|
||||
|
||||
for (const statement of node.init.body.body) {
|
||||
if (statement.type === "FunctionDeclaration")
|
||||
context.report({ messageId: "helper", node: statement });
|
||||
|
||||
else if (statement.type === "VariableDeclaration") {
|
||||
for (const declaration of statement.declarations) {
|
||||
if (
|
||||
["ArrowFunctionExpression", "FunctionExpression"].includes(
|
||||
declaration.init?.type,
|
||||
)
|
||||
)
|
||||
context.report({ messageId: "helper", node: declaration });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
meta: {
|
||||
messages: {
|
||||
helper:
|
||||
"Move helpers outside components; actions belong to the ViewModel.",
|
||||
optional:
|
||||
"Use an optional trailing parameter instead of a union with undefined.",
|
||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||
union: "Put complex union members on separate lines.",
|
||||
},
|
||||
schema: [],
|
||||
type: "suggestion",
|
||||
},
|
||||
};
|
||||
|
||||
function isComponent(node) {
|
||||
const pending = [node];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const child = pending.pop();
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case "JSXElement": {
|
||||
return true;
|
||||
}
|
||||
|
||||
case "JSXFragment": {
|
||||
return true;
|
||||
}
|
||||
|
||||
case "JSXExpressionContainer": {
|
||||
pending.push(child.expression);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "LogicalExpression": {
|
||||
pending.push(child.right);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "ConditionalExpression": {
|
||||
pending.push(child.consequent, child.alternate);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const jsxSpacing = {
|
||||
create(context) {
|
||||
function check(node) {
|
||||
let previous;
|
||||
|
||||
for (const child of node.children) {
|
||||
if (child.type === "JSXText" && !child.value.trim()) continue;
|
||||
if (isComponent(child)) {
|
||||
if (previous) {
|
||||
const gap = context.sourceCode.text.slice(
|
||||
previous.range[1],
|
||||
child.range[0],
|
||||
);
|
||||
|
||||
if (!/\n\s*\n/.test(gap))
|
||||
context.report({
|
||||
fix: fixer => fixer.insertTextAfter(previous, "\n"),
|
||||
messageId: "space",
|
||||
node: child,
|
||||
});
|
||||
}
|
||||
|
||||
previous = child;
|
||||
}
|
||||
else previous = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { JSXElement: check, JSXFragment: check };
|
||||
},
|
||||
meta: {
|
||||
fixable: "whitespace",
|
||||
messages: { space: "Separate sibling JSX components with a blank line." },
|
||||
schema: [],
|
||||
type: "layout",
|
||||
},
|
||||
};
|
||||
|
||||
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
|
||||
Reference in New Issue
Block a user