[FRONT COMPONENTS] Build front components from twenty apps for remote dom (#17566)
Modify the front components build to match the expected format for remote dom execution in a worker.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+191
@@ -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 = '<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+205
@@ -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<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
@@ -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<esbuild.OnLoadResult> => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
+124
@@ -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<string> => {
|
||||
const collectedReactImports = new Set<string>();
|
||||
|
||||
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>): 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<string, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
|
||||
if (path === 'react/jsx-runtime') {
|
||||
return {
|
||||
contents: JSX_RUNTIME_EXPORTS,
|
||||
loader: 'js',
|
||||
};
|
||||
}
|
||||
|
||||
if (path === 'react') {
|
||||
return {
|
||||
contents: generateReactExports(collectedReactImports),
|
||||
loader: 'js',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type ParsedImportSpecifier = {
|
||||
originalName: string;
|
||||
aliasName: string;
|
||||
};
|
||||
+20
@@ -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 };
|
||||
};
|
||||
+39
@@ -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;
|
||||
},
|
||||
);
|
||||
};
|
||||
+38
@@ -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;
|
||||
};
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<createTypeScriptFileArgs>(
|
||||
({ 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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-3
@@ -3,11 +3,13 @@ type PropertySchema = {
|
||||
optional: boolean;
|
||||
};
|
||||
|
||||
export const ALLOWED_HTML_ELEMENTS: Array<{
|
||||
export type AllowedHtmlElement = {
|
||||
tag: string;
|
||||
name: string;
|
||||
properties: Record<string, PropertySchema>;
|
||||
}> = [
|
||||
};
|
||||
|
||||
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;
|
||||
];
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type PropertySchema } from '../types/PropertySchema';
|
||||
import { type PropertySchema } from '../front-component/types/PropertySchema';
|
||||
|
||||
export const HTML_COMMON_PROPERTIES: Record<string, PropertySchema> = {
|
||||
id: { type: 'string', optional: true },
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ALLOWED_HTML_ELEMENTS } from './AllowedHtmlElements';
|
||||
|
||||
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,
|
||||
]),
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown>).React = React;
|
||||
(globalThis as Record<string, unknown>).RemoteComponents = RemoteComponents;
|
||||
(globalThis as Record<string, unknown>).jsx = jsx;
|
||||
(globalThis as Record<string, unknown>).jsxs = jsxs;
|
||||
|
||||
const render: WorkerExports['render'] = async (
|
||||
connection: RemoteConnection,
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
Reference in New Issue
Block a user