[FRONT COMPONENTS] Allow style librairies in remote dom (#17936)
https://github.com/user-attachments/assets/ce1b2b06-872f-41d0-844a-61db91696624 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -2,3 +2,4 @@ node_modules
|
||||
.twenty
|
||||
storybook-static
|
||||
src/front-component-renderer/__stories__/example-sources-built
|
||||
src/front-component-renderer/__stories__/example-sources-built-preact
|
||||
|
||||
@@ -28,6 +28,10 @@ const config: StorybookConfig = {
|
||||
from: '../src/front-component-renderer/__stories__/example-sources-built',
|
||||
to: '/built',
|
||||
},
|
||||
{
|
||||
from: '../src/front-component-renderer/__stories__/example-sources-built-preact',
|
||||
to: '/built-preact',
|
||||
},
|
||||
],
|
||||
|
||||
viteFinal: async (viteConfig) => {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"inquirer": "^10.0.0",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"preact": "^10.28.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.9.2",
|
||||
|
||||
@@ -116,6 +116,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"build:sdk": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"inputs": [
|
||||
"{projectRoot}/src/sdk/**/*"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/sdk"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx vite build -c vite.config.sdk.ts"
|
||||
}
|
||||
},
|
||||
"generate-remote-dom-elements": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
@@ -146,7 +163,16 @@
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"generate-remote-dom-elements"
|
||||
"generate-remote-dom-elements",
|
||||
"build:sdk",
|
||||
{
|
||||
"target": "build:individual",
|
||||
"projects": "twenty-ui"
|
||||
},
|
||||
{
|
||||
"target": "build:individual",
|
||||
"projects": "twenty-shared"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
"{projectRoot}/scripts/front-component-stories/**/*",
|
||||
|
||||
@@ -3,12 +3,67 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createFrontComponentBuildOptions } from './utils/create-front-component-build-options';
|
||||
import { getFrontComponentBuildPlugins } from '../../src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const exampleSourcesDir = path.resolve(dirname, '../../src/front-component-renderer/__stories__/example-sources');
|
||||
const exampleSourcesBuiltDir = path.resolve(dirname, '../../src/front-component-renderer/__stories__/example-sources-built');
|
||||
const sdkRoot = path.resolve(dirname, '../../../..');
|
||||
const exampleSourcesDir = path.resolve(
|
||||
dirname,
|
||||
'../../src/front-component-renderer/__stories__/example-sources',
|
||||
);
|
||||
const exampleSourcesBuiltDir = path.resolve(
|
||||
dirname,
|
||||
'../../src/front-component-renderer/__stories__/example-sources-built',
|
||||
);
|
||||
const exampleSourcesBuiltPreactDir = path.resolve(
|
||||
dirname,
|
||||
'../../src/front-component-renderer/__stories__/example-sources-built-preact',
|
||||
);
|
||||
|
||||
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
|
||||
|
||||
const twentyUiIndividualIndex = path.resolve(
|
||||
dirname,
|
||||
'../../../twenty-ui/dist/individual/individual-entry.js',
|
||||
);
|
||||
|
||||
const sdkIndividualIndex = path.resolve(
|
||||
dirname,
|
||||
'../../dist/sdk/index.js',
|
||||
);
|
||||
|
||||
const twentySharedIndividualDir = path.resolve(
|
||||
dirname,
|
||||
'../../../twenty-shared/dist/individual',
|
||||
);
|
||||
|
||||
const TWENTY_SHARED_SUBMODULES = [
|
||||
'ai',
|
||||
'application',
|
||||
'constants',
|
||||
'database-events',
|
||||
'metadata',
|
||||
'testing',
|
||||
'translations',
|
||||
'types',
|
||||
'utils',
|
||||
'workflow',
|
||||
'workspace',
|
||||
];
|
||||
|
||||
const twentySharedAliases = Object.fromEntries(
|
||||
TWENTY_SHARED_SUBMODULES.map((submodule) => [
|
||||
`twenty-shared/${submodule}`,
|
||||
path.join(twentySharedIndividualDir, submodule, 'index.js'),
|
||||
]),
|
||||
);
|
||||
|
||||
const storyAlias = {
|
||||
react: path.join(rootNodeModules, 'react'),
|
||||
'react-dom': path.join(rootNodeModules, 'react-dom'),
|
||||
'@/sdk': sdkIndividualIndex,
|
||||
'twenty-sdk/ui': twentyUiIndividualIndex,
|
||||
...twentySharedAliases,
|
||||
};
|
||||
|
||||
const STORY_COMPONENTS = [
|
||||
'static.front-component',
|
||||
@@ -21,35 +76,101 @@ const STORY_COMPONENTS = [
|
||||
'shadcn-example.front-component',
|
||||
'mui-example.front-component',
|
||||
'twenty-ui-example.front-component',
|
||||
'sdk-context-example.front-component',
|
||||
];
|
||||
|
||||
export const buildSourceExamples = async (): Promise<void> => {
|
||||
fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true });
|
||||
|
||||
const resolveEntryPoints = (): Record<string, string> => {
|
||||
const entryPoints: Record<string, string> = {};
|
||||
|
||||
for (const name of STORY_COMPONENTS) {
|
||||
const filePath = path.join(exampleSourcesDir, `${name}.tsx`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Story component source file not found: ${filePath}\n` +
|
||||
`Ensure the file exists in ${exampleSourcesDir} and the name in STORY_COMPONENTS is correct.`,
|
||||
);
|
||||
}
|
||||
|
||||
entryPoints[name] = filePath;
|
||||
}
|
||||
|
||||
const buildOptions = createFrontComponentBuildOptions({
|
||||
entryPoints,
|
||||
outdir: exampleSourcesBuiltDir,
|
||||
tsconfigPath: path.join(dirname, '../../tsconfig.json'),
|
||||
return entryPoints;
|
||||
};
|
||||
|
||||
type BundleSizeEntry = {
|
||||
name: string;
|
||||
reactBytes: number;
|
||||
preactBytes: number;
|
||||
};
|
||||
|
||||
const collectBundleSizes = (): BundleSizeEntry[] =>
|
||||
STORY_COMPONENTS.map((name) => {
|
||||
const reactFile = path.join(exampleSourcesBuiltDir, `${name}.mjs`);
|
||||
const preactFile = path.join(
|
||||
exampleSourcesBuiltPreactDir,
|
||||
`${name}.mjs`,
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
reactBytes: fs.existsSync(reactFile)
|
||||
? fs.statSync(reactFile).size
|
||||
: 0,
|
||||
preactBytes: fs.existsSync(preactFile)
|
||||
? fs.statSync(preactFile).size
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await esbuild.build(buildOptions);
|
||||
const buildSourceExamples = async (): Promise<void> => {
|
||||
const entryPoints = resolveEntryPoints();
|
||||
const tsconfigPath = path.join(dirname, '../../tsconfig.json');
|
||||
|
||||
const commonOptions: esbuild.BuildOptions = {
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
format: 'esm',
|
||||
outExtension: { '.js': '.mjs' },
|
||||
tsconfig: tsconfigPath,
|
||||
jsx: 'automatic',
|
||||
sourcemap: true,
|
||||
metafile: true,
|
||||
logLevel: 'silent',
|
||||
minify: true,
|
||||
alias: storyAlias,
|
||||
};
|
||||
|
||||
fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true });
|
||||
|
||||
await esbuild.build({
|
||||
...commonOptions,
|
||||
outdir: exampleSourcesBuiltDir,
|
||||
plugins: getFrontComponentBuildPlugins(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Built ${STORY_COMPONENTS.length} story components to ${exampleSourcesBuiltDir}`,
|
||||
`Built ${STORY_COMPONENTS.length} React story components to ${exampleSourcesBuiltDir}`,
|
||||
);
|
||||
|
||||
fs.mkdirSync(exampleSourcesBuiltPreactDir, { recursive: true });
|
||||
|
||||
await esbuild.build({
|
||||
...commonOptions,
|
||||
outdir: exampleSourcesBuiltPreactDir,
|
||||
plugins: getFrontComponentBuildPlugins({ usePreact: true }),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Built ${STORY_COMPONENTS.length} Preact story components to ${exampleSourcesBuiltPreactDir}`,
|
||||
);
|
||||
|
||||
const sizes = collectBundleSizes();
|
||||
const manifestPath = path.join(exampleSourcesBuiltDir, 'bundle-sizes.json');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(sizes, null, 2));
|
||||
console.log(`Wrote bundle size manifest to ${manifestPath}`);
|
||||
};
|
||||
|
||||
buildSourceExamples().catch((error) => {
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../../../src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
|
||||
import { getFrontComponentBuildPlugins } from '../../../src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
|
||||
|
||||
export type FrontComponentBuildOptions = {
|
||||
entryPoints: esbuild.BuildOptions['entryPoints'];
|
||||
outdir: string;
|
||||
tsconfigPath?: string;
|
||||
externalModules?: string[];
|
||||
logLevel?: esbuild.LogLevel;
|
||||
platform?: esbuild.Platform;
|
||||
minify?: boolean;
|
||||
metafile?: boolean;
|
||||
sourcemap?: boolean;
|
||||
};
|
||||
|
||||
export const createFrontComponentBuildOptions = ({
|
||||
entryPoints,
|
||||
outdir,
|
||||
tsconfigPath,
|
||||
externalModules = FRONT_COMPONENT_EXTERNAL_MODULES,
|
||||
logLevel = 'silent',
|
||||
platform,
|
||||
minify,
|
||||
metafile = true,
|
||||
sourcemap = true,
|
||||
}: FrontComponentBuildOptions): esbuild.BuildOptions => {
|
||||
return {
|
||||
entryPoints,
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
format: 'esm',
|
||||
platform,
|
||||
outdir,
|
||||
outExtension: { '.js': '.mjs' },
|
||||
external: externalModules,
|
||||
tsconfig: tsconfigPath,
|
||||
jsx: 'automatic',
|
||||
sourcemap,
|
||||
metafile,
|
||||
logLevel,
|
||||
minify,
|
||||
plugins: getFrontComponentBuildPlugins(),
|
||||
};
|
||||
};
|
||||
@@ -1,90 +1,71 @@
|
||||
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 { fileURLToPath } from 'url';
|
||||
|
||||
import { ALLOWED_HTML_ELEMENTS } from '../../src/sdk/front-component-api/constants/AllowedHtmlElements';
|
||||
import { COMMON_HTML_EVENTS } from '../../src/sdk/front-component-api/constants/CommonHtmlEvents';
|
||||
import { EVENT_TO_REACT } from '../../src/sdk/front-component-api/constants/EventToReact';
|
||||
import { HTML_COMMON_PROPERTIES } from '../../src/sdk/front-component-api/constants/HtmlCommonProperties';
|
||||
|
||||
import {
|
||||
type ComponentSchema,
|
||||
extractHtmlTag,
|
||||
generateHostRegistry,
|
||||
generateRemoteComponents,
|
||||
generateRemoteElements,
|
||||
HtmlElementConfigArrayZ,
|
||||
OUTPUT_FILES,
|
||||
} from './generators';
|
||||
import {
|
||||
logCount,
|
||||
logDetail,
|
||||
logEmpty,
|
||||
logError,
|
||||
logFileWritten,
|
||||
logGroupLabel,
|
||||
logSectionHeader,
|
||||
logSeparator,
|
||||
logSuccess,
|
||||
logTitle,
|
||||
setVerbose,
|
||||
} from './utils/logger';
|
||||
|
||||
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-renderer');
|
||||
const FRONT_COMPONENT_PATH = path.join(
|
||||
PACKAGE_PATH,
|
||||
'src/front-component-renderer',
|
||||
);
|
||||
const HOST_GENERATED_DIR = path.join(FRONT_COMPONENT_PATH, 'host/generated');
|
||||
const REMOTE_GENERATED_DIR = path.join(
|
||||
FRONT_COMPONENT_PATH,
|
||||
'remote/generated',
|
||||
);
|
||||
|
||||
const formatZodError = (error: {
|
||||
issues: { path: PropertyKey[]; message: string }[];
|
||||
}): string => {
|
||||
return error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('\n');
|
||||
};
|
||||
const extractHtmlTag = (tag: string): string => tag.slice(5);
|
||||
|
||||
const getHtmlElementSchemas = (): ComponentSchema[] => {
|
||||
const result = HtmlElementConfigArrayZ.safeParse(ALLOWED_HTML_ELEMENTS);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid HTML element configuration:\n${formatZodError(result.error)}`,
|
||||
);
|
||||
const details = result.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid HTML element configuration:\n${details}`);
|
||||
}
|
||||
|
||||
return result.data.map((element) => ({
|
||||
name: element.name,
|
||||
tagName: element.name,
|
||||
customElementName: element.tag,
|
||||
properties: {
|
||||
...HTML_COMMON_PROPERTIES,
|
||||
...element.properties,
|
||||
},
|
||||
events: COMMON_HTML_EVENTS,
|
||||
isHtmlElement: true,
|
||||
htmlTag: extractHtmlTag(element.tag),
|
||||
}));
|
||||
};
|
||||
|
||||
const createProject = (): Project => {
|
||||
return new Project({
|
||||
manipulationSettings: {
|
||||
indentationText: IndentationText.TwoSpaces,
|
||||
quoteKind: QuoteKind.Single,
|
||||
useTrailingCommas: true,
|
||||
const getUtilityComponentSchemas = (): ComponentSchema[] => [
|
||||
{
|
||||
name: 'RemoteStyle',
|
||||
customElementName: 'remote-style',
|
||||
properties: {
|
||||
cssText: { type: 'string', optional: true },
|
||||
styleKey: { type: 'string', optional: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
events: [],
|
||||
customHostRenderer: 'RemoteStyleRenderer',
|
||||
customHostRendererPath: '../components/RemoteStyleRenderer',
|
||||
},
|
||||
];
|
||||
|
||||
const writeGeneratedFile = (
|
||||
dir: string,
|
||||
@@ -100,67 +81,31 @@ const writeGeneratedFile = (
|
||||
endOfLine: 'lf',
|
||||
});
|
||||
fs.writeFileSync(filePath, formattedContent, 'utf-8');
|
||||
logFileWritten(filePath);
|
||||
};
|
||||
|
||||
const ensureDirectoriesExist = (): void => {
|
||||
if (!fs.existsSync(HOST_GENERATED_DIR)) {
|
||||
fs.mkdirSync(HOST_GENERATED_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(REMOTE_GENERATED_DIR)) {
|
||||
fs.mkdirSync(REMOTE_GENERATED_DIR, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const main = (): void => {
|
||||
const verbose = parseVerboseFlag();
|
||||
setVerbose(verbose);
|
||||
const htmlElements = getHtmlElementSchemas();
|
||||
const utilityComponents = getUtilityComponentSchemas();
|
||||
const allComponents = [...htmlElements, ...utilityComponents];
|
||||
|
||||
logTitle('Remote DOM Elements Generator');
|
||||
fs.mkdirSync(HOST_GENERATED_DIR, { recursive: true });
|
||||
fs.mkdirSync(REMOTE_GENERATED_DIR, { recursive: true });
|
||||
|
||||
let htmlElements: ComponentSchema[];
|
||||
const project = new Project({
|
||||
manipulationSettings: {
|
||||
indentationText: IndentationText.TwoSpaces,
|
||||
quoteKind: QuoteKind.Single,
|
||||
useTrailingCommas: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
htmlElements = getHtmlElementSchemas();
|
||||
} catch (error) {
|
||||
logError('Validation failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logSeparator();
|
||||
logSectionHeader('Summary');
|
||||
|
||||
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`);
|
||||
|
||||
const allComponents = [...htmlElements];
|
||||
|
||||
ensureDirectoriesExist();
|
||||
|
||||
const project = createProject();
|
||||
|
||||
logSeparator();
|
||||
logSectionHeader('Writing Files');
|
||||
|
||||
logGroupLabel('Host');
|
||||
|
||||
const hostRegistry = generateHostRegistry(
|
||||
project,
|
||||
allComponents,
|
||||
EVENT_TO_REACT,
|
||||
);
|
||||
const hostRegistry = generateHostRegistry(project, allComponents);
|
||||
writeGeneratedFile(
|
||||
HOST_GENERATED_DIR,
|
||||
OUTPUT_FILES.HOST_REGISTRY,
|
||||
hostRegistry.getFullText(),
|
||||
);
|
||||
|
||||
logEmpty();
|
||||
logGroupLabel('Remote');
|
||||
|
||||
const remoteElements = generateRemoteElements(
|
||||
project,
|
||||
allComponents,
|
||||
@@ -179,12 +124,6 @@ const main = (): void => {
|
||||
OUTPUT_FILES.REMOTE_COMPONENTS,
|
||||
remoteComponents.getFullText(),
|
||||
);
|
||||
|
||||
logSeparator();
|
||||
logSuccess('Done!', 'All generated files created.');
|
||||
logDetail(`Host: ${HOST_GENERATED_DIR}`);
|
||||
logDetail(`Remote: ${REMOTE_GENERATED_DIR}`);
|
||||
logEmpty();
|
||||
};
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const CUSTOM_ELEMENT_NAMES = {
|
||||
ROOT: 'remote-root',
|
||||
FRAGMENT: 'remote-fragment',
|
||||
} as const;
|
||||
|
||||
export const INTERNAL_ELEMENT_CLASSES = {
|
||||
ROOT: 'RemoteRootElement',
|
||||
FRAGMENT: 'RemoteFragmentElement',
|
||||
} as const;
|
||||
|
||||
export const OUTPUT_FILES = {
|
||||
REMOTE_ELEMENTS: 'remote-elements.ts',
|
||||
REMOTE_COMPONENTS: 'remote-components.ts',
|
||||
HOST_REGISTRY: 'host-component-registry.ts',
|
||||
} as const;
|
||||
|
||||
export const TYPE_NAMES = {
|
||||
COMMON_PROPERTIES: 'HtmlCommonProperties',
|
||||
COMMON_EVENTS: 'HtmlCommonEvents',
|
||||
COMMON_PROPERTIES_CONFIG: 'HTML_COMMON_PROPERTIES_CONFIG',
|
||||
COMMON_EVENTS_ARRAY: 'HTML_COMMON_EVENTS_ARRAY',
|
||||
EMPTY_RECORD: 'Record<string, never>',
|
||||
} as const;
|
||||
@@ -1,4 +0,0 @@
|
||||
export const CUSTOM_ELEMENT_NAMES = {
|
||||
ROOT: 'remote-root',
|
||||
FRAGMENT: 'remote-fragment',
|
||||
} as const;
|
||||
@@ -1,4 +0,0 @@
|
||||
export const INTERNAL_ELEMENT_CLASSES = {
|
||||
ROOT: 'RemoteRootElement',
|
||||
FRAGMENT: 'RemoteFragmentElement',
|
||||
} as const;
|
||||
@@ -1,5 +0,0 @@
|
||||
export const OUTPUT_FILES = {
|
||||
REMOTE_ELEMENTS: 'remote-elements.ts',
|
||||
REMOTE_COMPONENTS: 'remote-components.ts',
|
||||
HOST_REGISTRY: 'host-component-registry.ts',
|
||||
} as const;
|
||||
@@ -1,7 +0,0 @@
|
||||
export const TYPE_NAMES = {
|
||||
COMMON_PROPERTIES: 'HtmlCommonProperties',
|
||||
COMMON_EVENTS: 'HtmlCommonEvents',
|
||||
COMMON_PROPERTIES_CONFIG: 'HTML_COMMON_PROPERTIES_CONFIG',
|
||||
COMMON_EVENTS_ARRAY: 'HTML_COMMON_EVENTS_ARRAY',
|
||||
EMPTY_RECORD: 'Record<string, never>',
|
||||
} as const;
|
||||
@@ -1,4 +0,0 @@
|
||||
export { CUSTOM_ELEMENT_NAMES } from './CustomElementNames';
|
||||
export { INTERNAL_ELEMENT_CLASSES } from './InternalElementClasses';
|
||||
export { OUTPUT_FILES } from './OutputFiles';
|
||||
export { TYPE_NAMES } from './TypeNames';
|
||||
@@ -3,165 +3,40 @@ import type { Project, SourceFile } from 'ts-morph';
|
||||
import { isDefined } 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');
|
||||
const getCustomRendererImports = (
|
||||
components: ComponentSchema[],
|
||||
): Map<string, string[]> => {
|
||||
const importsByPath = new Map<string, string[]>();
|
||||
|
||||
return `const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
for (const component of components) {
|
||||
if (
|
||||
isDefined(component.customHostRenderer) &&
|
||||
isDefined(component.customHostRendererPath)
|
||||
) {
|
||||
const existing =
|
||||
importsByPath.get(component.customHostRendererPath) ?? [];
|
||||
|
||||
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 filterProps = <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;
|
||||
if (!existing.includes(component.customHostRenderer)) {
|
||||
existing.push(component.customHostRenderer);
|
||||
}
|
||||
|
||||
importsByPath.set(component.customHostRendererPath, existing);
|
||||
}
|
||||
}
|
||||
return filtered as T;
|
||||
};`;
|
||||
|
||||
return importsByPath;
|
||||
};
|
||||
|
||||
// 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 generateWrapperComponent = (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));
|
||||
};`;
|
||||
}
|
||||
|
||||
if (component.isHtmlElement) {
|
||||
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 = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement(${component.componentImport}, filterProps(props), children);
|
||||
};`;
|
||||
};
|
||||
|
||||
const generateRegistryMap = (components: ComponentSchema[]): string => {
|
||||
const generateRegistryEntries = (components: ComponentSchema[]): string => {
|
||||
const entries = components
|
||||
.map(
|
||||
(component) =>
|
||||
` ['${component.customElementName}', createRemoteComponentRenderer(${component.name}Wrapper)],`,
|
||||
)
|
||||
.map((component) => {
|
||||
if (isDefined(component.customHostRenderer)) {
|
||||
return ` ['${component.customElementName}', createRemoteComponentRenderer(${component.customHostRenderer})],`;
|
||||
}
|
||||
|
||||
return ` ['${component.customElementName}', createRemoteComponentRenderer(createHtmlHostWrapper('${component.htmlTag}'))],`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `type ComponentRegistryValue =
|
||||
@@ -177,7 +52,6 @@ ${entries}
|
||||
export const generateHostRegistry = (
|
||||
project: Project,
|
||||
components: ComponentSchema[],
|
||||
eventToReactMapping: Record<string, string>,
|
||||
): SourceFile => {
|
||||
const sourceFile = project.createSourceFile(
|
||||
'host-component-registry.ts',
|
||||
@@ -185,43 +59,26 @@ export const generateHostRegistry = (
|
||||
{ overwrite: true },
|
||||
);
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: 'react',
|
||||
defaultImport: 'React',
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@remote-dom/react/host',
|
||||
namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'],
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '../../../sdk/front-component-api/constants/SerializedEventData',
|
||||
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
|
||||
moduleSpecifier: '../utils/createHtmlHostWrapper',
|
||||
namedImports: ['createHtmlHostWrapper'],
|
||||
});
|
||||
|
||||
for (const component of components) {
|
||||
if (
|
||||
!component.isHtmlElement &&
|
||||
isDefined(component.componentPath) &&
|
||||
isDefined(component.componentImport)
|
||||
) {
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: component.componentPath,
|
||||
namedImports: [component.componentImport],
|
||||
});
|
||||
}
|
||||
const customRendererImports = getCustomRendererImports(components);
|
||||
|
||||
for (const [modulePath, namedImports] of customRendererImports) {
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: modulePath,
|
||||
namedImports,
|
||||
});
|
||||
}
|
||||
|
||||
addStatement(sourceFile, generateRuntimeUtilities(eventToReactMapping));
|
||||
|
||||
for (const component of components) {
|
||||
addStatement(sourceFile, generateWrapperComponent(component));
|
||||
}
|
||||
|
||||
addStatement(sourceFile, generateRegistryMap(components));
|
||||
|
||||
addFileHeader(sourceFile);
|
||||
sourceFile.addStatements(generateRegistryEntries(components));
|
||||
|
||||
return sourceFile;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Project, SourceFile } from 'ts-morph';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/sdk/front-component-api/constants/EventToReact';
|
||||
import { EVENT_TO_REACT } from '../../../src/sdk/front-component-api/constants/EventToReact';
|
||||
import { type ComponentSchema } from './schemas';
|
||||
import { addExportedConst, addFileHeader } from './utils';
|
||||
import { addExportedConst } from './utils';
|
||||
|
||||
const generateComponentDefinition = (
|
||||
sourceFile: SourceFile,
|
||||
component: ComponentSchema,
|
||||
): void => {
|
||||
const hasEvents = component.events.length > 0;
|
||||
const componentExportName = component.tagName;
|
||||
|
||||
let initializer: string;
|
||||
|
||||
@@ -30,7 +29,7 @@ ${eventProps}
|
||||
initializer = `createRemoteComponent('${component.customElementName}', ${component.name}Element)`;
|
||||
}
|
||||
|
||||
addExportedConst(sourceFile, componentExportName, initializer);
|
||||
addExportedConst(sourceFile, component.name, initializer);
|
||||
};
|
||||
|
||||
export const generateRemoteComponents = (
|
||||
@@ -46,20 +45,14 @@ export const generateRemoteComponents = (
|
||||
namedImports: ['createRemoteComponent'],
|
||||
});
|
||||
|
||||
const elementImports = components.map(
|
||||
(component) => `${component.name}Element`,
|
||||
);
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: './remote-elements',
|
||||
namedImports: elementImports,
|
||||
namedImports: components.map((component) => `${component.name}Element`),
|
||||
});
|
||||
|
||||
for (const component of components) {
|
||||
generateComponentDefinition(sourceFile, component);
|
||||
}
|
||||
|
||||
addFileHeader(sourceFile);
|
||||
|
||||
return sourceFile;
|
||||
};
|
||||
|
||||
@@ -5,29 +5,37 @@ import {
|
||||
VariableDeclarationKind,
|
||||
} from 'ts-morph';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CUSTOM_ELEMENT_NAMES,
|
||||
INTERNAL_ELEMENT_CLASSES,
|
||||
TYPE_NAMES,
|
||||
} from './constants';
|
||||
import { type ComponentSchema, type PropertySchema } from './schemas';
|
||||
import {
|
||||
addFileHeader,
|
||||
schemaTypeToConstructor,
|
||||
schemaTypeToTs,
|
||||
} from './utils';
|
||||
import { schemaTypeToConstructor } from './utils';
|
||||
|
||||
type ElementGenerationOptions = {
|
||||
useSharedEvents: boolean;
|
||||
useSharedPropertiesConfig: boolean;
|
||||
const schemaTypeToTs = (type: PropertySchema['type']): string => type;
|
||||
|
||||
const generatePropertyEntries = (
|
||||
properties: Record<string, PropertySchema>,
|
||||
): string[] =>
|
||||
Object.entries(properties).map(([name, schema]) => {
|
||||
const optional = schema.optional ? '?' : '';
|
||||
return `'${name}'${optional}: ${schemaTypeToTs(schema.type)}`;
|
||||
});
|
||||
|
||||
const writePropertyEntries = (
|
||||
writer: CodeBlockWriter,
|
||||
properties: Record<string, PropertySchema>,
|
||||
): void => {
|
||||
for (const [name, schema] of Object.entries(properties)) {
|
||||
writer.writeLine(
|
||||
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type ComponentWithSpecificProps = {
|
||||
component: ComponentSchema;
|
||||
specificProperties: Record<string, PropertySchema>;
|
||||
};
|
||||
|
||||
const getElementSpecificProperties = (
|
||||
const getSpecificProperties = (
|
||||
component: ComponentSchema,
|
||||
commonPropertyNames: Set<string>,
|
||||
): Record<string, PropertySchema> => {
|
||||
@@ -40,16 +48,6 @@ const getElementSpecificProperties = (
|
||||
return specific;
|
||||
};
|
||||
|
||||
const generatePropertyEntries = (
|
||||
properties: Record<string, PropertySchema>,
|
||||
): string[] => {
|
||||
return Object.entries(properties).map(([name, schema]) => {
|
||||
const tsType = schemaTypeToTs(schema.type);
|
||||
const optional = schema.optional ? '?' : '';
|
||||
return `'${name}'${optional}: ${tsType}`;
|
||||
});
|
||||
};
|
||||
|
||||
const generateCommonPropertiesType = (
|
||||
sourceFile: SourceFile,
|
||||
commonProperties: Record<string, PropertySchema>,
|
||||
@@ -123,8 +121,9 @@ const generateCommonPropertiesConfig = (
|
||||
initializer: (writer) => {
|
||||
writer.block(() => {
|
||||
for (const [name, schema] of Object.entries(commonProperties)) {
|
||||
const constructorType = schemaTypeToConstructor(schema.type);
|
||||
writer.writeLine(`'${name}': { type: ${constructorType} },`);
|
||||
writer.writeLine(
|
||||
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -146,7 +145,7 @@ const generateElementPropertyType = (
|
||||
isExported: true,
|
||||
name: `${component.name}Properties`,
|
||||
type: (writer) => {
|
||||
if (component.isHtmlElement) {
|
||||
if (isDefined(component.htmlTag)) {
|
||||
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
|
||||
}
|
||||
writer.block(() => {
|
||||
@@ -159,38 +158,27 @@ const generateElementPropertyType = (
|
||||
}
|
||||
};
|
||||
|
||||
const writePropertyEntries = (
|
||||
writer: CodeBlockWriter,
|
||||
properties: Record<string, PropertySchema>,
|
||||
): void => {
|
||||
for (const [name, schema] of Object.entries(properties)) {
|
||||
const constructorType = schemaTypeToConstructor(schema.type);
|
||||
writer.writeLine(`'${name}': { type: ${constructorType} },`);
|
||||
}
|
||||
};
|
||||
|
||||
const generateElementDefinition = (
|
||||
sourceFile: SourceFile,
|
||||
component: ComponentSchema,
|
||||
specificProperties: Record<string, PropertySchema>,
|
||||
options: ElementGenerationOptions,
|
||||
useSharedEvents: boolean,
|
||||
useSharedPropertiesConfig: boolean,
|
||||
): void => {
|
||||
const useSharedEvents = options.useSharedEvents && component.isHtmlElement;
|
||||
const { useSharedPropertiesConfig } = options;
|
||||
const isHtml = isDefined(component.htmlTag);
|
||||
const useShared = useSharedEvents && isHtml;
|
||||
const hasEvents = component.events.length > 0;
|
||||
const hasSpecificProps = Object.keys(specificProperties).length > 0;
|
||||
const hasProps = Object.keys(component.properties).length > 0;
|
||||
|
||||
const propsType = hasSpecificProps
|
||||
? `${component.name}Properties`
|
||||
: hasProps && component.isHtmlElement
|
||||
: hasProps && isHtml
|
||||
? TYPE_NAMES.COMMON_PROPERTIES
|
||||
: TYPE_NAMES.EMPTY_RECORD;
|
||||
|
||||
const slotsType = TYPE_NAMES.EMPTY_RECORD;
|
||||
|
||||
const eventsType = hasEvents
|
||||
? useSharedEvents
|
||||
? useShared
|
||||
? TYPE_NAMES.COMMON_EVENTS
|
||||
: `{ ${component.events.map((event) => `${event}(event: RemoteEvent<SerializedEventData>): void`).join('; ')} }`
|
||||
: TYPE_NAMES.EMPTY_RECORD;
|
||||
@@ -207,7 +195,7 @@ const generateElementDefinition = (
|
||||
writer.indent(() => {
|
||||
writer.writeLine(`${propsType},`);
|
||||
writer.writeLine('Record<string, never>,');
|
||||
writer.writeLine(`${slotsType},`);
|
||||
writer.writeLine(`${TYPE_NAMES.EMPTY_RECORD},`);
|
||||
writer.write(eventsType);
|
||||
});
|
||||
writer.newLine();
|
||||
@@ -222,7 +210,7 @@ const generateElementDefinition = (
|
||||
writer.write('(');
|
||||
writer.block(() => {
|
||||
if (hasProps) {
|
||||
if (hasSpecificProps && component.isHtmlElement) {
|
||||
if (hasSpecificProps && isHtml) {
|
||||
writer.write('properties: ');
|
||||
writer.block(() => {
|
||||
writer.writeLine(
|
||||
@@ -232,7 +220,7 @@ const generateElementDefinition = (
|
||||
});
|
||||
writer.write(',');
|
||||
writer.newLine();
|
||||
} else if (useSharedPropertiesConfig && component.isHtmlElement) {
|
||||
} else if (useSharedPropertiesConfig && isHtml) {
|
||||
writer.write(
|
||||
`properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
|
||||
);
|
||||
@@ -248,7 +236,7 @@ const generateElementDefinition = (
|
||||
}
|
||||
if (hasEvents) {
|
||||
writer.write(
|
||||
useSharedEvents
|
||||
useShared
|
||||
? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}],`
|
||||
: `events: [${component.events.map((event) => `'${event}'`).join(', ')}],`,
|
||||
);
|
||||
@@ -306,18 +294,6 @@ const generateTagNameMapDeclaration = (
|
||||
});
|
||||
};
|
||||
|
||||
const prepareComponentsWithSpecificProps = (
|
||||
components: ComponentSchema[],
|
||||
commonPropertyNames: Set<string>,
|
||||
): ComponentWithSpecificProps[] => {
|
||||
return components.map((component) => ({
|
||||
component,
|
||||
specificProperties: component.isHtmlElement
|
||||
? getElementSpecificProperties(component, commonPropertyNames)
|
||||
: component.properties,
|
||||
}));
|
||||
};
|
||||
|
||||
export const generateRemoteElements = (
|
||||
project: Project,
|
||||
components: ComponentSchema[],
|
||||
@@ -331,11 +307,6 @@ export const generateRemoteElements = (
|
||||
const useSharedEvents = commonEvents.length > 0;
|
||||
const useSharedPropertiesConfig = Object.keys(commonProperties).length > 0;
|
||||
|
||||
const options: ElementGenerationOptions = {
|
||||
useSharedEvents,
|
||||
useSharedPropertiesConfig,
|
||||
};
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@remote-dom/core/elements',
|
||||
namedImports: [
|
||||
@@ -347,15 +318,12 @@ export const generateRemoteElements = (
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '../../../sdk/front-component-api/constants/SerializedEventData',
|
||||
moduleSpecifier:
|
||||
'../../../sdk/front-component-api/constants/SerializedEventData',
|
||||
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
|
||||
});
|
||||
|
||||
const commonPropertyNames = new Set(Object.keys(commonProperties));
|
||||
const componentsWithProps = prepareComponentsWithSpecificProps(
|
||||
components,
|
||||
commonPropertyNames,
|
||||
);
|
||||
|
||||
generateCommonPropertiesType(sourceFile, commonProperties);
|
||||
|
||||
@@ -367,13 +335,18 @@ export const generateRemoteElements = (
|
||||
generateCommonPropertiesConfig(sourceFile, commonProperties);
|
||||
}
|
||||
|
||||
for (const { component, specificProperties } of componentsWithProps) {
|
||||
for (const component of components) {
|
||||
const specificProperties = isDefined(component.htmlTag)
|
||||
? getSpecificProperties(component, commonPropertyNames)
|
||||
: component.properties;
|
||||
|
||||
generateElementPropertyType(sourceFile, component, specificProperties);
|
||||
generateElementDefinition(
|
||||
sourceFile,
|
||||
component,
|
||||
specificProperties,
|
||||
options,
|
||||
useSharedEvents,
|
||||
useSharedPropertiesConfig,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,7 +358,5 @@ export const generateRemoteElements = (
|
||||
|
||||
generateTagNameMapDeclaration(sourceFile, components);
|
||||
|
||||
addFileHeader(sourceFile);
|
||||
|
||||
return sourceFile;
|
||||
};
|
||||
|
||||
@@ -17,14 +17,12 @@ export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ);
|
||||
|
||||
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),
|
||||
events: z.array(z.string()).readonly(),
|
||||
isHtmlElement: z.boolean(),
|
||||
htmlTag: z.string().optional(),
|
||||
componentImport: z.string().optional(),
|
||||
componentPath: z.string().optional(),
|
||||
customHostRenderer: z.string().optional(),
|
||||
customHostRendererPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PropertySchema = z.infer<typeof PropertySchemaZ>;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { type SourceFile } from 'ts-morph';
|
||||
|
||||
export const addExportedType = (
|
||||
sourceFile: SourceFile,
|
||||
name: string,
|
||||
type: string,
|
||||
): void => {
|
||||
sourceFile.addTypeAlias({
|
||||
isExported: true,
|
||||
name,
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { type SourceFile } from 'ts-morph';
|
||||
|
||||
import { GENERATED_FILE_HEADER } from './generated-file-header';
|
||||
|
||||
export const addFileHeader = (sourceFile: SourceFile): void => {
|
||||
sourceFile.insertText(0, GENERATED_FILE_HEADER + '\n\n');
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { type SourceFile } from 'ts-morph';
|
||||
|
||||
export const addStatement = (
|
||||
sourceFile: SourceFile,
|
||||
statement: string,
|
||||
): void => {
|
||||
sourceFile.addStatements(statement);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const extractHtmlTag = (tag: string): string => {
|
||||
return tag.startsWith('html-') ? tag.slice(5) : tag;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type PropertySchema } from '../schemas';
|
||||
import { schemaTypeToConstructor } from './schema-type-to-constructor';
|
||||
|
||||
export const generatePropertiesConfig = (
|
||||
properties: Record<string, PropertySchema>,
|
||||
): string => {
|
||||
const entries = Object.entries(properties);
|
||||
if (entries.length === 0) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
const props = entries
|
||||
.map(([name, schema]) => {
|
||||
const constructorType = schemaTypeToConstructor(schema.type);
|
||||
return `'${name}': { type: ${constructorType} }`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `{ ${props} }`;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { type PropertySchema } from '../schemas';
|
||||
import { schemaTypeToTs } from './schema-type-to-ts';
|
||||
|
||||
export const generatePropertiesType = (
|
||||
properties: Record<string, PropertySchema>,
|
||||
): string => {
|
||||
const entries = Object.entries(properties);
|
||||
if (entries.length === 0) {
|
||||
return 'Record<string, never>';
|
||||
}
|
||||
|
||||
const props = entries
|
||||
.map(([name, schema]) => {
|
||||
const tsType = schemaTypeToTs(schema.type);
|
||||
const optional = schema.optional ? '?' : '';
|
||||
return `'${name}'${optional}: ${tsType}`;
|
||||
})
|
||||
.join('; ');
|
||||
|
||||
return `{ ${props} }`;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export const GENERATED_FILE_HEADER = `/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
|
||||
* | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, |
|
||||
* |___/
|
||||
*/`;
|
||||
@@ -1,10 +1,2 @@
|
||||
export { addExportedConst } from './add-exported-const';
|
||||
export { addExportedType } from './add-exported-type';
|
||||
export { addFileHeader } from './add-file-header';
|
||||
export { addStatement } from './add-statement';
|
||||
export { extractHtmlTag } from './extract-html-tag';
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { type PropertySchema } from '../schemas';
|
||||
|
||||
const SCHEMA_TYPE_TO_TS: Record<PropertySchema['type'], string> = {
|
||||
boolean: 'boolean',
|
||||
number: 'number',
|
||||
string: 'string',
|
||||
};
|
||||
|
||||
export const schemaTypeToTs = (type: PropertySchema['type']): string =>
|
||||
SCHEMA_TYPE_TO_TS[type];
|
||||
@@ -1,135 +0,0 @@
|
||||
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(', ')}]`);
|
||||
-191
@@ -1,191 +0,0 @@
|
||||
import { transformJsxToRemoteComponents } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin';
|
||||
|
||||
describe('transformJsxToRemoteComponents', () => {
|
||||
describe('basic tag transformations', () => {
|
||||
it('should transform div tags', () => {
|
||||
const input = '<div>Hello</div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv>Hello</RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should transform span tags', () => {
|
||||
const input = '<span>Text</span>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlSpan>Text</RemoteComponents.HtmlSpan>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should transform button tags', () => {
|
||||
const input = '<button>Click me</button>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlButton>Click me</RemoteComponents.HtmlButton>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should transform self-closing tags', () => {
|
||||
const input = '<br />';
|
||||
const expected = '<RemoteComponents.HtmlBr />';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should transform img tags with attributes', () => {
|
||||
const input = '<img src="test.png" alt="Test" />';
|
||||
const expected = '<RemoteComponents.HtmlImg src="test.png" alt="Test" />';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested elements', () => {
|
||||
it('should transform nested elements', () => {
|
||||
const input = '<div><span>Nested</span></div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv><RemoteComponents.HtmlSpan>Nested</RemoteComponents.HtmlSpan></RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should transform deeply nested elements', () => {
|
||||
const input = '<div><ul><li>Item</li></ul></div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv><RemoteComponents.HtmlUl><RemoteComponents.HtmlLi>Item</RemoteComponents.HtmlLi></RemoteComponents.HtmlUl></RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attributes preservation', () => {
|
||||
it('should preserve className attribute', () => {
|
||||
const input = '<div className="container">Content</div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv className="container">Content</RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should preserve onClick handler', () => {
|
||||
const input = '<button onClick={handleClick}>Click</button>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlButton onClick={handleClick}>Click</RemoteComponents.HtmlButton>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should preserve multiple attributes', () => {
|
||||
const input =
|
||||
'<input type="text" value={value} onChange={handleChange} />';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlInput type="text" value={value} onChange={handleChange} />';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom components should not be transformed', () => {
|
||||
it('should not transform PascalCase components', () => {
|
||||
const input = '<MyComponent>Content</MyComponent>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should not transform components starting with uppercase', () => {
|
||||
const input = '<Button>Click</Button>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should transform HTML tags but not custom components in mixed content', () => {
|
||||
const input = '<div><MyComponent /></div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv><MyComponent /></RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fragments should not be transformed', () => {
|
||||
it('should not transform empty fragments', () => {
|
||||
const input = '<></>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should preserve fragments with content', () => {
|
||||
const input = '<><div>Content</div></>';
|
||||
const expected =
|
||||
'<><RemoteComponents.HtmlDiv>Content</RemoteComponents.HtmlDiv></>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all supported HTML elements', () => {
|
||||
const testCases: [string, string][] = [
|
||||
['div', 'HtmlDiv'],
|
||||
['span', 'HtmlSpan'],
|
||||
['section', 'HtmlSection'],
|
||||
['article', 'HtmlArticle'],
|
||||
['header', 'HtmlHeader'],
|
||||
['footer', 'HtmlFooter'],
|
||||
['main', 'HtmlMain'],
|
||||
['nav', 'HtmlNav'],
|
||||
['aside', 'HtmlAside'],
|
||||
['p', 'HtmlP'],
|
||||
['h1', 'HtmlH1'],
|
||||
['h2', 'HtmlH2'],
|
||||
['h3', 'HtmlH3'],
|
||||
['h4', 'HtmlH4'],
|
||||
['h5', 'HtmlH5'],
|
||||
['h6', 'HtmlH6'],
|
||||
['strong', 'HtmlStrong'],
|
||||
['em', 'HtmlEm'],
|
||||
['small', 'HtmlSmall'],
|
||||
['code', 'HtmlCode'],
|
||||
['pre', 'HtmlPre'],
|
||||
['blockquote', 'HtmlBlockquote'],
|
||||
['a', 'HtmlA'],
|
||||
['img', 'HtmlImg'],
|
||||
['ul', 'HtmlUl'],
|
||||
['ol', 'HtmlOl'],
|
||||
['li', 'HtmlLi'],
|
||||
['form', 'HtmlForm'],
|
||||
['label', 'HtmlLabel'],
|
||||
['input', 'HtmlInput'],
|
||||
['textarea', 'HtmlTextarea'],
|
||||
['select', 'HtmlSelect'],
|
||||
['option', 'HtmlOption'],
|
||||
['button', 'HtmlButton'],
|
||||
['table', 'HtmlTable'],
|
||||
['thead', 'HtmlThead'],
|
||||
['tbody', 'HtmlTbody'],
|
||||
['tfoot', 'HtmlTfoot'],
|
||||
['tr', 'HtmlTr'],
|
||||
['th', 'HtmlTh'],
|
||||
['td', 'HtmlTd'],
|
||||
['br', 'HtmlBr'],
|
||||
['hr', 'HtmlHr'],
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should transform <%s> to RemoteComponents.%s',
|
||||
(tag, component) => {
|
||||
const input = `<${tag}>Content</${tag}>`;
|
||||
const expected = `<RemoteComponents.${component}>Content</RemoteComponents.${component}>`;
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline JSX', () => {
|
||||
const input = `
|
||||
<div>
|
||||
<span>Hello</span>
|
||||
</div>
|
||||
`;
|
||||
const result = transformJsxToRemoteComponents(input);
|
||||
expect(result).toContain('<RemoteComponents.HtmlDiv>');
|
||||
expect(result).toContain('<RemoteComponents.HtmlSpan>');
|
||||
expect(result).toContain('</RemoteComponents.HtmlSpan>');
|
||||
expect(result).toContain('</RemoteComponents.HtmlDiv>');
|
||||
});
|
||||
|
||||
it('should handle JSX expressions', () => {
|
||||
const input =
|
||||
'<div>{items.map(item => <span key={item.id}>{item.name}</span>)}</div>';
|
||||
const expected =
|
||||
'<RemoteComponents.HtmlDiv>{items.map(item => <RemoteComponents.HtmlSpan key={item.id}>{item.name}</RemoteComponents.HtmlSpan>)}</RemoteComponents.HtmlDiv>';
|
||||
expect(transformJsxToRemoteComponents(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
-266
@@ -1,266 +0,0 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { reactGlobalsPlugin } from '../react-globals-plugin';
|
||||
|
||||
describe('reactGlobalsPlugin', () => {
|
||||
const tempDir = path.join(__dirname, '.temp-test');
|
||||
const tempFile = path.join(tempDir, 'test-component.tsx');
|
||||
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const buildWithPlugin = async (code: string): Promise<string> => {
|
||||
fs.writeFileSync(tempFile, code, 'utf-8');
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [tempFile],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
plugins: [reactGlobalsPlugin],
|
||||
});
|
||||
|
||||
return result.outputFiles[0].text;
|
||||
};
|
||||
|
||||
describe('react/jsx-runtime imports', () => {
|
||||
it('should replace jsx import with globalThis.jsx', async () => {
|
||||
const code = `
|
||||
import { jsx } from 'react/jsx-runtime';
|
||||
export const Component = () => jsx('div', {});
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.jsx');
|
||||
expect(result).not.toContain('from "react/jsx-runtime"');
|
||||
});
|
||||
|
||||
it('should replace jsxs import with globalThis.jsxs', async () => {
|
||||
const code = `
|
||||
import { jsxs } from 'react/jsx-runtime';
|
||||
export const Component = () => jsxs('div', {});
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.jsxs');
|
||||
expect(result).not.toContain('from "react/jsx-runtime"');
|
||||
});
|
||||
|
||||
it('should replace Fragment import with globalThis.React.Fragment', async () => {
|
||||
const code = `
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
export const Component = () => Fragment;
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.Fragment');
|
||||
expect(result).not.toContain('from "react/jsx-runtime"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('react imports', () => {
|
||||
it('should replace useState with globalThis.React.useState', async () => {
|
||||
const code = `
|
||||
import { useState } from 'react';
|
||||
export const Component = () => {
|
||||
const [state, setState] = useState(0);
|
||||
return state;
|
||||
};
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.useState');
|
||||
expect(result).not.toContain('from "react"');
|
||||
});
|
||||
|
||||
it('should replace multiple hooks with globalThis.React equivalents', async () => {
|
||||
const code = `
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
export const Component = () => {
|
||||
const [state, setState] = useState(0);
|
||||
useEffect(() => {}, []);
|
||||
const cb = useCallback(() => {}, []);
|
||||
return state;
|
||||
};
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.useState');
|
||||
expect(result).toContain('globalThis.React.useEffect');
|
||||
expect(result).toContain('globalThis.React.useCallback');
|
||||
expect(result).not.toContain('from "react"');
|
||||
});
|
||||
|
||||
it('should replace default React import with globalThis.React', async () => {
|
||||
const code = `
|
||||
import React from 'react';
|
||||
export const Component = () => React.createElement('div');
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React');
|
||||
expect(result).not.toContain('from "react"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSX transformation with plugin', () => {
|
||||
it('should transform JSX using globalThis.jsx', async () => {
|
||||
const code = `
|
||||
export const Component = () => <div>Hello</div>;
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.jsx');
|
||||
expect(result).not.toContain('from "react/jsx-runtime"');
|
||||
});
|
||||
|
||||
it('should transform JSX with multiple children using globalThis.jsxs', async () => {
|
||||
const code = `
|
||||
export const Component = () => (
|
||||
<div>
|
||||
<span>One</span>
|
||||
<span>Two</span>
|
||||
</div>
|
||||
);
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.jsxs');
|
||||
expect(result).not.toContain('from "react/jsx-runtime"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('only include used React exports', () => {
|
||||
it('should only include used React exports', async () => {
|
||||
const code = `
|
||||
import { useState, useEffect } from 'react';
|
||||
export const Component = () => {
|
||||
const [state, setState] = useState(0);
|
||||
useEffect(() => {}, []);
|
||||
return state;
|
||||
};
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.useState');
|
||||
expect(result).toContain('globalThis.React.useEffect');
|
||||
expect(result).not.toContain('globalThis.React.useCallback');
|
||||
expect(result).not.toContain('globalThis.React.useMemo');
|
||||
expect(result).not.toContain('globalThis.React.useRef');
|
||||
expect(result).not.toContain('globalThis.React.useReducer');
|
||||
});
|
||||
|
||||
it('should handle aliased imports', async () => {
|
||||
const code = `
|
||||
import { useState as useLocalState } from 'react';
|
||||
export const Component = () => {
|
||||
const [state] = useLocalState(0);
|
||||
return state;
|
||||
};
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.useState');
|
||||
expect(result).not.toContain('globalThis.React.useEffect');
|
||||
});
|
||||
|
||||
it('should handle mixed default and named imports', async () => {
|
||||
const code = `
|
||||
import React, { useState } from 'react';
|
||||
export const Component = () => {
|
||||
const [state] = useState(0);
|
||||
return React.createElement('div', null, state);
|
||||
};
|
||||
`;
|
||||
|
||||
const result = await buildWithPlugin(code);
|
||||
|
||||
expect(result).toContain('globalThis.React.useState');
|
||||
expect(result).toContain('globalThis.React');
|
||||
expect(result).not.toContain('globalThis.React.useEffect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple entry points', () => {
|
||||
it('should handle multiple files with different React imports', async () => {
|
||||
const fileA = path.join(tempDir, 'component-a.tsx');
|
||||
const fileB = path.join(tempDir, 'component-b.tsx');
|
||||
|
||||
fs.writeFileSync(
|
||||
fileA,
|
||||
`
|
||||
import { useState } from 'react';
|
||||
export const ComponentA = () => {
|
||||
const [state] = useState(0);
|
||||
return state;
|
||||
};
|
||||
`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
fileB,
|
||||
`
|
||||
import { useEffect } from 'react';
|
||||
export const ComponentB = () => {
|
||||
useEffect(() => {}, []);
|
||||
return null;
|
||||
};
|
||||
`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [fileA, fileB],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: 'esm',
|
||||
jsx: 'automatic',
|
||||
outdir: tempDir,
|
||||
plugins: [reactGlobalsPlugin],
|
||||
});
|
||||
|
||||
const outputA = result.outputFiles.find(
|
||||
(f) => path.basename(f.path) === 'component-a.js',
|
||||
)?.text;
|
||||
const outputB = result.outputFiles.find(
|
||||
(f) => path.basename(f.path) === 'component-b.js',
|
||||
)?.text;
|
||||
|
||||
expect(outputA).toBeDefined();
|
||||
expect(outputB).toBeDefined();
|
||||
|
||||
// Each file should only include the React exports it needs
|
||||
expect(outputA).toContain('globalThis.React.useState');
|
||||
expect(outputB).toContain('globalThis.React.useEffect');
|
||||
expect(outputA).not.toContain('globalThis.React.useEffect');
|
||||
expect(outputB).not.toContain('globalThis.React.useState');
|
||||
|
||||
// No raw react imports should remain
|
||||
expect(outputA).not.toContain('from "react"');
|
||||
expect(outputB).not.toContain('from "react"');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = ['react-dom'];
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [];
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
const SHARED_HELPERS = `
|
||||
export var customElementMap = globalThis.__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__ || {};
|
||||
|
||||
var _injectedStyleKeys = {};
|
||||
|
||||
export function injectStyleViaHead(cssText) {
|
||||
if (!cssText) return;
|
||||
var hash = 0;
|
||||
for (var i = 0; i < cssText.length; i++) {
|
||||
hash = ((hash << 5) - hash + cssText.charCodeAt(i)) | 0;
|
||||
}
|
||||
var key = 'jsx-style-' + hash;
|
||||
if (_injectedStyleKeys[key]) return;
|
||||
_injectedStyleKeys[key] = true;
|
||||
var el = document.createElement('style');
|
||||
el.setAttribute('data-jsx-style', key);
|
||||
el.textContent = cssText;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
export function extractCssText(children) {
|
||||
if (typeof children === 'string') return children;
|
||||
if (Array.isArray(children))
|
||||
return children
|
||||
.filter(function (c) { return typeof c === 'string'; })
|
||||
.join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
var _reactToDomEvent = {
|
||||
ondoubleclick: 'ondblclick',
|
||||
};
|
||||
|
||||
function _isEventProp(name) {
|
||||
return (
|
||||
name.length > 2 &&
|
||||
name.charCodeAt(0) === 111 &&
|
||||
name.charCodeAt(1) === 110 &&
|
||||
name.charCodeAt(2) >= 65 &&
|
||||
name.charCodeAt(2) <= 90
|
||||
);
|
||||
}
|
||||
|
||||
export function splitEventProps(props) {
|
||||
if (!props) return { cleanProps: props, events: null };
|
||||
var events = null;
|
||||
var cleanProps = null;
|
||||
for (var k in props) {
|
||||
if (_isEventProp(k) && typeof props[k] === 'function') {
|
||||
if (!events) {
|
||||
events = {};
|
||||
cleanProps = {};
|
||||
for (var j in props) {
|
||||
if (j === k) break;
|
||||
cleanProps[j] = props[j];
|
||||
}
|
||||
}
|
||||
events[k] = props[k];
|
||||
} else if (events) {
|
||||
cleanProps[k] = props[k];
|
||||
}
|
||||
}
|
||||
return { cleanProps: cleanProps || props, events: events };
|
||||
}
|
||||
|
||||
export function makeEventRef(events, userRef) {
|
||||
return function(el) {
|
||||
if (el) {
|
||||
for (var name in events) {
|
||||
var domName = _reactToDomEvent[name.toLowerCase()] || name.toLowerCase();
|
||||
el[domName] = events[name];
|
||||
}
|
||||
}
|
||||
if (typeof userRef === 'function') userRef(el);
|
||||
else if (userRef != null && typeof userRef === 'object') userRef.current = el;
|
||||
};
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const JSX_RUNTIME_WRAPPER = `
|
||||
import {
|
||||
jsx as _originalJsx,
|
||||
jsxs as _originalJsxs,
|
||||
Fragment,
|
||||
} from '__real_react_jsx_runtime__';
|
||||
|
||||
import {
|
||||
customElementMap,
|
||||
injectStyleViaHead,
|
||||
extractCssText,
|
||||
splitEventProps,
|
||||
makeEventRef,
|
||||
} from '__jsx_shared_helpers__';
|
||||
|
||||
function _wrapJsxFactory(originalFactory) {
|
||||
return function wrappedJsx(type, props, key) {
|
||||
if (typeof type === 'string') {
|
||||
if (type === 'style') {
|
||||
var css =
|
||||
props && props.dangerouslySetInnerHTML
|
||||
? props.dangerouslySetInnerHTML.__html || ''
|
||||
: extractCssText(props && props.children);
|
||||
injectStyleViaHead(css);
|
||||
return null;
|
||||
}
|
||||
|
||||
var customTag = customElementMap[type];
|
||||
if (customTag) {
|
||||
var split = splitEventProps(props);
|
||||
if (split.events) {
|
||||
var cp = split.cleanProps;
|
||||
cp.ref = makeEventRef(split.events, cp.ref);
|
||||
return originalFactory(customTag, cp, key);
|
||||
}
|
||||
return originalFactory(customTag, props, key);
|
||||
}
|
||||
}
|
||||
return originalFactory(type, props, key);
|
||||
};
|
||||
}
|
||||
|
||||
export var jsx = _wrapJsxFactory(_originalJsx);
|
||||
export var jsxs = _wrapJsxFactory(_originalJsxs);
|
||||
export { Fragment };
|
||||
`.trim();
|
||||
|
||||
const REACT_WRAPPER = `
|
||||
export * from '__real_react__';
|
||||
import _React from '__real_react__';
|
||||
|
||||
import {
|
||||
customElementMap,
|
||||
injectStyleViaHead,
|
||||
extractCssText,
|
||||
splitEventProps,
|
||||
makeEventRef,
|
||||
} from '__jsx_shared_helpers__';
|
||||
|
||||
var _originalCreateElement = _React.createElement;
|
||||
|
||||
function createElement(type) {
|
||||
var args = arguments;
|
||||
if (typeof type === 'string') {
|
||||
if (type === 'style') {
|
||||
var props = args.length > 1 ? args[1] : null;
|
||||
if (props) {
|
||||
var css = props.dangerouslySetInnerHTML
|
||||
? props.dangerouslySetInnerHTML.__html || ''
|
||||
: extractCssText(props.children);
|
||||
injectStyleViaHead(css);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var customTag = customElementMap[type];
|
||||
if (customTag) {
|
||||
var ceProps = args.length > 1 ? args[1] : null;
|
||||
var split = splitEventProps(ceProps);
|
||||
if (split.events) {
|
||||
var cp = split.cleanProps || {};
|
||||
cp.ref = makeEventRef(split.events, cp.ref);
|
||||
var newArgs = [customTag, cp];
|
||||
for (var i = 2; i < args.length; i++) newArgs.push(args[i]);
|
||||
return _originalCreateElement.apply(null, newArgs);
|
||||
}
|
||||
var newArgs2 = [customTag];
|
||||
for (var i2 = 1; i2 < args.length; i2++) newArgs2.push(args[i2]);
|
||||
return _originalCreateElement.apply(null, newArgs2);
|
||||
}
|
||||
}
|
||||
return _originalCreateElement.apply(null, args);
|
||||
}
|
||||
|
||||
export { createElement };
|
||||
export default Object.assign({}, _React, { createElement: createElement });
|
||||
`.trim();
|
||||
|
||||
type JsxRuntimeRemoteWrapperPluginOptions = {
|
||||
usePreact?: boolean;
|
||||
};
|
||||
|
||||
export const createJsxRuntimeRemoteWrapperPlugin = (
|
||||
options?: JsxRuntimeRemoteWrapperPluginOptions,
|
||||
): esbuild.Plugin => {
|
||||
const usePreact = options?.usePreact ?? false;
|
||||
|
||||
const jsxRuntimeModule = usePreact
|
||||
? 'preact/jsx-runtime'
|
||||
: 'react/jsx-runtime';
|
||||
const reactModule = usePreact ? 'preact/compat' : 'react';
|
||||
|
||||
return {
|
||||
name: 'jsx-runtime-remote-wrapper',
|
||||
setup: (build) => {
|
||||
let realJsxRuntimePath: string | undefined;
|
||||
let realReactPath: string | undefined;
|
||||
|
||||
build.onResolve({ filter: /^__jsx_shared_helpers__$/ }, () => ({
|
||||
path: '__jsx_shared_helpers__',
|
||||
namespace: 'jsx-shared-helpers',
|
||||
}));
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: 'jsx-shared-helpers' }, () => ({
|
||||
contents: SHARED_HELPERS,
|
||||
loader: 'js' as const,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: /^react\/jsx-runtime$/ }, async (args) => {
|
||||
if (args.pluginData?.skipJsxWrapper) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!realJsxRuntimePath) {
|
||||
const resolved = await build.resolve(jsxRuntimeModule, {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
pluginData: { skipJsxWrapper: true },
|
||||
});
|
||||
|
||||
realJsxRuntimePath = resolved.path;
|
||||
}
|
||||
|
||||
return {
|
||||
path: 'react/jsx-runtime',
|
||||
namespace: 'jsx-runtime-wrapper',
|
||||
};
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^__real_react_jsx_runtime__$/ }, () => {
|
||||
if (!realJsxRuntimePath) {
|
||||
throw new Error(
|
||||
'jsx-runtime-remote-wrapper: real jsx-runtime path not resolved yet',
|
||||
);
|
||||
}
|
||||
|
||||
return { path: realJsxRuntimePath };
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: 'jsx-runtime-wrapper' }, () => ({
|
||||
contents: JSX_RUNTIME_WRAPPER,
|
||||
loader: 'js' as const,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: /^react$/ }, async (args) => {
|
||||
if (args.pluginData?.skipJsxWrapper) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!realReactPath) {
|
||||
const resolved = await build.resolve(reactModule, {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
pluginData: { skipJsxWrapper: true },
|
||||
});
|
||||
|
||||
realReactPath = resolved.path;
|
||||
}
|
||||
|
||||
return {
|
||||
path: 'react',
|
||||
namespace: 'react-wrapper',
|
||||
};
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^__real_react__$/ }, () => {
|
||||
if (!realReactPath) {
|
||||
throw new Error(
|
||||
'jsx-runtime-remote-wrapper: real react path not resolved yet',
|
||||
);
|
||||
}
|
||||
|
||||
return { path: realReactPath };
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: 'react-wrapper' }, () => ({
|
||||
contents: REACT_WRAPPER,
|
||||
loader: 'js' as const,
|
||||
}));
|
||||
},
|
||||
};
|
||||
};
|
||||
+3
-17
@@ -1,11 +1,8 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
import { replaceHtmlTagsWithRemoteComponents } from './utils/replace-html-tags-with-remote-components';
|
||||
import { unwrapDefineFrontComponentToDirectExport } from './utils/unwrap-define-front-component-to-direct-export';
|
||||
|
||||
export { replaceHtmlTagsWithRemoteComponents as transformJsxToRemoteComponents } from './utils/replace-html-tags-with-remote-components';
|
||||
|
||||
export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
|
||||
name: 'jsx-transform-to-remote-dom-worker-format-plugin',
|
||||
setup: (esbuildBuild) => {
|
||||
@@ -15,20 +12,9 @@ export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
|
||||
try {
|
||||
const frontComponentSourceCode = await fs.readFile(path, 'utf8');
|
||||
|
||||
const sourceWithRemoteComponents =
|
||||
replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode);
|
||||
|
||||
const hasRemoteComponentReplacements =
|
||||
sourceWithRemoteComponents !== frontComponentSourceCode;
|
||||
|
||||
const sourceWithUnwrappedFrontComponent =
|
||||
unwrapDefineFrontComponentToDirectExport(
|
||||
sourceWithRemoteComponents,
|
||||
);
|
||||
|
||||
const transformedContents = hasRemoteComponentReplacements
|
||||
? `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`
|
||||
: sourceWithUnwrappedFrontComponent;
|
||||
const transformedContents = unwrapDefineFrontComponentToDirectExport(
|
||||
frontComponentSourceCode,
|
||||
);
|
||||
|
||||
return { contents: transformedContents, loader: 'tsx' };
|
||||
} catch (transformError) {
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
export const createPreactAliasPlugin = (): esbuild.Plugin => ({
|
||||
name: 'preact-alias',
|
||||
setup: (build) => {
|
||||
let preactCompatClientPath: string | undefined;
|
||||
|
||||
build.onResolve({ filter: /^react-dom\/client$/ }, async (args) => {
|
||||
if (!preactCompatClientPath) {
|
||||
const resolved = await build.resolve('preact/compat/client', {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
});
|
||||
|
||||
preactCompatClientPath = resolved.path;
|
||||
}
|
||||
|
||||
return { path: preactCompatClientPath };
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^react-dom$/ }, async (args) => {
|
||||
const resolved = await build.resolve('preact/compat', {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
});
|
||||
|
||||
return { path: resolved.path };
|
||||
});
|
||||
},
|
||||
});
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const REACT_IMPORT_PATTERN =
|
||||
/import\s+(?:(?<defaultImport>\w+)\s*,?\s*)?(?:\{(?<namedImports>[^}]*)\})?\s*from\s*['"]react['"];?/g;
|
||||
|
||||
const REACT_MODULE_FILTER_PATTERN = /^react(\/jsx-runtime)?$/;
|
||||
|
||||
const JSX_RUNTIME_EXPORTS = `
|
||||
export var jsx = /* @__PURE__ */ (() => globalThis.jsx)();
|
||||
export var jsxs = /* @__PURE__ */ (() => globalThis.jsxs)();
|
||||
export var Fragment = /* @__PURE__ */ (() => globalThis.React.Fragment)();
|
||||
`.trim();
|
||||
|
||||
const collectReactImports = (
|
||||
sourceContent: string,
|
||||
): Map<string, Set<string>> => {
|
||||
const namedImports = collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: REACT_IMPORT_PATTERN,
|
||||
});
|
||||
|
||||
let importMatch;
|
||||
|
||||
while (isDefined((importMatch = REACT_IMPORT_PATTERN.exec(sourceContent)))) {
|
||||
const defaultImportName = importMatch.groups?.defaultImport;
|
||||
|
||||
if (defaultImportName) {
|
||||
if (!namedImports.has('')) {
|
||||
namedImports.set('', new Set());
|
||||
}
|
||||
|
||||
namedImports.get('')!.add('default');
|
||||
}
|
||||
}
|
||||
|
||||
REACT_IMPORT_PATTERN.lastIndex = 0;
|
||||
|
||||
return namedImports;
|
||||
};
|
||||
|
||||
const generateReactExports = ({
|
||||
namedImports,
|
||||
}: {
|
||||
namedImports: Set<string>;
|
||||
}): string => {
|
||||
const exportLines: string[] = [];
|
||||
|
||||
for (const reactImportName of namedImports) {
|
||||
if (reactImportName === 'default') {
|
||||
exportLines.push(
|
||||
'export default /* @__PURE__ */ (() => globalThis.React)();',
|
||||
);
|
||||
} else {
|
||||
exportLines.push(
|
||||
`export var ${reactImportName} = /* @__PURE__ */ (() => globalThis.React.${reactImportName})();`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return exportLines.join('\n');
|
||||
};
|
||||
|
||||
export const reactGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'react-globals',
|
||||
namespace: 'react-globals',
|
||||
moduleName: 'react',
|
||||
moduleFilter: REACT_MODULE_FILTER_PATTERN,
|
||||
collectImports: collectReactImports,
|
||||
generateExports: generateReactExports,
|
||||
staticContents: {
|
||||
'react/jsx-runtime': JSX_RUNTIME_EXPORTS,
|
||||
},
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SDK_IMPORT_PATTERN =
|
||||
/import\s+(?:\{(?<namedImports>[^}]*)\})?\s*from\s*['"]twenty-sdk['"];?/g;
|
||||
|
||||
const TWENTY_SDK_MODULE_FILTER_PATTERN = /^twenty-sdk$/;
|
||||
|
||||
export const twentySdkGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-sdk-globals',
|
||||
namespace: 'twenty-sdk-globals',
|
||||
moduleName: 'twenty-sdk',
|
||||
moduleFilter: TWENTY_SDK_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SDK_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports }) =>
|
||||
[...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => globalThis.TwentySdk.${importName})();`,
|
||||
)
|
||||
.join('\n'),
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SDK_UI_IMPORT_PATTERN =
|
||||
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-sdk\/ui['"];?/g;
|
||||
|
||||
const TWENTY_SDK_UI_MODULE_FILTER_PATTERN = /^twenty-sdk\/ui$/;
|
||||
|
||||
export const twentySdkUiGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-sdk-ui-globals',
|
||||
namespace: 'twenty-sdk-ui-globals',
|
||||
moduleName: 'twenty-sdk/ui',
|
||||
moduleFilter: TWENTY_SDK_UI_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SDK_UI_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports }) =>
|
||||
[...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => globalThis.RemoteComponents.TwentyUi${importName})();`,
|
||||
)
|
||||
.join('\n'),
|
||||
});
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { collectNamedImports } from './utils/collect-named-imports';
|
||||
import { createGlobalsPlugin } from './utils/create-globals-plugin';
|
||||
|
||||
const TWENTY_SHARED_IMPORT_PATTERN =
|
||||
/import\s+\{(?<namedImports>[^}]*)\}\s*from\s*['"]twenty-shared(?:\/(?<subPath>[^'"]*?))?['"];?/g;
|
||||
|
||||
const TWENTY_SHARED_MODULE_FILTER_PATTERN = /^twenty-shared(\/.*)?$/;
|
||||
|
||||
const buildGlobalAccessorExpression = (moduleSubPath: string): string => {
|
||||
if (moduleSubPath === '') {
|
||||
return 'globalThis.TwentyShared';
|
||||
}
|
||||
|
||||
return `globalThis.TwentyShared['${moduleSubPath}']`;
|
||||
};
|
||||
|
||||
export const twentySharedGlobalsPlugin = createGlobalsPlugin({
|
||||
pluginName: 'twenty-shared-globals',
|
||||
namespace: 'twenty-shared-globals',
|
||||
moduleName: 'twenty-shared',
|
||||
moduleFilter: TWENTY_SHARED_MODULE_FILTER_PATTERN,
|
||||
collectImports: (sourceContent) =>
|
||||
collectNamedImports({
|
||||
sourceContent,
|
||||
pattern: TWENTY_SHARED_IMPORT_PATTERN,
|
||||
}),
|
||||
generateExports: ({ namedImports, moduleSubPath }) => {
|
||||
const globalAccessorExpression =
|
||||
buildGlobalAccessorExpression(moduleSubPath);
|
||||
|
||||
return [...namedImports]
|
||||
.map(
|
||||
(importName) =>
|
||||
`export var ${importName} = /* @__PURE__ */ (() => ${globalAccessorExpression}.${importName})();`,
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
});
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
export type ParsedImportSpecifier = {
|
||||
originalName: string;
|
||||
aliasName: string;
|
||||
};
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { extractNamesFromImportSpecifier } from './extract-names-from-import-specifier';
|
||||
|
||||
const parseImportSpecifiers = (namedImportsString: string): string[] => {
|
||||
return namedImportsString
|
||||
.split(',')
|
||||
.map((specifier) => specifier.trim())
|
||||
.filter((specifier) => specifier.length > 0)
|
||||
.filter((specifier) => !specifier.startsWith('type '))
|
||||
.map(
|
||||
(specifier) => extractNamesFromImportSpecifier(specifier).originalName,
|
||||
);
|
||||
};
|
||||
|
||||
export const collectNamedImports = ({
|
||||
sourceContent,
|
||||
pattern,
|
||||
}: {
|
||||
sourceContent: string;
|
||||
pattern: RegExp;
|
||||
}): Map<string, Set<string>> => {
|
||||
const collectedImports = new Map<string, Set<string>>();
|
||||
|
||||
let importMatch;
|
||||
|
||||
while (isDefined((importMatch = pattern.exec(sourceContent)))) {
|
||||
const namedImportsString = importMatch.groups?.namedImports;
|
||||
const subPath = importMatch.groups?.subPath ?? '';
|
||||
|
||||
if (!collectedImports.has(subPath)) {
|
||||
collectedImports.set(subPath, new Set());
|
||||
}
|
||||
|
||||
if (namedImportsString) {
|
||||
parseImportSpecifiers(namedImportsString).forEach((name) =>
|
||||
collectedImports.get(subPath)?.add(name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
return collectedImports;
|
||||
};
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
type GlobalsPluginData = {
|
||||
importerFilePath: string;
|
||||
originalPath: string;
|
||||
};
|
||||
|
||||
type GlobalsPluginConfig = {
|
||||
pluginName: string;
|
||||
moduleName: string;
|
||||
moduleFilter: RegExp;
|
||||
collectImports: (sourceContent: string) => Map<string, Set<string>>;
|
||||
generateExports: (params: {
|
||||
namedImports: Set<string>;
|
||||
moduleSubPath: string;
|
||||
}) => string;
|
||||
namespace?: string;
|
||||
staticContents?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const createGlobalsPlugin = (
|
||||
config: GlobalsPluginConfig,
|
||||
): esbuild.Plugin => {
|
||||
const namespace = config.namespace ?? config.pluginName;
|
||||
|
||||
return {
|
||||
name: config.pluginName,
|
||||
setup: (build) => {
|
||||
const importsByFilePath = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
build.onStart(() => {
|
||||
importsByFilePath.clear();
|
||||
});
|
||||
|
||||
build.onResolve(
|
||||
{ filter: config.moduleFilter },
|
||||
async ({ importer, path }) => {
|
||||
if (importer && !importsByFilePath.has(importer)) {
|
||||
try {
|
||||
const sourceFileContent = await fs.readFile(importer, 'utf-8');
|
||||
|
||||
importsByFilePath.set(
|
||||
importer,
|
||||
config.collectImports(sourceFileContent),
|
||||
);
|
||||
} catch {
|
||||
importsByFilePath.set(importer, new Map());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: importer
|
||||
? `${path}?importer=${encodeURIComponent(importer)}`
|
||||
: path,
|
||||
namespace,
|
||||
pluginData: {
|
||||
importerFilePath: importer,
|
||||
originalPath: path,
|
||||
} satisfies GlobalsPluginData,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace }, ({ pluginData }) => {
|
||||
const { originalPath, importerFilePath } =
|
||||
pluginData as GlobalsPluginData;
|
||||
|
||||
if (config.staticContents?.[originalPath]) {
|
||||
return {
|
||||
contents: config.staticContents[originalPath],
|
||||
loader: 'js' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const moduleSubPath =
|
||||
originalPath === config.moduleName
|
||||
? ''
|
||||
: originalPath.replace(`${config.moduleName}/`, '');
|
||||
|
||||
const importsBySubPath = importsByFilePath.get(importerFilePath);
|
||||
const namedImportsForSubPath =
|
||||
importsBySubPath?.get(moduleSubPath) ?? new Set<string>();
|
||||
|
||||
return {
|
||||
contents: config.generateExports({
|
||||
namedImports: namedImportsForSubPath,
|
||||
moduleSubPath,
|
||||
}),
|
||||
loader: 'js' as const,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ParsedImportSpecifier } from '../types/ParsedImportSpecifier';
|
||||
|
||||
const ALIASED_IMPORT_PATTERN = /^([\w$]+)\s+as\s+([\w$]+)$/;
|
||||
|
||||
export const extractNamesFromImportSpecifier = (
|
||||
importSpecifier: string,
|
||||
): ParsedImportSpecifier => {
|
||||
const trimmedSpecifier = importSpecifier.trim();
|
||||
const aliasMatch = trimmedSpecifier.match(ALIASED_IMPORT_PATTERN);
|
||||
|
||||
if (isDefined(aliasMatch)) {
|
||||
const [, originalName, aliasName] = aliasMatch;
|
||||
|
||||
return { originalName, aliasName };
|
||||
}
|
||||
|
||||
return { originalName: trimmedSpecifier, aliasName: trimmedSpecifier };
|
||||
};
|
||||
+13
-9
@@ -1,17 +1,21 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
|
||||
import { createJsxRuntimeRemoteWrapperPlugin } from '../jsx-runtime-remote-wrapper-plugin';
|
||||
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
|
||||
import { reactGlobalsPlugin } from '../react-globals-plugin';
|
||||
import { createPreactAliasPlugin } from '../preact-alias-plugin';
|
||||
import { stripCommentsPlugin } from '../strip-comments-plugin';
|
||||
import { twentySdkGlobalsPlugin } from '../twenty-sdk-globals-plugin';
|
||||
import { twentySdkUiGlobalsPlugin } from '../twenty-sdk-ui-globals-plugin';
|
||||
import { twentySharedGlobalsPlugin } from '../twenty-shared-globals-plugin';
|
||||
|
||||
export const getFrontComponentBuildPlugins = (): esbuild.Plugin[] => [
|
||||
reactGlobalsPlugin,
|
||||
twentySdkGlobalsPlugin,
|
||||
twentySdkUiGlobalsPlugin,
|
||||
twentySharedGlobalsPlugin,
|
||||
type GetFrontComponentBuildPluginsOptions = {
|
||||
usePreact?: boolean;
|
||||
};
|
||||
|
||||
export const getFrontComponentBuildPlugins = (
|
||||
options?: GetFrontComponentBuildPluginsOptions,
|
||||
): esbuild.Plugin[] => [
|
||||
createJsxRuntimeRemoteWrapperPlugin(
|
||||
options?.usePreact ? { usePreact: true } : undefined,
|
||||
),
|
||||
...(options?.usePreact ? [createPreactAliasPlugin()] : []),
|
||||
jsxTransformToRemoteDomWorkerFormatPlugin,
|
||||
stripCommentsPlugin,
|
||||
];
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { HTML_TAG_TO_REMOTE_COMPONENT } from '../../../../../../sdk/front-component-api';
|
||||
|
||||
const REMOTE_COMPONENTS_GLOBAL_NAMESPACE = 'RemoteComponents';
|
||||
|
||||
const buildHtmlTagToRemoteComponentPattern = (): RegExp => {
|
||||
const supportedHtmlTagNames = Object.keys(HTML_TAG_TO_REMOTE_COMPONENT).join(
|
||||
'|',
|
||||
);
|
||||
|
||||
return new RegExp(
|
||||
`(<\\/?)\\b(${supportedHtmlTagNames})\\b(?=[\\s>\\/>])`,
|
||||
'g',
|
||||
);
|
||||
};
|
||||
|
||||
const HTML_TAG_TO_REMOTE_COMPONENT_PATTERN =
|
||||
buildHtmlTagToRemoteComponentPattern();
|
||||
|
||||
export const replaceHtmlTagsWithRemoteComponents = (
|
||||
sourceCode: string,
|
||||
): string => {
|
||||
return sourceCode.replace(
|
||||
HTML_TAG_TO_REMOTE_COMPONENT_PATTERN,
|
||||
(fullMatch, tagPrefix: string, htmlTagName: string) => {
|
||||
const remoteComponentName =
|
||||
HTML_TAG_TO_REMOTE_COMPONENT[
|
||||
htmlTagName as keyof typeof HTML_TAG_TO_REMOTE_COMPONENT
|
||||
];
|
||||
|
||||
if (isDefined(remoteComponentName)) {
|
||||
return `${tagPrefix}${REMOTE_COMPONENTS_GLOBAL_NAMESPACE}.${remoteComponentName}`;
|
||||
}
|
||||
|
||||
return fullMatch;
|
||||
},
|
||||
);
|
||||
};
|
||||
+6
-1
@@ -28,9 +28,14 @@ export const unwrapDefineFrontComponentToDirectExport = (
|
||||
`$1 ${wrappedComponentName}`,
|
||||
);
|
||||
|
||||
transformedSource =
|
||||
`import { createRoot as __createRoot } from 'react-dom/client';\n` +
|
||||
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n` +
|
||||
transformedSource;
|
||||
|
||||
transformedSource = transformedSource.replace(
|
||||
DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
|
||||
`export default globalThis.jsx(${wrappedComponentName}, {});`,
|
||||
`export default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${wrappedComponentName}, {})); }`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import bundleSizes from './example-sources-built/bundle-sizes.json';
|
||||
|
||||
type BundleSizeEntry = {
|
||||
name: string;
|
||||
reactBytes: number;
|
||||
preactBytes: number;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const kb = bytes / 1024;
|
||||
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(kb / 1024).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const formatLabel = (name: string): string =>
|
||||
name
|
||||
.replace('.front-component', '')
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const BAR_MAX_WIDTH = 400;
|
||||
|
||||
const BundleSizesTable = () => {
|
||||
const entries = (bundleSizes as BundleSizeEntry[]).sort(
|
||||
(a, b) => b.reactBytes - a.reactBytes,
|
||||
);
|
||||
|
||||
const maxBytes = Math.max(...entries.map((entry) => entry.reactBytes));
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', padding: 24 }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 18, fontWeight: 600 }}>
|
||||
Front Component Bundle Sizes
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 13, color: '#666' }}>
|
||||
Each bar shows the minified .mjs size (React vs Preact runtime)
|
||||
</p>
|
||||
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
borderBottom: '2px solid #e4e4e7',
|
||||
}}
|
||||
>
|
||||
<th style={{ padding: '8px 12px', width: 180 }}>Component</th>
|
||||
<th style={{ padding: '8px 12px' }}>Size</th>
|
||||
<th style={{ padding: '8px 12px', width: 100, textAlign: 'right' }}>
|
||||
React
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 100, textAlign: 'right' }}>
|
||||
Preact
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 80, textAlign: 'right' }}>
|
||||
Saving
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const reactWidth =
|
||||
(entry.reactBytes / maxBytes) * BAR_MAX_WIDTH;
|
||||
const preactWidth =
|
||||
(entry.preactBytes / maxBytes) * BAR_MAX_WIDTH;
|
||||
const saving =
|
||||
entry.reactBytes > 0
|
||||
? (
|
||||
((entry.reactBytes - entry.preactBytes) /
|
||||
entry.reactBytes) *
|
||||
100
|
||||
).toFixed(0)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
style={{ borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatLabel(entry.name)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 14,
|
||||
width: reactWidth,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 3,
|
||||
minWidth: 4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
height: 14,
|
||||
width: preactWidth,
|
||||
backgroundColor: '#8b5cf6',
|
||||
borderRadius: 3,
|
||||
minWidth: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
textAlign: 'right',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{formatBytes(entry.reactBytes)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
textAlign: 'right',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: '#8b5cf6',
|
||||
}}
|
||||
>
|
||||
{formatBytes(entry.preactBytes)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
textAlign: 'right',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: '#16a34a',
|
||||
}}
|
||||
>
|
||||
{saving}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 2,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
React
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: '#8b5cf6',
|
||||
borderRadius: 2,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
Preact
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'FrontComponent/BundleSizes',
|
||||
component: BundleSizesTable,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Overview: Story = {};
|
||||
+51
-68
@@ -8,7 +8,7 @@ import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryCompon
|
||||
const errorHandler = fn();
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/FrontComponentRenderer',
|
||||
title: 'FrontComponent/Feature',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
@@ -25,10 +25,21 @@ const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const Static: Story = {
|
||||
const createComponentStory = (
|
||||
name: string,
|
||||
options?: { runtime?: 'preact'; play?: Story['play'] },
|
||||
): Story => ({
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender('static.front-component'),
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
`${name}.front-component`,
|
||||
options?.runtime,
|
||||
),
|
||||
},
|
||||
...(options?.play ? { play: options.play } : {}),
|
||||
});
|
||||
|
||||
export const Static: Story = {
|
||||
...createComponentStory('static'),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -54,9 +65,7 @@ export const Static: Story = {
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender('interactive.front-component'),
|
||||
},
|
||||
...createComponentStory('interactive'),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -74,9 +83,7 @@ export const Interactive: Story = {
|
||||
};
|
||||
|
||||
export const Lifecycle: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender('lifecycle.front-component'),
|
||||
},
|
||||
...createComponentStory('lifecycle'),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -94,66 +101,8 @@ export const Lifecycle: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ChakraExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'chakra-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const TailwindExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'tailwind-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const EmotionExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'emotion-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const StyledComponentsExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'styled-components-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ShadcnExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'shadcn-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const MuiExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'mui-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const TwentyUiExample: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
'twenty-ui-example.front-component',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorHandling: Story = {
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender('nonexistent.front-component'),
|
||||
},
|
||||
...createComponentStory('nonexistent'),
|
||||
play: async () => {
|
||||
await waitFor(
|
||||
() => {
|
||||
@@ -163,3 +112,37 @@ export const ErrorHandling: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SdkContext: Story = {
|
||||
...createComponentStory('sdk-context-example'),
|
||||
args: {
|
||||
...createComponentStory('sdk-context-example').args,
|
||||
executionContext: { userId: 'test-user-abc-123' },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'sdk-context-component',
|
||||
{},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
const userIdElement = await canvas.findByTestId('sdk-context-user-id');
|
||||
expect(userIdElement).toBeVisible();
|
||||
expect(userIdElement).toHaveTextContent('test-user-abc-123');
|
||||
|
||||
const jsonElement = await canvas.findByTestId('sdk-context-json');
|
||||
expect(jsonElement).toHaveTextContent('"userId": "test-user-abc-123"');
|
||||
|
||||
const button = await canvas.findByTestId('sdk-context-button');
|
||||
await userEvent.click(button);
|
||||
|
||||
const renderCount = await canvas.findByTestId(
|
||||
'sdk-context-render-count',
|
||||
);
|
||||
expect(renderCount).toHaveTextContent('Renders: 1');
|
||||
|
||||
expect(userIdElement).toHaveTextContent('test-user-abc-123');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
|
||||
|
||||
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
|
||||
|
||||
const errorHandler = fn();
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/UI Libraries',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onError: errorHandler,
|
||||
applicationAccessToken: 'fake-token',
|
||||
},
|
||||
beforeEach: () => {
|
||||
errorHandler.mockClear();
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
const createComponentStory = (
|
||||
name: string,
|
||||
options?: { runtime?: 'preact'; play?: Story['play'] },
|
||||
): Story => ({
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
`${name}.front-component`,
|
||||
options?.runtime,
|
||||
),
|
||||
},
|
||||
...(options?.play ? { play: options.play } : {}),
|
||||
});
|
||||
|
||||
const createCounterTest =
|
||||
(testIdPrefix: string): Story['play'] =>
|
||||
async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
`${testIdPrefix}-component`,
|
||||
{},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
expect(await canvas.findByText('Count: 0')).toBeVisible();
|
||||
|
||||
const button = await canvas.findByTestId(`${testIdPrefix}-button`);
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 1')).toBeVisible();
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 2')).toBeVisible();
|
||||
};
|
||||
|
||||
const chakraTest = createCounterTest('chakra');
|
||||
|
||||
export const ChakraReact: Story = createComponentStory('chakra-example', {
|
||||
play: chakraTest,
|
||||
});
|
||||
export const ChakraPreact: Story = createComponentStory('chakra-example', {
|
||||
runtime: 'preact',
|
||||
play: chakraTest,
|
||||
});
|
||||
|
||||
const tailwindTest = createCounterTest('tailwind');
|
||||
|
||||
export const TailwindReact: Story = createComponentStory('tailwind-example', {
|
||||
play: tailwindTest,
|
||||
});
|
||||
export const TailwindPreact: Story = createComponentStory('tailwind-example', {
|
||||
runtime: 'preact',
|
||||
play: tailwindTest,
|
||||
});
|
||||
|
||||
const emotionTest = createCounterTest('emotion');
|
||||
|
||||
export const EmotionReact: Story = createComponentStory('emotion-example', {
|
||||
play: emotionTest,
|
||||
});
|
||||
export const EmotionPreact: Story = createComponentStory('emotion-example', {
|
||||
runtime: 'preact',
|
||||
play: emotionTest,
|
||||
});
|
||||
|
||||
const styledComponentsTest = createCounterTest('styled-components');
|
||||
|
||||
export const StyledComponentsReact: Story = createComponentStory(
|
||||
'styled-components-example',
|
||||
{ play: styledComponentsTest },
|
||||
);
|
||||
export const StyledComponentsPreact: Story = createComponentStory(
|
||||
'styled-components-example',
|
||||
{ runtime: 'preact', play: styledComponentsTest },
|
||||
);
|
||||
|
||||
const shadcnTest = createCounterTest('shadcn');
|
||||
|
||||
export const ShadcnReact: Story = createComponentStory('shadcn-example', {
|
||||
play: shadcnTest,
|
||||
});
|
||||
export const ShadcnPreact: Story = createComponentStory('shadcn-example', {
|
||||
runtime: 'preact',
|
||||
play: shadcnTest,
|
||||
});
|
||||
|
||||
const muiTest = createCounterTest('mui');
|
||||
|
||||
export const MuiReact: Story = createComponentStory('mui-example', {
|
||||
play: muiTest,
|
||||
});
|
||||
export const MuiPreact: Story = createComponentStory('mui-example', {
|
||||
runtime: 'preact',
|
||||
play: muiTest,
|
||||
});
|
||||
|
||||
const twentyUiTest: Story['play'] = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId('twenty-ui-component', {}, { timeout: 30000 });
|
||||
|
||||
expect(await canvas.findByText('Count: 0')).toBeVisible();
|
||||
|
||||
const button = await canvas.findByText('Increment');
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 1')).toBeVisible();
|
||||
|
||||
await userEvent.click(button);
|
||||
expect(await canvas.findByText('Count: 2')).toBeVisible();
|
||||
};
|
||||
|
||||
export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', {
|
||||
play: twentyUiTest,
|
||||
});
|
||||
export const TwentyUiPreact: Story = createComponentStory(
|
||||
'twenty-ui-example',
|
||||
{ runtime: 'preact', play: twentyUiTest },
|
||||
);
|
||||
+71
-8
@@ -1,14 +1,77 @@
|
||||
import { Button, ChakraProvider, defaultSystem } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ChakraProvider,
|
||||
defaultSystem,
|
||||
Heading,
|
||||
HStack,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
export const ChakraComponent = () => {
|
||||
const ChakraComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Button colorPalette="blue" size="md">
|
||||
Click me
|
||||
</Button>
|
||||
</div>
|
||||
<Box
|
||||
data-testid="chakra-component"
|
||||
p={6}
|
||||
borderWidth="2px"
|
||||
borderRadius="xl"
|
||||
borderColor="teal.400"
|
||||
bg="teal.50"
|
||||
maxW="360px"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
>
|
||||
<VStack gap={4} align="start">
|
||||
<Heading size="md" color="teal.700">
|
||||
Chakra UI
|
||||
</Heading>
|
||||
<Text fontSize="sm" color="teal.600">
|
||||
Component library with built-in design tokens and responsive styles.
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Badge colorPalette="teal" variant="solid">
|
||||
Badge
|
||||
</Badge>
|
||||
<Badge colorPalette="purple" variant="solid">
|
||||
Styled
|
||||
</Badge>
|
||||
<Badge colorPalette="orange" variant="outline">
|
||||
Outline
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text
|
||||
data-testid="chakra-count"
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color="teal.800"
|
||||
>
|
||||
Count: {count}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
data-testid="chakra-button"
|
||||
colorPalette="teal"
|
||||
size="sm"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCount(0)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</ChakraProvider>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +79,6 @@ export const ChakraComponent = () => {
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
name: 'chakra-component',
|
||||
description: 'A front component with a Chakra UI button',
|
||||
description: 'A front component with Chakra UI',
|
||||
component: ChakraComponent,
|
||||
});
|
||||
|
||||
+67
-19
@@ -2,35 +2,70 @@ import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 20px;
|
||||
const Card = styled.div`
|
||||
padding: 24px;
|
||||
background-color: #fefce8;
|
||||
border: 2px solid #facc15;
|
||||
border-radius: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
color: #854d0e;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
color: #a16207;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ChipRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Chip = styled.span<{ color: string }>`
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background-color: ${({ color }) => color};
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Count = styled.p`
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #ca8a04;
|
||||
margin-bottom: 16px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledButton = styled.button`
|
||||
padding: 10px 20px;
|
||||
background-color: #eab308;
|
||||
color: white;
|
||||
border: none;
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled.button<{ variant?: 'outline' }>`
|
||||
padding: 8px 16px;
|
||||
background-color: ${({ variant }) =>
|
||||
variant === 'outline' ? 'transparent' : '#eab308'};
|
||||
color: ${({ variant }) => (variant === 'outline' ? '#854d0e' : 'white')};
|
||||
border: ${({ variant }) =>
|
||||
variant === 'outline' ? '1px solid #d4a90a' : 'none'};
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
@@ -38,16 +73,29 @@ const EmotionComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<Container data-testid="emotion-component">
|
||||
<Heading>Emotion Styled Component</Heading>
|
||||
<Card data-testid="emotion-component">
|
||||
<Heading>Emotion</Heading>
|
||||
<Description>
|
||||
CSS-in-JS library with tagged template literals and object styles.
|
||||
</Description>
|
||||
<ChipRow>
|
||||
<Chip color="#eab308">Badge</Chip>
|
||||
<Chip color="#a855f7">Styled</Chip>
|
||||
<Chip color="#f97316">Outline</Chip>
|
||||
</ChipRow>
|
||||
<Count data-testid="emotion-count">Count: {count}</Count>
|
||||
<StyledButton
|
||||
data-testid="emotion-button"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</StyledButton>
|
||||
</Container>
|
||||
<ButtonRow>
|
||||
<StyledButton
|
||||
data-testid="emotion-button"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</StyledButton>
|
||||
<StyledButton variant="outline" onClick={() => setCount(0)}>
|
||||
Reset
|
||||
</StyledButton>
|
||||
</ButtonRow>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+31
-7
@@ -1,4 +1,5 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import MuiChip from '@mui/material/Chip';
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
@@ -11,21 +12,43 @@ const MuiComponent = () => {
|
||||
style={{
|
||||
padding: 24,
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '2px solid #42a5f5',
|
||||
borderRadius: 12,
|
||||
maxWidth: 360,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
color: '#1976d2',
|
||||
marginBottom: 16,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: '#1565c0',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Material UI Component
|
||||
Material UI
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: '#1976d2',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Google's design system with comprehensive component library.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<MuiChip label="Badge" color="primary" size="small" />
|
||||
<MuiChip label="Styled" color="secondary" size="small" />
|
||||
<MuiChip label="Material" color="success" size="small" />
|
||||
</div>
|
||||
<p
|
||||
data-testid="mui-count"
|
||||
style={{ fontSize: 32, fontWeight: 700, marginBottom: 16 }}
|
||||
style={{ fontSize: 24, fontWeight: 800, margin: 0, color: '#1565c0' }}
|
||||
>
|
||||
Count: {count}
|
||||
</p>
|
||||
@@ -33,11 +56,12 @@ const MuiComponent = () => {
|
||||
<Button
|
||||
data-testid="mui-button"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => setCount(0)}>
|
||||
<Button variant="outlined" size="small" onClick={() => setCount(0)}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
defineFrontComponent,
|
||||
useFrontComponentExecutionContext,
|
||||
useUserId,
|
||||
} from '@/sdk';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
background: '#f0f9ff',
|
||||
borderRadius: 12,
|
||||
border: '2px solid #38bdf8',
|
||||
maxWidth: 400,
|
||||
};
|
||||
|
||||
const LABEL_STYLE = {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#64748b',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 1,
|
||||
};
|
||||
|
||||
const VALUE_STYLE = {
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: '#0c4a6e',
|
||||
wordBreak: 'break-all' as const,
|
||||
};
|
||||
|
||||
const SdkContextComponent = () => {
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
const fullContext = useFrontComponentExecutionContext(
|
||||
(context) => context,
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="sdk-context-component" style={CARD_STYLE}>
|
||||
<h2
|
||||
style={{
|
||||
color: '#0369a1',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
SDK Context
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<p style={LABEL_STYLE}>User ID (useUserId)</p>
|
||||
<p data-testid="sdk-context-user-id" style={VALUE_STYLE}>
|
||||
{userId ?? 'null'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p style={LABEL_STYLE}>Full Context (JSON)</p>
|
||||
<pre
|
||||
data-testid="sdk-context-json"
|
||||
style={{
|
||||
...VALUE_STYLE,
|
||||
fontSize: 13,
|
||||
background: '#e0f2fe',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(fullContext, null, 2) ?? 'undefined'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
|
||||
>
|
||||
<button
|
||||
data-testid="sdk-context-button"
|
||||
onClick={() => setRenderCount((previous) => previous + 1)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0284c7',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Re-render
|
||||
</button>
|
||||
<span
|
||||
data-testid="sdk-context-render-count"
|
||||
style={{ fontSize: 14, color: '#475569' }}
|
||||
>
|
||||
Renders: {renderCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-sdkc-0000-0000-0000-000000000011',
|
||||
name: 'sdk-context-component',
|
||||
description:
|
||||
'A front component that uses SDK context hooks (useFrontComponentExecutionContext, useUserId)',
|
||||
component: SdkContextComponent,
|
||||
});
|
||||
+66
-102
@@ -1,136 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
// shadcn UI outputs pre-built components that use Tailwind utility classes
|
||||
// with Radix UI primitives underneath. This example simulates that pattern:
|
||||
// className-based styling with a bundled CSS subset.
|
||||
const SHADCN_CSS = `
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
.inline-flex{display:inline-flex}
|
||||
.flex{display:flex}
|
||||
.flex-col{display:flex;flex-direction:column}
|
||||
.flex-wrap{flex-wrap:wrap}
|
||||
.items-center{align-items:center}
|
||||
.justify-center{justify-content:center}
|
||||
.gap-2{gap:.5rem}
|
||||
.gap-3{gap:.75rem}
|
||||
.gap-4{gap:1rem}
|
||||
.rounded-md{border-radius:.375rem}
|
||||
.rounded-lg{border-radius:.5rem}
|
||||
.border{border-width:1px}
|
||||
.border-input{border-color:#e2e8f0}
|
||||
.bg-background{background-color:#fff}
|
||||
.bg-primary{background-color:#0f172a}
|
||||
.bg-muted{background-color:#f1f5f9}
|
||||
.bg-destructive{background-color:#ef4444}
|
||||
.p-4{padding:1rem}
|
||||
.rounded-xl{border-radius:.75rem}
|
||||
.rounded-full{border-radius:9999px}
|
||||
.border-2{border-width:2px}
|
||||
.border-slate-300{border-color:#cbd5e1}
|
||||
.bg-white{background-color:#fff}
|
||||
.bg-slate-900{background-color:#0f172a}
|
||||
.bg-slate-100{background-color:#f1f5f9}
|
||||
.bg-red-500{background-color:#ef4444}
|
||||
.bg-emerald-500{background-color:#10b981}
|
||||
.bg-violet-500{background-color:#8b5cf6}
|
||||
.p-6{padding:1.5rem}
|
||||
.px-3{padding-left:.75rem;padding-right:.75rem}
|
||||
.px-4{padding-left:1rem;padding-right:1rem}
|
||||
.py-1{padding-top:.25rem;padding-bottom:.25rem}
|
||||
.py-2{padding-top:.5rem;padding-bottom:.5rem}
|
||||
.h-10{height:2.5rem}
|
||||
.w-full{width:100%}
|
||||
.h-9{height:2.25rem}
|
||||
.text-xs{font-size:.75rem;line-height:1rem}
|
||||
.text-sm{font-size:.875rem;line-height:1.25rem}
|
||||
.text-lg{font-size:1.125rem;line-height:1.75rem}
|
||||
.text-2xl{font-size:1.5rem;line-height:2rem}
|
||||
.font-medium{font-weight:500}
|
||||
.font-semibold{font-weight:600}
|
||||
.text-primary{color:#0f172a}
|
||||
.text-primary-foreground{color:#fff}
|
||||
.text-muted-foreground{color:#64748b}
|
||||
.text-destructive-foreground{color:#fff}
|
||||
.shadow-sm{box-shadow:0 1px 2px 0 rgb(0 0 0/.05)}
|
||||
.font-bold{font-weight:700}
|
||||
.font-extrabold{font-weight:800}
|
||||
.text-slate-900{color:#0f172a}
|
||||
.text-white{color:#fff}
|
||||
.text-slate-500{color:#64748b}
|
||||
.text-slate-700{color:#334155}
|
||||
.max-w-sm{max-width:24rem}
|
||||
.cursor-pointer{cursor:pointer}
|
||||
.ring-offset-background{--ring-offset:0px}
|
||||
.border{border-width:1px}
|
||||
.border-slate-200{border-color:#e2e8f0}
|
||||
.transition-colors{transition:color .15s,background-color .15s,border-color .15s}
|
||||
`;
|
||||
|
||||
// Simulated shadcn Button component (normally generated by shadcn CLI)
|
||||
const Button = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'destructive' | 'outline';
|
||||
className?: string;
|
||||
'data-testid'?: string;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 cursor-pointer transition-colors';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground',
|
||||
outline: 'border border-input bg-background',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Simulated shadcn Card component
|
||||
const Card = ({
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
'data-testid'?: string;
|
||||
}) => (
|
||||
<div
|
||||
className={`rounded-lg border border-input bg-background shadow-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ShadcnComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{SHADCN_CSS}</style>
|
||||
<Card data-testid="shadcn-component">
|
||||
<div className="flex-col gap-3 p-6">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
shadcn UI Component
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Simulates the shadcn pattern: Tailwind utilities + composable
|
||||
primitives.
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-4">
|
||||
<span
|
||||
data-testid="shadcn-count"
|
||||
className="text-2xl font-semibold text-primary"
|
||||
>
|
||||
Count: {count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
data-testid="shadcn-button"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCount(0)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
data-testid="shadcn-component"
|
||||
className="p-6 bg-white rounded-xl border-2 border-slate-300 max-w-sm flex-col gap-4"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-slate-900">shadcn / ui</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Composable primitives powered by Radix UI and Tailwind CSS.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 bg-slate-900 text-white rounded-full text-xs font-semibold">
|
||||
Badge
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-violet-500 text-white rounded-full text-xs font-semibold">
|
||||
Styled
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-emerald-500 text-white rounded-full text-xs font-semibold">
|
||||
Composable
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
<p
|
||||
data-testid="shadcn-count"
|
||||
className="text-2xl font-extrabold text-slate-700"
|
||||
>
|
||||
Count: {count}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid="shadcn-button"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 cursor-pointer bg-slate-900 text-white transition-colors"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 cursor-pointer border border-slate-200 bg-white text-slate-700 transition-colors"
|
||||
onClick={() => setCount(0)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+67
-19
@@ -2,35 +2,70 @@ import { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 20px;
|
||||
const Card = styled.div`
|
||||
padding: 24px;
|
||||
background-color: #fdf2f8;
|
||||
border: 2px solid #ec4899;
|
||||
border-radius: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
color: #9d174d;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
color: #be185d;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ChipRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Chip = styled.span<{ $color: string }>`
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background-color: ${({ $color }) => $color};
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Count = styled.p`
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #db2777;
|
||||
margin-bottom: 16px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledButton = styled.button`
|
||||
padding: 10px 20px;
|
||||
background-color: #ec4899;
|
||||
color: white;
|
||||
border: none;
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled.button<{ $variant?: 'outline' }>`
|
||||
padding: 8px 16px;
|
||||
background-color: ${({ $variant }) =>
|
||||
$variant === 'outline' ? 'transparent' : '#ec4899'};
|
||||
color: ${({ $variant }) => ($variant === 'outline' ? '#9d174d' : 'white')};
|
||||
border: ${({ $variant }) =>
|
||||
$variant === 'outline' ? '1px solid #ec4899' : 'none'};
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
@@ -38,16 +73,29 @@ const StyledComponentsComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<Container data-testid="styled-components-component">
|
||||
<Heading>Styled Components Example</Heading>
|
||||
<Card data-testid="styled-components-component">
|
||||
<Heading>Styled Components</Heading>
|
||||
<Description>
|
||||
CSS-in-JS with tagged templates and automatic critical CSS extraction.
|
||||
</Description>
|
||||
<ChipRow>
|
||||
<Chip $color="#ec4899">Badge</Chip>
|
||||
<Chip $color="#8b5cf6">Styled</Chip>
|
||||
<Chip $color="#f59e0b">Outline</Chip>
|
||||
</ChipRow>
|
||||
<Count data-testid="styled-components-count">Count: {count}</Count>
|
||||
<StyledButton
|
||||
data-testid="styled-components-button"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</StyledButton>
|
||||
</Container>
|
||||
<ButtonRow>
|
||||
<StyledButton
|
||||
data-testid="styled-components-button"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</StyledButton>
|
||||
<StyledButton $variant="outline" onClick={() => setCount(0)}>
|
||||
Reset
|
||||
</StyledButton>
|
||||
</ButtonRow>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+61
-34
@@ -1,35 +1,46 @@
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
import { useState } from 'react';
|
||||
|
||||
// Tailwind CSS subset (only the utilities used by this component)
|
||||
// In a real setup, this would be generated by the Tailwind CLI at build time.
|
||||
const TAILWIND_CSS = `
|
||||
.p-5{padding:1.25rem}
|
||||
.mb-4{margin-bottom:1rem}
|
||||
.mb-2{margin-bottom:.5rem}
|
||||
.space-y-3>:not(:first-child){margin-top:.75rem}
|
||||
.rounded-lg{border-radius:.5rem}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
.p-6{padding:1.5rem}
|
||||
.space-y-4>:not(:first-child){margin-top:1rem}
|
||||
.rounded-xl{border-radius:.75rem}
|
||||
.rounded-md{border-radius:.375rem}
|
||||
.border{border-width:1px}
|
||||
.border-gray-200{border-color:#e5e7eb}
|
||||
.bg-white{background-color:#fff}
|
||||
.rounded-full{border-radius:9999px}
|
||||
.border-2{border-width:2px}
|
||||
.border-blue-400{border-color:#60a5fa}
|
||||
.bg-blue-50{background-color:#eff6ff}
|
||||
.bg-blue-600{background-color:#2563eb}
|
||||
.bg-blue-700{background-color:#1d4ed8}
|
||||
.bg-gray-50{background-color:#f9fafb}
|
||||
.bg-blue-100{background-color:#dbeafe}
|
||||
.bg-purple-500{background-color:#a855f7}
|
||||
.bg-orange-500{background-color:#f97316}
|
||||
.bg-green-500{background-color:#22c55e}
|
||||
.px-3{padding-left:.75rem;padding-right:.75rem}
|
||||
.px-4{padding-left:1rem;padding-right:1rem}
|
||||
.py-1{padding-top:.25rem;padding-bottom:.25rem}
|
||||
.py-2{padding-top:.5rem;padding-bottom:.5rem}
|
||||
.text-xs{font-size:.75rem;line-height:1rem}
|
||||
.text-sm{font-size:.875rem;line-height:1.25rem}
|
||||
.text-lg{font-size:1.125rem;line-height:1.75rem}
|
||||
.text-2xl{font-size:1.5rem;line-height:2rem}
|
||||
.font-semibold{font-weight:600}
|
||||
.font-bold{font-weight:700}
|
||||
.text-gray-500{color:#6b7280}
|
||||
.text-gray-900{color:#111827}
|
||||
.font-extrabold{font-weight:800}
|
||||
.text-blue-800{color:#1e40af}
|
||||
.text-blue-600{color:#2563eb}
|
||||
.text-blue-700{color:#1d4ed8}
|
||||
.text-white{color:#fff}
|
||||
.shadow-sm{box-shadow:0 1px 2px 0 rgb(0 0 0/.05)}
|
||||
.cursor-pointer{cursor:pointer}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
.max-w-sm{max-width:24rem}
|
||||
.flex{display:flex}
|
||||
.flex-col{flex-direction:column}
|
||||
.flex-wrap{flex-wrap:wrap}
|
||||
.gap-2{gap:.5rem}
|
||||
.gap-4{gap:1rem}
|
||||
.items-center{align-items:center}
|
||||
.border{border-width:1px}
|
||||
.border-blue-200{border-color:#bfdbfe}
|
||||
`;
|
||||
|
||||
const TailwindComponent = () => {
|
||||
@@ -40,29 +51,45 @@ const TailwindComponent = () => {
|
||||
<style>{TAILWIND_CSS}</style>
|
||||
<div
|
||||
data-testid="tailwind-component"
|
||||
className="p-5 bg-white rounded-lg border border-gray-200 shadow-sm space-y-3"
|
||||
className="p-6 bg-blue-50 rounded-xl border-2 border-blue-400 max-w-sm space-y-4"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-gray-900">
|
||||
Tailwind CSS Component
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
This component uses Tailwind utility classes via className.
|
||||
<h2 className="text-lg font-bold text-blue-800">Tailwind CSS</h2>
|
||||
<p className="text-sm text-blue-600">
|
||||
Utility-first CSS framework with atomic class composition.
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-md p-5">
|
||||
<span
|
||||
data-testid="tailwind-count"
|
||||
className="text-2xl font-semibold text-blue-600"
|
||||
>
|
||||
Count: {count}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-semibold">
|
||||
Badge
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-purple-500 text-white rounded-full text-xs font-semibold">
|
||||
Styled
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-orange-500 text-white rounded-full text-xs font-semibold">
|
||||
Utility
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
data-testid="tailwind-button"
|
||||
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md text-sm cursor-pointer"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
<p
|
||||
data-testid="tailwind-count"
|
||||
className="text-2xl font-extrabold text-blue-700"
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
Count: {count}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid="tailwind-button"
|
||||
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md text-sm cursor-pointer"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 border border-blue-200 text-blue-700 font-semibold rounded-md text-sm cursor-pointer bg-blue-100"
|
||||
onClick={() => setCount(0)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+67
-22
@@ -1,34 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
import { Button, Chip, ChipVariant, H2Title, Tag } from 'twenty-sdk/ui';
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
ChipVariant,
|
||||
H2Title,
|
||||
Status,
|
||||
Tag,
|
||||
THEME_LIGHT,
|
||||
ThemeProvider,
|
||||
} from 'twenty-sdk/ui';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
background: '#fafafa',
|
||||
borderRadius: 12,
|
||||
border: '2px solid #e4e4e7',
|
||||
maxWidth: 360,
|
||||
};
|
||||
|
||||
const ROW_STYLE = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: 8,
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
|
||||
const TwentyUiComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="twenty-ui-component"
|
||||
style={{
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<H2Title title="Twenty UI Component" />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Tag text={`Count: ${count}`} color="blue" />
|
||||
<Chip label="Remote Component" variant={ChipVariant.Highlighted} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
data-testid="twenty-ui-button"
|
||||
title="Increment"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
<ThemeProvider theme={THEME_LIGHT}>
|
||||
<div data-testid="twenty-ui-component" style={CARD_STYLE}>
|
||||
<H2Title
|
||||
title="Twenty UI"
|
||||
description="The CRM's own component library with theme-aware styling."
|
||||
/>
|
||||
<Button title="Reset" variant="secondary" onClick={() => setCount(0)} />
|
||||
<div style={ROW_STYLE}>
|
||||
<Tag color="green" text="Badge" variant="solid" />
|
||||
<Tag color="purple" text="Styled" variant="solid" />
|
||||
<Tag color="blue" text="Themed" variant="outline" />
|
||||
</div>
|
||||
<div style={ROW_STYLE}>
|
||||
<Status color="green" text="Online" />
|
||||
<Status color="red" text="Offline" />
|
||||
<Status color="orange" text="Away" />
|
||||
</div>
|
||||
<div style={ROW_STYLE}>
|
||||
<Chip label="Highlighted" variant={ChipVariant.Highlighted} />
|
||||
<Chip label="Rounded" variant={ChipVariant.Rounded} />
|
||||
</div>
|
||||
<p
|
||||
data-testid="twenty-ui-count"
|
||||
style={{ fontSize: 24, fontWeight: 800, margin: 0 }}
|
||||
>
|
||||
Count: {count}
|
||||
</p>
|
||||
<div style={ROW_STYLE}>
|
||||
<Button
|
||||
title="Increment"
|
||||
accent="blue"
|
||||
onClick={() => setCount((previous) => previous + 1)}
|
||||
/>
|
||||
<Button
|
||||
title="Reset"
|
||||
variant="secondary"
|
||||
onClick={() => setCount(0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+5
-3
@@ -1,9 +1,11 @@
|
||||
// Returns an absolute URL because the worker runs inside a Blob URL
|
||||
// where relative paths cannot be resolved.
|
||||
type StoryComponentVariant = 'react' | 'preact';
|
||||
|
||||
export const getBuiltStoryComponentPathForRender = (
|
||||
componentName: string,
|
||||
variant: StoryComponentVariant = 'react',
|
||||
): string => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const basePath = variant === 'preact' ? '/built-preact' : '/built';
|
||||
|
||||
return `${origin}/built/${componentName}.mjs`;
|
||||
return `${origin}${basePath}/${componentName}.mjs`;
|
||||
};
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type RemoteStyleRendererProps = {
|
||||
cssText?: string;
|
||||
styleKey?: string;
|
||||
};
|
||||
|
||||
export const RemoteStyleRenderer = ({
|
||||
cssText,
|
||||
styleKey,
|
||||
}: RemoteStyleRendererProps) => {
|
||||
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.setAttribute('data-remote-style', styleKey ?? '');
|
||||
document.head.appendChild(styleElement);
|
||||
styleRef.current = styleElement;
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(styleElement);
|
||||
styleRef.current = null;
|
||||
};
|
||||
}, [styleKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(styleRef.current) && isDefined(cssText)) {
|
||||
styleRef.current.textContent = cssText;
|
||||
}
|
||||
}, [cssText]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+76
-443
@@ -1,454 +1,87 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
RemoteFragmentRenderer,
|
||||
createRemoteComponentRenderer,
|
||||
} from '@remote-dom/react/host';
|
||||
import { type SerializedEventData } from '../../../sdk/front-component-api/constants/SerializedEventData';
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
|
||||
const EVENT_NAME_MAP: Record<string, string> = {
|
||||
onclick: 'onClick',
|
||||
ondblclick: 'onDoubleClick',
|
||||
onmousedown: 'onMouseDown',
|
||||
onmouseup: 'onMouseUp',
|
||||
onmouseover: 'onMouseOver',
|
||||
onmouseout: 'onMouseOut',
|
||||
onmouseenter: 'onMouseEnter',
|
||||
onmouseleave: 'onMouseLeave',
|
||||
onkeydown: 'onKeyDown',
|
||||
onkeyup: 'onKeyUp',
|
||||
onkeypress: 'onKeyPress',
|
||||
onfocus: 'onFocus',
|
||||
onblur: 'onBlur',
|
||||
onchange: 'onChange',
|
||||
oninput: 'onInput',
|
||||
onsubmit: 'onSubmit',
|
||||
onscroll: 'onScroll',
|
||||
onwheel: 'onWheel',
|
||||
oncontextmenu: 'onContextMenu',
|
||||
ondrag: 'onDrag',
|
||||
};
|
||||
|
||||
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 filterProps = <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 HtmlDivWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('div', filterProps(props), children);
|
||||
};
|
||||
const HtmlSpanWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('span', filterProps(props), children);
|
||||
};
|
||||
const HtmlSectionWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('section', filterProps(props), children);
|
||||
};
|
||||
const HtmlArticleWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('article', filterProps(props), children);
|
||||
};
|
||||
const HtmlHeaderWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('header', filterProps(props), children);
|
||||
};
|
||||
const HtmlFooterWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('footer', filterProps(props), children);
|
||||
};
|
||||
const HtmlMainWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('main', filterProps(props), children);
|
||||
};
|
||||
const HtmlNavWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('nav', filterProps(props), children);
|
||||
};
|
||||
const HtmlAsideWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('aside', filterProps(props), children);
|
||||
};
|
||||
const HtmlPWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('p', filterProps(props), children);
|
||||
};
|
||||
const HtmlH1Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h1', filterProps(props), children);
|
||||
};
|
||||
const HtmlH2Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h2', filterProps(props), children);
|
||||
};
|
||||
const HtmlH3Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h3', filterProps(props), children);
|
||||
};
|
||||
const HtmlH4Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h4', filterProps(props), children);
|
||||
};
|
||||
const HtmlH5Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h5', filterProps(props), children);
|
||||
};
|
||||
const HtmlH6Wrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('h6', filterProps(props), children);
|
||||
};
|
||||
const HtmlStrongWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('strong', filterProps(props), children);
|
||||
};
|
||||
const HtmlEmWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('em', filterProps(props), children);
|
||||
};
|
||||
const HtmlSmallWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('small', filterProps(props), children);
|
||||
};
|
||||
const HtmlCodeWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('code', filterProps(props), children);
|
||||
};
|
||||
const HtmlPreWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('pre', filterProps(props), children);
|
||||
};
|
||||
const HtmlBlockquoteWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('blockquote', filterProps(props), children);
|
||||
};
|
||||
const HtmlAWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('a', filterProps(props), children);
|
||||
};
|
||||
const HtmlImgWrapper = ({
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('img', filterProps(props));
|
||||
};
|
||||
const HtmlUlWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('ul', filterProps(props), children);
|
||||
};
|
||||
const HtmlOlWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('ol', filterProps(props), children);
|
||||
};
|
||||
const HtmlLiWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('li', filterProps(props), children);
|
||||
};
|
||||
const HtmlFormWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('form', filterProps(props), children);
|
||||
};
|
||||
const HtmlLabelWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('label', filterProps(props), children);
|
||||
};
|
||||
const HtmlInputWrapper = ({
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('input', filterProps(props));
|
||||
};
|
||||
const HtmlTextareaWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('textarea', filterProps(props), children);
|
||||
};
|
||||
const HtmlSelectWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('select', filterProps(props), children);
|
||||
};
|
||||
const HtmlOptionWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('option', filterProps(props), children);
|
||||
};
|
||||
const HtmlButtonWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('button', filterProps(props), children);
|
||||
};
|
||||
const HtmlTableWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('table', filterProps(props), children);
|
||||
};
|
||||
const HtmlTheadWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('thead', filterProps(props), children);
|
||||
};
|
||||
const HtmlTbodyWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('tbody', filterProps(props), children);
|
||||
};
|
||||
const HtmlTfootWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('tfoot', filterProps(props), children);
|
||||
};
|
||||
const HtmlTrWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('tr', filterProps(props), children);
|
||||
};
|
||||
const HtmlThWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('th', filterProps(props), children);
|
||||
};
|
||||
const HtmlTdWrapper = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('td', filterProps(props), children);
|
||||
};
|
||||
const HtmlBrWrapper = ({
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('br', filterProps(props));
|
||||
};
|
||||
const HtmlHrWrapper = ({
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('hr', filterProps(props));
|
||||
};
|
||||
import { createHtmlHostWrapper } from '../utils/createHtmlHostWrapper';
|
||||
import { RemoteStyleRenderer } from '../components/RemoteStyleRenderer';
|
||||
type ComponentRegistryValue =
|
||||
| ReturnType<typeof createRemoteComponentRenderer>
|
||||
| typeof RemoteFragmentRenderer;
|
||||
|
||||
export const componentRegistry: Map<string, ComponentRegistryValue> = new Map([
|
||||
['html-div', createRemoteComponentRenderer(HtmlDivWrapper)],
|
||||
['html-span', createRemoteComponentRenderer(HtmlSpanWrapper)],
|
||||
['html-section', createRemoteComponentRenderer(HtmlSectionWrapper)],
|
||||
['html-article', createRemoteComponentRenderer(HtmlArticleWrapper)],
|
||||
['html-header', createRemoteComponentRenderer(HtmlHeaderWrapper)],
|
||||
['html-footer', createRemoteComponentRenderer(HtmlFooterWrapper)],
|
||||
['html-main', createRemoteComponentRenderer(HtmlMainWrapper)],
|
||||
['html-nav', createRemoteComponentRenderer(HtmlNavWrapper)],
|
||||
['html-aside', createRemoteComponentRenderer(HtmlAsideWrapper)],
|
||||
['html-p', createRemoteComponentRenderer(HtmlPWrapper)],
|
||||
['html-h1', createRemoteComponentRenderer(HtmlH1Wrapper)],
|
||||
['html-h2', createRemoteComponentRenderer(HtmlH2Wrapper)],
|
||||
['html-h3', createRemoteComponentRenderer(HtmlH3Wrapper)],
|
||||
['html-h4', createRemoteComponentRenderer(HtmlH4Wrapper)],
|
||||
['html-h5', createRemoteComponentRenderer(HtmlH5Wrapper)],
|
||||
['html-h6', createRemoteComponentRenderer(HtmlH6Wrapper)],
|
||||
['html-strong', createRemoteComponentRenderer(HtmlStrongWrapper)],
|
||||
['html-em', createRemoteComponentRenderer(HtmlEmWrapper)],
|
||||
['html-small', createRemoteComponentRenderer(HtmlSmallWrapper)],
|
||||
['html-code', createRemoteComponentRenderer(HtmlCodeWrapper)],
|
||||
['html-pre', createRemoteComponentRenderer(HtmlPreWrapper)],
|
||||
['html-blockquote', createRemoteComponentRenderer(HtmlBlockquoteWrapper)],
|
||||
['html-a', createRemoteComponentRenderer(HtmlAWrapper)],
|
||||
['html-img', createRemoteComponentRenderer(HtmlImgWrapper)],
|
||||
['html-ul', createRemoteComponentRenderer(HtmlUlWrapper)],
|
||||
['html-ol', createRemoteComponentRenderer(HtmlOlWrapper)],
|
||||
['html-li', createRemoteComponentRenderer(HtmlLiWrapper)],
|
||||
['html-form', createRemoteComponentRenderer(HtmlFormWrapper)],
|
||||
['html-label', createRemoteComponentRenderer(HtmlLabelWrapper)],
|
||||
['html-input', createRemoteComponentRenderer(HtmlInputWrapper)],
|
||||
['html-textarea', createRemoteComponentRenderer(HtmlTextareaWrapper)],
|
||||
['html-select', createRemoteComponentRenderer(HtmlSelectWrapper)],
|
||||
['html-option', createRemoteComponentRenderer(HtmlOptionWrapper)],
|
||||
['html-button', createRemoteComponentRenderer(HtmlButtonWrapper)],
|
||||
['html-table', createRemoteComponentRenderer(HtmlTableWrapper)],
|
||||
['html-thead', createRemoteComponentRenderer(HtmlTheadWrapper)],
|
||||
['html-tbody', createRemoteComponentRenderer(HtmlTbodyWrapper)],
|
||||
['html-tfoot', createRemoteComponentRenderer(HtmlTfootWrapper)],
|
||||
['html-tr', createRemoteComponentRenderer(HtmlTrWrapper)],
|
||||
['html-th', createRemoteComponentRenderer(HtmlThWrapper)],
|
||||
['html-td', createRemoteComponentRenderer(HtmlTdWrapper)],
|
||||
['html-br', createRemoteComponentRenderer(HtmlBrWrapper)],
|
||||
['html-hr', createRemoteComponentRenderer(HtmlHrWrapper)],
|
||||
['html-div', createRemoteComponentRenderer(createHtmlHostWrapper('div'))],
|
||||
['html-span', createRemoteComponentRenderer(createHtmlHostWrapper('span'))],
|
||||
[
|
||||
'html-section',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('section')),
|
||||
],
|
||||
[
|
||||
'html-article',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('article')),
|
||||
],
|
||||
[
|
||||
'html-header',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('header')),
|
||||
],
|
||||
[
|
||||
'html-footer',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('footer')),
|
||||
],
|
||||
['html-main', createRemoteComponentRenderer(createHtmlHostWrapper('main'))],
|
||||
['html-nav', createRemoteComponentRenderer(createHtmlHostWrapper('nav'))],
|
||||
['html-aside', createRemoteComponentRenderer(createHtmlHostWrapper('aside'))],
|
||||
['html-p', createRemoteComponentRenderer(createHtmlHostWrapper('p'))],
|
||||
['html-h1', createRemoteComponentRenderer(createHtmlHostWrapper('h1'))],
|
||||
['html-h2', createRemoteComponentRenderer(createHtmlHostWrapper('h2'))],
|
||||
['html-h3', createRemoteComponentRenderer(createHtmlHostWrapper('h3'))],
|
||||
['html-h4', createRemoteComponentRenderer(createHtmlHostWrapper('h4'))],
|
||||
['html-h5', createRemoteComponentRenderer(createHtmlHostWrapper('h5'))],
|
||||
['html-h6', createRemoteComponentRenderer(createHtmlHostWrapper('h6'))],
|
||||
[
|
||||
'html-strong',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('strong')),
|
||||
],
|
||||
['html-em', createRemoteComponentRenderer(createHtmlHostWrapper('em'))],
|
||||
['html-small', createRemoteComponentRenderer(createHtmlHostWrapper('small'))],
|
||||
['html-code', createRemoteComponentRenderer(createHtmlHostWrapper('code'))],
|
||||
['html-pre', createRemoteComponentRenderer(createHtmlHostWrapper('pre'))],
|
||||
[
|
||||
'html-blockquote',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('blockquote')),
|
||||
],
|
||||
['html-a', createRemoteComponentRenderer(createHtmlHostWrapper('a'))],
|
||||
['html-img', createRemoteComponentRenderer(createHtmlHostWrapper('img'))],
|
||||
['html-ul', createRemoteComponentRenderer(createHtmlHostWrapper('ul'))],
|
||||
['html-ol', createRemoteComponentRenderer(createHtmlHostWrapper('ol'))],
|
||||
['html-li', createRemoteComponentRenderer(createHtmlHostWrapper('li'))],
|
||||
['html-form', createRemoteComponentRenderer(createHtmlHostWrapper('form'))],
|
||||
['html-label', createRemoteComponentRenderer(createHtmlHostWrapper('label'))],
|
||||
['html-input', createRemoteComponentRenderer(createHtmlHostWrapper('input'))],
|
||||
[
|
||||
'html-textarea',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('textarea')),
|
||||
],
|
||||
[
|
||||
'html-select',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('select')),
|
||||
],
|
||||
[
|
||||
'html-option',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('option')),
|
||||
],
|
||||
[
|
||||
'html-button',
|
||||
createRemoteComponentRenderer(createHtmlHostWrapper('button')),
|
||||
],
|
||||
['html-table', createRemoteComponentRenderer(createHtmlHostWrapper('table'))],
|
||||
['html-thead', createRemoteComponentRenderer(createHtmlHostWrapper('thead'))],
|
||||
['html-tbody', createRemoteComponentRenderer(createHtmlHostWrapper('tbody'))],
|
||||
['html-tfoot', createRemoteComponentRenderer(createHtmlHostWrapper('tfoot'))],
|
||||
['html-tr', createRemoteComponentRenderer(createHtmlHostWrapper('tr'))],
|
||||
['html-th', createRemoteComponentRenderer(createHtmlHostWrapper('th'))],
|
||||
['html-td', createRemoteComponentRenderer(createHtmlHostWrapper('td'))],
|
||||
['html-br', createRemoteComponentRenderer(createHtmlHostWrapper('br'))],
|
||||
['html-hr', createRemoteComponentRenderer(createHtmlHostWrapper('hr'))],
|
||||
['remote-style', createRemoteComponentRenderer(RemoteStyleRenderer)],
|
||||
['remote-fragment', RemoteFragmentRenderer],
|
||||
]);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { componentRegistry } from './host-component-registry';
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/sdk/front-component-api/constants/EventToReact';
|
||||
import { type SerializedEventData } from '@/sdk/front-component-api/constants/SerializedEventData';
|
||||
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
|
||||
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
|
||||
`on${domEvent}`,
|
||||
reactProp,
|
||||
]),
|
||||
);
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'input',
|
||||
'br',
|
||||
'hr',
|
||||
'img',
|
||||
'area',
|
||||
'base',
|
||||
'col',
|
||||
'embed',
|
||||
'link',
|
||||
'meta',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
const parseCssString = (
|
||||
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 filterProps = <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 = parseCssString(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;
|
||||
};
|
||||
|
||||
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
|
||||
|
||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||
|
||||
return ({ children, ...props }: WrapperProps) =>
|
||||
React.createElement(
|
||||
htmlTag,
|
||||
filterProps(props),
|
||||
isVoid ? undefined : children,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
type OnChangeCallback = (cssText: string) => void;
|
||||
|
||||
class MockCSSRule {
|
||||
cssText: string;
|
||||
|
||||
constructor(cssText: string) {
|
||||
this.cssText = cssText;
|
||||
}
|
||||
}
|
||||
|
||||
class MockCSSRuleList extends Array<MockCSSRule> {
|
||||
item(index: number): MockCSSRule | null {
|
||||
return this[index] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class MockCSSStyleSheet {
|
||||
cssRules = new MockCSSRuleList();
|
||||
private onChange: OnChangeCallback;
|
||||
|
||||
constructor(onChange: OnChangeCallback) {
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
insertRule(rule: string, index?: number): number {
|
||||
const insertAt = index ?? this.cssRules.length;
|
||||
this.cssRules.splice(insertAt, 0, new MockCSSRule(rule));
|
||||
this.notify();
|
||||
return insertAt;
|
||||
}
|
||||
|
||||
deleteRule(index: number): void {
|
||||
this.cssRules.splice(index, 1);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
const cssText = this.cssRules.map((rule) => rule.cssText).join('\n');
|
||||
this.onChange(cssText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { type RemoteRootElement } from '@remote-dom/core/elements';
|
||||
|
||||
import { type RemoteStyleProperties } from '@/front-component-renderer/remote/generated/remote-elements';
|
||||
import { MockCSSStyleSheet } from './MockCSSStyleSheet';
|
||||
|
||||
export const installStyleBridge = (remoteRoot: RemoteRootElement): void => {
|
||||
const styleElementMap = new WeakMap<
|
||||
Element,
|
||||
Element & RemoteStyleProperties
|
||||
>();
|
||||
const styleObserverMap = new WeakMap<Element, { disconnect: () => void }>();
|
||||
|
||||
const trackStyleElement = (styleElement: Element): void => {
|
||||
if (styleElementMap.has(styleElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteStyleElement = document.createElement(
|
||||
'remote-style',
|
||||
) as Element & RemoteStyleProperties;
|
||||
|
||||
styleElementMap.set(styleElement, remoteStyleElement);
|
||||
|
||||
const attributeNames = styleElement.getAttributeNames?.() ?? [];
|
||||
const dataAttributes = attributeNames
|
||||
.filter((attributeName: string) => attributeName.startsWith('data-'))
|
||||
.map(
|
||||
(attributeName: string) =>
|
||||
`${attributeName}=${styleElement.getAttribute(attributeName) ?? ''}`,
|
||||
)
|
||||
.join(';');
|
||||
|
||||
if (dataAttributes.length > 0) {
|
||||
remoteStyleElement.styleKey = dataAttributes;
|
||||
}
|
||||
|
||||
const syncCssFromStyleElement = () => {
|
||||
remoteStyleElement.cssText = styleElement.textContent ?? '';
|
||||
};
|
||||
|
||||
const mockSheet = new MockCSSStyleSheet((cssText: string) => {
|
||||
remoteStyleElement.cssText = cssText;
|
||||
});
|
||||
|
||||
try {
|
||||
Object.defineProperty(styleElement, 'sheet', {
|
||||
get: () => mockSheet,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
|
||||
const prototypeChain = Object.getPrototypeOf(styleElement);
|
||||
const textContentDescriptor =
|
||||
Object.getOwnPropertyDescriptor(styleElement, 'textContent') ??
|
||||
Object.getOwnPropertyDescriptor(prototypeChain, 'textContent') ??
|
||||
Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(prototypeChain),
|
||||
'textContent',
|
||||
);
|
||||
|
||||
if (textContentDescriptor?.set) {
|
||||
const originalTextContentSet = textContentDescriptor.set;
|
||||
try {
|
||||
Object.defineProperty(styleElement, 'textContent', {
|
||||
get: textContentDescriptor.get,
|
||||
set(value: string) {
|
||||
originalTextContentSet.call(this, value);
|
||||
remoteStyleElement.cssText = value ?? '';
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
|
||||
const originalAppendChild = styleElement.appendChild.bind(styleElement);
|
||||
try {
|
||||
(
|
||||
styleElement as Element & { appendChild: typeof originalAppendChild }
|
||||
).appendChild = (child: Node) => {
|
||||
const result = originalAppendChild(child);
|
||||
syncCssFromStyleElement();
|
||||
return result;
|
||||
};
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
|
||||
const originalInsertBeforeOnStyle =
|
||||
styleElement.insertBefore.bind(styleElement);
|
||||
try {
|
||||
(
|
||||
styleElement as Element & {
|
||||
insertBefore: typeof originalInsertBeforeOnStyle;
|
||||
}
|
||||
).insertBefore = <T extends Node>(child: T, ref: Node | null): T => {
|
||||
const result = originalInsertBeforeOnStyle(child, ref);
|
||||
syncCssFromStyleElement();
|
||||
return result;
|
||||
};
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
|
||||
const originalRemoveChildOnStyle =
|
||||
styleElement.removeChild.bind(styleElement);
|
||||
try {
|
||||
(
|
||||
styleElement as Element & {
|
||||
removeChild: typeof originalRemoveChildOnStyle;
|
||||
}
|
||||
).removeChild = <T extends Node>(child: T): T => {
|
||||
const result = originalRemoveChildOnStyle(child);
|
||||
syncCssFromStyleElement();
|
||||
return result;
|
||||
};
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
try {
|
||||
const styleObserver = new MutationObserver(() => {
|
||||
syncCssFromStyleElement();
|
||||
});
|
||||
|
||||
if (typeof styleObserver.observe === 'function') {
|
||||
styleObserver.observe(styleElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
styleObserverMap.set(styleElement, styleObserver);
|
||||
}
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
|
||||
const existingContent = styleElement.textContent;
|
||||
if (existingContent) {
|
||||
remoteStyleElement.cssText = existingContent;
|
||||
}
|
||||
|
||||
remoteRoot.appendChild(remoteStyleElement);
|
||||
};
|
||||
|
||||
const untrackStyleElement = (styleElement: Element): void => {
|
||||
const styleObserver = styleObserverMap.get(styleElement);
|
||||
if (styleObserver) {
|
||||
styleObserver.disconnect();
|
||||
styleObserverMap.delete(styleElement);
|
||||
}
|
||||
|
||||
const remoteStyleElement = styleElementMap.get(styleElement);
|
||||
|
||||
if (remoteStyleElement && remoteStyleElement.parentNode) {
|
||||
remoteStyleElement.parentNode.removeChild(remoteStyleElement);
|
||||
styleElementMap.delete(styleElement);
|
||||
}
|
||||
};
|
||||
|
||||
const headElement = document.head;
|
||||
const originalAppendChild = headElement.appendChild.bind(headElement);
|
||||
const originalInsertBefore = headElement.insertBefore.bind(headElement);
|
||||
const originalAppend = headElement.append.bind(headElement);
|
||||
const originalPrepend = headElement.prepend.bind(headElement);
|
||||
const originalReplaceChild = headElement.replaceChild.bind(headElement);
|
||||
const originalRemoveChild = headElement.removeChild.bind(headElement);
|
||||
|
||||
headElement.appendChild = <T extends Node>(child: T): T => {
|
||||
const result = originalAppendChild(child);
|
||||
if (isStyleElement(child)) {
|
||||
trackStyleElement(child as unknown as Element);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
headElement.insertBefore = <T extends Node>(
|
||||
child: T,
|
||||
ref: Node | null,
|
||||
): T => {
|
||||
const result = originalInsertBefore(child, ref);
|
||||
if (isStyleElement(child)) {
|
||||
trackStyleElement(child as unknown as Element);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
headElement.append = (...nodes: (Node | string)[]) => {
|
||||
originalAppend(...nodes);
|
||||
for (const node of nodes) {
|
||||
if (isStyleElement(node)) {
|
||||
trackStyleElement(node as Element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
headElement.prepend = (...nodes: (Node | string)[]) => {
|
||||
originalPrepend(...nodes);
|
||||
for (const node of nodes) {
|
||||
if (isStyleElement(node)) {
|
||||
trackStyleElement(node as Element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
headElement.replaceChild = <T extends Node>(
|
||||
newChild: Node,
|
||||
oldChild: T,
|
||||
): T => {
|
||||
const result = originalReplaceChild(newChild, oldChild);
|
||||
if (isStyleElement(oldChild)) {
|
||||
untrackStyleElement(oldChild as unknown as Element);
|
||||
}
|
||||
if (isStyleElement(newChild)) {
|
||||
trackStyleElement(newChild as unknown as Element);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
headElement.removeChild = <T extends Node>(child: T): T => {
|
||||
const result = originalRemoveChild(child);
|
||||
if (isStyleElement(child)) {
|
||||
untrackStyleElement(child as unknown as Element);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const existingStyleElements = Array.from(
|
||||
headElement.querySelectorAll('style'),
|
||||
);
|
||||
for (const styleElement of existingStyleElements) {
|
||||
trackStyleElement(styleElement);
|
||||
}
|
||||
|
||||
Object.defineProperty(document, 'styleSheets', {
|
||||
get: () => [],
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const isStyleElement = (node: Node | string): node is Element => {
|
||||
return (
|
||||
typeof node !== 'string' &&
|
||||
'localName' in node &&
|
||||
node.localName === 'style'
|
||||
);
|
||||
};
|
||||
-1
@@ -49,7 +49,6 @@ export const FrontComponentWorkerEffect = ({
|
||||
setError(workerError);
|
||||
};
|
||||
|
||||
// Expose host functions to the worker via stable refs to avoid recreating threads
|
||||
const stableFrontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
|
||||
{
|
||||
navigate: (...args) =>
|
||||
|
||||
+5
-9
@@ -1,12 +1,3 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
import { createRemoteComponent } from '@remote-dom/react';
|
||||
import {
|
||||
HtmlDivElement,
|
||||
@@ -52,6 +43,7 @@ import {
|
||||
HtmlTdElement,
|
||||
HtmlBrElement,
|
||||
HtmlHrElement,
|
||||
RemoteStyleElement,
|
||||
} from './remote-elements';
|
||||
|
||||
export const HtmlDiv = createRemoteComponent('html-div', HtmlDivElement, {
|
||||
@@ -1126,3 +1118,7 @@ export const HtmlHr = createRemoteComponent('html-hr', HtmlHrElement, {
|
||||
onDrag: { event: 'drag' },
|
||||
},
|
||||
});
|
||||
export const RemoteStyle = createRemoteComponent(
|
||||
'remote-style',
|
||||
RemoteStyleElement,
|
||||
);
|
||||
|
||||
+19
-9
@@ -1,12 +1,3 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
import {
|
||||
createRemoteElement,
|
||||
RemoteRootElement,
|
||||
@@ -609,6 +600,23 @@ export const HtmlHrElement = createRemoteElement<
|
||||
properties: HTML_COMMON_PROPERTIES_CONFIG,
|
||||
events: [...HTML_COMMON_EVENTS_ARRAY],
|
||||
});
|
||||
|
||||
export type RemoteStyleProperties = {
|
||||
cssText?: string;
|
||||
styleKey?: string;
|
||||
};
|
||||
|
||||
export const RemoteStyleElement = createRemoteElement<
|
||||
RemoteStyleProperties,
|
||||
Record<string, never>,
|
||||
Record<string, never>,
|
||||
Record<string, never>
|
||||
>({
|
||||
properties: {
|
||||
cssText: { type: String },
|
||||
styleKey: { type: String },
|
||||
},
|
||||
});
|
||||
customElements.define('html-div', HtmlDivElement);
|
||||
customElements.define('html-span', HtmlSpanElement);
|
||||
customElements.define('html-section', HtmlSectionElement);
|
||||
@@ -652,6 +660,7 @@ customElements.define('html-th', HtmlThElement);
|
||||
customElements.define('html-td', HtmlTdElement);
|
||||
customElements.define('html-br', HtmlBrElement);
|
||||
customElements.define('html-hr', HtmlHrElement);
|
||||
customElements.define('remote-style', RemoteStyleElement);
|
||||
customElements.define('remote-root', RemoteRootElement);
|
||||
customElements.define('remote-fragment', RemoteFragmentElement);
|
||||
export { RemoteRootElement, RemoteFragmentElement };
|
||||
@@ -700,6 +709,7 @@ declare global {
|
||||
'html-td': InstanceType<typeof HtmlTdElement>;
|
||||
'html-br': InstanceType<typeof HtmlBrElement>;
|
||||
'html-hr': InstanceType<typeof HtmlHrElement>;
|
||||
'remote-style': InstanceType<typeof RemoteStyleElement>;
|
||||
'remote-root': InstanceType<typeof RemoteRootElement>;
|
||||
'remote-fragment': InstanceType<typeof RemoteFragmentElement>;
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export * from './remote-elements';
|
||||
export * from './remote-components';
|
||||
@@ -1,51 +0,0 @@
|
||||
/* eslint-disable */
|
||||
//@ts-nocheck
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
HtmlButton,
|
||||
HtmlDiv,
|
||||
HtmlH3,
|
||||
HtmlP,
|
||||
} from '../generated/remote-components';
|
||||
|
||||
const FrontComponent = () => {
|
||||
const [clickCount, setClickCount] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(
|
||||
new Date().toLocaleTimeString(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date().toLocaleTimeString());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return React.createElement(
|
||||
HtmlDiv,
|
||||
null,
|
||||
React.createElement(HtmlH3, null, 'Remote DOM front component'),
|
||||
React.createElement(
|
||||
HtmlP,
|
||||
null,
|
||||
'Rendered in a web worker and mirrored on the host.',
|
||||
),
|
||||
React.createElement(
|
||||
HtmlButton,
|
||||
{ onClick: () => setClickCount(clickCount + 1) },
|
||||
'Click me',
|
||||
),
|
||||
React.createElement(
|
||||
HtmlP,
|
||||
null,
|
||||
'Clicked ',
|
||||
clickCount,
|
||||
' time',
|
||||
clickCount === 1 ? '' : 's',
|
||||
),
|
||||
React.createElement(HtmlP, null, 'Current time: ', currentTime),
|
||||
);
|
||||
};
|
||||
|
||||
export default React.createElement(FrontComponent);
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { ALLOWED_HTML_ELEMENTS } from '@/sdk/front-component-api/constants/AllowedHtmlElements';
|
||||
|
||||
const camelToKebab = (property: string): string =>
|
||||
property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
||||
|
||||
const UNITLESS_CSS_PROPERTIES = new Set([
|
||||
'animationIterationCount',
|
||||
'borderImageOutset',
|
||||
'borderImageSlice',
|
||||
'borderImageWidth',
|
||||
'boxFlex',
|
||||
'boxFlexGroup',
|
||||
'boxOrdinalGroup',
|
||||
'columnCount',
|
||||
'columns',
|
||||
'flex',
|
||||
'flexGrow',
|
||||
'flexPositive',
|
||||
'flexShrink',
|
||||
'flexNegative',
|
||||
'flexOrder',
|
||||
'gridArea',
|
||||
'gridRow',
|
||||
'gridRowEnd',
|
||||
'gridRowSpan',
|
||||
'gridRowStart',
|
||||
'gridColumn',
|
||||
'gridColumnEnd',
|
||||
'gridColumnSpan',
|
||||
'gridColumnStart',
|
||||
'fontWeight',
|
||||
'lineClamp',
|
||||
'lineHeight',
|
||||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'tabSize',
|
||||
'widows',
|
||||
'zIndex',
|
||||
'zoom',
|
||||
'fillOpacity',
|
||||
'floodOpacity',
|
||||
'stopOpacity',
|
||||
'strokeDasharray',
|
||||
'strokeDashoffset',
|
||||
'strokeMiterlimit',
|
||||
'strokeOpacity',
|
||||
'strokeWidth',
|
||||
]);
|
||||
|
||||
type FlushFn = (cssText: string) => void;
|
||||
|
||||
const createStyleProxy = (flush: FlushFn): Record<string, unknown> => {
|
||||
const styleStore: Record<string, string> = {};
|
||||
|
||||
const flushToRemoteProperty = (): void => {
|
||||
const cssText = Object.entries(styleStore)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';');
|
||||
|
||||
flush(cssText);
|
||||
};
|
||||
|
||||
return new Proxy(styleStore, {
|
||||
get: (target, property) => {
|
||||
if (property === 'cssText') {
|
||||
return Object.entries(target)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
if (property === 'setProperty') {
|
||||
return (name: string, value: string | null) => {
|
||||
if (value === null || value === '') {
|
||||
delete target[name];
|
||||
} else {
|
||||
target[name] = String(value);
|
||||
}
|
||||
|
||||
flushToRemoteProperty();
|
||||
};
|
||||
}
|
||||
|
||||
if (property === 'removeProperty') {
|
||||
return (name: string): string => {
|
||||
const oldValue = target[name] ?? '';
|
||||
|
||||
delete target[name];
|
||||
flushToRemoteProperty();
|
||||
|
||||
return oldValue;
|
||||
};
|
||||
}
|
||||
|
||||
if (property === 'getPropertyValue') {
|
||||
return (name: string): string => target[name] ?? '';
|
||||
}
|
||||
|
||||
if (typeof property === 'string') {
|
||||
const kebabKey = camelToKebab(property);
|
||||
|
||||
return target[kebabKey] ?? '';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
set: (target, property, value) => {
|
||||
if (property === 'cssText') {
|
||||
for (const key of Object.keys(target)) {
|
||||
delete target[key];
|
||||
}
|
||||
|
||||
String(value)
|
||||
.split(';')
|
||||
.forEach((pair) => {
|
||||
const colonIndex = pair.indexOf(':');
|
||||
|
||||
if (colonIndex > 0) {
|
||||
const key = pair.slice(0, colonIndex).trim();
|
||||
const val = pair.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key && val) {
|
||||
target[key] = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
flushToRemoteProperty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof property === 'string') {
|
||||
const kebabKey = camelToKebab(property);
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete target[kebabKey];
|
||||
} else {
|
||||
let stringValue = String(value);
|
||||
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
value !== 0 &&
|
||||
!UNITLESS_CSS_PROPERTIES.has(property)
|
||||
) {
|
||||
stringValue = `${value}px`;
|
||||
}
|
||||
|
||||
target[kebabKey] = stringValue;
|
||||
}
|
||||
|
||||
flushToRemoteProperty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type RemoteElementLike = Element & {
|
||||
updateRemoteProperty: (name: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const installStylePropertyOnRemoteElements = (): void => {
|
||||
for (const elementConfig of ALLOWED_HTML_ELEMENTS) {
|
||||
const elementConstructor = customElements.get(elementConfig.tag);
|
||||
|
||||
if (!elementConstructor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const styleProxies = new WeakMap<Element, Record<string, unknown>>();
|
||||
|
||||
Object.defineProperty(elementConstructor.prototype, 'style', {
|
||||
get(this: RemoteElementLike) {
|
||||
let proxy = styleProxies.get(this);
|
||||
|
||||
if (!proxy) {
|
||||
const element = this;
|
||||
|
||||
const flush: FlushFn = (cssText: string) => {
|
||||
element.updateRemoteProperty('style', cssText || undefined);
|
||||
};
|
||||
|
||||
proxy = createStyleProxy(flush);
|
||||
styleProxies.set(this, proxy);
|
||||
}
|
||||
|
||||
return proxy;
|
||||
},
|
||||
set(this: RemoteElementLike, value: unknown) {
|
||||
let proxy = styleProxies.get(this);
|
||||
|
||||
if (!proxy) {
|
||||
const element = this;
|
||||
const flush: FlushFn = (cssText: string) => {
|
||||
element.updateRemoteProperty('style', cssText || undefined);
|
||||
};
|
||||
|
||||
proxy = createStyleProxy(flush);
|
||||
styleProxies.set(this, proxy);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
(proxy as Record<string, unknown>).cssText = value;
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { ALLOWED_HTML_ELEMENTS } from '@/sdk/front-component-api/constants/AllowedHtmlElements';
|
||||
|
||||
const ATTRIBUTE_TO_PROPERTY_MAP: Record<string, string> = {
|
||||
className: 'className',
|
||||
class: 'className',
|
||||
|
||||
htmlFor: 'htmlFor',
|
||||
for: 'htmlFor',
|
||||
|
||||
tabIndex: 'tabIndex',
|
||||
tabindex: 'tabIndex',
|
||||
};
|
||||
|
||||
export const patchRemoteElementSetAttribute = (): void => {
|
||||
for (const elementConfig of ALLOWED_HTML_ELEMENTS) {
|
||||
const elementConstructor = customElements.get(elementConfig.tag);
|
||||
|
||||
if (!elementConstructor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalSetAttribute = elementConstructor.prototype.setAttribute as (
|
||||
name: string,
|
||||
value: string,
|
||||
) => void;
|
||||
|
||||
elementConstructor.prototype.setAttribute = function (
|
||||
this: Element & Record<string, unknown>,
|
||||
name: string,
|
||||
value: string,
|
||||
) {
|
||||
const propertyName = ATTRIBUTE_TO_PROPERTY_MAP[name];
|
||||
|
||||
if (propertyName) {
|
||||
this[propertyName] = value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
originalSetAttribute.call(this, name, value);
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
type RemoteConnection,
|
||||
type RemoteRootElement,
|
||||
} from '@remote-dom/core/elements';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { jsx, jsxs } from 'react/jsx-runtime';
|
||||
import * as TwentySharedTypes from 'twenty-shared/types';
|
||||
import * as TwentySharedUtils from 'twenty-shared/utils';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import * as TwentySdk from '@/sdk';
|
||||
import { installStyleBridge } from '@/front-component-renderer/polyfills/installStyleBridge';
|
||||
import { installStylePropertyOnRemoteElements } from '@/front-component-renderer/remote/utils/installStylePropertyOnRemoteElements';
|
||||
import { patchRemoteElementSetAttribute } from '@/front-component-renderer/remote/utils/patchRemoteElementSetAttribute';
|
||||
import { HTML_TAG_TO_CUSTOM_ELEMENT_TAG } from '@/sdk/front-component-api/constants/HtmlTagToRemoteComponent';
|
||||
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
|
||||
import { setNavigate } from '@/sdk/front-component-api/functions/navigate';
|
||||
|
||||
@@ -24,20 +23,14 @@ import { type FrontComponentExecutionContext } from '../../types/FrontComponentE
|
||||
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
|
||||
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
|
||||
import { type WorkerExports } from '../../types/WorkerExports';
|
||||
import * as RemoteComponents from '../generated/remote-components';
|
||||
import { exposeGlobals } from '../utils/exposeGlobals';
|
||||
import { setWorkerEnv } from './utils/setWorkerEnv';
|
||||
|
||||
installStylePropertyOnRemoteElements();
|
||||
patchRemoteElementSetAttribute();
|
||||
|
||||
exposeGlobals({
|
||||
React,
|
||||
RemoteComponents,
|
||||
jsx,
|
||||
jsxs,
|
||||
TwentySdk,
|
||||
TwentyShared: {
|
||||
utils: TwentySharedUtils,
|
||||
types: TwentySharedTypes,
|
||||
},
|
||||
__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__: HTML_TAG_TO_CUSTOM_ELEMENT_TAG,
|
||||
});
|
||||
|
||||
const render: WorkerExports['render'] = async (
|
||||
@@ -46,8 +39,11 @@ const render: WorkerExports['render'] = async (
|
||||
) => {
|
||||
const batchedConnection = new BatchingRemoteConnection(connection);
|
||||
const root = document.createElement('remote-root') as RemoteRootElement;
|
||||
const renderContainer = document.createElement('remote-fragment');
|
||||
root.connect(batchedConnection);
|
||||
root.append(renderContainer);
|
||||
document.body.append(root);
|
||||
installStyleBridge(root);
|
||||
|
||||
if (
|
||||
isDefined(renderContext.applicationAccessToken) &&
|
||||
@@ -83,14 +79,7 @@ const render: WorkerExports['render'] = async (
|
||||
/* @vite-ignore */
|
||||
const componentModule = await import(importUrl);
|
||||
|
||||
const reactRoot = createRoot(root);
|
||||
reactRoot.render(componentModule.default);
|
||||
} catch (importError) {
|
||||
console.error(
|
||||
'[FrontComponentWorker] Failed to load or render component:',
|
||||
importError,
|
||||
);
|
||||
throw importError;
|
||||
componentModule.default(renderContainer);
|
||||
} finally {
|
||||
URL.revokeObjectURL(importUrl);
|
||||
}
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
// Serializable execution context that can be passed via postMessage (no functions)
|
||||
export type FrontComponentExecutionContext = {
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
+22
-3
@@ -1,9 +1,28 @@
|
||||
import { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements';
|
||||
|
||||
export const HTML_TAG_TO_REMOTE_COMPONENT: Record<string, string> =
|
||||
Object.fromEntries(
|
||||
const UTILITY_TAG_MAPPINGS: Record<string, string> = {
|
||||
'remote-style': 'RemoteStyle',
|
||||
};
|
||||
|
||||
export const HTML_TAG_TO_REMOTE_COMPONENT: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
ALLOWED_HTML_ELEMENTS.map((element) => [
|
||||
element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag,
|
||||
element.name,
|
||||
]),
|
||||
);
|
||||
),
|
||||
...UTILITY_TAG_MAPPINGS,
|
||||
};
|
||||
|
||||
// Maps standard HTML tag names to their custom element equivalents
|
||||
// used by the remote DOM polyfill (e.g. "div" → "html-div").
|
||||
// Consumed by the jsx-runtime wrapper so React creates the correct
|
||||
// custom elements instead of standard HTML tags.
|
||||
export const HTML_TAG_TO_CUSTOM_ELEMENT_TAG: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
ALLOWED_HTML_ELEMENTS.map((element) => [
|
||||
element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag,
|
||||
element.tag,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -2,16 +2,28 @@ import { type FrontComponentExecutionContext } from '../types/FrontComponentExec
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let executionContext: FrontComponentExecutionContext | undefined;
|
||||
// State is stored on globalThis so the worker's SDK instance and each
|
||||
// front component's bundled SDK copy share the same backing store.
|
||||
const CONTEXT_KEY = '__twentySdkExecutionContext__';
|
||||
const LISTENERS_KEY = '__twentySdkContextListeners__';
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const getListeners = (): Set<Listener> => {
|
||||
if (!(globalThis as Record<string, unknown>)[LISTENERS_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[LISTENERS_KEY] =
|
||||
new Set<Listener>();
|
||||
}
|
||||
|
||||
return (globalThis as Record<string, unknown>)[
|
||||
LISTENERS_KEY
|
||||
] as Set<Listener>;
|
||||
};
|
||||
|
||||
export const setFrontComponentExecutionContext = (
|
||||
context: FrontComponentExecutionContext,
|
||||
): void => {
|
||||
executionContext = context;
|
||||
(globalThis as Record<string, unknown>)[CONTEXT_KEY] = context;
|
||||
|
||||
for (const listener of listeners) {
|
||||
for (const listener of getListeners()) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
@@ -19,17 +31,19 @@ export const setFrontComponentExecutionContext = (
|
||||
export const getFrontComponentExecutionContext = ():
|
||||
| FrontComponentExecutionContext
|
||||
| undefined => {
|
||||
return executionContext;
|
||||
return (globalThis as Record<string, unknown>)[CONTEXT_KEY] as
|
||||
| FrontComponentExecutionContext
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const subscribeToFrontComponentExecutionContext = (
|
||||
listener: Listener,
|
||||
): void => {
|
||||
listeners.add(listener);
|
||||
getListeners().add(listener);
|
||||
};
|
||||
|
||||
export const unsubscribeFromFrontComponentExecutionContext = (
|
||||
listener: Listener,
|
||||
): void => {
|
||||
listeners.delete(listener);
|
||||
getListeners().delete(listener);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,12 @@ type NavigateFunction = (
|
||||
options?: NavigateOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
let navigateFunction: NavigateFunction | undefined;
|
||||
// State is stored on globalThis so the worker's SDK instance and each
|
||||
// front component's bundled SDK copy share the same backing store.
|
||||
const NAVIGATE_KEY = '__twentySdkNavigateFunction__';
|
||||
|
||||
export const setNavigate = (fn: NavigateFunction): void => {
|
||||
navigateFunction = fn;
|
||||
(globalThis as Record<string, unknown>)[NAVIGATE_KEY] = fn;
|
||||
};
|
||||
|
||||
export const navigate: NavigateFunction = (
|
||||
@@ -20,8 +22,13 @@ export const navigate: NavigateFunction = (
|
||||
queryParams?: Record<string, unknown>,
|
||||
options?: NavigateOptions,
|
||||
): Promise<void> => {
|
||||
const navigateFunction = (globalThis as Record<string, unknown>)[
|
||||
NAVIGATE_KEY
|
||||
] as NavigateFunction | undefined;
|
||||
|
||||
if (!isDefined(navigateFunction)) {
|
||||
throw new Error('navigateFunction is not set');
|
||||
}
|
||||
|
||||
return navigateFunction(to, params, queryParams, options);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export type { ApplicationConfig } from './application/application-config';
|
||||
export { defineApplication } from './application/define-application';
|
||||
export type {
|
||||
ValidationResult,
|
||||
DefinableEntity,
|
||||
DefineEntity,
|
||||
ValidationResult,
|
||||
} from './common/types/define-entity.type';
|
||||
export type { SyncableEntityOptions } from './common/types/syncable-entity-options.type';
|
||||
export { createValidationResult } from './common/utils/create-validation-result';
|
||||
@@ -24,24 +24,24 @@ export { OnDeleteAction } from './fields/on-delete-action';
|
||||
export { RelationType } from './fields/relation-type';
|
||||
export { validateFields } from './fields/validate-fields';
|
||||
export type {
|
||||
FrontComponentType,
|
||||
FrontComponentConfig,
|
||||
FrontComponentType,
|
||||
} from './front-component-config';
|
||||
export { defineLogicFunction } from './logic-functions/define-logic-function';
|
||||
export type {
|
||||
LogicFunctionHandler,
|
||||
LogicFunctionConfig,
|
||||
LogicFunctionHandler,
|
||||
} from './logic-functions/logic-function-config';
|
||||
export type { CronPayload } from './logic-functions/triggers/cron-payload-type';
|
||||
export type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordBaseEvent,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
ObjectRecordEvent,
|
||||
ObjectRecordDeleteEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordBaseEvent,
|
||||
ObjectRecordEvent,
|
||||
ObjectRecordRestoreEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
ObjectRecordUpsertEvent,
|
||||
} from './logic-functions/triggers/database-event-payload-type';
|
||||
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
|
||||
@@ -51,15 +51,23 @@ export { defineRole } from './roles/define-role';
|
||||
export { PermissionFlag } from './roles/permission-flag-type';
|
||||
|
||||
// Front Component API exports
|
||||
export { useFrontComponentExecutionContext } from './front-component-api';
|
||||
export { navigate } from './front-component-api';
|
||||
export { useUserId } from './front-component-api';
|
||||
export {
|
||||
navigate,
|
||||
useFrontComponentExecutionContext,
|
||||
useUserId,
|
||||
} from './front-component-api';
|
||||
export type { FrontComponentExecutionContext } from './front-component-api';
|
||||
|
||||
// Front Component Common exports
|
||||
export {
|
||||
ALLOWED_HTML_ELEMENTS,
|
||||
COMMON_HTML_EVENTS,
|
||||
EVENT_TO_REACT,
|
||||
HTML_COMMON_PROPERTIES,
|
||||
HTML_TAG_TO_REMOTE_COMPONENT,
|
||||
} from './front-component-api';
|
||||
export type { AllowedHtmlElement } from './front-component-api';
|
||||
export { ALLOWED_HTML_ELEMENTS } from './front-component-api';
|
||||
export { COMMON_HTML_EVENTS } from './front-component-api';
|
||||
export { EVENT_TO_REACT } from './front-component-api';
|
||||
export { HTML_COMMON_PROPERTIES } from './front-component-api';
|
||||
export { HTML_TAG_TO_REMOTE_COMPONENT } from './front-component-api';
|
||||
|
||||
// Style bridge utilities for CSS-in-JS libraries in remote components
|
||||
export { installStyleBridge } from '../front-component-renderer/polyfills/installStyleBridge';
|
||||
export { exposeGlobals } from '../front-component-renderer/remote/utils/exposeGlobals';
|
||||
|
||||
@@ -9,3 +9,7 @@ export * from 'twenty-ui/layout';
|
||||
export * from 'twenty-ui/navigation';
|
||||
export * from 'twenty-ui/theme';
|
||||
export * from 'twenty-ui/utilities';
|
||||
|
||||
// Re-export Emotion's ThemeProvider so front components can wrap
|
||||
// their content with the Twenty UI theme without a direct @emotion/react dependency
|
||||
export { ThemeProvider } from '@emotion/react';
|
||||
|
||||
@@ -28,12 +28,14 @@
|
||||
"vite.config.ts",
|
||||
"vite.config.node.ts",
|
||||
"vite.config.browser.ts",
|
||||
"vite.config.sdk.ts",
|
||||
"jest.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"src/front-component-renderer/remote/mock/**/*",
|
||||
"src/front-component-renderer/host/generated/host-component-registry.ts",
|
||||
"src/front-component-renderer/remote/generated/remote-components.ts",
|
||||
"src/front-component-renderer/remote/generated/remote-elements.ts"
|
||||
"src/front-component-renderer/remote/generated/remote-elements.ts",
|
||||
"src/front-component-renderer/__stories__/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.e2e-spec.ts",
|
||||
"**/__tests__/**"
|
||||
"**/__tests__/**",
|
||||
"**/__stories__/**",
|
||||
"**/*.stories.ts",
|
||||
"**/*.stories.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
|
||||
return `${chunk.name}.${extension}`;
|
||||
}
|
||||
|
||||
// Remove 'src/' prefix and '/index.ts' suffix to get the module path
|
||||
const modulePath = entry.replace('src/', '').replace('/index.ts', '');
|
||||
|
||||
return `${modulePath}/index.${extension}`;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as path from 'path';
|
||||
import { type UserConfig, defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
const isExternal = (id: string): boolean => {
|
||||
if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.startsWith('src/') || id.startsWith('@/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default defineConfig((): UserConfig => {
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/twenty-sdk-sdk',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': path.resolve(__dirname, 'src') + '/',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
outDir: './dist/sdk',
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: {
|
||||
index: 'src/sdk/index.ts',
|
||||
},
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: isExternal,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: 'src/sdk',
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "twenty-shared",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -52,6 +52,24 @@
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"build:individual": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/individual"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx vite build -c vite.config.individual.ts"
|
||||
}
|
||||
},
|
||||
"generateBarrels": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './ai';
|
||||
export * from './application';
|
||||
export * from './constants';
|
||||
export * from './database-events';
|
||||
export * from './metadata';
|
||||
export * from './testing';
|
||||
export * from './translations';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './workflow';
|
||||
export * from './workspace';
|
||||
@@ -18,6 +18,10 @@
|
||||
"src/**/*.ts",
|
||||
"scripts/**/*.ts",
|
||||
"**/__mocks__/**/*",
|
||||
"jest.config.mjs"
|
||||
"jest.config.mjs",
|
||||
"vite.config.*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/individual-entry.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/__mocks__/**/*"
|
||||
"**/__mocks__/**/*",
|
||||
"src/individual-entry.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import { type UserConfig, defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
// @ts-ignore
|
||||
import packageJson from './package.json';
|
||||
|
||||
const submodules = Object.keys((packageJson as any).exports || {})
|
||||
.filter((key) => key !== '.' && !key.startsWith('./src/'))
|
||||
.map((key) => key.replace(/^\.\//, ''));
|
||||
|
||||
const entries: Record<string, string> = {
|
||||
'individual-entry': 'src/individual-entry.ts',
|
||||
};
|
||||
|
||||
for (const submodule of submodules) {
|
||||
entries[`${submodule}/index`] = `src/${submodule}/index.ts`;
|
||||
}
|
||||
|
||||
const isExternal = (id: string): boolean => {
|
||||
if (id.startsWith('.') || id.startsWith('/') || id.startsWith('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.startsWith('src/') || id.startsWith('@/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default defineConfig((): UserConfig => {
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/twenty-shared-individual',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': path.resolve(__dirname, 'src') + '/',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
outDir: './dist/individual',
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: entries,
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: isExternal,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: 'src',
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -49,6 +49,7 @@ export default defineConfig(() => {
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: 'dist',
|
||||
lib: { entry: entries, name: 'twenty-shared' },
|
||||
rollupOptions: {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"module": "dist/index.mjs",
|
||||
"style": "./dist/style.css",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
@@ -64,9 +67,6 @@
|
||||
"theme",
|
||||
"utilities"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -55,6 +55,24 @@
|
||||
"command": "tsx {projectRoot}/scripts/generateBarrels.ts"
|
||||
}
|
||||
},
|
||||
"build:individual": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"inputs": [
|
||||
"production",
|
||||
"^production"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/dist/individual"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx vite build -c vite.config.individual.ts"
|
||||
}
|
||||
},
|
||||
"clean": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { type ThemeColor } from '@ui/theme';
|
||||
import { themeColorSchema } from '@ui/theme/utils/themeColorSchema';
|
||||
import { MAIN_COLOR_NAMES, type ThemeColor } from '@ui/theme';
|
||||
|
||||
import { Loader } from '@ui/feedback/loader/components/Loader';
|
||||
|
||||
const parseThemeColor = (color: string): ThemeColor =>
|
||||
(MAIN_COLOR_NAMES as string[]).includes(color)
|
||||
? (color as ThemeColor)
|
||||
: 'gray';
|
||||
|
||||
const StyledStatus = styled.h3<{
|
||||
color: ThemeColor;
|
||||
weight: 'regular' | 'medium';
|
||||
@@ -62,7 +66,7 @@ export const Status = ({
|
||||
}: StatusProps) => (
|
||||
<StyledStatus
|
||||
className={className}
|
||||
color={themeColorSchema.catch('gray').parse(color)}
|
||||
color={parseThemeColor(color)}
|
||||
onClick={onClick}
|
||||
weight={weight}
|
||||
isLoaderVisible={isLoaderVisible}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Entry point for the individual/self-contained build (vite.config.individual.ts).
|
||||
// This re-exports all public modules so a single .mjs bundle contains
|
||||
// every component with internal deps bundled, while React and Emotion
|
||||
// remain external for the consumer's bundler to resolve.
|
||||
|
||||
export * from './accessibility';
|
||||
export * from './components';
|
||||
export * from './display';
|
||||
export * from './feedback';
|
||||
export * from './input';
|
||||
export * from './json-visualizer';
|
||||
export * from './layout';
|
||||
export * from './navigation';
|
||||
export * from './theme';
|
||||
export * from './utilities';
|
||||
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
ThemeContext,
|
||||
ThemeContextProvider,
|
||||
} from './provider/ThemeContextProvider';
|
||||
export { ThemeProvider } from './provider/ThemeProvider';
|
||||
export type { ThemeType } from './types/ThemeType';
|
||||
export { getNextThemeColor } from './utils/getNextThemeColor';
|
||||
export { themeColorSchema } from './utils/themeColorSchema';
|
||||
|
||||
@@ -10,12 +10,10 @@ type ThemeProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ThemeProvider = ({ theme, children }: ThemeProviderProps) => {
|
||||
export const ThemeProvider = ({ theme, children }: ThemeProviderProps) => {
|
||||
return (
|
||||
<EmotionThemeProvider theme={theme}>
|
||||
<ThemeContextProvider theme={theme}>{children}</ThemeContextProvider>
|
||||
</EmotionThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import wyw from '@wyw-in-js/vite';
|
||||
import * as path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
import packageJson from './package.json';
|
||||
|
||||
const depNames = Object.keys(packageJson.dependencies || {});
|
||||
|
||||
const isExternal = (id: string): boolean =>
|
||||
depNames.some((dep) => id === dep || id.startsWith(dep + '/'));
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@ui/': path.resolve(__dirname, 'src') + '/',
|
||||
'@assets/': path.resolve(__dirname, 'src/assets') + '/',
|
||||
},
|
||||
},
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/twenty-ui-individual',
|
||||
assetsInclude: ['src/**/*.svg'],
|
||||
plugins: [
|
||||
react({
|
||||
jsxImportSource: '@emotion/react',
|
||||
plugins: [['@swc/plugin-emotion', {}]],
|
||||
}),
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
projects: ['tsconfig.json'],
|
||||
}),
|
||||
svgr(),
|
||||
wyw({
|
||||
include: [
|
||||
'**/OverflowingTextWithTooltip.tsx',
|
||||
'**/Tag.tsx',
|
||||
'**/Avatar.tsx',
|
||||
'**/Chip.tsx',
|
||||
'**/LinkChip.tsx',
|
||||
'**/Avatar.tsx',
|
||||
'**/AvatarChipLeftComponent.tsx',
|
||||
'**/ContactLink.tsx',
|
||||
'**/RoundedLink.tsx',
|
||||
],
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-typescript', '@babel/preset-react'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
cssCodeSplit: false,
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
outDir: './dist/individual',
|
||||
emptyOutDir: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
interopDefault: true,
|
||||
defaultIsModuleExports: true,
|
||||
requireReturnsDefault: 'auto',
|
||||
},
|
||||
lib: {
|
||||
entry: 'src/individual-entry.ts',
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: isExternal,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: 'src',
|
||||
entryFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -101,12 +101,11 @@ export default defineConfig(({ command }) => {
|
||||
},
|
||||
}),
|
||||
],
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
cssCodeSplit: false,
|
||||
minify: 'esbuild',
|
||||
sourcemap: false,
|
||||
emptyOutDir: false,
|
||||
outDir: './dist',
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
@@ -120,7 +119,6 @@ export default defineConfig(({ command }) => {
|
||||
name: 'twenty-ui',
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: Object.keys(packageJson.dependencies || {}),
|
||||
output: [
|
||||
{
|
||||
|
||||
@@ -51048,6 +51048,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"preact@npm:^10.28.3":
|
||||
version: 10.28.3
|
||||
resolution: "preact@npm:10.28.3"
|
||||
checksum: 10c0/e8854fae8d8d40c918538f7a90888dd4f8acd388b8ba8e0c612ed0eb1a7d2cc6c15a7dc0a7b7c7bb5c361d2df63aa283e501cf1d47f8eb1413979cee611a38ef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prebuild-install@npm:^7.1.1":
|
||||
version: 7.1.2
|
||||
resolution: "prebuild-install@npm:7.1.2"
|
||||
@@ -58363,6 +58370,7 @@ __metadata:
|
||||
jsonc-parser: "npm:^3.2.0"
|
||||
lodash.camelcase: "npm:^4.3.0"
|
||||
playwright: "npm:^1.56.1"
|
||||
preact: "npm:^10.28.3"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
storybook: "npm:^10.1.11"
|
||||
|
||||
Reference in New Issue
Block a user