[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:
Raphaël Bosi
2026-01-30 15:09:08 +01:00
committed by GitHub
parent ffdbb57eaa
commit d624652e36
24 changed files with 842 additions and 81 deletions
@@ -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,
],
},
});
@@ -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);
});
});
});
@@ -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');
});
});
});
@@ -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 },
},
],
};
}
},
);
},
};
@@ -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;
},
);
},
};
@@ -0,0 +1,4 @@
export type ParsedImportSpecifier = {
originalName: string;
aliasName: string;
};
@@ -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 };
};
@@ -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;
},
);
};
@@ -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;
};
+2
View File
@@ -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: [
{
+9
View File
@@ -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"
],
+2
View File
@@ -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,
@@ -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,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>;
};