teqo-backup/eslint-local-rules.mjs
Francisco Ignácio Fontoura Solla a864adc1b5 Add ESLint guard for invalid use server exports and fix plaza staff messages.
Catch Next.js invalid-use-server-value at lint time and move shared form-error copy out of plazaStaffFormActions so the use server module only exports async actions.
2026-07-21 20:07:06 -03:00

291 lines
8.4 KiB
JavaScript

const isUppercaseName = (node) =>
node.type === 'Identifier' && /^[A-Z][A-Za-z0-9]*$/.test(node.name)
const isJsx = (node) => node?.type === 'JSXElement' || node?.type === 'JSXFragment'
const noComponentDefaultExport = {
meta: {
type: 'suggestion',
docs: {
description: 'Require ordinary React components to use named exports',
},
schema: [],
messages: {
defaultExport: 'React component files must not use default exports.',
},
},
create(context) {
return {
ExportDefaultDeclaration(node) {
context.report({ node, messageId: 'defaultExport' })
},
}
},
}
const frameworkDefaultExport = {
meta: {
type: 'problem',
docs: {
description: 'Require Next framework components to use inline named function defaults',
},
schema: [],
messages: {
invalidDefault:
'Next framework components must default-export an inline named function declaration.',
missingDefault: 'Next framework components must have a default export.',
},
},
create(context) {
let hasDefaultExport = false
return {
ExportDefaultDeclaration(node) {
hasDefaultExport = true
const declaration = node.declaration
const isNamedFunction =
declaration.type === 'FunctionDeclaration' && declaration.id !== null
if (!isNamedFunction) {
context.report({ node, messageId: 'invalidDefault' })
}
},
'Program:exit'(node) {
if (!hasDefaultExport) {
context.report({ node, messageId: 'missingDefault' })
}
},
}
},
}
const componentArrowConventions = {
meta: {
type: 'suggestion',
docs: {
description: 'Require const component arrows and implicit returns for pure JSX bodies',
},
fixable: 'code',
schema: [],
messages: {
useConst: 'Uppercase component arrow declarations must use const.',
implicitReturn: 'Pure JSX component arrow bodies must use an implicit return.',
},
},
create(context) {
const sourceCode = context.sourceCode
return {
VariableDeclarator(node) {
if (!isUppercaseName(node.id) || node.init?.type !== 'ArrowFunctionExpression') {
return
}
const declaration = node.parent
if (declaration.type === 'VariableDeclaration' && declaration.kind !== 'const') {
context.report({ node: node.id, messageId: 'useConst' })
}
const body = node.init.body
if (
body.type !== 'BlockStatement' ||
body.body.length !== 1 ||
body.body[0].type !== 'ReturnStatement' ||
!isJsx(body.body[0].argument)
) {
return
}
const returnedJsx = sourceCode.getText(body.body[0].argument)
const hasComments = sourceCode.getCommentsInside(body).length > 0
context.report({
node: body,
messageId: 'implicitReturn',
...(hasComments
? {}
: {
fix: (fixer) => fixer.replaceText(body, `(${returnedJsx})`),
}),
})
},
}
},
}
const isFunctionNode = (node) =>
node?.type === 'FunctionDeclaration' ||
node?.type === 'FunctionExpression' ||
node?.type === 'ArrowFunctionExpression'
const isAsyncFunctionNode = (node) => isFunctionNode(node) && node.async === true
const isUseServerDirectiveStatement = (statement) =>
statement?.type === 'ExpressionStatement' &&
statement.expression?.type === 'Literal' &&
statement.expression.value === 'use server'
const hasTopLevelUseServer = (program) =>
Array.isArray(program.body) && program.body.some(isUseServerDirectiveStatement)
const isTypeOnlyNamedExport = (node) => {
if (node.exportKind === 'type') return true
const declaration = node.declaration
if (!declaration) return false
return (
declaration.type === 'TSTypeAliasDeclaration' ||
declaration.type === 'TSInterfaceDeclaration' ||
declaration.type === 'TSModuleDeclaration'
)
}
const resolveLocalAsyncBinding = (program, name) => {
for (const statement of program.body) {
if (statement.type === 'FunctionDeclaration' && statement.id?.name === name) {
return isAsyncFunctionNode(statement)
}
if (statement.type === 'VariableDeclaration') {
for (const declarator of statement.declarations) {
if (declarator.id?.type === 'Identifier' && declarator.id.name === name) {
return isAsyncFunctionNode(declarator.init)
}
}
}
}
return false
}
/**
* Next.js rejects any non-async-function export from a top-level `'use server'`
* module (https://nextjs.org/docs/messages/invalid-use-server-value). Inspired by
* eslint-plugin-next-recommended's export-server-actions-only, kept local because
* that package is unmaintained and ESLint-8-era.
*/
const useServerAsyncExports = {
meta: {
type: 'problem',
docs: {
description:
'Require top-level `use server` modules to export only async functions (plus type-only exports)',
},
schema: [],
messages: {
nonAsyncExport:
'A `"use server"` file can only export async functions (found non-async export). Move constants, types-as-values, and helpers to a separate module. See https://nextjs.org/docs/messages/invalid-use-server-value',
reexport:
'A `"use server"` file cannot re-export values (`export … from` / `export *`). Export async functions defined in this file only.',
},
},
create(context) {
let programNode = null
let useServer = false
const report = (node, messageId) => {
context.report({ node, messageId })
}
return {
Program(node) {
programNode = node
useServer = hasTopLevelUseServer(node)
},
ExportAllDeclaration(node) {
if (!useServer) return
if (node.exportKind === 'type') return
report(node, 'reexport')
},
ExportDefaultDeclaration(node) {
if (!useServer) return
const declaration = node.declaration
if (isAsyncFunctionNode(declaration)) return
if (declaration?.type === 'Identifier' && programNode) {
if (resolveLocalAsyncBinding(programNode, declaration.name)) return
}
report(node, 'nonAsyncExport')
},
ExportNamedDeclaration(node) {
if (!useServer) return
if (isTypeOnlyNamedExport(node)) return
if (node.source) {
const hasValueSpecifier =
!node.specifiers?.length ||
node.specifiers.some((specifier) => specifier.exportKind !== 'type')
if (hasValueSpecifier) {
report(node, 'reexport')
}
return
}
const declaration = node.declaration
if (declaration?.type === 'FunctionDeclaration') {
if (!isAsyncFunctionNode(declaration)) {
report(node, 'nonAsyncExport')
}
return
}
if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations) {
if (!isAsyncFunctionNode(declarator.init)) {
report(node, 'nonAsyncExport')
return
}
}
return
}
if (declaration?.type === 'TSEnumDeclaration' || declaration?.type === 'ClassDeclaration') {
report(node, 'nonAsyncExport')
return
}
if (declaration) {
// Unrecognized value declaration (e.g. const enum already covered).
if (
declaration.type !== 'TSTypeAliasDeclaration' &&
declaration.type !== 'TSInterfaceDeclaration'
) {
report(node, 'nonAsyncExport')
}
return
}
for (const specifier of node.specifiers ?? []) {
if (specifier.exportKind === 'type') continue
if (specifier.type !== 'ExportSpecifier') {
report(node, 'nonAsyncExport')
continue
}
const localName = specifier.local?.name
if (!localName || !programNode || !resolveLocalAsyncBinding(programNode, localName)) {
report(specifier, 'nonAsyncExport')
}
}
},
}
},
}
export const localRules = {
rules: {
'component-arrow-conventions': componentArrowConventions,
'framework-default-export': frameworkDefaultExport,
'no-component-default-export': noComponentDefaultExport,
'use-server-async-exports': useServerAsyncExports,
},
}