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
This commit is contained in:
alex 2024-06-13 14:09:27 +01:00 committed by GitHub
parent 69e6dbc407
commit 6cb797a074
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 998 additions and 485 deletions

View file

@ -295,4 +295,137 @@ exports.rules = {
},
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: [],
}),
}