diff --git a/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts b/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts index f0dfdfdb70..ec0742cb7d 100644 --- a/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts +++ b/packages/twenty-front/src/modules/front-components/utils/mockFrontComponent.ts @@ -1,54 +1,9 @@ -// Mock component code that runs inside the remote worker - import { isDefined } from 'twenty-shared/utils'; -// Uses globalThis.React and globalThis.RemoteComponents which are exposed by the worker +// eslint-disable-next-line const mockFrontComponentCode = ` -const React = globalThis.React; -const { useEffect, useState } = React; - -const { HtmlButton, HtmlDiv, HtmlH3, HtmlP } = globalThis.RemoteComponents; - -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); +var r=globalThis.React.useState,s=globalThis.React.useEffect;var n=globalThis.jsx,o=globalThis.jsxs,g=globalThis.React.Fragment;var e=globalThis.RemoteComponents,x=()=>{let[a,l]=r(0),[m,p]=r(0);return s(()=>{let t=setInterval(()=>{p(i=>i+1)},1e3);return()=>clearInterval(t)},[]),o(e.HtmlDiv,{style:{padding:"20px",fontFamily:"Arial, sans-serif",maxWidth:"400px",margin:"0 auto",display:"flex",flexDirection:"column",alignItems:"center"},children:[n(e.HtmlH1,{style:{color:"#333",marginBottom:"20px",fontSize:"24px"},children:"Test Component"}),o(e.HtmlP,{style:{fontSize:"18px",marginBottom:"10px",color:"#666"},children:["Count: ",a]}),o(e.HtmlP,{style:{fontSize:"18px",marginBottom:"20px",color:"#666"},children:["Timer: ",m,"s"]}),n(e.HtmlButton,{onClick:()=>l(a+1),style:{padding:"10px 20px",fontSize:"16px",backgroundColor:"#007bff",color:"white",border:"none",borderRadius:"5px",cursor:"pointer",transition:"all 0.3s ease",boxShadow:"0 2px 4px rgba(0, 0, 0, 0.2)"},onMouseEnter:t=>{t.currentTarget.style.backgroundColor="#0056b3",t.currentTarget.style.transform="translateY(-2px)",t.currentTarget.style.boxShadow="0 4px 8px rgba(0, 0, 0, 0.3)"},onMouseLeave:t=>{t.currentTarget.style.backgroundColor="#007bff",t.currentTarget.style.transform="translateY(0)",t.currentTarget.style.boxShadow="0 2px 4px rgba(0, 0, 0, 0.2)"},children:"Increment"})]})},b=globalThis.jsx(x,{});export{b as default}; +//# sourceMappingURL=test-component.front-component.mjs.map `; let cachedMockBlobUrl: string | null = null; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts index 800f7f7837..1146fd084b 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts @@ -1,15 +1,17 @@ -import * as esbuild from 'esbuild'; -import path from 'path'; import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files'; import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor'; +import { jsxTransformToRemoteDomWorkerFormatPlugin } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin'; +import { reactGlobalsPlugin } from '@/cli/utilities/build/common/front-component-build/react-globals-plugin'; import { type OnBuildErrorCallback, type OnFileBuiltCallback, type RestartableWatcher, type RestartableWatcherOptions, } from '@/cli/utilities/build/common/restartable-watcher-interface'; -import { FileFolder } from 'twenty-shared/types'; +import * as esbuild from 'esbuild'; +import path from 'path'; import { OUTPUT_DIR } from 'twenty-shared/application'; +import { FileFolder } from 'twenty-shared/types'; export const FUNCTION_EXTERNAL_MODULES: string[] = [ 'path', @@ -37,10 +39,7 @@ export const FUNCTION_EXTERNAL_MODULES: string[] = [ ]; export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ - 'react', 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', 'twenty-sdk', 'twenty-sdk/*', 'twenty-shared', @@ -53,6 +52,7 @@ export type EsbuildWatcherConfig = { platform?: esbuild.Platform; jsx?: 'automatic'; extraPlugins?: esbuild.Plugin[]; + minify?: boolean; }; export type EsbuildWatcherOptions = RestartableWatcherOptions & { @@ -177,6 +177,7 @@ export class EsbuildWatcher implements RestartableWatcher { sourcemap: true, metafile: true, logLevel: 'silent', + minify: this.config.minify, plugins, }); @@ -225,5 +226,9 @@ export const createFrontComponentsWatcher = ( externalModules: FRONT_COMPONENT_EXTERNAL_MODULES, fileFolder: FileFolder.BuiltFrontComponent, jsx: 'automatic', + extraPlugins: [ + reactGlobalsPlugin, + jsxTransformToRemoteDomWorkerFormatPlugin, + ], }, }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts new file mode 100644 index 0000000000..4ce5d6f77f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/jsx-transform-to-remote-dom-worker-format-plugin.spec.ts @@ -0,0 +1,191 @@ +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 = '
Hello
'; + const expected = + 'Hello'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should transform span tags', () => { + const input = 'Text'; + const expected = + 'Text'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should transform button tags', () => { + const input = ''; + const expected = + 'Click me'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should transform self-closing tags', () => { + const input = '
'; + const expected = ''; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should transform img tags with attributes', () => { + const input = 'Test'; + const expected = ''; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + }); + + describe('nested elements', () => { + it('should transform nested elements', () => { + const input = '
Nested
'; + const expected = + 'Nested'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should transform deeply nested elements', () => { + const input = '
  • Item
'; + const expected = + 'Item'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + }); + + describe('attributes preservation', () => { + it('should preserve className attribute', () => { + const input = '
Content
'; + const expected = + 'Content'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should preserve onClick handler', () => { + const input = ''; + const expected = + 'Click'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + + it('should preserve multiple attributes', () => { + const input = + ''; + const expected = + ''; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + }); + + describe('custom components should not be transformed', () => { + it('should not transform PascalCase components', () => { + const input = 'Content'; + expect(transformJsxToRemoteComponents(input)).toBe(input); + }); + + it('should not transform components starting with uppercase', () => { + const input = ''; + expect(transformJsxToRemoteComponents(input)).toBe(input); + }); + + it('should transform HTML tags but not custom components in mixed content', () => { + const input = '
'; + const expected = + ''; + 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 = '<>
Content
'; + const expected = + '<>Content'; + 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`; + const expected = `Content`; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }, + ); + }); + + describe('edge cases', () => { + it('should handle multiline JSX', () => { + const input = ` +
+ Hello +
+ `; + const result = transformJsxToRemoteComponents(input); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should handle JSX expressions', () => { + const input = + '
{items.map(item => {item.name})}
'; + const expected = + '{items.map(item => {item.name})}'; + expect(transformJsxToRemoteComponents(input)).toBe(expected); + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts new file mode 100644 index 0000000000..936d7ac5d9 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts @@ -0,0 +1,205 @@ +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 => { + 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 = () =>
Hello
; + `; + + 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 = () => ( +
+ One + Two +
+ ); + `; + + 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'); + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts new file mode 100644 index 0000000000..95abc42eee --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin.ts @@ -0,0 +1,42 @@ +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) => { + esbuildBuild.onLoad( + { filter: /\.tsx$/ }, + async ({ path }): Promise => { + try { + const frontComponentSourceCode = await fs.readFile(path, 'utf8'); + + const sourceWithRemoteComponents = + replaceHtmlTagsWithRemoteComponents(frontComponentSourceCode); + + const sourceWithUnwrappedFrontComponent = + unwrapDefineFrontComponentToDirectExport( + sourceWithRemoteComponents, + ); + + const transformedContents = `var RemoteComponents = globalThis.RemoteComponents;\n${sourceWithUnwrappedFrontComponent}`; + + return { contents: transformedContents, loader: 'tsx' }; + } catch (transformError) { + return { + errors: [ + { + text: `Failed to transform front component: ${transformError instanceof Error ? transformError.message : String(transformError)}`, + location: { file: path }, + }, + ], + }; + } + }, + ); + }, +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts new file mode 100644 index 0000000000..d33022dd51 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs/promises'; + +import type * as esbuild from 'esbuild'; + +import { isDefined } from 'twenty-shared/utils'; +import { extractNamesFromImportSpecifier } from './utils/extract-names-from-import-specifier'; + +const REACT_IMPORT_PATTERN = + /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]react['"];?/g; + +const REACT_MODULE_FILTER_PATTERN = /^react(\/jsx-runtime)?$/; + +const JSX_RUNTIME_EXPORTS = ` +export var jsx = globalThis.jsx; +export var jsxs = globalThis.jsxs; +export var Fragment = globalThis.React.Fragment; +`.trim(); + +const collectReactImports = (sourceContent: string): Set => { + const collectedReactImports = new Set(); + + let importMatch; + + while (isDefined((importMatch = REACT_IMPORT_PATTERN.exec(sourceContent)))) { + const defaultImportName = importMatch[1]; + const namedImportsString = importMatch[2]; + + if (defaultImportName) { + collectedReactImports.add('default'); + } + + if (namedImportsString) { + namedImportsString + .split(',') + .filter((importSpecifier) => importSpecifier.trim()) + .forEach((importSpecifier) => { + const { originalName } = + extractNamesFromImportSpecifier(importSpecifier); + + collectedReactImports.add(originalName); + }); + } + } + + REACT_IMPORT_PATTERN.lastIndex = 0; + + return collectedReactImports; +}; + +const generateReactExports = (reactImports: Set): string => { + const exportStatements: string[] = []; + + for (const reactImportName of reactImports) { + if (reactImportName === 'default') { + exportStatements.push('export default globalThis.React;'); + } else { + exportStatements.push( + `export var ${reactImportName} = globalThis.React.${reactImportName};`, + ); + } + } + + return exportStatements.join('\n'); +}; + +export const reactGlobalsPlugin: esbuild.Plugin = { + name: 'react-globals', + setup: async (build) => { + const reactImportsByFilePath = new Map>(); + + build.onStart(() => { + reactImportsByFilePath.clear(); + }); + + build.onResolve( + { filter: REACT_MODULE_FILTER_PATTERN }, + async ({ importer, path }) => { + if (importer && !reactImportsByFilePath.has(importer)) { + try { + const sourceFileContent = await fs.readFile(importer, 'utf-8'); + + reactImportsByFilePath.set( + importer, + collectReactImports(sourceFileContent), + ); + } catch { + reactImportsByFilePath.set(importer, new Set()); + } + } + + return { + path, + namespace: 'react-globals', + pluginData: { importer }, + }; + }, + ); + + build.onLoad( + { filter: /.*/, namespace: 'react-globals' }, + ({ pluginData, path }) => { + const importerFilePath = pluginData?.importer || ''; + const collectedReactImports = + reactImportsByFilePath.get(importerFilePath) || new Set(); + + if (path === 'react/jsx-runtime') { + return { + contents: JSX_RUNTIME_EXPORTS, + loader: 'js', + }; + } + + if (path === 'react') { + return { + contents: generateReactExports(collectedReactImports), + loader: 'js', + }; + } + + return null; + }, + ); + }, +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts new file mode 100644 index 0000000000..19d8542fd3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/types/ParsedImportSpecifier.ts @@ -0,0 +1,4 @@ +export type ParsedImportSpecifier = { + originalName: string; + aliasName: string; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts new file mode 100644 index 0000000000..385503fc51 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/extract-names-from-import-specifier.ts @@ -0,0 +1,20 @@ +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 }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts new file mode 100644 index 0000000000..08d2e28507 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts @@ -0,0 +1,39 @@ +import { HTML_TAG_TO_REMOTE_COMPONENT } from 'twenty-shared/front-component-constants'; + +import { isDefined } from 'twenty-shared/utils'; + +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; + }, + ); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts new file mode 100644 index 0000000000..95abf55323 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts @@ -0,0 +1,38 @@ +const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN = + /import\s*\{\s*defineFrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g; + +const DEFINE_FRONT_COMPONENT_EXPORT_PATTERN = + /export\s+default\s+defineFrontComponent\s*\(\s*\{[^}]*component\s*:\s*(\w+)[^}]*\}\s*\)\s*;?/s; + +export const unwrapDefineFrontComponentToDirectExport = ( + sourceCode: string, +): string => { + let transformedSource = sourceCode.replace( + DEFINE_FRONT_COMPONENT_IMPORT_PATTERN, + '', + ); + + const defineFrontComponentMatch = transformedSource.match( + DEFINE_FRONT_COMPONENT_EXPORT_PATTERN, + ); + + if (defineFrontComponentMatch) { + const wrappedComponentName = defineFrontComponentMatch[1]; + + const exportedComponentDeclarationPattern = new RegExp( + `export\\s+(const|function)\\s+${wrappedComponentName}\\b`, + ); + + transformedSource = transformedSource.replace( + exportedComponentDeclarationPattern, + `$1 ${wrappedComponentName}`, + ); + + transformedSource = transformedSource.replace( + DEFINE_FRONT_COMPONENT_EXPORT_PATTERN, + `export default globalThis.jsx(${wrappedComponentName}, {});`, + ); + } + + return transformedSource; +}; diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts index b853591dca..33cdcb5a3f 100644 --- a/packages/twenty-sdk/vite.config.ts +++ b/packages/twenty-sdk/vite.config.ts @@ -89,12 +89,14 @@ export default defineConfig(() => { ...Object.keys((packageJson as any).dependencies || {}), 'path', 'fs', + 'fs/promises', 'url', 'crypto', 'stream', 'util', 'os', 'module', + /^node:/, ], output: [ { diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 2b622d94bd..20d640c715 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -75,6 +75,11 @@ "import": "./dist/front-component.mjs", "require": "./dist/front-component.cjs" }, + "./front-component-constants": { + "types": "./dist/front-component-constants/index.d.ts", + "import": "./dist/front-component-constants.mjs", + "require": "./dist/front-component-constants.cjs" + }, "./metadata": { "types": "./dist/metadata/index.d.ts", "import": "./dist/metadata.mjs", @@ -118,6 +123,7 @@ "constants", "database-events", "front-component", + "front-component-constants", "metadata", "testing", "translations", @@ -143,6 +149,9 @@ "front-component": [ "dist/front-component/index.d.ts" ], + "front-component-constants": [ + "dist/front-component-constants/index.d.ts" + ], "metadata": [ "dist/metadata/index.d.ts" ], diff --git a/packages/twenty-shared/project.json b/packages/twenty-shared/project.json index e91edfaf93..11ba3245b0 100644 --- a/packages/twenty-shared/project.json +++ b/packages/twenty-shared/project.json @@ -24,6 +24,8 @@ "{projectRoot}/database-events/dist", "{projectRoot}/front-component/package.json", "{projectRoot}/front-component/dist", + "{projectRoot}/front-component-constants/package.json", + "{projectRoot}/front-component-constants/dist", "{projectRoot}/metadata/package.json", "{projectRoot}/metadata/dist", "{projectRoot}/testing/package.json", diff --git a/packages/twenty-shared/scripts/generateBarrels.ts b/packages/twenty-shared/scripts/generateBarrels.ts index 70d3d0a153..28aac3dc55 100644 --- a/packages/twenty-shared/scripts/generateBarrels.ts +++ b/packages/twenty-shared/scripts/generateBarrels.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import { globSync } from 'glob'; // @ts-ignore import path from 'path'; -import { Options } from 'prettier'; +import { type Options } from 'prettier'; import slash from 'slash'; // @ts-ignore import ts from 'typescript'; @@ -105,15 +105,7 @@ const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => { const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => { return exportByBarrel.map( - ({ barrel: { moduleDirectory, moduleName }, allFileExports }) => { - if (moduleName === 'front-component') { - return { - content: `export { FrontComponentRenderer } from './host/components/FrontComponentRenderer'`, - path: moduleDirectory, - filename: INDEX_FILENAME, - }; - } - + ({ barrel: { moduleDirectory }, allFileExports }) => { const content = allFileExports .sort((a, b) => a.file.localeCompare(b.file)) .map(({ exports, file }) => { diff --git a/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts b/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts index 9fd560d37a..823aac42ff 100644 --- a/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts +++ b/packages/twenty-shared/scripts/remote-dom/generateRemoteDomElements.ts @@ -4,10 +4,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { IndentationText, Project, QuoteKind } from 'ts-morph'; -import { ALLOWED_HTML_ELEMENTS } from '../../src/front-component/constants/AllowedHtmlElements'; -import { COMMON_HTML_EVENTS } from '../../src/front-component/constants/CommonHtmlEvents'; -import { EVENT_TO_REACT } from '../../src/front-component/constants/EventToReact'; -import { HTML_COMMON_PROPERTIES } from '../../src/front-component/constants/HtmlCommonProperties'; +import { ALLOWED_HTML_ELEMENTS } from '../../src/front-component-constants/AllowedHtmlElements'; +import { COMMON_HTML_EVENTS } from '../../src/front-component-constants/CommonHtmlEvents'; +import { EVENT_TO_REACT } from '../../src/front-component-constants/EventToReact'; +import { HTML_COMMON_PROPERTIES } from '../../src/front-component-constants/HtmlCommonProperties'; import { type ComponentSchema, diff --git a/packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts b/packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts similarity index 97% rename from packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts rename to packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts index 504f9e8f8a..46cb05e6f0 100644 --- a/packages/twenty-shared/src/front-component/constants/AllowedHtmlElements.ts +++ b/packages/twenty-shared/src/front-component-constants/AllowedHtmlElements.ts @@ -3,11 +3,13 @@ type PropertySchema = { optional: boolean; }; -export const ALLOWED_HTML_ELEMENTS: Array<{ +export type AllowedHtmlElement = { tag: string; name: string; properties: Record; -}> = [ +}; + +export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [ { tag: 'html-div', name: 'HtmlDiv', properties: {} }, { tag: 'html-span', name: 'HtmlSpan', properties: {} }, { tag: 'html-section', name: 'HtmlSection', properties: {} }, @@ -143,4 +145,4 @@ export const ALLOWED_HTML_ELEMENTS: Array<{ }, { tag: 'html-br', name: 'HtmlBr', properties: {} }, { tag: 'html-hr', name: 'HtmlHr', properties: {} }, -] as const; +]; diff --git a/packages/twenty-shared/src/front-component/constants/CommonHtmlEvents.ts b/packages/twenty-shared/src/front-component-constants/CommonHtmlEvents.ts similarity index 100% rename from packages/twenty-shared/src/front-component/constants/CommonHtmlEvents.ts rename to packages/twenty-shared/src/front-component-constants/CommonHtmlEvents.ts diff --git a/packages/twenty-shared/src/front-component/constants/EventToReact.ts b/packages/twenty-shared/src/front-component-constants/EventToReact.ts similarity index 100% rename from packages/twenty-shared/src/front-component/constants/EventToReact.ts rename to packages/twenty-shared/src/front-component-constants/EventToReact.ts diff --git a/packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts b/packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts similarity index 86% rename from packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts rename to packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts index 8dd3d29ad3..29c0e25d70 100644 --- a/packages/twenty-shared/src/front-component/constants/HtmlCommonProperties.ts +++ b/packages/twenty-shared/src/front-component-constants/HtmlCommonProperties.ts @@ -1,4 +1,4 @@ -import { type PropertySchema } from '../types/PropertySchema'; +import { type PropertySchema } from '../front-component/types/PropertySchema'; export const HTML_COMMON_PROPERTIES: Record = { id: { type: 'string', optional: true }, diff --git a/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts b/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts new file mode 100644 index 0000000000..e33dba8048 --- /dev/null +++ b/packages/twenty-shared/src/front-component-constants/HtmlTagToRemoteComponent.ts @@ -0,0 +1,9 @@ +import { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements'; + +export const HTML_TAG_TO_REMOTE_COMPONENT: Record = + Object.fromEntries( + ALLOWED_HTML_ELEMENTS.map((element) => [ + element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag, + element.name, + ]), + ); diff --git a/packages/twenty-shared/src/front-component-constants/index.ts b/packages/twenty-shared/src/front-component-constants/index.ts new file mode 100644 index 0000000000..9f5919390c --- /dev/null +++ b/packages/twenty-shared/src/front-component-constants/index.ts @@ -0,0 +1,15 @@ +/* + * _____ _ + *|_ _|_ _____ _ __ | |_ _ _ + * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file + * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden + * |_| \_/\_/ \___|_| |_|\__|\__, | + * |___/ + */ + +export type { AllowedHtmlElement } from './AllowedHtmlElements'; +export { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements'; +export { COMMON_HTML_EVENTS } from './CommonHtmlEvents'; +export { EVENT_TO_REACT } from './EventToReact'; +export { HTML_COMMON_PROPERTIES } from './HtmlCommonProperties'; +export { HTML_TAG_TO_REMOTE_COMPONENT } from './HtmlTagToRemoteComponent'; diff --git a/packages/twenty-shared/src/front-component/index.ts b/packages/twenty-shared/src/front-component/index.ts index dcbc61a205..21d35f5940 100644 --- a/packages/twenty-shared/src/front-component/index.ts +++ b/packages/twenty-shared/src/front-component/index.ts @@ -8,3 +8,116 @@ */ export { FrontComponentRenderer } from './host/components/FrontComponentRenderer'; +export { componentRegistry } from './host/generated/host-component-registry'; +export { FrontComponentWorkerEffect } from './remote/components/FrontComponentWorkerEffect'; +export { + HtmlDiv, + HtmlSpan, + HtmlSection, + HtmlArticle, + HtmlHeader, + HtmlFooter, + HtmlMain, + HtmlNav, + HtmlAside, + HtmlP, + HtmlH1, + HtmlH2, + HtmlH3, + HtmlH4, + HtmlH5, + HtmlH6, + HtmlStrong, + HtmlEm, + HtmlSmall, + HtmlCode, + HtmlPre, + HtmlBlockquote, + HtmlA, + HtmlImg, + HtmlUl, + HtmlOl, + HtmlLi, + HtmlForm, + HtmlLabel, + HtmlInput, + HtmlTextarea, + HtmlSelect, + HtmlOption, + HtmlButton, + HtmlTable, + HtmlThead, + HtmlTbody, + HtmlTfoot, + HtmlTr, + HtmlTh, + HtmlTd, + HtmlBr, + HtmlHr, +} from './remote/generated/remote-components'; +export type { + HtmlCommonProperties, + HtmlCommonEvents, + HtmlAProperties, + HtmlImgProperties, + HtmlFormProperties, + HtmlLabelProperties, + HtmlInputProperties, + HtmlTextareaProperties, + HtmlSelectProperties, + HtmlOptionProperties, + HtmlButtonProperties, + HtmlThProperties, + HtmlTdProperties, +} from './remote/generated/remote-elements'; +export { + HtmlDivElement, + HtmlSpanElement, + HtmlSectionElement, + HtmlArticleElement, + HtmlHeaderElement, + HtmlFooterElement, + HtmlMainElement, + HtmlNavElement, + HtmlAsideElement, + HtmlPElement, + HtmlH1Element, + HtmlH2Element, + HtmlH3Element, + HtmlH4Element, + HtmlH5Element, + HtmlH6Element, + HtmlStrongElement, + HtmlEmElement, + HtmlSmallElement, + HtmlCodeElement, + HtmlPreElement, + HtmlBlockquoteElement, + HtmlAElement, + HtmlImgElement, + HtmlUlElement, + HtmlOlElement, + HtmlLiElement, + HtmlFormElement, + HtmlLabelElement, + HtmlInputElement, + HtmlTextareaElement, + HtmlSelectElement, + HtmlOptionElement, + HtmlButtonElement, + HtmlTableElement, + HtmlTheadElement, + HtmlTbodyElement, + HtmlTfootElement, + HtmlTrElement, + HtmlThElement, + HtmlTdElement, + HtmlBrElement, + HtmlHrElement, + RemoteRootElement, + RemoteFragmentElement, +} from './remote/generated/remote-elements'; +export { createRemoteWorker } from './remote/worker/createRemoteWorker'; +export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext'; +export type { PropertySchema } from './types/PropertySchema'; +export type { WorkerExports } from './types/WorkerExports'; diff --git a/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts b/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts index d3d17e8cc2..c5b2a945a8 100644 --- a/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts +++ b/packages/twenty-shared/src/front-component/remote/worker/remote-worker.ts @@ -11,12 +11,15 @@ import { } from '@remote-dom/core/elements'; import React from 'react'; import { createRoot } from 'react-dom/client'; +import { jsx, jsxs } from 'react/jsx-runtime'; import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext'; import { type WorkerExports } from '../../types/WorkerExports'; import * as RemoteComponents from '../generated/remote-components'; (globalThis as Record).React = React; (globalThis as Record).RemoteComponents = RemoteComponents; +(globalThis as Record).jsx = jsx; +(globalThis as Record).jsxs = jsxs; const render: WorkerExports['render'] = async ( connection: RemoteConnection, diff --git a/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts b/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts deleted file mode 100644 index 102a72711c..0000000000 --- a/packages/twenty-shared/src/front-component/types/WorkerToHostBridge.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type RemoteConnection } from '@remote-dom/core/elements'; -import { type HostToWorkerRenderContext } from './HostToWorkerRenderContext'; - -export type WorkerExports = { - render: ( - connection: RemoteConnection, - context: HostToWorkerRenderContext, - ) => Promise; -};