[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
This commit is contained in:
+57
-31
@@ -1,10 +1,10 @@
|
||||
import * as prettier from '@prettier/sync';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { IndentationText, Project, QuoteKind } from 'ts-morph';
|
||||
|
||||
import { ALLOWED_HTML_ELEMENTS } from '../../src/sdk/front-component-common/AllowedHtmlElements';
|
||||
import { ALLOWED_UI_COMPONENTS } from '../../src/sdk/front-component-common/AllowedUiComponents';
|
||||
import { COMMON_HTML_EVENTS } from '../../src/sdk/front-component-common/CommonHtmlEvents';
|
||||
import { EVENT_TO_REACT } from '../../src/sdk/front-component-common/EventToReact';
|
||||
import { HTML_COMMON_PROPERTIES } from '../../src/sdk/front-component-common/HtmlCommonProperties';
|
||||
@@ -17,10 +17,27 @@ import {
|
||||
generateRemoteElements,
|
||||
HtmlElementConfigArrayZ,
|
||||
OUTPUT_FILES,
|
||||
UiComponentConfigArrayZ,
|
||||
} from './generators';
|
||||
import { extractAllComponentsFromTwentyUi } from './twenty-ui-extractor';
|
||||
import {
|
||||
logCount,
|
||||
logDetail,
|
||||
logEmpty,
|
||||
logError,
|
||||
logFileWritten,
|
||||
logGroupLabel,
|
||||
logSectionHeader,
|
||||
logSeparator,
|
||||
logSuccess,
|
||||
logTitle,
|
||||
setVerbose,
|
||||
} from './utils/logger';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
||||
const parseVerboseFlag = (): boolean => {
|
||||
return process.argv.includes('--verbose');
|
||||
};
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PACKAGE_PATH = path.resolve(SCRIPT_DIR, '../..');
|
||||
const FRONT_COMPONENT_PATH = path.join(PACKAGE_PATH, 'src/front-component');
|
||||
const HOST_GENERATED_DIR = path.join(FRONT_COMPONENT_PATH, 'host/generated');
|
||||
@@ -61,24 +78,21 @@ const getHtmlElementSchemas = (): ComponentSchema[] => {
|
||||
};
|
||||
|
||||
const getUiComponentSchemas = (): ComponentSchema[] => {
|
||||
const result = UiComponentConfigArrayZ.safeParse(ALLOWED_UI_COMPONENTS);
|
||||
const discoveredComponents = extractAllComponentsFromTwentyUi();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid UI component configuration:\n${formatZodError(result.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data.map((component) => ({
|
||||
return discoveredComponents.map((component) => ({
|
||||
name: component.name,
|
||||
tagName: component.name,
|
||||
customElementName: component.tag,
|
||||
properties: component.properties,
|
||||
events: COMMON_HTML_EVENTS,
|
||||
slots: component.slots,
|
||||
events: component.events,
|
||||
isHtmlElement: false,
|
||||
htmlTag: undefined,
|
||||
componentImport: component.componentImport,
|
||||
componentPath: component.componentPath,
|
||||
propsTypeName: component.propsTypeName,
|
||||
supportsRefForwarding: component.supportsRefForwarding,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -106,7 +120,7 @@ const writeGeneratedFile = (
|
||||
endOfLine: 'lf',
|
||||
});
|
||||
fs.writeFileSync(filePath, formattedContent, 'utf-8');
|
||||
console.log(`✓ Generated ${filePath}`);
|
||||
logFileWritten(filePath);
|
||||
};
|
||||
|
||||
const ensureDirectoriesExist = (): void => {
|
||||
@@ -119,7 +133,10 @@ const ensureDirectoriesExist = (): void => {
|
||||
};
|
||||
|
||||
const main = (): void => {
|
||||
console.log('📖 Generating remote DOM elements...\n');
|
||||
const verbose = parseVerboseFlag();
|
||||
setVerbose(verbose);
|
||||
|
||||
logTitle('Remote DOM Elements Generator');
|
||||
|
||||
let htmlElements: ComponentSchema[];
|
||||
let uiComponents: ComponentSchema[];
|
||||
@@ -128,23 +145,24 @@ const main = (): void => {
|
||||
htmlElements = getHtmlElementSchemas();
|
||||
uiComponents = getUiComponentSchemas();
|
||||
} catch (error) {
|
||||
console.error('❌ Validation failed:', error);
|
||||
logError('Validation failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`HTML Elements: ${htmlElements.length} elements`);
|
||||
console.log(
|
||||
` Tags: ${htmlElements.map((element) => element.htmlTag).join(', ')}`,
|
||||
);
|
||||
console.log(
|
||||
` Events: ${COMMON_HTML_EVENTS.length} common events per element`,
|
||||
);
|
||||
logSeparator();
|
||||
logSectionHeader('Summary');
|
||||
|
||||
console.log(`\nUI Components: ${uiComponents.length} components`);
|
||||
console.log(
|
||||
` Tags: ${uiComponents.map((component) => component.customElementName).join(', ')}`,
|
||||
logCount('HTML Elements', htmlElements.length, 'element', 'elements');
|
||||
logDetail(
|
||||
`Tags: ${htmlElements.map((element) => element.htmlTag).join(', ')}`,
|
||||
);
|
||||
logDetail(`Events: ${COMMON_HTML_EVENTS.length} common events per element`);
|
||||
|
||||
logEmpty();
|
||||
logCount('UI Components', uiComponents.length, 'component', 'components');
|
||||
logDetail(
|
||||
`Tags: ${uiComponents.map((component) => component.customElementName).join(', ')}`,
|
||||
);
|
||||
console.log('');
|
||||
|
||||
const allComponents = [...htmlElements, ...uiComponents];
|
||||
|
||||
@@ -152,7 +170,11 @@ const main = (): void => {
|
||||
|
||||
const project = createProject();
|
||||
|
||||
console.log('Host files:');
|
||||
logSeparator();
|
||||
logSectionHeader('Writing Files');
|
||||
|
||||
logGroupLabel('Host');
|
||||
|
||||
const hostRegistry = generateHostRegistry(
|
||||
project,
|
||||
allComponents,
|
||||
@@ -164,7 +186,9 @@ const main = (): void => {
|
||||
hostRegistry.getFullText(),
|
||||
);
|
||||
|
||||
console.log('\nRemote files:');
|
||||
logEmpty();
|
||||
logGroupLabel('Remote');
|
||||
|
||||
const remoteElements = generateRemoteElements(
|
||||
project,
|
||||
allComponents,
|
||||
@@ -184,9 +208,11 @@ const main = (): void => {
|
||||
remoteComponents.getFullText(),
|
||||
);
|
||||
|
||||
console.log('\n✅ All generated files created');
|
||||
console.log(` Host: ${HOST_GENERATED_DIR}`);
|
||||
console.log(` Remote: ${REMOTE_GENERATED_DIR}`);
|
||||
logSeparator();
|
||||
logSuccess('Done!', 'All generated files created.');
|
||||
logDetail(`Host: ${HOST_GENERATED_DIR}`);
|
||||
logDetail(`Remote: ${REMOTE_GENERATED_DIR}`);
|
||||
logEmpty();
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
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';
|
||||
@@ -98,7 +99,7 @@ const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
|
||||
};
|
||||
};
|
||||
|
||||
const filterProps = (props: Record<string, unknown>) => {
|
||||
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;
|
||||
@@ -114,7 +115,23 @@ const filterProps = (props: Record<string, unknown>) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
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;
|
||||
};`;
|
||||
};
|
||||
|
||||
@@ -140,19 +157,35 @@ const generateHtmlWrapperComponent = (component: ComponentSchema): string => {
|
||||
const isVoidElement = VOID_ELEMENTS.has(component.htmlTag ?? '');
|
||||
|
||||
if (isVoidElement) {
|
||||
return `const ${component.name}Wrapper = ({ children: _children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('${component.htmlTag}', filterProps(props));
|
||||
};`;
|
||||
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 = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('${component.htmlTag}', filterProps(props), children);
|
||||
};`;
|
||||
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 => {
|
||||
return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement(${component.componentImport}, filterProps(props), children);
|
||||
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});
|
||||
};`;
|
||||
};
|
||||
|
||||
@@ -181,10 +214,15 @@ ${entries}
|
||||
]);`;
|
||||
};
|
||||
|
||||
type ImportGroup = {
|
||||
namedImports: string[];
|
||||
typeImports: string[];
|
||||
};
|
||||
|
||||
const groupImportsByPath = (
|
||||
components: ComponentSchema[],
|
||||
): Map<string, string[]> => {
|
||||
const importsByPath = new Map<string, string[]>();
|
||||
): Map<string, ImportGroup> => {
|
||||
const importsByPath = new Map<string, ImportGroup>();
|
||||
|
||||
for (const component of components) {
|
||||
if (
|
||||
@@ -192,10 +230,22 @@ const groupImportsByPath = (
|
||||
isDefined(component.componentPath) &&
|
||||
isDefined(component.componentImport)
|
||||
) {
|
||||
const existing = importsByPath.get(component.componentPath) ?? [];
|
||||
if (!existing.includes(component.componentImport)) {
|
||||
existing.push(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);
|
||||
}
|
||||
}
|
||||
@@ -231,10 +281,18 @@ export const generateHostRegistry = (
|
||||
|
||||
const uiImports = groupImportsByPath(components);
|
||||
|
||||
for (const [modulePath, namedImports] of uiImports) {
|
||||
for (const [modulePath, importGroup] of uiImports) {
|
||||
const allImports = [
|
||||
...importGroup.namedImports,
|
||||
...importGroup.typeImports.map((typeName) => ({
|
||||
name: typeName,
|
||||
isTypeOnly: true,
|
||||
})),
|
||||
];
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: modulePath,
|
||||
namedImports,
|
||||
namedImports: allImports,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/sdk/front-component-common/EventToReact';
|
||||
import { type ComponentSchema } from './schemas';
|
||||
import { addExportedConst, addFileHeader, eventToReactProp } from './utils';
|
||||
import { addExportedConst, addFileHeader } from './utils';
|
||||
|
||||
const generateComponentDefinition = (
|
||||
sourceFile: SourceFile,
|
||||
@@ -15,7 +16,7 @@ const generateComponentDefinition = (
|
||||
if (hasEvents) {
|
||||
const eventProps = component.events
|
||||
.map((event) => {
|
||||
const propName = eventToReactProp(event);
|
||||
const propName = EVENT_TO_REACT[event];
|
||||
return ` ${propName}: { event: '${event}' },`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type CodeBlockWriter,
|
||||
type Project,
|
||||
type SourceFile,
|
||||
VariableDeclarationKind,
|
||||
type WriterFunction,
|
||||
} from 'ts-morph';
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { type ComponentSchema, type PropertySchema } from './schemas';
|
||||
import {
|
||||
addFileHeader,
|
||||
generatePropertiesConfig,
|
||||
schemaTypeToConstructor,
|
||||
schemaTypeToTs,
|
||||
} from './utils';
|
||||
@@ -147,7 +146,9 @@ const generateElementPropertyType = (
|
||||
isExported: true,
|
||||
name: `${component.name}Properties`,
|
||||
type: (writer) => {
|
||||
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
|
||||
if (component.isHtmlElement) {
|
||||
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
|
||||
}
|
||||
writer.block(() => {
|
||||
for (const entry of entries) {
|
||||
writer.writeLine(`${entry};`);
|
||||
@@ -158,12 +159,14 @@ const generateElementPropertyType = (
|
||||
}
|
||||
};
|
||||
|
||||
const createPropertiesConfigWriter = (
|
||||
const writePropertyEntries = (
|
||||
writer: CodeBlockWriter,
|
||||
properties: Record<string, PropertySchema>,
|
||||
): WriterFunction => {
|
||||
return (writer) => {
|
||||
writer.write(generatePropertiesConfig(properties));
|
||||
};
|
||||
): void => {
|
||||
for (const [name, schema] of Object.entries(properties)) {
|
||||
const constructorType = schemaTypeToConstructor(schema.type);
|
||||
writer.writeLine(`'${name}': { type: ${constructorType} },`);
|
||||
}
|
||||
};
|
||||
|
||||
const generateElementDefinition = (
|
||||
@@ -172,17 +175,23 @@ const generateElementDefinition = (
|
||||
specificProperties: Record<string, PropertySchema>,
|
||||
options: ElementGenerationOptions,
|
||||
): void => {
|
||||
const { useSharedEvents, useSharedPropertiesConfig } = options;
|
||||
const useSharedEvents = options.useSharedEvents && component.isHtmlElement;
|
||||
const { useSharedPropertiesConfig } = options;
|
||||
const hasEvents = component.events.length > 0;
|
||||
const hasSpecificProps = Object.keys(specificProperties).length > 0;
|
||||
const hasProps = Object.keys(component.properties).length > 0;
|
||||
const hasSlots = (component.slots ?? []).length > 0;
|
||||
|
||||
const propsType = hasSpecificProps
|
||||
? `${component.name}Properties`
|
||||
: hasProps
|
||||
: hasProps && component.isHtmlElement
|
||||
? TYPE_NAMES.COMMON_PROPERTIES
|
||||
: TYPE_NAMES.EMPTY_RECORD;
|
||||
|
||||
const slotsType = hasSlots
|
||||
? `{ ${(component.slots ?? []).map((slot) => `'${slot}': true`).join('; ')} }`
|
||||
: TYPE_NAMES.EMPTY_RECORD;
|
||||
|
||||
const eventsType = hasEvents
|
||||
? useSharedEvents
|
||||
? TYPE_NAMES.COMMON_EVENTS
|
||||
@@ -201,13 +210,13 @@ const generateElementDefinition = (
|
||||
writer.indent(() => {
|
||||
writer.writeLine(`${propsType},`);
|
||||
writer.writeLine('Record<string, never>,');
|
||||
writer.writeLine('Record<string, never>,');
|
||||
writer.writeLine(`${slotsType},`);
|
||||
writer.write(eventsType);
|
||||
});
|
||||
writer.newLine();
|
||||
writer.write('>');
|
||||
|
||||
const hasConfig = hasProps || hasEvents;
|
||||
const hasConfig = hasProps || hasEvents || hasSlots;
|
||||
if (!hasConfig) {
|
||||
writer.write('({})');
|
||||
return;
|
||||
@@ -215,34 +224,33 @@ const generateElementDefinition = (
|
||||
|
||||
writer.write('(');
|
||||
writer.block(() => {
|
||||
if (hasSlots) {
|
||||
writer.write(
|
||||
`slots: [${(component.slots ?? []).map((slot) => `'${slot}'`).join(', ')}],`,
|
||||
);
|
||||
writer.newLine();
|
||||
}
|
||||
if (hasProps) {
|
||||
if (hasSpecificProps) {
|
||||
if (hasSpecificProps && component.isHtmlElement) {
|
||||
writer.write('properties: ');
|
||||
writer.block(() => {
|
||||
writer.writeLine(
|
||||
`...${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
|
||||
);
|
||||
for (const [name, schema] of Object.entries(
|
||||
specificProperties,
|
||||
)) {
|
||||
const constructorType = schemaTypeToConstructor(
|
||||
schema.type,
|
||||
);
|
||||
writer.writeLine(
|
||||
`'${name}': { type: ${constructorType} },`,
|
||||
);
|
||||
}
|
||||
writePropertyEntries(writer, specificProperties);
|
||||
});
|
||||
writer.write(',');
|
||||
writer.newLine();
|
||||
} else if (useSharedPropertiesConfig) {
|
||||
} else if (useSharedPropertiesConfig && component.isHtmlElement) {
|
||||
writer.write(
|
||||
`properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
|
||||
);
|
||||
writer.newLine();
|
||||
} else {
|
||||
writer.write('properties: ');
|
||||
createPropertiesConfigWriter(component.properties)(writer);
|
||||
writer.block(() => {
|
||||
writePropertyEntries(writer, specificProperties);
|
||||
});
|
||||
writer.write(',');
|
||||
writer.newLine();
|
||||
}
|
||||
@@ -313,10 +321,9 @@ const prepareComponentsWithSpecificProps = (
|
||||
): ComponentWithSpecificProps[] => {
|
||||
return components.map((component) => ({
|
||||
component,
|
||||
specificProperties: getElementSpecificProperties(
|
||||
component,
|
||||
commonPropertyNames,
|
||||
),
|
||||
specificProperties: component.isHtmlElement
|
||||
? getElementSpecificProperties(component, commonPropertyNames)
|
||||
: component.properties,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -381,12 +388,9 @@ export const generateRemoteElements = (
|
||||
|
||||
generateCustomElementRegistrations(sourceFile, components);
|
||||
|
||||
sourceFile.addExportDeclaration({
|
||||
namedExports: [
|
||||
INTERNAL_ELEMENT_CLASSES.ROOT,
|
||||
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
|
||||
],
|
||||
});
|
||||
sourceFile.addStatements(
|
||||
`export { ${INTERNAL_ELEMENT_CLASSES.ROOT}, ${INTERNAL_ELEMENT_CLASSES.FRAGMENT} };`,
|
||||
);
|
||||
|
||||
generateTagNameMapDeclaration(sourceFile, components);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PropertySchemaZ = z.object({
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
type: z.enum(['string', 'number', 'boolean', 'array', 'object', 'function']),
|
||||
optional: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -15,33 +15,21 @@ export const HtmlElementConfigZ = z.object({
|
||||
|
||||
export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ);
|
||||
|
||||
export const UiComponentConfigZ = z.object({
|
||||
tag: z
|
||||
.string()
|
||||
.regex(/^twenty-ui-[a-z0-9-]+$/, 'Tag must start with "twenty-ui-"'),
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^TwentyUi[A-Z]/, 'Name must be PascalCase starting with TwentyUi'),
|
||||
properties: z.record(z.string(), PropertySchemaZ),
|
||||
componentImport: z.string().min(1),
|
||||
componentPath: z.string().min(1),
|
||||
});
|
||||
|
||||
export const UiComponentConfigArrayZ = z.array(UiComponentConfigZ);
|
||||
|
||||
export const ComponentSchemaZ = z.object({
|
||||
name: z.string().min(1),
|
||||
tagName: z.string().min(1),
|
||||
customElementName: z.string().min(1),
|
||||
properties: z.record(z.string(), PropertySchemaZ),
|
||||
slots: z.array(z.string()).optional(),
|
||||
events: z.array(z.string()).readonly(),
|
||||
isHtmlElement: z.boolean(),
|
||||
htmlTag: z.string().optional(),
|
||||
componentImport: z.string().optional(),
|
||||
componentPath: z.string().optional(),
|
||||
propsTypeName: z.string().optional(),
|
||||
supportsRefForwarding: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type PropertySchema = z.infer<typeof PropertySchemaZ>;
|
||||
export type HtmlElementConfig = z.infer<typeof HtmlElementConfigZ>;
|
||||
export type UiComponentConfig = z.infer<typeof UiComponentConfigZ>;
|
||||
export type ComponentSchema = z.infer<typeof ComponentSchemaZ>;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const eventToReactProp = (eventName: string): string => {
|
||||
return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`;
|
||||
};
|
||||
@@ -2,10 +2,9 @@ export { addExportedConst } from './add-exported-const';
|
||||
export { addExportedType } from './add-exported-type';
|
||||
export { addFileHeader } from './add-file-header';
|
||||
export { addStatement } from './add-statement';
|
||||
export { eventToReactProp } from './event-to-react-prop';
|
||||
export { extractHtmlTag } from './extract-html-tag';
|
||||
export { GENERATED_FILE_HEADER } from './generated-file-header';
|
||||
export { generatePropertiesConfig } from './generate-properties-config';
|
||||
export { generatePropertiesType } from './generate-properties-type';
|
||||
export { GENERATED_FILE_HEADER } from './generated-file-header';
|
||||
export { schemaTypeToConstructor } from './schema-type-to-constructor';
|
||||
export { schemaTypeToTs } from './schema-type-to-ts';
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ const SCHEMA_TYPE_TO_CONSTRUCTOR: Record<PropertySchema['type'], string> = {
|
||||
boolean: 'Boolean',
|
||||
number: 'Number',
|
||||
string: 'String',
|
||||
array: 'Array',
|
||||
object: 'Object',
|
||||
function: 'Function',
|
||||
};
|
||||
|
||||
export const schemaTypeToConstructor = (type: PropertySchema['type']): string =>
|
||||
|
||||
@@ -4,6 +4,9 @@ const SCHEMA_TYPE_TO_TS: Record<PropertySchema['type'], string> = {
|
||||
boolean: 'boolean',
|
||||
number: 'number',
|
||||
string: 'string',
|
||||
array: 'unknown[]',
|
||||
object: 'Record<string, unknown>',
|
||||
function: '(...args: unknown[]) => unknown',
|
||||
};
|
||||
|
||||
export const schemaTypeToTs = (type: PropertySchema['type']): string =>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { EVENT_TO_REACT } from '@/sdk/front-component-common/EventToReact';
|
||||
|
||||
export const REACT_PROP_TO_DOM_EVENT: Record<string, string> =
|
||||
Object.fromEntries(
|
||||
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
|
||||
reactProp,
|
||||
domEvent,
|
||||
]),
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export const TWENTY_UI_COMPONENT_CATEGORIES_TO_SCAN = [
|
||||
'input',
|
||||
'components',
|
||||
'display',
|
||||
'feedback',
|
||||
'layout',
|
||||
'navigation',
|
||||
'accessibility',
|
||||
];
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORKSPACE_ROOT = path.resolve(SCRIPT_DIR, '../../../../../../');
|
||||
const TWENTY_UI_ROOT = path.join(WORKSPACE_ROOT, 'packages/twenty-ui');
|
||||
|
||||
export const TWENTY_UI_ROOT_PATH = TWENTY_UI_ROOT;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { REACT_PROP_TO_DOM_EVENT } from './ReactPropToDomEvent';
|
||||
export { TWENTY_UI_COMPONENT_CATEGORIES_TO_SCAN } from './TwentyUiComponentCategoriesToScan';
|
||||
export { TWENTY_UI_ROOT_PATH } from './TwentyUiRootPath';
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import * as path from 'path';
|
||||
import { type ExportSpecifier, Project } from 'ts-morph';
|
||||
import { isDefined, pascalToKebab } from 'twenty-shared/utils';
|
||||
|
||||
import { type PropertySchema } from '@/front-component/types/PropertySchema';
|
||||
|
||||
import {
|
||||
logCategory,
|
||||
logCountInline,
|
||||
logDimText,
|
||||
logEmpty,
|
||||
logLine,
|
||||
logWarning,
|
||||
} from '../utils/logger';
|
||||
import {
|
||||
TWENTY_UI_COMPONENT_CATEGORIES_TO_SCAN,
|
||||
TWENTY_UI_ROOT_PATH,
|
||||
} from './constants';
|
||||
import { classifyComponentPropsForRemoteDomGeneration } from './utils/classify-component-props-for-remote-dom-generation';
|
||||
import { doesComponentSupportRefForwarding } from './utils/does-component-support-ref-forwarding';
|
||||
import { getTwentyUiComponentCategoryIndexPath } from './utils/get-twenty-ui-component-category-index-path';
|
||||
import { isReactComponentExport } from './utils/is-react-component-export';
|
||||
import { logDiscoveredComponents } from './utils/log-discovered-components';
|
||||
import { shouldSkipExport } from './utils/should-skip-export';
|
||||
|
||||
export type DiscoveredComponent = {
|
||||
tag: string;
|
||||
name: string;
|
||||
properties: Record<string, PropertySchema>;
|
||||
events: string[];
|
||||
slots: string[];
|
||||
supportsRefForwarding: boolean;
|
||||
componentImport: string;
|
||||
componentPath: string;
|
||||
propsTypeName: string;
|
||||
};
|
||||
|
||||
const extractComponentsFromCategory = (
|
||||
project: Project,
|
||||
category: string,
|
||||
): DiscoveredComponent[] => {
|
||||
const indexPath = getTwentyUiComponentCategoryIndexPath(category);
|
||||
const sourceFile = project.getSourceFile(indexPath);
|
||||
|
||||
if (!isDefined(sourceFile)) {
|
||||
logWarning(`Could not find barrel file at ${indexPath}`);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const allNamedExports = sourceFile
|
||||
.getExportDeclarations()
|
||||
.flatMap((declaration) => declaration.getNamedExports());
|
||||
|
||||
const propsTypeExportsByName = new Map<string, ExportSpecifier>();
|
||||
const componentExports: ExportSpecifier[] = [];
|
||||
|
||||
for (const namedExport of allNamedExports) {
|
||||
if (namedExport.getName().endsWith('Props')) {
|
||||
propsTypeExportsByName.set(namedExport.getName(), namedExport);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isReactComponentExport(namedExport)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
componentExports.push(namedExport);
|
||||
}
|
||||
|
||||
const discoveredComponents: DiscoveredComponent[] = [];
|
||||
|
||||
for (const namedExport of componentExports) {
|
||||
const exportName = namedExport.getName();
|
||||
|
||||
if (shouldSkipExport(exportName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedPropsTypeName = `${exportName}Props`;
|
||||
const propsTypeExport = propsTypeExportsByName.get(expectedPropsTypeName);
|
||||
|
||||
if (!isDefined(propsTypeExport)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const propsType = propsTypeExport.getNameNode().getType();
|
||||
|
||||
const { properties, events, slots } =
|
||||
classifyComponentPropsForRemoteDomGeneration(propsType);
|
||||
|
||||
const supportsRefForwarding =
|
||||
doesComponentSupportRefForwarding(namedExport);
|
||||
const kebabName = pascalToKebab(exportName);
|
||||
|
||||
discoveredComponents.push({
|
||||
tag: `twenty-ui-${kebabName}`,
|
||||
name: `TwentyUi${exportName}`,
|
||||
properties,
|
||||
events,
|
||||
slots,
|
||||
supportsRefForwarding,
|
||||
componentImport: exportName,
|
||||
componentPath: `twenty-ui/${category}`,
|
||||
propsTypeName: expectedPropsTypeName,
|
||||
});
|
||||
}
|
||||
|
||||
return discoveredComponents;
|
||||
};
|
||||
|
||||
export const extractAllComponentsFromTwentyUi = (): DiscoveredComponent[] => {
|
||||
logDimText(' Loading twenty-ui TypeScript project...');
|
||||
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(TWENTY_UI_ROOT_PATH, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: false,
|
||||
});
|
||||
|
||||
logLine(
|
||||
' ' +
|
||||
logCountInline(
|
||||
project.getSourceFiles().length,
|
||||
'source file loaded from twenty-ui',
|
||||
'source files loaded from twenty-ui',
|
||||
),
|
||||
);
|
||||
logEmpty();
|
||||
|
||||
const allDiscoveredComponents: DiscoveredComponent[] = [];
|
||||
|
||||
for (const [
|
||||
index,
|
||||
category,
|
||||
] of TWENTY_UI_COMPONENT_CATEGORIES_TO_SCAN.entries()) {
|
||||
if (index > 0) {
|
||||
logEmpty();
|
||||
}
|
||||
|
||||
logCategory(category);
|
||||
|
||||
const discoveredComponents = extractComponentsFromCategory(
|
||||
project,
|
||||
category,
|
||||
);
|
||||
|
||||
logDiscoveredComponents(discoveredComponents);
|
||||
|
||||
allDiscoveredComponents.push(...discoveredComponents);
|
||||
}
|
||||
|
||||
return allDiscoveredComponents;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
extractAllComponentsFromTwentyUi,
|
||||
type DiscoveredComponent,
|
||||
} from './extract-all-components-from-twenty-ui';
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { type Type } from 'ts-morph';
|
||||
|
||||
import { type PropertySchema } from '@/front-component/types/PropertySchema';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { REACT_PROP_TO_DOM_EVENT } from '../constants/ReactPropToDomEvent';
|
||||
import { mapTypeToPropertySchema } from './map-type-to-property-schema';
|
||||
import { isDomEventHandler } from './is-dom-event-handler';
|
||||
import { isReactElementType } from './is-react-element-type';
|
||||
|
||||
export type ClassifiedComponentProps = {
|
||||
properties: Record<string, PropertySchema>;
|
||||
events: string[];
|
||||
slots: string[];
|
||||
};
|
||||
|
||||
export const classifyComponentPropsForRemoteDomGeneration = (
|
||||
propsType: Type,
|
||||
): ClassifiedComponentProps => {
|
||||
const properties: Record<string, PropertySchema> = {};
|
||||
const events: string[] = [];
|
||||
const slots: string[] = [];
|
||||
const propsTypeProperties = propsType.getProperties();
|
||||
|
||||
for (const propertySymbol of propsTypeProperties) {
|
||||
const propertyName = propertySymbol.getName();
|
||||
|
||||
const propertyDeclarations = propertySymbol.getDeclarations();
|
||||
|
||||
if (!isNonEmptyArray(propertyDeclarations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstDeclaration = propertyDeclarations[0];
|
||||
const propertyType = firstDeclaration.getType();
|
||||
|
||||
const correspondingDomEvent = REACT_PROP_TO_DOM_EVENT[propertyName];
|
||||
|
||||
if (isDefined(correspondingDomEvent) && isDomEventHandler(propertyType)) {
|
||||
events.push(correspondingDomEvent);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOptional =
|
||||
propertySymbol.isOptional() ||
|
||||
propertyType.isNullable() ||
|
||||
propertyType.isUndefined();
|
||||
|
||||
if (isReactElementType(propertyType)) {
|
||||
slots.push(propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const classifiedType = mapTypeToPropertySchema(propertyType);
|
||||
|
||||
if (isDefined(classifiedType)) {
|
||||
properties[propertyName] = {
|
||||
type: classifiedType,
|
||||
optional: isOptional,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { properties, events, slots };
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type ExportSpecifier } from 'ts-morph';
|
||||
|
||||
const REF_FORWARDING_TYPE_MARKERS = [
|
||||
'StyledComponent',
|
||||
'ForwardRefExoticComponent',
|
||||
];
|
||||
|
||||
export const doesComponentSupportRefForwarding = (
|
||||
namedExport: ExportSpecifier,
|
||||
): boolean => {
|
||||
const typeText = namedExport.getNameNode().getType().getText();
|
||||
|
||||
return REF_FORWARDING_TYPE_MARKERS.some((marker) =>
|
||||
typeText.includes(marker),
|
||||
);
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import { TWENTY_UI_ROOT_PATH } from '../constants/TwentyUiRootPath';
|
||||
|
||||
const TWENTY_UI_SRC_PATH = path.join(TWENTY_UI_ROOT_PATH, 'src');
|
||||
|
||||
export const getTwentyUiComponentCategoryIndexPath = (
|
||||
category: string,
|
||||
): string => path.join(TWENTY_UI_SRC_PATH, `${category}/index.ts`);
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './does-component-support-ref-forwarding';
|
||||
export * from './map-type-to-property-schema';
|
||||
export * from './classify-component-props-for-remote-dom-generation';
|
||||
export * from './is-react-element-type';
|
||||
export * from './log-discovered-components';
|
||||
export * from './should-skip-export';
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { type Type } from 'ts-morph';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
const DOM_EVENT_TYPE_NAMES = new Set([
|
||||
'Event',
|
||||
'UIEvent',
|
||||
'MouseEvent',
|
||||
'KeyboardEvent',
|
||||
'FocusEvent',
|
||||
'ChangeEvent',
|
||||
'FormEvent',
|
||||
'DragEvent',
|
||||
'WheelEvent',
|
||||
'ClipboardEvent',
|
||||
'TouchEvent',
|
||||
'PointerEvent',
|
||||
'AnimationEvent',
|
||||
'TransitionEvent',
|
||||
'SyntheticEvent',
|
||||
'BaseSyntheticEvent',
|
||||
]);
|
||||
|
||||
const isEventType = (type: Type): boolean => {
|
||||
const symbol = type.getSymbol() ?? type.getAliasSymbol();
|
||||
|
||||
if (isDefined(symbol) && DOM_EVENT_TYPE_NAMES.has(symbol.getName())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return type.getBaseTypes().some(isEventType);
|
||||
};
|
||||
|
||||
export const isDomEventHandler = (propertyType: Type): boolean => {
|
||||
const nonNullableType = propertyType.getNonNullableType();
|
||||
|
||||
const callSignatures = nonNullableType.getCallSignatures();
|
||||
|
||||
if (!isNonEmptyArray(callSignatures)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return callSignatures.some((signature) => {
|
||||
const firstParam = signature.getParameters()[0];
|
||||
|
||||
if (!isDefined(firstParam)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paramType = firstParam.getValueDeclaration()?.getType();
|
||||
|
||||
if (!isDefined(paramType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isEventType(paramType);
|
||||
});
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { type ExportSpecifier } from 'ts-morph';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
import { typeSymbolMatchesAnyName } from './type-symbol-matches-any-name';
|
||||
|
||||
const REACT_RETURN_TYPE_NAMES = new Set([
|
||||
'ReactNode',
|
||||
'ReactElement',
|
||||
'Element',
|
||||
]);
|
||||
|
||||
export const isReactComponentExport = (
|
||||
namedExport: ExportSpecifier,
|
||||
): boolean => {
|
||||
const exportType = namedExport.getNameNode().getType();
|
||||
const callSignatures = exportType.getCallSignatures();
|
||||
|
||||
if (!isNonEmptyArray(callSignatures)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return callSignatures.some((signature) =>
|
||||
typeSymbolMatchesAnyName(
|
||||
signature.getReturnType(),
|
||||
REACT_RETURN_TYPE_NAMES,
|
||||
),
|
||||
);
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type Type } from 'ts-morph';
|
||||
|
||||
import { typeSymbolMatchesAnyName } from './type-symbol-matches-any-name';
|
||||
|
||||
const REACT_ELEMENT_TYPE_NAMES = new Set([
|
||||
'ReactNode',
|
||||
'ReactElement',
|
||||
'Element',
|
||||
]);
|
||||
|
||||
const REACT_COMPONENT_TYPE_NAMES = new Set([
|
||||
'FunctionComponent',
|
||||
'ComponentType',
|
||||
'IconComponent',
|
||||
]);
|
||||
|
||||
const ALL_REACT_TYPE_NAMES = new Set([
|
||||
...REACT_ELEMENT_TYPE_NAMES,
|
||||
...REACT_COMPONENT_TYPE_NAMES,
|
||||
]);
|
||||
|
||||
export const isReactElementType = (propertyType: Type): boolean => {
|
||||
return typeSymbolMatchesAnyName(propertyType, ALL_REACT_TYPE_NAMES);
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
formatEvents,
|
||||
formatProps,
|
||||
formatSlots,
|
||||
logCountInline,
|
||||
logDimText,
|
||||
logLine,
|
||||
} from '../../utils/logger';
|
||||
|
||||
type ComponentSummary = {
|
||||
tag: string;
|
||||
componentImport: string;
|
||||
properties: Record<string, unknown>;
|
||||
events: string[];
|
||||
slots: string[];
|
||||
};
|
||||
|
||||
export const logDiscoveredComponents = (
|
||||
discoveredComponents: ComponentSummary[],
|
||||
): void => {
|
||||
if (discoveredComponents.length === 0) {
|
||||
logDimText(' No components found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logLine(
|
||||
' ' +
|
||||
logCountInline(
|
||||
discoveredComponents.length,
|
||||
'component found',
|
||||
'components found',
|
||||
),
|
||||
);
|
||||
|
||||
for (const discoveredComponent of discoveredComponents) {
|
||||
const propertyCount = Object.keys(discoveredComponent.properties).length;
|
||||
const eventCount = discoveredComponent.events.length;
|
||||
const slotCount = discoveredComponent.slots.length;
|
||||
|
||||
const parts: string[] = [formatProps(propertyCount)];
|
||||
|
||||
if (eventCount > 0) {
|
||||
parts.push(formatEvents(eventCount, discoveredComponent.events));
|
||||
}
|
||||
|
||||
if (slotCount > 0) {
|
||||
parts.push(formatSlots(slotCount, discoveredComponent.slots));
|
||||
}
|
||||
|
||||
logLine(
|
||||
chalk.green(' · ') +
|
||||
chalk.green(discoveredComponent.componentImport) +
|
||||
chalk.gray(' -> ') +
|
||||
chalk.white(discoveredComponent.tag) +
|
||||
chalk.gray(' (') +
|
||||
parts.join(chalk.green(', ')) +
|
||||
chalk.gray(')'),
|
||||
);
|
||||
}
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type Type } from 'ts-morph';
|
||||
|
||||
import { type PropertySchema } from '@/front-component/types/PropertySchema';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
type PropertyType = PropertySchema['type'];
|
||||
|
||||
export const mapTypeToPropertySchema = (
|
||||
propertyType: Type,
|
||||
): PropertyType | null => {
|
||||
if (propertyType.isString() || propertyType.isStringLiteral()) {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
if (propertyType.isNumber() || propertyType.isNumberLiteral()) {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
if (propertyType.isBoolean() || propertyType.isBooleanLiteral()) {
|
||||
return 'boolean';
|
||||
}
|
||||
|
||||
if (propertyType.isArray()) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
if (propertyType.isTuple()) {
|
||||
return 'array';
|
||||
}
|
||||
|
||||
const callSignatures = propertyType.getCallSignatures();
|
||||
|
||||
if (isNonEmptyArray(callSignatures)) {
|
||||
return 'function';
|
||||
}
|
||||
|
||||
if (propertyType.isUnion()) {
|
||||
const unionMemberTypes = propertyType.getUnionTypes();
|
||||
|
||||
const nonNullableTypes = unionMemberTypes.filter(
|
||||
(memberType) => !memberType.isUndefined() && !memberType.isNull(),
|
||||
);
|
||||
|
||||
if (!isNonEmptyArray(nonNullableTypes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classifiedTypeSet = new Set(
|
||||
nonNullableTypes.map((memberType) => mapTypeToPropertySchema(memberType)),
|
||||
);
|
||||
|
||||
if (classifiedTypeSet.size === 1) {
|
||||
return classifiedTypeSet.values().next().value ?? null;
|
||||
}
|
||||
|
||||
const primitiveTypes = new Set(['string', 'number', 'boolean']);
|
||||
const allPrimitive = [...classifiedTypeSet].every(
|
||||
(type) => type !== null && primitiveTypes.has(type),
|
||||
);
|
||||
|
||||
if (allPrimitive && classifiedTypeSet.size > 0) {
|
||||
return 'string';
|
||||
}
|
||||
}
|
||||
|
||||
if (propertyType.isEnum() || propertyType.isEnumLiteral()) {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
if (propertyType.isObject() && !propertyType.isArray()) {
|
||||
return 'object';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
const SCREAMING_SNAKE_CASE_REGEX = /^[A-Z][A-Z_0-9]+$/;
|
||||
|
||||
const SKIP_PREFIXES = [
|
||||
'Styled',
|
||||
'use',
|
||||
'Icon',
|
||||
'Illustration',
|
||||
'base',
|
||||
'BASE',
|
||||
'get',
|
||||
] as const;
|
||||
|
||||
const SKIP_SUFFIXES = ['Provider', 'State', 'state'] as const;
|
||||
|
||||
const NEVER_SKIP = new Set(['Icon']);
|
||||
|
||||
export const shouldSkipExport = (exportName: string): boolean => {
|
||||
if (NEVER_SKIP.has(exportName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasSkipPrefix = SKIP_PREFIXES.some((prefix) =>
|
||||
exportName.startsWith(prefix),
|
||||
);
|
||||
const hasSkipSuffix = SKIP_SUFFIXES.some((suffix) =>
|
||||
exportName.endsWith(suffix),
|
||||
);
|
||||
const isScreamingSnakeCase = SCREAMING_SNAKE_CASE_REGEX.test(exportName);
|
||||
|
||||
return hasSkipPrefix || hasSkipSuffix || isScreamingSnakeCase;
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type Type } from 'ts-morph';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const getTypeSymbolName = (type: Type): string | undefined => {
|
||||
return (type.getSymbol() ?? type.getAliasSymbol())?.getName();
|
||||
};
|
||||
|
||||
export const typeSymbolMatchesAnyName = (
|
||||
type: Type,
|
||||
names: Set<string>,
|
||||
): boolean => {
|
||||
const symbolName = getTypeSymbolName(type);
|
||||
|
||||
if (isDefined(symbolName) && names.has(symbolName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type.isUnion()) {
|
||||
return type
|
||||
.getUnionTypes()
|
||||
.filter((memberType) => !memberType.isUndefined() && !memberType.isNull())
|
||||
.some((memberType) => typeSymbolMatchesAnyName(memberType, names));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
const SEPARATOR_WIDTH = 60;
|
||||
|
||||
let _verbose = false;
|
||||
|
||||
export const setVerbose = (value: boolean): void => {
|
||||
_verbose = value;
|
||||
};
|
||||
|
||||
export const isVerbose = (): boolean => _verbose;
|
||||
|
||||
export const logSeparator = (): void => {
|
||||
if (!_verbose) return;
|
||||
console.log('');
|
||||
console.log(chalk.gray('─'.repeat(SEPARATOR_WIDTH)));
|
||||
console.log('');
|
||||
};
|
||||
|
||||
export const logTitle = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log('');
|
||||
console.log(chalk.bold.white(` ${text}`));
|
||||
console.log('');
|
||||
console.log(chalk.gray('─'.repeat(SEPARATOR_WIDTH)));
|
||||
console.log('');
|
||||
};
|
||||
|
||||
export const logSectionHeader = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.bold.white(` ${text}`));
|
||||
console.log('');
|
||||
};
|
||||
|
||||
export const logCategory = (name: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.green(' ▸ ') + chalk.green.bold(name));
|
||||
};
|
||||
|
||||
export const logSubItem = (label: string, value: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(
|
||||
chalk.gray(' ') +
|
||||
chalk.green(label) +
|
||||
chalk.gray(' -> ') +
|
||||
chalk.white(value),
|
||||
);
|
||||
};
|
||||
|
||||
const pluralize = (count: number, singular: string, plural: string): string =>
|
||||
count === 1 ? singular : plural;
|
||||
|
||||
export const logCount = (
|
||||
label: string,
|
||||
count: number,
|
||||
singularUnit: string,
|
||||
pluralUnit?: string,
|
||||
): void => {
|
||||
if (!_verbose) return;
|
||||
const unit = pluralize(count, singularUnit, pluralUnit ?? singularUnit + 's');
|
||||
console.log(
|
||||
chalk.green(` ${label} `) +
|
||||
chalk.white.bold(`${count}`) +
|
||||
chalk.gray(` ${unit}`),
|
||||
);
|
||||
};
|
||||
|
||||
export const logDetail = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.gray(` ${text}`));
|
||||
};
|
||||
|
||||
export const logDimText = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.gray(text));
|
||||
};
|
||||
|
||||
export const logFileWritten = (filePath: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.green(' ✓ ') + chalk.gray(filePath));
|
||||
};
|
||||
|
||||
export const logGroupLabel = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(chalk.green(` ${text}`));
|
||||
};
|
||||
|
||||
export const logSuccess = (message: string, detail?: string): void => {
|
||||
if (!_verbose) return;
|
||||
const detailSuffix = detail ? chalk.gray(` ${detail}`) : '';
|
||||
|
||||
console.log(chalk.green(` ✔ `) + chalk.green.bold(message) + detailSuffix);
|
||||
};
|
||||
|
||||
export const logError = (message: string, error?: unknown): void => {
|
||||
console.error(chalk.red.bold(` ✖ ${message}`), error ?? '');
|
||||
};
|
||||
|
||||
export const logWarning = (message: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.warn(chalk.yellow(` ${message}`));
|
||||
};
|
||||
|
||||
export const logEmpty = (): void => {
|
||||
if (!_verbose) return;
|
||||
console.log('');
|
||||
};
|
||||
|
||||
export const logLine = (text: string): void => {
|
||||
if (!_verbose) return;
|
||||
console.log(text);
|
||||
};
|
||||
|
||||
export const logCountInline = (
|
||||
count: number,
|
||||
singularUnit: string,
|
||||
pluralUnit?: string,
|
||||
prefix?: string,
|
||||
): string => {
|
||||
const unit = pluralize(count, singularUnit, pluralUnit ?? singularUnit + 's');
|
||||
const prefixText = prefix ? chalk.gray(`${prefix} `) : '';
|
||||
|
||||
return prefixText + chalk.white.bold(`${count}`) + chalk.gray(` ${unit}`);
|
||||
};
|
||||
|
||||
export const formatProps = (count: number): string =>
|
||||
chalk.green(`${count} ${pluralize(count, 'prop', 'props')}`);
|
||||
|
||||
export const formatEvents = (count: number, names: string[]): string =>
|
||||
chalk.yellow(`${count} ${pluralize(count, 'event', 'events')}`) +
|
||||
chalk.gray(` [${names.join(', ')}]`);
|
||||
|
||||
export const formatSlots = (count: number, names: string[]): string =>
|
||||
chalk.magenta(`${count} ${pluralize(count, 'slot', 'slots')}`) +
|
||||
chalk.gray(` [${names.join(', ')}]`);
|
||||
Reference in New Issue
Block a user