tldraw/scripts/lib/eslint-plugin.ts
alex 6cb797a074
Better generated docs for react components (#3930)
Before:
![Screenshot 2024-06-12 at 12 57
26](https://github.com/tldraw/tldraw/assets/1489520/2a9f6098-ef2a-4f52-88f5-d6e4311c067d)

After:
![Screenshot 2024-06-12 at 12 59
16](https://github.com/tldraw/tldraw/assets/1489520/51733c2a-a2b4-4084-a89a-85bce5b47672)

React components in docs now list their props, and appear under a new
"Component" section instead of randomly under either `Function` or
`Variable`. In order to have our docs generate this, a few criteria need
to be met:
1. They need to be tagged with the `@react` tsdoc tag
2. Their props need to be a simple type alias, typically to an
interface.

Both of these rules are enforced with a new lint rule - any component
tagged as `@public` will have these rules enforced.

### Change Type

- [x] `docs` — Changes to the documentation, examples, or templates.
- [x] `improvement` — Improving existing features
2024-06-13 13:09:27 +00:00

431 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-var-requires */
// eslint plugins can't use esm
// @ts-ignore - no import/require
import ts = require('typescript')
// @ts-ignore - no import/require
import utils = require('@typescript-eslint/utils')
const { isReassignmentTarget } = require('tsutils') as typeof import('tsutils')
const { ESLintUtils } = utils
import TSESTree = utils.TSESTree
exports.rules = {
'no-export-star': ESLintUtils.RuleCreator.withoutDocs({
create(context) {
return {
ExportAllDeclaration(node) {
if (node.exported !== null) {
// we're exporting a specific name, so that's OK!
return
}
// 1. Grab the TypeScript program from parser services
const parserServices = ESLintUtils.getParserServices(context)
const checker = parserServices.program.getTypeChecker()
// 2. Find the backing TS node for the ES node, then the symbol for the imported file
const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node)
const importedFileSymbol = checker.getSymbolAtLocation(originalNode.moduleSpecifier!)!
// 3. Find all the imported names from the file
const importedNames = checker.getExportsOfModule(importedFileSymbol).map((imported) => ({
name: imported.getEscapedName(),
isType: !(imported.flags & ts.SymbolFlags.Value),
}))
// report the error and offer a fix (listing imported names)
context.report({
messageId: 'named',
node,
fix: (fixer) => {
return fixer.replaceText(
node,
[
'export {',
...importedNames.map(
(imported) => ` ${imported.isType ? 'type ' : ''}${imported.name},`
),
`} from ${JSON.stringify(node.source.value)};`,
].join('\n')
)
},
})
},
}
},
meta: {
messages: {
named: 'Use specific named exports instead of export *',
},
type: 'suggestion',
schema: [],
fixable: 'code',
},
defaultOptions: [],
}),
'no-internal-imports': ESLintUtils.RuleCreator.withoutDocs({
create(context) {
return {
ImportDeclaration(node) {
const path = node.source.value
const parts = path.split('/')
switch (parts[0]) {
case 'tldraw':
// 'tldraw'
if (parts.length === 1) return
// 'tldraw/**/*.css'
if (path.endsWith('.css')) return
break
case '@tldraw':
// '@tldraw/*'
if (parts.length === 2) return
// '@tldraw/**/*.css'
if (path.endsWith('.css')) return
// '@tldraw/assets/*'
if (parts[1] === 'assets' && parts.length === 3) return
break
default:
return
}
context.report({
messageId: 'internal',
node: node.source,
data: { path },
})
},
}
},
meta: {
messages: {
internal: "Don't import from internal tldraw source ({{path}})",
},
type: 'problem',
schema: [],
},
defaultOptions: [],
}),
'no-at-internal': ESLintUtils.RuleCreator.withoutDocs({
create(context) {
// adapted from https://github.com/gund/eslint-plugin-deprecation
function identifierRule(id: TSESTree.Identifier | TSESTree.JSXIdentifier) {
const services = ESLintUtils.getParserServices(context)
// Don't consider deprecations in certain cases:
// - On JSX closing elements (only flag the opening element)
const isClosingElement =
id.type === 'JSXIdentifier' && id.parent?.type === 'JSXClosingElement'
if (isClosingElement) {
return
}
// - Inside an import
const isInsideImport = context.getAncestors().some((anc) => anc.type.includes('Import'))
if (isInsideImport) {
return
}
const internalMarker = getInternalMarker(id, services)
if (internalMarker) {
context.report({
node: id,
messageId: 'internal',
data: {
name: id.name,
},
})
}
}
function getInternalMarker(
id: TSESTree.Identifier | TSESTree.JSXIdentifier,
services: utils.ParserServices
) {
const tc = services.program.getTypeChecker()
const callExpression = getCallExpression(id)
if (callExpression) {
const tsCallExpression = services.esTreeNodeToTSNodeMap.get(
callExpression
) as ts.CallLikeExpression
const signature = tc.getResolvedSignature(tsCallExpression)
if (signature) {
const deprecation = getJsDocInternal(signature.getJsDocTags())
if (deprecation) {
return deprecation
}
}
}
const symbol = getSymbol(id, services, tc)
if (!symbol) {
return undefined
}
if (callExpression && isFunction(symbol)) {
return undefined
}
return getJsDocInternal(symbol.getJsDocTags())
}
function isFunction(symbol: ts.Symbol) {
const { declarations } = symbol
if (declarations === undefined || declarations.length === 0) {
return false
}
switch (declarations[0].kind) {
case ts.SyntaxKind.MethodDeclaration:
case ts.SyntaxKind.FunctionDeclaration:
case ts.SyntaxKind.FunctionExpression:
case ts.SyntaxKind.MethodSignature:
return true
default:
return false
}
}
function getCallExpression(
id: TSESTree.Node
): TSESTree.CallExpression | TSESTree.TaggedTemplateExpression | undefined {
const ancestors = context.getAncestors()
let callee = id
let parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : undefined
if (parent && parent.type === 'MemberExpression' && parent.property === id) {
callee = parent
parent = ancestors.length > 1 ? ancestors[ancestors.length - 2] : undefined
}
if (isCallExpression(parent, callee)) {
return parent
}
return undefined
}
function isCallExpression(
node: TSESTree.Node | undefined,
callee: TSESTree.Node
): node is TSESTree.CallExpression | TSESTree.TaggedTemplateExpression {
if (node) {
if (node.type === 'NewExpression' || node.type === 'CallExpression') {
return node.callee === callee
} else if (node.type === 'TaggedTemplateExpression') {
return node.tag === callee
} else if (node.type === 'JSXOpeningElement') {
return node.name === callee
}
}
return false
}
function getJsDocInternal(tags: ts.JSDocTagInfo[]) {
for (const tag of tags) {
if (tag.name === 'internal') {
return { reason: ts.displayPartsToString(tag.text) }
}
}
return undefined
}
function getSymbol(
id: TSESTree.Identifier | TSESTree.JSXIdentifier,
services: utils.ParserServices,
tc: ts.TypeChecker
) {
let symbol: ts.Symbol | undefined
const tsId = services.esTreeNodeToTSNodeMap.get(id as TSESTree.Node) as ts.Identifier
const parent = tsId.parent
if (parent.kind === ts.SyntaxKind.BindingElement) {
symbol = tc.getTypeAtLocation(parent.parent).getProperty(tsId.text)
} else if (
(isPropertyAssignment(parent) && parent.name === tsId) ||
(isShorthandPropertyAssignment(parent) &&
parent.name === tsId &&
isReassignmentTarget(tsId))
) {
try {
symbol = tc.getPropertySymbolOfDestructuringAssignment(tsId)
} catch (e) {
// we are in object literal, not destructuring
// no obvious easy way to check that in advance
symbol = tc.getSymbolAtLocation(tsId)
}
} else {
symbol = tc.getSymbolAtLocation(tsId)
}
if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) {
symbol = tc.getAliasedSymbol(symbol)
}
return symbol
}
function isPropertyAssignment(node: ts.Node): node is ts.PropertyAssignment {
return node.kind === ts.SyntaxKind.PropertyAssignment
}
function isShorthandPropertyAssignment(
node: ts.Node
): node is ts.ShorthandPropertyAssignment {
return node.kind === ts.SyntaxKind.ShorthandPropertyAssignment
}
return {
Identifier: identifierRule,
JSXIdentifier: identifierRule,
}
},
meta: {
messages: {
internal: '"{{name}}" is internal and can\'t be used publicly.',
},
type: 'problem',
schema: [],
},
defaultOptions: [],
}),
'tagged-components': ESLintUtils.RuleCreator.withoutDocs({
create(context) {
function isComponentName(node: TSESTree.Node) {
return node.type === 'Identifier' && /^[A-Z]/.test(node.name)
}
function checkComponentDeclaration(
services: utils.ParserServices,
node: TSESTree.VariableDeclarator | TSESTree.FunctionDeclaration,
propsType: ts.TypeNode | undefined
) {
const declaration = findTopLevelParent(node)
const comments = context.getSourceCode().getCommentsBefore(declaration)
// we only care about components tagged as public
const publicComment = comments.find((comment) => comment.value.includes('@public'))
if (!publicComment) return
// if it's not tagged as a react component, it should be:
if (!publicComment.value.includes('@react')) {
context.report({
messageId: 'untagged',
node: publicComment,
fix: (fixer) => {
const hasLines = publicComment.value.includes('\n')
let replacement
if (hasLines) {
const lines = publicComment.value.split('\n')
const publicLineIdx = lines.findIndex((line) => line.includes('@public'))
if (!publicLineIdx) throw new Error('Could not find @public line')
const indent = lines[publicLineIdx].match(/^\s*/)![0]
lines.splice(publicLineIdx + 1, 0, `${indent}* @react`)
replacement = lines.join('\n')
} else {
replacement = publicComment.value.replace('@public', '@public @react')
}
return fixer.replaceText(publicComment, `/*${replacement}*/`)
},
})
return
}
// if it is tagged as a react component, the props should be a named export:
if (!propsType) return
if (propsType.kind !== ts.SyntaxKind.TypeReference) {
context.report({
messageId: 'nonNamedProps',
node: services.tsNodeToESTreeNodeMap.get(propsType)!,
})
}
}
function findTopLevelParent(node: TSESTree.Node): TSESTree.Node {
let current: TSESTree.Node = node
while (current.parent && current.parent.type !== 'Program') {
current = current.parent
}
return current
}
function checkFunctionExpression(
node: TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression
) {
const services = ESLintUtils.getParserServices(context)
const parent = node.parent!
if (parent.type === utils.AST_NODE_TYPES.VariableDeclarator && isComponentName(parent.id)) {
const propsType = services.esTreeNodeToTSNodeMap.get(node).parameters[0]?.type
checkComponentDeclaration(services, parent, propsType)
}
if (parent.type === utils.AST_NODE_TYPES.CallExpression) {
const callee = parent.callee
const grandparent = parent.parent!
const isMemoFn =
(callee.type === utils.AST_NODE_TYPES.Identifier && callee.name === 'memo') ||
(callee.type === utils.AST_NODE_TYPES.MemberExpression &&
callee.property.type === utils.AST_NODE_TYPES.Identifier &&
callee.property.name === 'memo')
const isForwardRefFn =
(callee.type === utils.AST_NODE_TYPES.Identifier && callee.name === 'forwardRef') ||
(callee.type === utils.AST_NODE_TYPES.MemberExpression &&
callee.property.type === utils.AST_NODE_TYPES.Identifier &&
callee.property.name === 'forwardRef')
const isComponenty =
grandparent.type === utils.AST_NODE_TYPES.VariableDeclarator &&
isComponentName(grandparent.id)
if (isMemoFn && isComponenty) {
const propsType = services.esTreeNodeToTSNodeMap.get(node).parameters[0]?.type
checkComponentDeclaration(services, grandparent, propsType)
}
if (isForwardRefFn && isComponenty) {
const propsType =
services.esTreeNodeToTSNodeMap.get(node).parameters[1]?.type ||
services.esTreeNodeToTSNodeMap.get(parent).typeArguments?.[1]
checkComponentDeclaration(services, grandparent, propsType)
}
}
}
return {
FunctionDeclaration(node) {
if (node.id && isComponentName(node.id)) {
const services = ESLintUtils.getParserServices(context)
const propsType = services.esTreeNodeToTSNodeMap.get(node).parameters[0]?.type
checkComponentDeclaration(services, node, propsType)
}
},
FunctionExpression(node) {
checkFunctionExpression(node)
},
ArrowFunctionExpression(node) {
checkFunctionExpression(node)
},
}
},
meta: {
messages: {
untagged: 'This react component should be tagged with @react',
nonNamedProps: 'Props should be a separate named & public exported type/interface.',
},
type: 'problem',
schema: [],
fixable: 'code',
},
defaultOptions: [],
}),
}