Files
twenty/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.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

311 lines
10 KiB
TypeScript

import type { Project, SourceFile } from 'ts-morph';
import { EVENT_TO_REACT } from '@/sdk/front-component-common/EventToReact';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { CUSTOM_ELEMENT_NAMES } from './constants';
import { type ComponentSchema } from './schemas';
import { addFileHeader, addStatement } from './utils';
const generateRuntimeUtilities = (
eventToReactMapping: Record<string, string>,
): string => {
const eventMapEntries = Object.entries(eventToReactMapping)
.map(([domEvent, reactProp]) => ` on${domEvent}: '${reactProp}',`)
.join('\n');
return `const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = {
${eventMapEntries}
};
const parseStyle = (styleString: string | undefined): React.CSSProperties | undefined => {
if (!styleString || typeof styleString !== 'string') {
return styleString as React.CSSProperties | undefined;
}
const style: Record<string, string> = {};
const declarations = styleString.split(';').filter(Boolean);
for (const declaration of declarations) {
const colonIndex = declaration.indexOf(':');
if (colonIndex === -1) continue;
const property = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
const camelProperty = property.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
style[camelProperty] = value;
}
return style;
};
const serializeEvent = (event: unknown): SerializedEventData => {
if (!event || typeof event !== 'object') {
return { type: 'unknown' };
}
const domEvent = event as Record<string, unknown>;
const serialized: SerializedEventData = {
type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown',
};
if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean;
if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean;
if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean;
if ('shiftKey' in domEvent) serialized.shiftKey = domEvent.shiftKey as boolean;
if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number;
if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number;
if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number;
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
if ('button' in domEvent) serialized.button = domEvent.button as number;
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
if ('key' in domEvent) serialized.key = domEvent.key as string;
if ('code' in domEvent) serialized.code = domEvent.code as string;
if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean;
if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number;
if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number;
if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number;
if ('deltaMode' in domEvent) serialized.deltaMode = domEvent.deltaMode as number;
const target = domEvent.target as Record<string, unknown> | undefined;
if (target && typeof target === 'object') {
if ('value' in target && typeof target.value === 'string') {
serialized.value = target.value;
}
if ('checked' in target && typeof target.checked === 'boolean') {
serialized.checked = target.checked;
}
if ('scrollTop' in target && typeof target.scrollTop === 'number') {
serialized.scrollTop = target.scrollTop;
}
if ('scrollLeft' in target && typeof target.scrollLeft === 'number') {
serialized.scrollLeft = target.scrollLeft;
}
}
return serialized;
};
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
return (event: unknown) => {
handler(serializeEvent(event));
};
};
const filterHtmlProps = <T extends object>(props: T): T => {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (INTERNAL_PROPS.has(key) || value === undefined) continue;
if (key === 'style') {
filtered.style = parseStyle(value as string | undefined);
} else {
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (normalizedKey.startsWith('on') && typeof value === 'function') {
filtered[normalizedKey] = wrapEventHandler(value as (detail: SerializedEventData) => void);
} else {
filtered[normalizedKey] = value;
}
}
}
return filtered as T;
};
const filterUiProps = <T extends object>(props: T, eventPropNames?: Set<string>): T => {
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (INTERNAL_PROPS.has(key) || value === undefined) continue;
if (key === 'style') {
filtered.style = parseStyle(value as string | undefined);
} else if (eventPropNames?.has(key) && typeof value === 'function') {
filtered[key] = wrapEventHandler(value as (detail: SerializedEventData) => void);
} else {
filtered[key] = value;
}
}
return filtered as T;
};`;
};
// HTML void elements cannot have children
// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const VOID_ELEMENTS = new Set([
'input',
'br',
'hr',
'img',
'area',
'base',
'col',
'embed',
'link',
'meta',
'source',
'track',
'wbr',
]);
const generateHtmlWrapperComponent = (component: ComponentSchema): string => {
const isVoidElement = VOID_ELEMENTS.has(component.htmlTag ?? '');
if (isVoidElement) {
return `const ${component.name}Wrapper = React.forwardRef<HTMLElement, { children?: React.ReactNode } & Record<string, unknown>>(({ children: _children, ...props }, ref) => {
return React.createElement('${component.htmlTag}', { ...filterHtmlProps(props), ref });
});`;
}
return `const ${component.name}Wrapper = React.forwardRef<HTMLElement, { children?: React.ReactNode } & Record<string, unknown>>(({ children, ...props }, ref) => {
return React.createElement('${component.htmlTag}', { ...filterHtmlProps(props), ref }, children);
});`;
};
const generateUiWrapperComponent = (component: ComponentSchema): string => {
const propsType = isDefined(component.propsTypeName)
? `${component.propsTypeName} & { children?: React.ReactNode }`
: '{ children?: React.ReactNode } & Record<string, unknown>';
const hasEvents = isNonEmptyArray(component.events);
const filterCall = hasEvents
? `filterUiProps(props, new Set([${component.events.map((event) => `'${EVENT_TO_REACT[event]}'`).join(', ')}]))`
: 'filterUiProps(props)';
if (component.supportsRefForwarding) {
return `const ${component.name}Wrapper = React.forwardRef<unknown, ${propsType}>((props, ref) => {
return React.createElement(${component.componentImport} as React.ElementType, { ...${filterCall}, ref });
});`;
}
return `const ${component.name}Wrapper = (props: ${propsType}) => {
return React.createElement(${component.componentImport}, ${filterCall});
};`;
};
const generateWrapperComponent = (component: ComponentSchema): string => {
if (component.isHtmlElement) {
return generateHtmlWrapperComponent(component);
}
return generateUiWrapperComponent(component);
};
const generateRegistryMap = (components: ComponentSchema[]): string => {
const entries = components
.map(
(component) =>
` ['${component.customElementName}', createRemoteComponentRenderer(${component.name}Wrapper)],`,
)
.join('\n');
return `type ComponentRegistryValue =
| ReturnType<typeof createRemoteComponentRenderer>
| typeof RemoteFragmentRenderer;
export const componentRegistry: Map<string, ComponentRegistryValue> = new Map([
${entries}
['${CUSTOM_ELEMENT_NAMES.FRAGMENT}', RemoteFragmentRenderer],
]);`;
};
type ImportGroup = {
namedImports: string[];
typeImports: string[];
};
const groupImportsByPath = (
components: ComponentSchema[],
): Map<string, ImportGroup> => {
const importsByPath = new Map<string, ImportGroup>();
for (const component of components) {
if (
!component.isHtmlElement &&
isDefined(component.componentPath) &&
isDefined(component.componentImport)
) {
const existing = importsByPath.get(component.componentPath) ?? {
namedImports: [],
typeImports: [],
};
if (!existing.namedImports.includes(component.componentImport)) {
existing.namedImports.push(component.componentImport);
}
if (
isDefined(component.propsTypeName) &&
!existing.typeImports.includes(component.propsTypeName)
) {
existing.typeImports.push(component.propsTypeName);
}
importsByPath.set(component.componentPath, existing);
}
}
return importsByPath;
};
export const generateHostRegistry = (
project: Project,
components: ComponentSchema[],
eventToReactMapping: Record<string, string>,
): SourceFile => {
const sourceFile = project.createSourceFile(
'host-component-registry.ts',
'',
{ overwrite: true },
);
sourceFile.addImportDeclaration({
moduleSpecifier: 'react',
defaultImport: 'React',
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@remote-dom/react/host',
namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '../../../sdk/front-component-common/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
});
const uiImports = groupImportsByPath(components);
for (const [modulePath, importGroup] of uiImports) {
const allImports = [
...importGroup.namedImports,
...importGroup.typeImports.map((typeName) => ({
name: typeName,
isTypeOnly: true,
})),
];
sourceFile.addImportDeclaration({
moduleSpecifier: modulePath,
namedImports: allImports,
});
}
addStatement(sourceFile, generateRuntimeUtilities(eventToReactMapping));
for (const component of components) {
addStatement(sourceFile, generateWrapperComponent(component));
}
addStatement(sourceFile, generateRegistryMap(components));
addFileHeader(sourceFile);
return sourceFile;
};