[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,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;
|
||||
};
|
||||
Reference in New Issue
Block a user