Files
twenty/packages/twenty-eslint-rules/rules/export-component-props.ts
T
Raphaël Bosi cb7d0b83a8 [FRONT COMPONENTS] Twenty UI elements generation (#17866)
## PR description

This PR:
- Creates a TypeScript-based extractor that discovers all exported
twenty-ui components by scanning barrel files, extracting
props/slots/events via ts-morph type analysis, and generating the remote
DOM bindings automatically.

- Adds a new ESLint rule which enforces all *Props types in twenty-ui
components to be exported, which is required for the extractor to
discover component prop types. Existing twenty-ui components are updated
to comply with this rule.

- Extends the remote DOM generation to support slots, per-component
events, forwardRef wrappers, and richer property types (array, object,
function)

## Edge cases to fix in another PR

- Icons cannot be rendered inside buttons
- IconButtons throw an error when mounted
- MenuItems are not displayed correctly
- MenuItemNavigate throws on click

## Video Demo


https://github.com/user-attachments/assets/c2ed67cf-6a15-4896-9fec-e83fac0e862b
2026-02-12 12:48:16 +01:00

100 lines
2.6 KiB
TypeScript

import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
import { isIdentifier } from '@typescript-eslint/utils/ast-utils';
export const RULE_NAME = 'export-component-props';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-export-component-props"
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description:
'Ensure types/interfaces ending with "Props" are exported',
},
fixable: 'code',
schema: [],
messages: {
mustExportProps:
"Props type '{{ typeName }}' must be exported.",
},
},
defaultOptions: [],
create: (context) => {
const reExportedNames = new Set<string>();
const unexportedPropsNodes = new Map<
string,
| TSESTree.TSTypeAliasDeclaration
| TSESTree.TSInterfaceDeclaration
>();
const collectUnexportedProps = (
node:
| TSESTree.TSTypeAliasDeclaration
| TSESTree.TSInterfaceDeclaration,
) => {
const typeName = node.id.name;
if (!typeName.endsWith('Props')) {
return;
}
const isExported =
node.parent?.type ===
TSESTree.AST_NODE_TYPES.ExportNamedDeclaration;
if (!isExported) {
unexportedPropsNodes.set(typeName, node);
}
};
return {
TSTypeAliasDeclaration: collectUnexportedProps,
TSInterfaceDeclaration: collectUnexportedProps,
ExportNamedDeclaration: (
node: TSESTree.ExportNamedDeclaration,
) => {
for (const specifier of node.specifiers) {
if (
specifier.type ===
TSESTree.AST_NODE_TYPES.ExportSpecifier &&
isIdentifier(specifier.local)
) {
const name = specifier.local.name;
if (name.endsWith('Props')) {
reExportedNames.add(name);
}
}
}
},
'Program:exit': () => {
for (const [typeName, node] of unexportedPropsNodes) {
if (reExportedNames.has(typeName)) {
continue;
}
context.report({
node: node.id,
messageId: 'mustExportProps',
data: { typeName },
fix: (fixer) => {
const sourceCode = context.sourceCode;
const firstToken = sourceCode.getFirstToken(node);
const target = firstToken ?? node;
if (firstToken?.value === 'export') {
return null;
}
return fixer.insertTextBefore(target, 'export ');
},
});
}
},
};
},
});