6cb797a074
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
431 lines
12 KiB
TypeScript
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: [],
|
|
}),
|
|
}
|