diff --git a/.gitignore b/.gitignore index 551e10e02..eb9799096 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ _brisa **/out/* **/out-*/* **/dist/* +**/.temp-test-files/* packages/brisa/index.js packages/brisa/out packages/brisa/jsx-runtime/ diff --git a/docs/building-your-application/configuring/plugins.md b/docs/building-your-application/configuring/plugins.md index ea639cc4d..3e7e3827b 100644 --- a/docs/building-your-application/configuring/plugins.md +++ b/docs/building-your-application/configuring/plugins.md @@ -19,7 +19,7 @@ import type { Configuration } from "brisa"; import { MyPlugin } from "my-plugin"; export default { - extendPlugins(plugins, { dev, isServer, entrypoint }) { + extendPlugins(plugins, { dev, isServer }) { return [...plugins, MyPlugin]; }, } satisfies Configuration; @@ -32,15 +32,10 @@ export default { **Options:** -| Field | Type | Description | -| ---------- | --------------------- | ---------------------------------------------------- | -| dev | `boolean` | Indicates whether it's a development build. | -| isServer | `boolean` | Indicates whether it's a server build. | -| entrypoint | `string \| undefined` | Entry point for client builds, optional for servers. | - -> [!NOTE] -> -> On the server it is only executed once and the build is with all the entrypoints, while on the client a separate build is made for each page, that's why on the client there is the `entrypoint` field in the options. +| Field | Type | Description | +| -------- | --------- | ------------------------------------------- | +| dev | `boolean` | Indicates whether it's a development build. | +| isServer | `boolean` | Indicates whether it's a server build. | A plugin is defined as simple JavaScript object containing a name property and a setup function. Example of one: diff --git a/packages/brisa/src/cli/build-standalone/index.ts b/packages/brisa/src/cli/build-standalone/index.ts index 1bed3f15a..5613c0e50 100644 --- a/packages/brisa/src/cli/build-standalone/index.ts +++ b/packages/brisa/src/cli/build-standalone/index.ts @@ -164,11 +164,10 @@ async function compileStandaloneWebComponents(standaloneWC: string[]) { let code = await Bun.file(path).text(); try { - const res = clientBuildPlugin(code, path, { + code = clientBuildPlugin(code, path, { forceTranspilation: true, customElementSelectorToDefine: webComponentsSelector[path], }); - code = res.code; } catch (error) { console.log(LOG_PREFIX.ERROR, `Error transforming ${path}`); console.log(LOG_PREFIX.ERROR, (error as Error).message); @@ -184,7 +183,7 @@ async function compileStandaloneWebComponents(standaloneWC: string[]) { }, createContextPlugin(), ], - { dev: !IS_PRODUCTION, isServer: false, entrypoint: '' }, + { dev: !IS_PRODUCTION, isServer: false }, ), }); } diff --git a/packages/brisa/src/cli/build.ts b/packages/brisa/src/cli/build.ts index 2aab495e7..cef7a8158 100644 --- a/packages/brisa/src/cli/build.ts +++ b/packages/brisa/src/cli/build.ts @@ -5,6 +5,7 @@ import { getConstants } from '@/constants'; import byteSizeToString from '@/utils/byte-size-to-string'; import { logTable, generateStaticExport } from './build-utils'; import compileBrisaInternalsToDoBuildPortable from '@/utils/compile-serve-internals-into-build'; +import { log } from '@/utils/log/log-build'; const outputText = { bun: 'Bun.js Web Service App', @@ -16,7 +17,6 @@ const outputText = { }; export default async function build() { - const log = process.env.QUIET_MODE === 'true' ? () => {} : console.log; const constants = getConstants(); const { IS_PRODUCTION, diff --git a/packages/brisa/src/core/test/run-web-components/index.ts b/packages/brisa/src/core/test/run-web-components/index.ts index 47afdfe4c..3abbd2e9c 100644 --- a/packages/brisa/src/core/test/run-web-components/index.ts +++ b/packages/brisa/src/core/test/run-web-components/index.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import fs from 'node:fs'; import { getConstants } from '@/constants'; -import { transformToWebComponents } from '@/utils/get-client-code-in-page'; +import { transformToWebComponents } from '@/utils/client-build/layout-build'; import getWebComponentsList from '@/utils/get-web-components-list'; import getImportableFilepath from '@/utils/get-importable-filepath'; @@ -29,7 +29,7 @@ export default async function runWebComponents() { } const res = await transformToWebComponents({ - pagePath: '__tests__', + layoutPath: '__tests__', webComponentsList: allWebComponents, integrationsPath, useContextProvider: true, diff --git a/packages/brisa/src/types/index.d.ts b/packages/brisa/src/types/index.d.ts index e42160fb4..175254311 100644 --- a/packages/brisa/src/types/index.d.ts +++ b/packages/brisa/src/types/index.d.ts @@ -866,16 +866,10 @@ export type JSXComponent< error?: JSXComponent; }; -export type ExtendPluginOptions = - | { - dev: boolean; - isServer: true; - } - | { - dev: boolean; - isServer: false; - entrypoint: string; - }; +export type ExtendPluginOptions = { + dev: boolean; + isServer: boolean; +}; export type ExtendPlugins = ( plugins: BunPlugin[], diff --git a/packages/brisa/src/utils/ast/index.ts b/packages/brisa/src/utils/ast/index.ts index 193df1328..7c790fcdb 100644 --- a/packages/brisa/src/utils/ast/index.ts +++ b/packages/brisa/src/utils/ast/index.ts @@ -4,10 +4,14 @@ import { type ESTree, parseScript } from 'meriyah'; import { logError } from '../log/log-build'; export default function AST(loader: JavaScriptLoader = 'tsx') { - const transpiler = - typeof Bun !== 'undefined' - ? new Bun.Transpiler({ loader }) - : { transformSync: (code: string) => code }; + const isBun = typeof Bun !== 'undefined'; + const defaultTranspiler = { transformSync: (code: string) => code }; + + const minifier = isBun + ? new Bun.Transpiler({ loader, minifyWhitespace: true }) + : defaultTranspiler; + + const transpiler = isBun ? new Bun.Transpiler({ loader }) : defaultTranspiler; return { parseCodeToAST(code: string): ESTree.Program { @@ -28,5 +32,8 @@ export default function AST(loader: JavaScriptLoader = 'tsx') { generateCodeFromAST(ast: ESTree.Program) { return generate(ast, { indent: ' ' }); }, + minify(code: string) { + return minifier.transformSync(code); + }, }; } diff --git a/packages/brisa/src/utils/brisa-error-dialog/inject-code.ts b/packages/brisa/src/utils/brisa-error-dialog/inject-code.ts index 749ac3b71..e289a75fc 100644 --- a/packages/brisa/src/utils/brisa-error-dialog/inject-code.ts +++ b/packages/brisa/src/utils/brisa-error-dialog/inject-code.ts @@ -28,7 +28,7 @@ export async function injectBrisaDialogErrorCode() { // https://github.com/oven-sh/bun/issues/7611 await Bun.readableStreamToText(Bun.file(path).stream()), internalComponentId, - ).code, + ), loader, })); }, diff --git a/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.test.ts b/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.test.ts deleted file mode 100644 index e5f4559cc..000000000 --- a/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -import AST from '@/utils/ast'; -import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; -import addI18nBridge from '.'; -import { normalizeHTML } from '@/helpers'; -import { GlobalRegistrator } from '@happy-dom/global-registrator'; -import type { I18nConfig } from '@/types'; - -const I18N_CONFIG = { - defaultLocale: 'en', - locales: ['en', 'pt'], - messages: { - en: { - hello: 'Hello {{name}}', - }, - pt: { - hello: 'Olá {{name}}', - }, - }, - pages: {}, -}; - -mock.module('@/constants', () => ({ - default: { I18N_CONFIG }, -})); - -const { parseCodeToAST, generateCodeFromAST } = AST('tsx'); -const emptyAst = parseCodeToAST(''); - -describe('utils', () => { - beforeEach(() => { - mock.module('@/constants', () => ({ - default: { I18N_CONFIG }, - })); - }); - describe('client-build-plugin', () => { - describe('add-i18n-bridge', () => { - it('should add the code at the bottom', () => { - const code = ` - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - `; - const outputAst = addI18nBridge(parseCodeToAST(code), { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const outputCode = normalizeHTML(generateCodeFromAST(outputAst)); - const expectedCode = normalizeHTML(` - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - - const i18nConfig = { - defaultLocale: "en", - locales: ["en", "pt"] - }; - - window.i18n = { - ...i18nConfig, - get locale() {return document.documentElement.lang;} - }; - `); - expect(outputCode).toBe(expectedCode); - }); - - it('should add only the i18n keys logic at the botton if "i18nAdded" is true', () => { - const code = ` - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - `; - const outputAst = addI18nBridge(parseCodeToAST(code), { - usei18nKeysLogic: true, - i18nAdded: true, - isTranslateCoreAdded: false, - }); - const outputCode = normalizeHTML(generateCodeFromAST(outputAst)); - const expectedCode = normalizeHTML(` - import {translateCore} from "brisa"; - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - - Object.assign(window.i18n, { - get t() {return translateCore(this.locale, {...{defaultLocale: "en",locales: ["en", "pt"]},messages: this.messages});}, - get messages() {return {[this.locale]: window.i18nMessages};}, - overrideMessages(callback) { - const p = callback(window.i18nMessages); - const a = m => Object.assign(window.i18nMessages, m); - return p.then?.(a) ?? a(p); - } - }); - `); - expect(outputCode).toBe(expectedCode); - }); - - it('should add the code at the bottom with i18n keys logic and the import on top', () => { - const code = ` - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - `; - const outputAst = addI18nBridge(parseCodeToAST(code), { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const outputCode = normalizeHTML(generateCodeFromAST(outputAst)); - const expectedCode = normalizeHTML(` - import {translateCore} from "brisa"; - import foo from 'bar'; - import baz from 'qux'; - - const a = 1; - - const i18nConfig = { - defaultLocale: "en", - locales: ["en", "pt"] - }; - - window.i18n = { - ...i18nConfig, - get locale() {return document.documentElement.lang;}, - get t() {return translateCore(this.locale, {...i18nConfig,messages: this.messages});}, - get messages() {return {[this.locale]: window.i18nMessages};}, - overrideMessages(callback) { - const p = callback(window.i18nMessages); - const a = m => Object.assign(window.i18nMessages, m); - return p.then?.(a) ?? a(p); - } - }; - `); - expect(outputCode).toBe(expectedCode); - }); - - it('should add work with empty code', () => { - const outputAst = addI18nBridge(emptyAst, { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const outputCode = normalizeHTML(generateCodeFromAST(outputAst)); - const expectedCode = normalizeHTML(` - const i18nConfig = { - defaultLocale: "en", - locales: ["en", "pt"] - }; - - window.i18n = { - ...i18nConfig, - get locale() {return document.documentElement.lang;} - }; - `); - expect(outputCode).toBe(expectedCode); - }); - - it('should work with empty code with i18n keys logic', () => { - const outputAst = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const outputCode = normalizeHTML(generateCodeFromAST(outputAst)); - const expectedCode = normalizeHTML(` - import {translateCore} from "brisa"; - - const i18nConfig = { - defaultLocale: "en", - locales: ["en", "pt"] - }; - - window.i18n = { - ...i18nConfig, - get locale() {return document.documentElement.lang;}, - get t() {return translateCore(this.locale, {...i18nConfig,messages: this.messages});}, - get messages() {return {[this.locale]: window.i18nMessages};}, - overrideMessages(callback) { - const p = callback(window.i18nMessages); - const a = m => Object.assign(window.i18nMessages, m); - return p.then?.(a) ?? a(p); - } - }; - `); - expect(outputCode).toBe(expectedCode); - }); - }); - describe('add-i18n-bridge functionality', () => { - beforeEach(() => GlobalRegistrator.register()); - afterEach(() => GlobalRegistrator.unregister()); - - it('should work the window.i18n without translate code', async () => { - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const output = generateCodeFromAST(ast); - const script = document.createElement('script'); - script.innerHTML = output; - document.documentElement.lang = 'pt'; - document.body.appendChild(script); - - expect(window.i18n.locale).toBe('pt'); - }); - - it('should work the window.i18n with translate code', async () => { - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - let output = generateCodeFromAST(ast); - - output = output.replace( - normalizeHTML("import {translateCore} from 'brisa';"), - 'const translateCore = () => (k) => "Olá John";', - ); - - window.i18nMessages = I18N_CONFIG.messages['pt']; - - const script = document.createElement('script'); - script.innerHTML = output; - document.documentElement.lang = 'pt'; - document.body.appendChild(script); - - expect(window.i18n.locale).toBe('pt'); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olá John'); - }); - - it('should work the window.i18n with translate code in separate steps', async () => { - const ast1 = addI18nBridge(emptyAst, { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - const ast2 = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: true, - isTranslateCoreAdded: false, - }); - - let output = generateCodeFromAST(ast1) + generateCodeFromAST(ast2); - - output = output.replace( - normalizeHTML("import {translateCore} from 'brisa';"), - 'const translateCore = () => (k) => "Olá John";', - ); - - window.i18nMessages = I18N_CONFIG.messages['pt']; - - const script = document.createElement('script'); - script.innerHTML = output; - document.documentElement.lang = 'pt'; - document.body.appendChild(script); - - expect(window.i18n.locale).toBe('pt'); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olá John'); - }); - - it('should override messages using i18n.overrideMessages util', () => { - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: true, - }); - let output = generateCodeFromAST(ast); - - output = output.replace( - normalizeHTML("import {translateCore} from 'brisa';"), - "const translateCore = () => (k) => window.i18nMessages[k].replace('{{name}}', 'John');", - ); - - window.i18nMessages = structuredClone(I18N_CONFIG.messages['pt']); - - const script = document.createElement('script'); - script.innerHTML = output; - document.documentElement.lang = 'pt'; - document.body.appendChild(script); - - expect(window.i18n.locale).toBe('pt'); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olá John'); - - window.i18n.overrideMessages((messages: Record) => ({ - ...messages, - hello: 'Olááá {{name}}', - })); - - expect(window.i18n.messages).toEqual({ pt: window.i18nMessages }); - expect(window.i18nMessages).toEqual({ hello: 'Olááá {{name}}' }); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olááá John'); - }); - - it('should override messages using ASYNC i18n.overrideMessages util', async () => { - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: true, - }); - let output = generateCodeFromAST(ast); - - output = output.replace( - normalizeHTML("import {translateCore} from 'brisa';"), - "const translateCore = () => (k) => window.i18nMessages[k].replace('{{name}}', 'John');", - ); - - window.i18nMessages = structuredClone(I18N_CONFIG.messages['pt']); - - const script = document.createElement('script'); - script.innerHTML = output; - document.documentElement.lang = 'pt'; - document.body.appendChild(script); - - expect(window.i18n.locale).toBe('pt'); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olá John'); - - await window.i18n.overrideMessages( - async (messages: Record) => ({ - ...messages, - hello: 'Olááá {{name}}', - }), - ); - - expect(window.i18n.messages).toEqual({ pt: window.i18nMessages }); - expect(window.i18nMessages).toEqual({ hello: 'Olááá {{name}}' }); - expect(window.i18n.t('hello', { name: 'John' })).toBe('Olááá John'); - }); - - it('should import the i18n pages when config.transferToClient is true', () => { - mock.module('@/constants', () => ({ - default: { - I18N_CONFIG: { - ...I18N_CONFIG, - pages: { - config: { - transferToClient: true, - }, - '/about-us': { - en: '/about-us/', - es: '/sobre-nosotros/', - }, - '/user/[username]': { - en: '/user/[username]', - es: '/usuario/[username]', - }, - '/somepage': { - en: '/somepage', - es: '/alguna-pagina', - }, - }, - } as I18nConfig, - }, - })); - - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - - const output = generateCodeFromAST(ast); - const script = document.createElement('script'); - - script.innerHTML = output; - - document.body.appendChild(script); - - expect(window.i18n.pages).toEqual({ - '/about-us': { - en: '/about-us/', - es: '/sobre-nosotros/', - }, - '/user/[username]': { - en: '/user/[username]', - es: '/usuario/[username]', - }, - '/somepage': { - en: '/somepage', - es: '/alguna-pagina', - }, - }); - }); - - it('should import the i18n pages when config.transferToClient is an array', () => { - mock.module('@/constants', () => ({ - default: { - I18N_CONFIG: { - ...I18N_CONFIG, - pages: { - config: { - transferToClient: ['/about-us', '/user/[username]'], - }, - '/about-us': { - en: '/about-us/', - es: '/sobre-nosotros/', - }, - '/user/[username]': { - en: '/user/[username]', - es: '/usuario/[username]', - }, - '/somepage': { - en: '/somepage', - es: '/alguna-pagina', - }, - }, - } as I18nConfig, - }, - })); - - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: false, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - - const output = generateCodeFromAST(ast); - const script = document.createElement('script'); - - script.innerHTML = output; - - document.body.appendChild(script); - - expect(window.i18n.pages).toEqual({ - '/about-us': { - en: '/about-us/', - es: '/sobre-nosotros/', - }, - '/user/[username]': { - en: '/user/[username]', - es: '/usuario/[username]', - }, - }); - }); - - it('should add interpolation.format function on the client-side', () => { - mock.module('@/constants', () => ({ - default: { - I18N_CONFIG: { - ...I18N_CONFIG, - interpolation: { - format: (value, formatName, lang) => { - if (formatName === 'uppercase') { - return (value as string).toUpperCase(); - } - if (formatName === 'lang') return lang; - return value; - }, - }, - } as I18nConfig, - }, - })); - - const ast = addI18nBridge(emptyAst, { - usei18nKeysLogic: true, - i18nAdded: false, - isTranslateCoreAdded: false, - }); - - const output = generateCodeFromAST(ast); - expect(normalizeHTML(output)).toContain( - normalizeHTML(`return translateCore(this.locale, { - ...i18nConfig, - messages: this.messages, - interpolation: { - ...i18nConfig.interpolation, - format: (value, formatName, lang) => { - if (formatName === "uppercase") return value.toUpperCase(); - if (formatName === "lang") return lang; - return value; - } - } - } - );`), - ); - }); - }); - }); -}); diff --git a/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.ts b/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.ts deleted file mode 100644 index 054401de5..000000000 --- a/packages/brisa/src/utils/client-build-plugin/add-i18n-bridge/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import constants from '@/constants'; -import AST from '@/utils/ast'; -import { TRANSLATE_CORE_IMPORT } from '@/utils/client-build-plugin/constants'; -import transferTranslatedPagePaths from '@/utils/transfer-translated-page-paths'; -import type { ESTree } from 'meriyah'; - -const { parseCodeToAST } = AST('tsx'); - -type I18nBridgeConfig = { - usei18nKeysLogic: boolean; - isTranslateCoreAdded: boolean; - i18nAdded: boolean; -}; - -const i18nKeysLogic = (configText = 'i18nConfig') => { - const formatters = - typeof constants.I18N_CONFIG?.interpolation?.format === 'function' - ? `interpolation: {...i18nConfig.interpolation, format:${constants.I18N_CONFIG.interpolation?.format.toString()}},` - : ''; - - return ` - get t() { - return translateCore(this.locale, { ...${configText}, messages: this.messages, ${formatters} }); - }, - get messages() { return {[this.locale]: window.i18nMessages } }, - overrideMessages(callback) { - const p = callback(window.i18nMessages); - const a = m => Object.assign(window.i18nMessages, m); - return p.then?.(a) ?? a(p); - } -`; -}; - -export default function addI18nBridge( - ast: ESTree.Program, - { usei18nKeysLogic, i18nAdded, isTranslateCoreAdded }: I18nBridgeConfig, -) { - if (i18nAdded && isTranslateCoreAdded) return ast; - - const i18nConfig = JSON.stringify({ - ...constants.I18N_CONFIG, - messages: undefined, - pages: transferTranslatedPagePaths(constants.I18N_CONFIG?.pages), - }); - let body = ast.body; - - const bridgeAst = parseCodeToAST(` - const i18nConfig = ${i18nConfig}; - - window.i18n = { - ...i18nConfig, - get locale(){ return document.documentElement.lang }, - ${usei18nKeysLogic ? i18nKeysLogic() : ''} - } - `); - - if (usei18nKeysLogic && i18nAdded && !isTranslateCoreAdded) { - const newAst = parseCodeToAST( - `Object.assign(window.i18n, {${i18nKeysLogic(i18nConfig)}})`, - ); - body = [TRANSLATE_CORE_IMPORT, ...ast.body, ...newAst.body]; - } else if (usei18nKeysLogic) { - body = [TRANSLATE_CORE_IMPORT, ...ast.body, ...bridgeAst.body]; - } else { - body = [...ast.body, ...bridgeAst.body]; - } - - return { ...ast, body }; -} diff --git a/packages/brisa/src/utils/client-build-plugin/index.test.ts b/packages/brisa/src/utils/client-build-plugin/index.test.ts index afbc1c88a..0d5617858 100644 --- a/packages/brisa/src/utils/client-build-plugin/index.test.ts +++ b/packages/brisa/src/utils/client-build-plugin/index.test.ts @@ -23,7 +23,7 @@ describe('utils', () => { clientBuildPlugin( input, '/src/web-components/_native/my-component.tsx', - ).code, + ), ); const expected = toInline(input); expect(output).toBe(expected); @@ -41,7 +41,7 @@ describe('utils', () => { clientBuildPlugin( input, '/src/web-components/_partials/my-component.tsx', - ).code, + ), ); const expected = toInline(` export default function partial() { @@ -58,7 +58,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, '__BRISA_CLIENT__ContextProvider').code, + clientBuildPlugin(input, '__BRISA_CLIENT__ContextProvider'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -79,7 +79,7 @@ describe('utils', () => { const output = toInline( clientBuildPlugin(input, '/src/components/my-component.tsx', { forceTranspilation: true, - }).code, + }), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -100,7 +100,7 @@ describe('utils', () => { const output = toInline( clientBuildPlugin(input, '/src/web-components/my-component.tsx', { customElementSelectorToDefine: 'my-component', - }).code, + }), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -125,7 +125,7 @@ describe('utils', () => { const output = toInline( clientBuildPlugin(input, '/src/web-components/my-component.tsx', { customElementSelectorToDefine: 'my-component', - }).code, + }), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -146,7 +146,7 @@ describe('utils', () => { const output = toInline( clientBuildPlugin(input, '/src/web-components/my-component.tsx', { customElementSelectorToDefine: 'my-component', - }).code, + }), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -165,7 +165,7 @@ describe('utils', () => { const element =
foo
`; const output = toInline( - clientBuildPlugin(input, '/src/components/my-component.tsx').code, + clientBuildPlugin(input, '/src/components/my-component.tsx'), ); const expected = toInline( `const element = ['div', {}, 'foo'];export default null;`, @@ -178,7 +178,7 @@ describe('utils', () => { const element = () =>
foo
`; const output = toInline( - clientBuildPlugin(input, '/src/components/my-component.tsx').code, + clientBuildPlugin(input, '/src/components/my-component.tsx'), ); const expected = toInline( `const element = () => ['div', {}, 'foo'];export default null;`, @@ -193,7 +193,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -214,7 +214,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -235,7 +235,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -260,7 +260,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -285,7 +285,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -308,7 +308,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -332,7 +332,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -355,7 +355,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -378,7 +378,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -415,7 +415,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -459,7 +459,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -517,7 +517,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -542,7 +542,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -572,7 +572,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -601,7 +601,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -624,7 +624,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -648,7 +648,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -670,7 +670,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -690,7 +690,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -712,7 +712,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -736,7 +736,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -760,7 +760,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -784,7 +784,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -806,7 +806,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -826,7 +826,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -846,7 +846,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -866,7 +866,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -887,7 +887,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -910,7 +910,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -934,7 +934,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -957,7 +957,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -981,7 +981,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -1006,7 +1006,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -1031,7 +1031,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1060,7 +1060,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1089,7 +1089,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1118,7 +1118,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1153,7 +1153,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -1181,7 +1181,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = @@ -1205,7 +1205,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1231,7 +1231,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1257,7 +1257,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1283,7 +1283,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1314,8 +1314,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/conditional-render.tsx') - .code, + clientBuildPlugin(input, 'src/web-components/conditional-render.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1341,7 +1340,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1367,7 +1366,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1392,7 +1391,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1416,7 +1415,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1439,7 +1438,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1463,7 +1462,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1498,7 +1497,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1529,7 +1528,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` @@ -1559,7 +1558,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1611,7 +1610,7 @@ describe('utils', () => { } `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` import {brisaElement, _on, _off} from "brisa/client"; @@ -1646,7 +1645,7 @@ describe('utils', () => { `; const output = toInline( - clientBuildPlugin(input, 'src/web-components/my-component.tsx').code, + clientBuildPlugin(input, 'src/web-components/my-component.tsx'), ); const expected = toInline(` diff --git a/packages/brisa/src/utils/client-build-plugin/index.ts b/packages/brisa/src/utils/client-build-plugin/index.ts index 397717b88..fe6fd726f 100644 --- a/packages/brisa/src/utils/client-build-plugin/index.ts +++ b/packages/brisa/src/utils/client-build-plugin/index.ts @@ -13,7 +13,6 @@ import replaceExportDefault from './replace-export-default'; import processClientAst from './process-client-ast'; import getReactiveReturnStatement from './get-reactive-return-statement'; import { WEB_COMPONENT_ALTERNATIVE_REGEX, NATIVE_FOLDER } from './constants'; -import addI18nBridge from './add-i18n-bridge'; import defineCustomElementToAST from './define-custom-element-to-ast'; type ClientBuildPluginConfig = { @@ -23,12 +22,6 @@ type ClientBuildPluginConfig = { customElementSelectorToDefine?: null | string; }; -type ClientBuildPluginResult = { - code: string; - useI18n: boolean; - i18nKeys: Set; -}; - const { parseCodeToAST, generateCodeFromAST } = AST('tsx'); const BRISA_INTERNAL_PATH = '__BRISA_CLIENT__'; const DEFAULT_CONFIG: ClientBuildPluginConfig = { @@ -42,7 +35,7 @@ export default function clientBuildPlugin( code: string, path: string, config = DEFAULT_CONFIG, -): ClientBuildPluginResult { +): string { const isInternal = path.startsWith(BRISA_INTERNAL_PATH); if ( @@ -50,20 +43,11 @@ export default function clientBuildPlugin( !isInternal && !config.forceTranspilation ) { - return { code, useI18n: false, i18nKeys: new Set() }; + return code; } const rawAst = parseCodeToAST(code); - let { useI18n, i18nKeys, ast } = processClientAst(rawAst, path); - - if (useI18n) { - ast = addI18nBridge(ast, { - usei18nKeysLogic: i18nKeys.size > 0, - i18nAdded: Boolean(config.isI18nAdded), - isTranslateCoreAdded: Boolean(config.isTranslateCoreAdded), - }); - } - + const ast = processClientAst(rawAst, path); const astWithDirectExport = transformToDirectExport(ast); const out = transformToReactiveProps(astWithDirectExport); const reactiveAst = transformToReactiveArrays(out.ast, path); @@ -77,11 +61,7 @@ export default function clientBuildPlugin( !isInternal && !config.forceTranspilation) ) { - return { - code: generateCodeFromAST(reactiveAst), - useI18n, - i18nKeys, - }; + return generateCodeFromAST(reactiveAst); } for (const { observedAttributes = new Set() } of Object.values( @@ -135,13 +115,9 @@ export default function clientBuildPlugin( // Useful for internal web components as context-provider if (isInternal) { const internalComponentName = path.split(BRISA_INTERNAL_PATH).at(-1)!; - return { - code: generateCodeFromAST( - replaceExportDefault(reactiveAst, internalComponentName), - ), - useI18n, - i18nKeys, - }; + return generateCodeFromAST( + replaceExportDefault(reactiveAst, internalComponentName), + ); } // This is used for standalone component builds (library components) @@ -152,5 +128,5 @@ export default function clientBuildPlugin( }); } - return { code: generateCodeFromAST(reactiveAst), useI18n, i18nKeys }; + return generateCodeFromAST(reactiveAst); } diff --git a/packages/brisa/src/utils/client-build-plugin/integration.test.tsx b/packages/brisa/src/utils/client-build-plugin/integration.test.tsx index 0222ca59b..46ba5213f 100644 --- a/packages/brisa/src/utils/client-build-plugin/integration.test.tsx +++ b/packages/brisa/src/utils/client-build-plugin/integration.test.tsx @@ -19,9 +19,7 @@ declare global { function defineBrisaWebComponent(code: string, path: string) { const componentName = path.split('/').pop()?.split('.')[0] as string; - const webComponent = `(() => {${normalizeHTML( - clientBuildPlugin(code, path).code, - ) + const webComponent = `(() => {${normalizeHTML(clientBuildPlugin(code, path)) .replace('import {brisaElement, _on, _off} from "brisa/client";', '') .replace('export default', 'const _Test =')}return _Test;})()`; diff --git a/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.test.ts b/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.test.ts index 5fd2c230c..0cf0b7099 100644 --- a/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.test.ts +++ b/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.test.ts @@ -1,12 +1,58 @@ import { describe, it, expect, spyOn } from 'bun:test'; +import { join } from 'node:path'; import AST from '@/utils/ast'; -import processClientAst from '.'; +import processClientAST from '.'; import { normalizeHTML, toInline } from '@/helpers'; +const brisaClient = join('brisa', 'client', 'index.js'); const { parseCodeToAST, generateCodeFromAST } = AST('tsx'); describe('utils', () => { describe('process-client-ast', () => { + it('should remove import from "react/jsx-runtime" (some TSX -> JS transpilers like @swc add it, but then jsx-runtme is not used...)', () => { + const ast = parseCodeToAST(` + import { jsx } from 'react/jsx-runtime'; + export default function Component() { + return jsx('div', null, 'Hello World'); + } + `); + + const res = processClientAST(ast); + + expect(toInline(generateCodeFromAST(res))).toBe( + normalizeHTML(` + export default function Component() { + return jsx('div', null, 'Hello World'); + } + `), + ); + }); + + it('should remove ".css" imports and log a warning', () => { + const mockLog = spyOn(console, 'log'); + const ast = parseCodeToAST(` + import './styles.css'; + export default function Component() { + return jsx('div', null, 'Hello World'); + } + `); + + const res = processClientAST(ast); + + const logs = mockLog.mock.calls.toString(); + mockLog.mockRestore(); + + expect(toInline(generateCodeFromAST(res))).toBe( + normalizeHTML(` + export default function Component() { + return jsx('div', null, 'Hello World'); + } + `), + ); + expect(logs).toContain( + 'Add this global import into the layout or the page.', + ); + }); it('should detect i18n when is declated and used to consume the locale', () => { const ast = parseCodeToAST(` export default function Component({i18n}) { @@ -15,10 +61,24 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); + + expect(res).toContain('useI18n = true'); + expect(res).not.toContain('i18nKeys'); + }); + + it('should not detect i18n when the path is brisa client', () => { + const ast = parseCodeToAST(` + export default function Component({i18n}) { + const { locale } = i18n; + return
{locale}
+ } + `); + + const res = generateCodeFromAST(processClientAST(ast, brisaClient)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toBeEmpty(); + expect(res).not.toContain('useI18n = true'); + expect(res).not.toContain('i18nKeys'); }); it('should detect i18n when is used to consume the locale from webContext identifier', () => { @@ -29,10 +89,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toBeEmpty(); + expect(res).toContain('useI18n = true'); + expect(res).not.toContain('i18nKeys'); }); it('should detect i18n when is used to consume the locale from webContext identifier + destructuring', () => { @@ -43,10 +103,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toBeEmpty(); + expect(res).toContain('useI18n = true'); + expect(res).not.toContain('i18nKeys'); }); it('should detect i18n when is used to consume t function', () => { @@ -56,10 +116,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res).toContain('useI18n = true'); + expect(res).toContain('i18nKeys = ["hello"]'); }); it('should detect i18n when is used to consume t function from arrow function', () => { @@ -71,10 +131,10 @@ describe('utils', () => { export default Component; `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res).toContain('useI18n = true'); + expect(res).toContain('i18nKeys = ["hello"]'); }); it('should return all the i18n keys used in the component', () => { @@ -84,10 +144,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res).toContain('useI18n = true'); + expect(res).toContain('i18nKeys = ["hello"]'); }); it('should return all the i18n keys used in the component using destructuring', () => { @@ -97,10 +157,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res).toContain('useI18n = true'); + expect(res).toContain('i18nKeys = ["hello"]'); }); it('should return all the i18n keys used in the component using webContext identifier', () => { @@ -110,10 +170,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res).toContain('useI18n = true'); + expect(res).toContain('i18nKeys = ["hello"]'); }); it('should not return as i18n keys when is not using i18n', () => { @@ -125,10 +185,10 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeFalse(); - expect(res.i18nKeys).toBeEmpty(); + expect(res).not.toContain('useI18n'); + expect(res).not.toContain('i18nKeys'); }); it('should log a warning and no return i18n keys when there is no literal as first argument', () => { @@ -141,13 +201,14 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); + + expect(res).toContain('useI18n = true'); + expect(res).not.toContain('i18nKeys'); const logs = mockLog.mock.calls.toString(); mockLog.mockRestore(); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toBeEmpty(); expect(logs).toContain('Ops! Warning:'); expect(logs).toContain('Addressing Dynamic i18n Key Export Limitations'); expect(logs).toContain( @@ -164,15 +225,16 @@ describe('utils', () => { Component.i18nKeys = ["hello-world"]; `); - const res = processClientAst(ast); + const res = generateCodeFromAST(processClientAST(ast)); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello', 'hello-world'])); - expect(toInline(generateCodeFromAST(res.ast))).toBe( + expect(toInline(res)).toBe( toInline(` export default function Component({}, {i18n}) { return i18n.t("hello"); } + + window.i18nKeys = ["hello", "hello-world"]; + window.useI18n = true; `), ); }); @@ -188,18 +250,20 @@ describe('utils', () => { Component.i18nKeys = ["hello-world"]; `); - const res = processClientAst(ast); + const res = processClientAST(ast); expect(mockLog).not.toHaveBeenCalled(); mockLog.mockRestore(); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello-world'])); - expect(toInline(generateCodeFromAST(res.ast))).toBe( + + expect(toInline(generateCodeFromAST(res))).toBe( toInline(` export default function Component({}, {i18n}) { const someVar = "hello-world"; return i18n.t(someVar); } + + window.i18nKeys = ["hello-world"]; + window.useI18n = true; `), ); }); @@ -216,11 +280,9 @@ describe('utils', () => { } `); - const res = processClientAst(ast); + const res = processClientAST(ast); - expect(res.useI18n).toBeTrue(); - expect(res.i18nKeys).toEqual(new Set(['hello-world'])); - expect(toInline(generateCodeFromAST(res.ast))).toBe( + expect(toInline(generateCodeFromAST(res))).toBe( toInline(` export default function Component({}, {i18n}) { const someVar = "hello-world"; @@ -228,53 +290,10 @@ describe('utils', () => { } if (true) {} + window.i18nKeys = ["hello-world"]; + window.useI18n = true; `), ); }); - - it('should remove import from "react/jsx-runtime" (some TSX -> JS transpilers like @swc add it, but then jsx-runtme is not used...)', () => { - const ast = parseCodeToAST(` - import { jsx } from 'react/jsx-runtime'; - export default function Component() { - return jsx('div', null, 'Hello World'); - } - `); - - const res = processClientAst(ast); - - expect(toInline(generateCodeFromAST(res.ast))).toBe( - normalizeHTML(` - export default function Component() { - return jsx('div', null, 'Hello World'); - } - `), - ); - }); - - it('should remove ".css" imports and log a warning', () => { - const mockLog = spyOn(console, 'log'); - const ast = parseCodeToAST(` - import './styles.css'; - export default function Component() { - return jsx('div', null, 'Hello World'); - } - `); - - const res = processClientAst(ast); - - const logs = mockLog.mock.calls.toString(); - mockLog.mockRestore(); - - expect(toInline(generateCodeFromAST(res.ast))).toBe( - normalizeHTML(` - export default function Component() { - return jsx('div', null, 'Hello World'); - } - `), - ); - expect(logs).toContain( - 'Add this global import into the layout or the page.', - ); - }); }); }); diff --git a/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.ts b/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.ts index 996b807ce..3bd209854 100644 --- a/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.ts +++ b/packages/brisa/src/utils/client-build-plugin/process-client-ast/index.ts @@ -1,19 +1,49 @@ import type { ESTree } from 'meriyah'; +import { join } from 'node:path'; import { logWarning } from '@/utils/log/log-build'; -import AST from '@/utils/ast'; import { toInline } from '@/helpers'; +import AST from '@/utils/ast'; const { generateCodeFromAST } = AST('tsx'); +const brisaClientPath = join('brisa', 'client', 'index.js'); export default function processClientAst(ast: ESTree.Program, path = '') { let i18nKeys = new Set(); let useI18n = false; const logs: any[] = []; + const isBrisaClient = path.endsWith(brisaClientPath); let isDynamicKeysSpecified = false; const newAst = JSON.parse(JSON.stringify(ast), (key, value) => { - useI18n ||= value?.type === 'Identifier' && value?.name === 'i18n'; + useI18n ||= + !isBrisaClient && value?.type === 'Identifier' && value?.name === 'i18n'; + + if ( + value?.type === 'CallExpression' && + ((value?.callee?.type === 'Identifier' && value?.callee?.name === 't') || + (value?.callee?.property?.type === 'Identifier' && + value?.callee?.property?.name === 't')) + ) { + if (value?.arguments?.[0]?.type === 'Literal') { + i18nKeys.add(value?.arguments?.[0]?.value); + } else { + logs.push(value); + } + } + // Add dynamic keys from: MyWebComponent.i18nKeys = ['footer', /projects.*title/]; + if ( + value?.type === 'ExpressionStatement' && + value.expression.left?.property?.name === 'i18nKeys' && + value.expression?.right?.type === 'ArrayExpression' + ) { + for (const element of value.expression.right.elements ?? []) { + i18nKeys.add(element.value); + isDynamicKeysSpecified = true; + } + // Remove the expression statement + return null; + } // Remove react/jsx-runtime import, some transpilers like @swc add it, // but we are not using jsx-runtime here, we are using jsx-buildtime if ( @@ -48,35 +78,8 @@ export default function processClientAst(ast: ESTree.Program, path = '') { return null; } - if ( - value?.type === 'CallExpression' && - ((value?.callee?.type === 'Identifier' && value?.callee?.name === 't') || - (value?.callee?.property?.type === 'Identifier' && - value?.callee?.property?.name === 't')) - ) { - if (value?.arguments?.[0]?.type === 'Literal') { - i18nKeys.add(value?.arguments?.[0]?.value); - } else { - logs.push(value); - } - } - - // Add dynamic keys from: MyWebComponent.i18nKeys = ['footer', /projects.*title/]; - if ( - value?.type === 'ExpressionStatement' && - value.expression.left?.property?.name === 'i18nKeys' && - value.expression?.right?.type === 'ArrayExpression' - ) { - for (const element of value.expression.right.elements ?? []) { - i18nKeys.add(element.value); - isDynamicKeysSpecified = true; - } - // Remove the expression statement - return null; - } - - // Remove arrays with empty values - if (Array.isArray(value)) return value.filter((v) => v); + // Clean null values inside arrays + if (Array.isArray(value)) return value.filter(Boolean); return value; }); @@ -107,7 +110,65 @@ export default function processClientAst(ast: ESTree.Program, path = '') { ); } - if (!useI18n) i18nKeys = new Set(); + // This is a workaround to in a post-analysis collect all i18nKeys and useI18n from the + // entrypoint to inject the i18n bridge and clean these variables. That is, they are not + // real gobal variables, it is a communication between dependency graph for the post-analysis. + // + // It is necessary to think that each file can be connected to different entrypoints, so at + // this point we note the keys and if it uses i18n (lang or other attributes without translations). + // + // Communication variables: window.i18nKeys & window.useI18n + if (useI18n && i18nKeys.size) { + newAst.body.push({ + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'window', + }, + property: { + type: 'Identifier', + name: 'i18nKeys', + }, + }, + right: { + type: 'ArrayExpression', + elements: Array.from(i18nKeys).map((key) => ({ + type: 'Literal', + value: key, + })), + }, + }, + }); + } + if (useI18n) { + newAst.body.push({ + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'window', + }, + property: { + type: 'Identifier', + name: 'useI18n', + }, + }, + right: { + type: 'Literal', + value: useI18n, + }, + }, + }); + } - return { useI18n, i18nKeys, ast: newAst }; + return newAst; } diff --git a/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.test.ts b/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.test.ts new file mode 100644 index 000000000..9e4ca46ee --- /dev/null +++ b/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import path from 'node:path'; +import { rm, readFile, exists, mkdir } from 'node:fs/promises'; +import { normalizeHTML } from '@/helpers'; +import { + removeTempEntrypoint, + removeTempEntrypoints, + writeTempEntrypoint, +} from '.'; + +const TEMP_DIR = path.join(import.meta.dirname, '.temp-test-files'); + +describe('client build -> fs-temp-entrypoint-manager', () => { + beforeEach(async () => { + globalThis.mockConstants = { + PAGES_DIR: TEMP_DIR, + BUILD_DIR: TEMP_DIR, + }; + await mkdir(path.join(TEMP_DIR, '_brisa'), { recursive: true }); + }); + + afterEach(async () => { + delete globalThis.mockConstants; + await rm(TEMP_DIR, { recursive: true }); + }); + + it('should write and remove temp entrypoint', async () => { + const { entrypoint } = await writeTempEntrypoint({ + webComponentsList: { + 'test-component': 'test-component.ts', + }, + useContextProvider: false, + pagePath: '/test-page.ts', + }); + expect(await exists(entrypoint)).toBeTrue(); + expect(normalizeHTML(await readFile(entrypoint, 'utf-8'))).toBe( + normalizeHTML(` + import testComponent from "test-component.ts"; + + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("test-component", testComponent); + `), + ); + await removeTempEntrypoint(entrypoint); + expect(await exists(entrypoint)).toBeFalse(); + }); + + it('should write and remove temp entrypoints', async () => { + const { entrypoint: entrypoint1 } = await writeTempEntrypoint({ + webComponentsList: { + 'test-component-1': 'test-component-1.ts', + }, + useContextProvider: false, + pagePath: '/test-page-1.ts', + }); + const { entrypoint: entrypoint2 } = await writeTempEntrypoint({ + webComponentsList: { + 'test-component-2': 'test-component-2.ts', + }, + useContextProvider: false, + pagePath: '/test-page-2.ts', + }); + expect(await exists(entrypoint1)).toBeTrue(); + expect(await exists(entrypoint2)).toBeTrue(); + await removeTempEntrypoints([entrypoint1, entrypoint2]); + expect(await exists(entrypoint1)).toBeFalse(); + expect(await exists(entrypoint2)).toBeFalse(); + }); +}); diff --git a/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.ts b/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.ts new file mode 100644 index 000000000..460c5630c --- /dev/null +++ b/packages/brisa/src/utils/client-build/fs-temp-entrypoint-manager/index.ts @@ -0,0 +1,36 @@ +import { writeFile, rm } from 'node:fs/promises'; +import { generateEntryPointCode } from '../generate-entrypoint-code'; +import { getTempPageName } from '../get-temp-page-name'; + +type TransformOptions = { + webComponentsList: Record; + useContextProvider: boolean; + integrationsPath?: string | null; + pagePath: string; +}; + +export async function writeTempEntrypoint({ + webComponentsList, + useContextProvider, + integrationsPath, + pagePath, +}: TransformOptions) { + const webEntrypoint = getTempPageName(pagePath); + const { code, useWebContextPlugins } = await generateEntryPointCode({ + webComponentsList, + useContextProvider, + integrationsPath, + }); + + await writeFile(webEntrypoint, code); + + return { entrypoint: webEntrypoint, useWebContextPlugins }; +} + +export async function removeTempEntrypoint(entrypoint: string) { + return rm(entrypoint); +} + +export async function removeTempEntrypoints(entrypoints: string[]) { + return Promise.all(entrypoints.map(removeTempEntrypoint)); +} diff --git a/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.test.ts b/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.test.ts new file mode 100644 index 000000000..2a6dc7143 --- /dev/null +++ b/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, afterEach, mock } from 'bun:test'; +import { normalizeHTML } from '@/helpers'; +import { generateEntryPointCode } from '.'; +import { getConstants } from '@/constants'; + +describe('client build -> generateEntryPointCode', () => { + afterEach(() => { + globalThis.mockConstants = undefined; + }); + it('should generate the entrypoint code with the imports and customElements definition', async () => { + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: false, + }); + + expect(normalizeHTML(res.code)).toBe( + normalizeHTML(` + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + expect(res.useWebContextPlugins).toBeFalse(); + }); + + it('should generate the entrypoint code with the imports, customElements definition and context provider', async () => { + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: true, + }); + + const code = normalizeHTML(res.code); + + expect(code).toContain( + normalizeHTML(` + brisaElement(ClientContextProvider, ["context", "value", "pid", "cid"]); + `), + ); + + expect(code).toContain( + normalizeHTML(` + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + `), + ); + + expect(code).toContain( + normalizeHTML(` + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("context-provider", contextProvider); + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + expect(res.useWebContextPlugins).toBeFalse(); + }); + + it('should generate the entrypoint code with the imports, customElements definition and brisa-error-dialog (in dev)', async () => { + globalThis.mockConstants = { + ...getConstants(), + IS_DEVELOPMENT: true, + }; + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: false, + }); + + const code = normalizeHTML(res.code); + + expect(code).toContain( + normalizeHTML(` + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + `), + ); + + expect(code).toContain( + normalizeHTML(` + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("brisa-error-dialog", brisaErrorDialog); + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + + expect(code).toContain( + normalizeHTML(` + var brisaErrorDialog = brisaElement(ErrorDialog); + `), + ); + expect(res.useWebContextPlugins).toBeFalse(); + }); + + it('should return context provider without any web component', async () => { + const res = await generateEntryPointCode({ + webComponentsList: {}, + useContextProvider: true, + }); + + expect(normalizeHTML(res.code)).toContain( + normalizeHTML(` + brisaElement(ClientContextProvider, ["context", "value", "pid", "cid"]); + + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("context-provider", contextProvider); + `), + ); + expect(res.useWebContextPlugins).toBeFalse(); + }); + + it('should return empty when is without context and without any web component', async () => { + mock.module('/path/to/integrations', () => ({ + webContextPlugins: ['plugin1', 'plugin2'], + })); + const res = await generateEntryPointCode({ + webComponentsList: {}, + useContextProvider: false, + integrationsPath: '/path/to/integrations', + }); + + expect(normalizeHTML(res.code)).toBeEmpty(); + expect(res.useWebContextPlugins).toBeFalse(); + }); + + it('should generate the entrypoint code with the imports, customElements definition and webContextPlugins', async () => { + mock.module('/path/to/integrations', () => ({ + webContextPlugins: ['plugin1', 'plugin2'], + })); + + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: false, + integrationsPath: '/path/to/integrations', + }); + + const code = normalizeHTML(res.code); + + expect(code).toBe( + normalizeHTML(` + window._P=webContextPlugins; + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + import {webContextPlugins} from "/path/to/integrations"; + + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + + expect(res.useWebContextPlugins).toBeTrue(); + }); + + it('should generate the entrypoint code with the imports, customElements definition, context provider and webContextPlugins', async () => { + mock.module('/path/to/integrations', () => ({ + webContextPlugins: ['plugin1', 'plugin2'], + })); + + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: true, + integrationsPath: '/path/to/integrations', + }); + + const code = normalizeHTML(res.code); + + expect(code).toStartWith( + normalizeHTML(` + window._P=webContextPlugins; + + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + import {webContextPlugins} from "/path/to/integrations"; + `), + ); + + expect(code).toContain('function ClientContextProvider'); + + expect(code).toEndWith( + normalizeHTML(` + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("context-provider", contextProvider); + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + + expect(res.useWebContextPlugins).toBeTrue(); + }); + + it('should generate the entrypoint code with the imports, customElements definition, brisa-error-dialog (in dev) and webContextPlugins', async () => { + globalThis.mockConstants = { + ...getConstants(), + IS_DEVELOPMENT: true, + }; + mock.module('/path/to/integrations', () => ({ + webContextPlugins: ['plugin1', 'plugin2'], + })); + + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: false, + integrationsPath: '/path/to/integrations', + }); + + const code = normalizeHTML(res.code); + + expect(code).toStartWith( + normalizeHTML(` + window._P=webContextPlugins; + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + import {webContextPlugins} from "/path/to/integrations"; + `), + ); + + expect(code).toContain( + normalizeHTML(` + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("brisa-error-dialog", brisaErrorDialog); + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + + expect(code).toContain( + normalizeHTML(` + var brisaErrorDialog = brisaElement(ErrorDialog); + `), + ); + expect(res.useWebContextPlugins).toBeTrue(); + }); + + it('should generate the entrypoint code with the imports, customElements definition, context provider, brisa-error-dialog (in dev) and webContextPlugins', async () => { + globalThis.mockConstants = { + ...getConstants(), + IS_DEVELOPMENT: true, + }; + mock.module('/path/to/integrations', () => ({ + webContextPlugins: ['plugin1', 'plugin2'], + })); + + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: true, + integrationsPath: '/path/to/integrations', + }); + + const code = normalizeHTML(res.code); + + expect(code).toStartWith( + normalizeHTML(` + window._P=webContextPlugins; + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + import {webContextPlugins} from "/path/to/integrations"; + `), + ); + + expect(code).toContain( + normalizeHTML(` + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("brisa-error-dialog", brisaErrorDialog); + defineElement("context-provider", contextProvider); + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + + expect(code).toContain( + normalizeHTML(` + var brisaErrorDialog = brisaElement(ErrorDialog); + `), + ); + + expect(code).toContain( + normalizeHTML(` + brisaElement(ClientContextProvider, ["context", "value", "pid", "cid"]); + `), + ); + expect(res.useWebContextPlugins).toBeTrue(); + }); + + it('should generate the entrypoint code with the imports, customElements definition and webContextPlugins with no plugins', async () => { + mock.module('/path/to/integrations', () => ({ + webContextPlugins: [], + })); + + const res = await generateEntryPointCode({ + webComponentsList: { + 'my-component': '/path/to/my-component', + 'my-component2': '/path/to/my-component2', + }, + useContextProvider: false, + integrationsPath: '/path/to/integrations', + }); + + const code = normalizeHTML(res.code); + + expect(code).toBe( + normalizeHTML(` + import myComponent from "/path/to/my-component"; + import myComponent2 from "/path/to/my-component2"; + + const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component); + + defineElement("my-component", myComponent); + defineElement("my-component2", myComponent2); + `), + ); + expect(res.useWebContextPlugins).toBeFalse(); + }); +}); diff --git a/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.ts b/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.ts new file mode 100644 index 000000000..5ff82bdd5 --- /dev/null +++ b/packages/brisa/src/utils/client-build/generate-entrypoint-code/index.ts @@ -0,0 +1,140 @@ +import { sep } from 'node:path'; +import { getFilterDevRuntimeErrors } from '@/utils/brisa-error-dialog/utils'; +import { getConstants } from '@/constants'; +import { normalizePath } from '../normalize-path'; +import snakeToCamelCase from '@/utils/snake-to-camelcase'; +import { injectClientContextProviderCode } from '@/utils/context-provider/inject-client' with { + type: 'macro', +}; +import { injectBrisaDialogErrorCode } from '@/utils/brisa-error-dialog/inject-code' with { + type: 'macro', +}; + +type EntrypointOptions = { + webComponentsList: Record; + useContextProvider: boolean; + integrationsPath?: string | null; +}; + +/** + * Generates the complete entry point code for client-side rendering. + * This includes imports, Web Components registration, development-only + * debugging tools, and optional context provider support. + */ +export async function generateEntryPointCode({ + webComponentsList, + useContextProvider, + integrationsPath, +}: EntrypointOptions) { + const { IS_DEVELOPMENT } = getConstants(); + const entries = Object.entries(webComponentsList); + + if (!useContextProvider && entries.length === 0) { + return { code: '', useWebContextPlugins: false }; + } + + const { imports, useWebContextPlugins } = await getImports( + entries, + integrationsPath, + ); + + // Note: window._P should be in the first line, in this way, the imports + // can use this variable + let code = useWebContextPlugins + ? `window._P=webContextPlugins;\n${imports}` + : `${imports}\n`; + + const wcSelectors = getWebComponentSelectors(entries, { + useContextProvider, + isDevelopment: IS_DEVELOPMENT, + }); + + if (useContextProvider) { + code += injectClientContextProviderCode(); + } + + if (IS_DEVELOPMENT) { + code += await injectDevelopmentCode(); + } + + code += defineElements(wcSelectors); + + return { code, useWebContextPlugins }; +} + +/** + * Generates import statements for Web Components and optional integrations. + * Also determines if web context plugins are present in the integration module. + */ +async function getImports( + entries: [string, string][], + integrationsPath?: string | null, +) { + const imports = entries.map(([name, path]) => + path[0] === '{' + ? `require("${normalizePath(path)}");` + : `import ${snakeToCamelCase(name)} from "${path.replaceAll(sep, '/')}";`, + ); + + if (integrationsPath) { + const module = await import(integrationsPath); + + if (module.webContextPlugins?.length > 0) { + imports.push(`import {webContextPlugins} from "${integrationsPath}";`); + return { imports: imports.join('\n'), useWebContextPlugins: true }; + } + } + + return { imports: imports.join('\n'), useWebContextPlugins: false }; +} + +/** + * Generates the list of Web Component selectors to define. + * Includes internal components like context provider and error dialog. + */ +function getWebComponentSelectors( + entries: [string, string][], + { + useContextProvider, + isDevelopment, + }: { useContextProvider: boolean; isDevelopment: boolean }, +) { + const customElementKeys = entries + .filter(([_, path]) => path[0] !== '{') + .map(([key]) => key); + + if (useContextProvider) { + customElementKeys.unshift('context-provider'); + } + if (isDevelopment) { + customElementKeys.unshift('brisa-error-dialog'); + } + + return customElementKeys; +} + +/** + * Generates the JavaScript code to define all Web Components. + * Uses the `defineElement` function to register each Web Component + * only if it is not already defined in the custom elements registry. + */ +function defineElements(selectors: string[]): string { + const defineElementCode = + 'const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component);'; + const definitions = selectors + .map((key) => `defineElement("${key}", ${snakeToCamelCase(key)});`) + .join('\n'); + + return `${defineElementCode}\n${definitions}`; +} + +/** + * Injects development-only debugging tools like the brisa-error-dialog. + * This code is only included when running in development mode. + */ +async function injectDevelopmentCode(): Promise { + return (await injectBrisaDialogErrorCode()).replace( + '__FILTER_DEV_RUNTIME_ERRORS__', + getFilterDevRuntimeErrors(), + ); +} diff --git a/packages/brisa/src/utils/client-build/get-client-build-details/index.test.ts b/packages/brisa/src/utils/client-build/get-client-build-details/index.test.ts new file mode 100644 index 000000000..9ef9d80aa --- /dev/null +++ b/packages/brisa/src/utils/client-build/get-client-build-details/index.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdir, rm, writeFile, readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { getClientBuildDetails } from '.'; +import type { Options, WCs } from '../types'; +import { getConstants } from '@/constants'; + +const TEMP_DIR = path.join(import.meta.dirname, '.temp-test-files'); +const PAGES_DIR = path.join(TEMP_DIR, 'pages'); +const INTERNAL_DIR = path.join(TEMP_DIR, '_brisa'); + +function createTempFileSync(dir: string, content: string, extension = 'tsx') { + const fileName = `page-${Bun.hash(content)}.${extension}`; + const filePath = path.join(dir, fileName); + return { filePath, content }; +} + +async function writeTempFiles( + files: Array<{ filePath: string; content: string }>, +) { + await Promise.all( + files.map(({ filePath, content }) => writeFile(filePath, content, 'utf-8')), + ); +} + +describe('client build -> get-client-build-details', () => { + beforeEach(async () => { + await mkdir(PAGES_DIR, { recursive: true }); + await mkdir(INTERNAL_DIR, { recursive: true }); + globalThis.mockConstants = { + ...getConstants(), + PAGES_DIR, + BUILD_DIR: TEMP_DIR, + }; + }); + + afterEach(async () => { + await rm(TEMP_DIR, { recursive: true, force: true }); + globalThis.mockConstants = undefined; + }); + + it('should process a single page without web components', async () => { + const page = createTempFileSync( + PAGES_DIR, + ` + export default function Page() { + return
Hello World
; + } + `, + ); + + await writeTempFiles([page]); + + const pages = [{ path: page.filePath }] as any; + const options = { + allWebComponents: {}, + webComponentsPerEntrypoint: {}, + layoutWebComponents: {}, + layoutHasContextProvider: false, + }; + + const result = await getClientBuildDetails(pages, options); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + unsuspense: '', + rpc: '', + lazyRPC: '', + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: page.filePath, + useContextProvider: false, + webComponents: {}, + }); + }); + + it('should process a page with web components and generate an entrypoint', async () => { + const page = createTempFileSync( + PAGES_DIR, + ` + export default function Page() { + return Hello World; + } + `, + ); + + const wcFile = createTempFileSync( + PAGES_DIR, + ` + export default function MyComponent() { + return
My Component
; + } + `, + ); + + await writeTempFiles([page, wcFile]); + + const webComponentsMap: WCs = { + 'my-component': wcFile.filePath, + }; + + const pages = [{ path: page.filePath }] as any; + const options: Options = { + allWebComponents: webComponentsMap, + webComponentsPerEntrypoint: { + [page.filePath]: webComponentsMap, + }, + layoutWebComponents: {}, + layoutHasContextProvider: false, + }; + + const result = await getClientBuildDetails(pages, options); + + expect(result).toHaveLength(1); + const entrypointResult = result[0]; + + expect(entrypointResult).toMatchObject({ + unsuspense: '', + rpc: '', + lazyRPC: '', + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: page.filePath, + useContextProvider: false, + webComponents: { + 'my-component': wcFile.filePath, + }, + }); + + expect(entrypointResult.entrypoint).toBeDefined(); + expect(entrypointResult.useWebContextPlugins).toBe(false); + + // Validate the entrypoint file was written + const entrypointDir = path.dirname(entrypointResult.entrypoint!); + const files = await readdir(entrypointDir); + expect(files).toContain(path.basename(entrypointResult.entrypoint!)); + + const entrypointContent = await readFile( + entrypointResult.entrypoint!, + 'utf-8', + ); + expect(entrypointContent).toContain('my-component'); + }); + + it('should skip non-page files', async () => { + const nonPageFile = createTempFileSync( + TEMP_DIR, + ` + export default function NotAPage() { + return
This is not a page
; + } + `, + ); + + await writeTempFiles([nonPageFile]); + + const pages = [{ path: nonPageFile.filePath }] as any; + const options = { + allWebComponents: {}, + webComponentsPerEntrypoint: {}, + layoutWebComponents: {}, + layoutHasContextProvider: false, + }; + + const result = await getClientBuildDetails(pages, options); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple pages and aggregate results', async () => { + const page1 = createTempFileSync( + PAGES_DIR, + ` + export default function Page1() { + return Hello Page 1; + } + `, + ); + + const page2 = createTempFileSync( + PAGES_DIR, + ` + export default function Page2() { + return Hello Page 2; + } + `, + ); + + const wcFile = createTempFileSync( + PAGES_DIR, + ` + export default function MyComponent() { + return
My Component
; + } + `, + ); + + await writeTempFiles([page1, page2, wcFile]); + + const webComponentsMap: WCs = { + 'my-component': wcFile.filePath, + }; + + const pagesOutputs = [ + { path: page1.filePath }, + { path: page2.filePath }, + ] as any; + + const options = { + allWebComponents: webComponentsMap, + webComponentsPerEntrypoint: { + [page2.filePath]: webComponentsMap, + [page1.filePath]: webComponentsMap, + }, + layoutWebComponents: {}, + layoutHasContextProvider: false, + }; + + const result = await getClientBuildDetails(pagesOutputs, options); + const pages = [page1, page2]; + + for (let i = 0; i < pages.length; i++) { + const entrypointResult = result[i]; + + expect(entrypointResult).toMatchObject({ + unsuspense: '', + rpc: '', + lazyRPC: '', + pagePath: pages[i].filePath, + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + useContextProvider: false, + webComponents: { + 'my-component': wcFile.filePath, + }, + }); + + expect(entrypointResult.entrypoint).toBeDefined(); + + const entrypointContent = await readFile( + entrypointResult.entrypoint!, + 'utf-8', + ); + expect(entrypointContent).toContain('my-component'); + } + }); + + it('should return rpc when there is an hyperlink', async () => { + const page = createTempFileSync( + PAGES_DIR, + ` + export default function Page() { + return Click me; + } + `, + ); + + await writeTempFiles([page]); + const pages = [{ path: page.filePath }] as any; + + const options = { + allWebComponents: {}, + webComponentsPerEntrypoint: {}, + layoutWebComponents: {}, + layoutHasContextProvider: false, + }; + + const result = await getClientBuildDetails(pages, options); + + // Valida que se generaron datos para RPC + expect(result).toHaveLength(1); + expect(result[0].rpc.length).toBeGreaterThan(0); + expect(result[0].size).toBeGreaterThan(0); + }); +}); diff --git a/packages/brisa/src/utils/client-build/get-client-build-details/index.ts b/packages/brisa/src/utils/client-build/get-client-build-details/index.ts new file mode 100644 index 000000000..36bd23699 --- /dev/null +++ b/packages/brisa/src/utils/client-build/get-client-build-details/index.ts @@ -0,0 +1,58 @@ +import type { BuildArtifact } from 'bun'; +import { sep } from 'node:path'; +import type { EntryPointData, Options } from '../types'; +import { getConstants } from '@/constants'; +import { preEntrypointAnalysis } from '../pre-entrypoint-analysis'; +import { writeTempEntrypoint } from '../fs-temp-entrypoint-manager'; + +export async function getClientBuildDetails( + pages: BuildArtifact[], + options: Options, +) { + return ( + await Promise.all( + pages.map((p) => getClientEntrypointBuildDetails(p, options)), + ) + ).filter(Boolean) as EntryPointData[]; +} + +export async function getClientEntrypointBuildDetails( + page: BuildArtifact, + { + allWebComponents, + webComponentsPerEntrypoint, + layoutWebComponents, + integrationsPath, + layoutHasContextProvider, + }: Options, +): Promise { + const { BUILD_DIR } = getConstants(); + const route = page.path.replace(BUILD_DIR, ''); + const pagePath = page.path; + const isPage = route.startsWith(sep + 'pages' + sep); + + if (!isPage) return; + + const wcs = webComponentsPerEntrypoint[pagePath] ?? {}; + const pageWebComponents = layoutWebComponents + ? { ...layoutWebComponents, ...wcs } + : wcs; + + const analysis = await preEntrypointAnalysis( + pagePath, + allWebComponents, + pageWebComponents, + layoutHasContextProvider, + ); + + if (!Object.keys(analysis.webComponents).length) return analysis; + + const { entrypoint, useWebContextPlugins } = await writeTempEntrypoint({ + webComponentsList: analysis.webComponents, + useContextProvider: analysis.useContextProvider, + integrationsPath, + pagePath, + }); + + return { ...analysis, entrypoint, useWebContextPlugins }; +} diff --git a/packages/brisa/src/utils/client-build/get-temp-page-name/index.test.ts b/packages/brisa/src/utils/client-build/get-temp-page-name/index.test.ts new file mode 100644 index 000000000..1445f9e56 --- /dev/null +++ b/packages/brisa/src/utils/client-build/get-temp-page-name/index.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test'; +import { getConstants } from '@/constants'; +import { join } from 'node:path'; +import { getTempPageName } from '.'; + +describe('build utils -> client build', () => { + describe('getTempPageName', () => { + it('should return the correct temp file name', () => { + const { BUILD_DIR } = getConstants(); + const pagePath = '/path/to/page.tsx'; + const expected = join(BUILD_DIR, '_brisa', 'temp-path-to-page.ts'); + const result = getTempPageName(pagePath); + expect(result).toBe(expected); + }); + + it('should not conflict between similar page paths', () => { + const { BUILD_DIR } = getConstants(); + const pagePath1 = '/path/to/page-example.tsx'; + const pagePath2 = '/path/to/page/example.tsx'; + + const expected1 = join( + BUILD_DIR, + '_brisa', + 'temp-path-to-page_example.ts', + ); + const expected2 = join( + BUILD_DIR, + '_brisa', + 'temp-path-to-page-example.ts', + ); + + const result1 = getTempPageName(pagePath1); + const result2 = getTempPageName(pagePath2); + + expect(result1).toBe(expected1); + expect(result2).toBe(expected2); + }); + + it('should handle page paths with multiple extensions correctly', () => { + const { BUILD_DIR } = getConstants(); + const pagePath = '/path/to/page.min.tsx'; + const expected = join(BUILD_DIR, '_brisa', 'temp-path-to-page.min.ts'); + const result = getTempPageName(pagePath); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/brisa/src/utils/client-build/get-temp-page-name/index.ts b/packages/brisa/src/utils/client-build/get-temp-page-name/index.ts new file mode 100644 index 000000000..d8aa17883 --- /dev/null +++ b/packages/brisa/src/utils/client-build/get-temp-page-name/index.ts @@ -0,0 +1,34 @@ +import { getConstants } from '@/constants'; +import { join, sep } from 'node:path'; + +const REGEX = new RegExp(`${sep}|-|\\.[a-z]+$`, 'g'); + +/** + * Generates a temporary TypeScript file path for a given page. + * This is used during the build process to create intermediate files. + * + * This function is part of the client build process. During the server build, + * all Web Components used on a page are analyzed and associated with their + * respective entrypoints. + * + * For each entrypoint, the client build requires a temporary file that contains: + * + * 1. Import statements for all the Web Components needed by the client page. + * 2. Definitions for those Web Components, ensuring they are registered correctly. + * + * This function creates a unique temporary file path for each entrypoint, ensuring + * that the client build can correctly generate the necessary imports and definitions. + */ +export function getTempPageName(pagePath: string) { + const { PAGES_DIR, BUILD_DIR } = getConstants(); + + const tempName = pagePath.replace(PAGES_DIR, '').replace(REGEX, resolveRegex); + + return join(BUILD_DIR, '_brisa', `temp${tempName}.ts`); +} + +function resolveRegex(match: string) { + if (match === sep) return '-'; + if (match === '-') return '_'; + return ''; +} diff --git a/packages/brisa/src/utils/client-build/index.ts b/packages/brisa/src/utils/client-build/index.ts new file mode 100644 index 000000000..e085fcf38 --- /dev/null +++ b/packages/brisa/src/utils/client-build/index.ts @@ -0,0 +1,256 @@ +import { gzipSync, type BuildArtifact } from 'bun'; +import { brotliCompressSync } from 'node:zlib'; +import fs from 'node:fs'; +import { join } from 'node:path'; + +import { getConstants } from '@/constants'; +import layoutBuild from '@/utils/client-build/layout-build'; +import { getEntrypointsRouter } from '@/utils/get-entrypoints'; +import getI18nClientMessages from '@/utils/get-i18n-client-messages'; +import generateDynamicTypes from '@/utils/generate-dynamic-types'; +import clientPageBuild from '@/utils/client-build/pages-build'; + +const TS_REGEX = /\.tsx?$/; + +export async function clientBuild( + pages: BuildArtifact[], + { + allWebComponents, + webComponentsPerEntrypoint, + integrationsPath, + layoutPath, + pagesRoutes, + }: { + allWebComponents: Record; + webComponentsPerEntrypoint: Record>; + integrationsPath?: string | null; + layoutPath?: string | null; + pagesRoutes: ReturnType; + }, +) { + const { BUILD_DIR, I18N_CONFIG, IS_PRODUCTION } = getConstants(); + const pagesClientPath = join(BUILD_DIR, 'pages-client'); + const internalPath = join(BUILD_DIR, '_brisa'); + const layoutBuildPath = layoutPath ? getBuildPath(layoutPath) : ''; + const writes = []; + + // During hotreloading it is important to clean pages-client because + // new client files are generated with hash, this hash can change + // and many files would be accumulated during development. + // + // On the other hand, in production it will always be empty because + // the whole build is cleaned at startup. + if (fs.existsSync(pagesClientPath)) { + fs.rmSync(pagesClientPath, { recursive: true }); + } + // Create pages-client + fs.mkdirSync(pagesClientPath); + + if (!fs.existsSync(internalPath)) fs.mkdirSync(internalPath); + + const clientSizesPerPage: Record = {}; + const layoutWebComponents = webComponentsPerEntrypoint[layoutBuildPath]; + const layoutCode = layoutBuildPath + ? await layoutBuild({ + layoutPath: layoutBuildPath, + allWebComponents, + pageWebComponents: layoutWebComponents, + integrationsPath, + }) + : null; + + const pagesData = await clientPageBuild(pages, { + webComponentsPerEntrypoint, + layoutWebComponents, + allWebComponents, + integrationsPath, + layoutHasContextProvider: layoutCode?.useContextProvider, + }); + + for (const data of pagesData) { + let { size, rpc, lazyRPC, code, unsuspense, useI18n, i18nKeys, pagePath } = + data; + const clientPagePath = pagePath.replace('pages', 'pages-client'); + const route = pagePath.replace(BUILD_DIR, ''); + + // If there are no actions in the page but there are actions in + // the layout, then it is as if the page also has actions. + if (!rpc && layoutCode?.rpc) { + size += layoutCode.rpc.length; + rpc = layoutCode.rpc; + } + + // It is not necessary to increase the size here because this + // code even if it is necessary to generate it if it does not + // exist yet, it is not part of the initial size of the page + // because it is loaded in a lazy way. + if (!lazyRPC && layoutCode?.lazyRPC) { + lazyRPC = layoutCode.lazyRPC; + } + + // If there is no unsuspense in the page but there is unsuspense + // in the layout, then it is as if the page also has unsuspense. + if (!unsuspense && layoutCode?.unsuspense) { + size += layoutCode.unsuspense.length; + unsuspense = layoutCode.unsuspense; + } + + // fix i18n when it is not defined in the page but it is defined + // in the layout + if (!useI18n && layoutCode?.useI18n) { + useI18n = layoutCode.useI18n; + } + if (layoutCode?.i18nKeys.size) { + i18nKeys = new Set([...i18nKeys, ...layoutCode.i18nKeys]); + } + + clientSizesPerPage[route] = size; + + if (!size) continue; + + const hash = Bun.hash(code); + const clientPage = clientPagePath.replace('.js', `-${hash}.js`); + clientSizesPerPage[route] = 0; + + // create _unsuspense.js and _unsuspense.txt (list of pages with unsuspense) + clientSizesPerPage[route] += addExtraChunk(unsuspense, '_unsuspense', { + pagesClientPath, + pagePath, + writes, + }); + + // create _rpc-[versionhash].js and _rpc.txt (list of pages with actions) + clientSizesPerPage[route] += addExtraChunk(rpc, '_rpc', { + pagesClientPath, + pagePath, + writes, + }); + + // create _rpc-lazy-[versionhash].js + clientSizesPerPage[route] += addExtraChunk(lazyRPC, '_rpc-lazy', { + pagesClientPath, + pagePath, + skipList: true, + writes, + }); + + if (!code) continue; + + if (useI18n && i18nKeys.size && I18N_CONFIG?.messages) { + for (const locale of I18N_CONFIG?.locales ?? []) { + const i18nPagePath = clientPage.replace('.js', `-${locale}.js`); + const messages = getI18nClientMessages(locale, i18nKeys); + const i18nCode = `window.i18nMessages={...window.i18nMessages,...(${JSON.stringify(messages)})};`; + + writes.push(Bun.write(i18nPagePath, i18nCode)); + + // Compression in production + if (IS_PRODUCTION) { + writes.push( + Bun.write( + `${i18nPagePath}.gz`, + gzipSync(new TextEncoder().encode(i18nCode)), + ), + ); + writes.push( + Bun.write(`${i18nPagePath}.br`, brotliCompressSync(i18nCode)), + ); + } + } + } + + // create page file + writes.push( + Bun.write(clientPagePath.replace('.js', '.txt'), hash.toString()), + ); + writes.push(Bun.write(clientPage, code)); + + // Compression in production + if (IS_PRODUCTION) { + const gzipClientPage = gzipSync(new TextEncoder().encode(code)); + + writes.push(Bun.write(`${clientPage}.gz`, gzipClientPage)); + writes.push(Bun.write(`${clientPage}.br`, brotliCompressSync(code))); + clientSizesPerPage[route] += gzipClientPage.length; + } + } + + writes.push( + Bun.write( + join(internalPath, 'types.ts'), + generateDynamicTypes({ allWebComponents, pagesRoutes }), + ), + ); + + // Although on Mac it can work without await, on Windows it does not and it is mandatory + await Promise.all(writes); + + return clientSizesPerPage; +} + +function addExtraChunk( + code: string, + filename: string, + { + pagesClientPath, + pagePath, + skipList = false, + writes, + }: { + pagesClientPath: string; + pagePath: string; + skipList?: boolean; + writes: Promise[]; + }, +) { + const { BUILD_DIR, VERSION, IS_PRODUCTION } = getConstants(); + const jsFilename = `${filename}-${VERSION}.js`; + + if (!code) return 0; + + if (!skipList && fs.existsSync(join(pagesClientPath, jsFilename))) { + const listPath = join(pagesClientPath, `${filename}.txt`); + + writes.push( + Bun.write( + listPath, + `${fs.readFileSync(listPath).toString()}\n${pagePath.replace(BUILD_DIR, '')}`, + ), + ); + + return 0; + } + + writes.push(Bun.write(join(pagesClientPath, jsFilename), code)); + + if (!skipList) { + writes.push( + Bun.write( + join(pagesClientPath, `${filename}.txt`), + pagePath.replace(BUILD_DIR, ''), + ), + ); + } + + if (IS_PRODUCTION) { + const gzipUnsuspense = gzipSync(new TextEncoder().encode(code)); + + writes.push( + Bun.write(join(pagesClientPath, `${jsFilename}.gz`), gzipUnsuspense), + ); + writes.push( + Bun.write( + join(pagesClientPath, `${jsFilename}.br`), + brotliCompressSync(code), + ), + ); + return gzipUnsuspense.length; + } + + return code.length; +} + +function getBuildPath(path: string) { + const { SRC_DIR, BUILD_DIR } = getConstants(); + return path.replace(SRC_DIR, BUILD_DIR).replace(TS_REGEX, '.js'); +} diff --git a/packages/brisa/src/utils/get-client-code-in-page/index.test.ts b/packages/brisa/src/utils/client-build/layout-build/index.test.ts similarity index 74% rename from packages/brisa/src/utils/get-client-code-in-page/index.test.ts rename to packages/brisa/src/utils/client-build/layout-build/index.test.ts index 057457ef9..bc78649eb 100644 --- a/packages/brisa/src/utils/get-client-code-in-page/index.test.ts +++ b/packages/brisa/src/utils/client-build/layout-build/index.test.ts @@ -3,11 +3,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { GlobalRegistrator } from '@happy-dom/global-registrator'; -import getClientCodeInPage from '.'; +import layoutBuild from '.'; import { getConstants } from '@/constants'; import getWebComponentsList from '@/utils/get-web-components-list'; -const src = path.join(import.meta.dir, '..', '..', '__fixtures__'); +const src = path.join(import.meta.dir, '..', '..', '..', '__fixtures__'); const webComponentsDir = path.join(src, 'web-components'); const build = path.join(src, `out-${crypto.randomUUID()}}`); const brisaInternals = path.join(build, '_brisa'); @@ -18,9 +18,9 @@ const pageWebComponents = { 'native-some-example': allWebComponents['native-some-example'], }; -const i18nCode = 2799; -const brisaSize = 5720; // TODO: Reduce this size :/ -const webComponents = 1107; +const i18nCode = 3653; +const brisaSize = 5638; // TODO: Reduce this size :/ +const webComponents = 1118; const unsuspenseSize = 213; const rpcSize = 2500; // TODO: Reduce this size const lazyRPCSize = 4105; // TODO: Reduce this size @@ -28,7 +28,7 @@ const lazyRPCSize = 4105; // TODO: Reduce this size // so it's not included in the initial size const initialSize = unsuspenseSize + rpcSize; -describe('utils', () => { +describe('client-build', () => { beforeEach(async () => { fs.mkdirSync(build, { recursive: true }); fs.mkdirSync(brisaInternals, { recursive: true }); @@ -47,27 +47,29 @@ describe('utils', () => { globalThis.mockConstants = undefined; }); - describe('getClientCodeInPage', () => { + describe('layout-build', () => { it('should not return client code in page without web components, without suspense, without server actions', async () => { - const pagePath = path.join(pages, 'somepage.tsx'); - const output = await getClientCodeInPage({ pagePath, allWebComponents }); + const layoutPath = path.join(pages, 'somepage.tsx'); + const output = await layoutBuild({ layoutPath, allWebComponents }); const expected = { code: '', rpc: '', lazyRPC: '', unsuspense: '', + pagePath: path.join(pages, 'somepage.tsx'), size: 0, useI18n: false, useContextProvider: false, i18nKeys: new Set(), + webComponents: {}, }; expect(output).toEqual(expected); }); it('should return client code size of brisa + 2 web-components in page with web components', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -79,8 +81,8 @@ describe('utils', () => { }); it('should return client code size as 0 when a page does not have web components', async () => { - const pagePath = path.join(pages, 'somepage.tsx'); - const output = await getClientCodeInPage({ pagePath, allWebComponents }); + const layoutPath = path.join(pages, 'somepage.tsx'); + const output = await layoutBuild({ layoutPath, allWebComponents }); expect(output!.size).toEqual(0); }); @@ -90,9 +92,9 @@ describe('utils', () => { IS_STATIC_EXPORT: true, IS_PRODUCTION: true, }; - const pagePath = path.join(pages, 'index.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'index.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -106,9 +108,9 @@ describe('utils', () => { IS_STATIC_EXPORT: true, IS_PRODUCTION: false, }; - const pagePath = path.join(pages, 'index.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'index.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -124,9 +126,9 @@ describe('utils', () => { IS_STATIC_EXPORT: false, IS_PRODUCTION: true, }; - const pagePath = path.join(pages, 'index.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'index.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -137,8 +139,8 @@ describe('utils', () => { }); it('should return client code in page with suspense and rpc', async () => { - const pagePath = path.join(pages, 'index.tsx'); - const output = await getClientCodeInPage({ pagePath, allWebComponents }); + const layoutPath = path.join(pages, 'index.tsx'); + const output = await layoutBuild({ layoutPath, allWebComponents }); expect(output?.unsuspense.length).toBe(unsuspenseSize); expect(output?.rpc.length).toBe(rpcSize); @@ -150,9 +152,9 @@ describe('utils', () => { }); it('should define 2 web components if there is 1 web component and another one inside', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -161,9 +163,9 @@ describe('utils', () => { }); it('should load lazyRPC in /somepage because it has an hyperlink', async () => { - const webComponentSize = 366; - const output = await getClientCodeInPage({ - pagePath: path.join(pages, 'somepage.tsx'), + const webComponentSize = 377; + const output = await layoutBuild({ + layoutPath: path.join(pages, 'somepage.tsx'), allWebComponents, pageWebComponents: { 'with-link': allWebComponents['with-link'], @@ -179,9 +181,9 @@ describe('utils', () => { }); it('should add context-provider if the page has a context-provider without serverOnly attribute', async () => { - const pagePath = path.join(pages, 'somepage-with-context.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'somepage-with-context.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -194,9 +196,9 @@ describe('utils', () => { IS_DEVELOPMENT: true, IS_PRODUCTION: false, }; - const pagePath = path.join(pages, 'somepage.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'somepage.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -209,9 +211,9 @@ describe('utils', () => { IS_DEVELOPMENT: false, IS_PRODUCTION: true, }; - const pagePath = path.join(pages, 'somepage.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'somepage.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -219,10 +221,10 @@ describe('utils', () => { }); it('should add context-provider if the page has not a context-provider but layoutHasContextProvider is true', async () => { - const pagePath = path.join(pages, 'somepage.tsx'); + const layoutPath = path.join(pages, 'somepage.tsx'); const layoutHasContextProvider = true; - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, layoutHasContextProvider, @@ -236,29 +238,29 @@ describe('utils', () => { IS_DEVELOPMENT: true, IS_PRODUCTION: false, }; - const pagePath = path.join(pages, 'somepage.tsx'); + const layoutPath = path.join(pages, 'somepage.tsx'); const layoutHasContextProvider = true; - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, layoutHasContextProvider, }); const allDefineElementCalls = output!.code.match( - /(defineElement\("([a-z]|-)+", ([a-z]|[A-Z])+\))/gm, + /(defineElement\("([a-z]|-)+",([a-z]|[A-Z])+\))/gm, ); expect(allDefineElementCalls).toEqual([ - `defineElement("brisa-error-dialog", brisaErrorDialog)`, - `defineElement("context-provider", contextProvider)`, - `defineElement("native-some-example", SomeExample)`, + `defineElement("brisa-error-dialog",brisaErrorDialog)`, + `defineElement("context-provider",contextProvider)`, + `defineElement("native-some-example",SomeExample)`, ]); }); it('should not add context-provider if the page has a context-provider with serverOnly attribute', async () => { - const pagePath = path.join(pages, 'somepage.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const layoutPath = path.join(pages, 'somepage.tsx'); + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -266,10 +268,10 @@ describe('utils', () => { }); it('should allow environment variables in web components with BRISA_PUBLIC_ prefix', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); Bun.env.BRISA_PUBLIC_TEST = 'value of test env variable'; - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, }); @@ -277,40 +279,46 @@ describe('utils', () => { }); it('should NOT add the integrations web context plugins when there are not plugins', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); const integrationsPath = path.join(webComponentsDir, '_integrations.tsx'); - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, integrationsPath, }); + // Declaration expect(output!.code).not.toContain('window._P='); + // Brisa element usage + expect(output!.code).not.toContain('._P)'); }); it('should add the integrations web context plugins when there are plugins', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); const integrationsPath = path.join( webComponentsDir, '_integrations2.tsx', ); - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents, integrationsPath, }); + // Declaration expect(output!.code).toContain('window._P='); + // Brisa element usage + expect(output!.code).toContain('._P)'); }); it('should add the integrations with emoji-picker as direct import', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); const integrationsPath = path.join( webComponentsDir, '_integrations3.tsx', ); - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents: { ...pageWebComponents, @@ -323,14 +331,14 @@ describe('utils', () => { }); it('should integrate some-lib with web context plugins', async () => { - const pagePath = path.join(pages, 'page-with-web-component.tsx'); + const layoutPath = path.join(pages, 'page-with-web-component.tsx'); const integrationsPath = path.join( webComponentsDir, '_integrations4.tsx', ); - const output = await getClientCodeInPage({ - pagePath, + const output = await layoutBuild({ + layoutPath, allWebComponents, pageWebComponents: { ...pageWebComponents, diff --git a/packages/brisa/src/utils/client-build/layout-build/index.ts b/packages/brisa/src/utils/client-build/layout-build/index.ts new file mode 100644 index 000000000..5c1c05f31 --- /dev/null +++ b/packages/brisa/src/utils/client-build/layout-build/index.ts @@ -0,0 +1,95 @@ +import { log, logBuildError } from '@/utils/log/log-build'; +import { preEntrypointAnalysis } from '../pre-entrypoint-analysis'; +import { + removeTempEntrypoint, + writeTempEntrypoint, +} from '../fs-temp-entrypoint-manager'; +import { runBuild } from '../run-build'; +import { processI18n } from '../process-i18n'; +import { getConstants } from '@/constants'; + +type TransformOptions = { + webComponentsList: Record; + useContextProvider: boolean; + integrationsPath?: string | null; + layoutPath: string; +}; + +type ClientCodeInPageProps = { + layoutPath: string; + allWebComponents?: Record; + pageWebComponents?: Record; + integrationsPath?: string | null; + layoutHasContextProvider?: boolean; +}; + +export default async function layoutBuild({ + layoutPath, + allWebComponents = {}, + pageWebComponents = {}, + integrationsPath, + layoutHasContextProvider, +}: ClientCodeInPageProps) { + const { LOG_PREFIX } = getConstants(); + const analysis = await preEntrypointAnalysis( + layoutPath, + allWebComponents, + pageWebComponents, + layoutHasContextProvider, + ); + + if (!Object.keys(analysis.webComponents).length) { + return analysis; + } + + log(LOG_PREFIX.WAIT, `compiling layout...`); + + const transformedCode = await transformToWebComponents({ + webComponentsList: analysis.webComponents, + useContextProvider: analysis.useContextProvider, + integrationsPath, + layoutPath, + }); + + if (!transformedCode) return null; + + return { + code: analysis.code + transformedCode?.code, + unsuspense: analysis.unsuspense, + rpc: analysis.rpc, + useContextProvider: analysis.useContextProvider, + lazyRPC: analysis.lazyRPC, + size: analysis.size + (transformedCode?.size ?? 0), + useI18n: transformedCode.useI18n, + i18nKeys: transformedCode.i18nKeys, + }; +} + +export async function transformToWebComponents({ + webComponentsList, + useContextProvider, + integrationsPath, + layoutPath, +}: TransformOptions) { + const { entrypoint, useWebContextPlugins } = await writeTempEntrypoint({ + webComponentsList, + useContextProvider, + integrationsPath, + pagePath: layoutPath, + }); + + const { success, logs, outputs } = await runBuild( + [entrypoint], + webComponentsList, + useWebContextPlugins, + ); + + await removeTempEntrypoint(entrypoint); + + if (!success) { + logBuildError('Failed to compile web components', logs); + return null; + } + + return processI18n(await outputs[0].text()); +} diff --git a/packages/brisa/src/utils/client-build/normalize-path/index.test.ts b/packages/brisa/src/utils/client-build/normalize-path/index.test.ts new file mode 100644 index 000000000..b84680be8 --- /dev/null +++ b/packages/brisa/src/utils/client-build/normalize-path/index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { normalizePath } from '.'; + +describe('build utils -> client build', () => { + describe('normalizePath', () => { + it('should return the correct normalized path', () => { + const rawPathname = '/path/to/page.tsx'; + const expected = '/path/to/page.tsx'; + const result = normalizePath(rawPathname); + expect(result).toBe(expected); + }); + + it('should return the correct normalized path with custom separator', () => { + const rawPathname = '/path/to/page.tsx'; + const expected = '/path/to/page.tsx'; + const result = normalizePath(rawPathname, '/'); + expect(result).toBe(expected); + }); + + it('should return the correct normalized path with custom separator', () => { + const rawPathname = '\\path\\to\\page.tsx'; + const expected = '/path/to/page.tsx'; + const result = normalizePath(rawPathname, '\\'); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/brisa/src/utils/client-build/normalize-path/index.ts b/packages/brisa/src/utils/client-build/normalize-path/index.ts new file mode 100644 index 000000000..38535d024 --- /dev/null +++ b/packages/brisa/src/utils/client-build/normalize-path/index.ts @@ -0,0 +1,8 @@ +import { sep } from 'node:path'; + +export function normalizePath(rawPathname: string, separator = sep) { + const pathname = + rawPathname[0] === '{' ? JSON.parse(rawPathname).client : rawPathname; + + return pathname.replaceAll(separator, '/'); +} diff --git a/packages/brisa/src/utils/client-build/pages-build/index.test.ts b/packages/brisa/src/utils/client-build/pages-build/index.test.ts new file mode 100644 index 000000000..de77e325a --- /dev/null +++ b/packages/brisa/src/utils/client-build/pages-build/index.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import clientPageBuild from '.'; +import { getConstants } from '@/constants'; +import getWebComponentsList from '@/utils/get-web-components-list'; +import type { BuildArtifact } from 'bun'; + +const src = path.join(import.meta.dir, '..', '..', '..', '__fixtures__'); +const build = path.join(src, `out-${crypto.randomUUID()}}`); +const webComponentsDir = path.join(src, 'web-components'); +const brisaInternals = path.join(build, '_brisa'); +const allWebComponents = await getWebComponentsList(src); +const pageWebComponents = { + 'web-component': allWebComponents['web-component'], + 'native-some-example': allWebComponents['native-some-example'], +}; + +const toArtifact = (path: string) => + ({ path, text: () => Bun.file(path).text() }) as BuildArtifact; + +describe('client-build', () => { + beforeEach(async () => { + fs.mkdirSync(build, { recursive: true }); + fs.mkdirSync(brisaInternals, { recursive: true }); + const constants = getConstants() ?? {}; + globalThis.mockConstants = { + ...constants, + SRC_DIR: src, + IS_PRODUCTION: true, + IS_DEVELOPMENT: false, + BUILD_DIR: src, + }; + }); + + afterEach(() => { + fs.rmSync(build, { recursive: true }); + globalThis.mockConstants = undefined; + }); + + describe('clientPageBuild', () => { + it('should not compile client code in page without web components, without suspense, without server actions', async () => { + const pagePath = path.join(src, 'pages', 'somepage.tsx'); + const output = await clientPageBuild([toArtifact(pagePath)], { + allWebComponents, + webComponentsPerEntrypoint: {}, + layoutWebComponents: {}, + }); + + expect(output).toEqual([ + { + unsuspense: '', + rpc: '', + lazyRPC: '', + pagePath: pagePath, + code: '', + size: 0, + useContextProvider: false, + useI18n: false, + i18nKeys: new Set(), + webComponents: {}, + }, + ]); + }); + + it('should return client code size of brisa + 2 web-components in page with web components', async () => { + const pagePath = path.join(src, 'pages', 'page-with-web-component.tsx'); + const output = await clientPageBuild([toArtifact(pagePath)], { + allWebComponents, + webComponentsPerEntrypoint: { + [pagePath]: pageWebComponents, + }, + layoutWebComponents: {}, + }); + + expect(output.length).toEqual(1); + expect(output[0].size).toBeGreaterThan(0); + expect(output[0].unsuspense).toBeEmpty(); + expect(output[0].rpc).toBeEmpty(); + expect(output[0].lazyRPC).toBeEmpty(); + expect(output[0].useContextProvider).toBe(false); + expect(output[0].useI18n).toBe(true); + expect(output[0].i18nKeys).toEqual(new Set(['hello'])); + expect(output[0].webComponents).toEqual({ + 'web-component': allWebComponents['web-component'], + 'native-some-example': allWebComponents['native-some-example'], + }); + }); + + it('should return client code size of brisa + 2 wc + rpc + suspense', async () => { + const pagePath = path.join(src, 'pages', 'index.tsx'); + const output = await clientPageBuild([toArtifact(pagePath)], { + allWebComponents, + webComponentsPerEntrypoint: { + [pagePath]: pageWebComponents, + }, + layoutWebComponents: {}, + }); + + expect(output.length).toEqual(1); + expect(output[0].size).toBeGreaterThan(0); + expect(output[0].unsuspense).not.toBeEmpty(); + expect(output[0].rpc).not.toBeEmpty(); + expect(output[0].lazyRPC).not.toBeEmpty(); + expect(output[0].useContextProvider).toBe(false); + expect(output[0].useI18n).toBe(true); + expect(output[0].i18nKeys).toEqual(new Set(['hello'])); + expect(output[0].webComponents).toEqual({ + 'web-component': allWebComponents['web-component'], + 'native-some-example': allWebComponents['native-some-example'], + }); + }); + + it('should build multi pages', async () => { + const pagePath = path.join(src, 'pages', 'index.tsx'); + const pagePath2 = path.join(src, 'pages', 'page-with-web-component.tsx'); + + const output = await clientPageBuild( + [toArtifact(pagePath), toArtifact(pagePath2)], + { + allWebComponents, + webComponentsPerEntrypoint: { + [pagePath]: {}, + [pagePath2]: pageWebComponents, + }, + layoutWebComponents: {}, + }, + ); + + expect(output.length).toEqual(2); + + // First page + expect(output[0].size).toBeGreaterThan(0); + expect(output[0].unsuspense).not.toBeEmpty(); + expect(output[0].rpc).not.toBeEmpty(); + expect(output[0].lazyRPC).not.toBeEmpty(); + expect(output[0].useContextProvider).toBe(false); + expect(output[0].useI18n).toBe(false); + expect(output[0].i18nKeys).toBeEmpty(); + expect(output[0].webComponents).toBeEmpty(); + + // Second page + expect(output[1].size).toBeGreaterThan(0); + expect(output[1].unsuspense).toBeEmpty(); + expect(output[1].rpc).toBeEmpty(); + expect(output[1].lazyRPC).toBeEmpty(); + expect(output[1].useContextProvider).toBe(false); + expect(output[1].useI18n).toBe(true); + expect(output[1].i18nKeys).toEqual(new Set(['hello'])); + expect(output[1].webComponents).toEqual({ + 'web-component': allWebComponents['web-component'], + 'native-some-example': allWebComponents['native-some-example'], + }); + }); + }); + + it('should NOT add the integrations web context plugins when there are not plugins', async () => { + const pagePath = path.join(src, 'pages', 'page-with-web-component.tsx'); + const integrationsPath = path.join(webComponentsDir, '_integrations.tsx'); + const output = await clientPageBuild([toArtifact(pagePath)], { + allWebComponents, + webComponentsPerEntrypoint: { + [pagePath]: allWebComponents, + }, + integrationsPath, + layoutWebComponents: {}, + }); + + // Declaration + expect(output[0].code).not.toContain('window._P='); + // Brisa element usage + expect(output[0].code).not.toContain('._P)'); + }); + + it('should add the integrations web context plugins when there are plugins', async () => { + const pagePath = path.join(src, 'pages', 'page-with-web-component.tsx'); + const integrationsPath = path.join(webComponentsDir, '_integrations2.tsx'); + const output = await clientPageBuild([toArtifact(pagePath)], { + allWebComponents, + webComponentsPerEntrypoint: { + [pagePath]: allWebComponents, + }, + integrationsPath, + layoutWebComponents: {}, + }); + + // Declaration + expect(output[0].code).toContain('window._P='); + // Brisa element usage + expect(output[0].code).toContain('._P)'); + }); +}); diff --git a/packages/brisa/src/utils/client-build/pages-build/index.ts b/packages/brisa/src/utils/client-build/pages-build/index.ts new file mode 100644 index 000000000..22645d6c8 --- /dev/null +++ b/packages/brisa/src/utils/client-build/pages-build/index.ts @@ -0,0 +1,61 @@ +import type { BuildArtifact } from 'bun'; +import { log, logBuildError } from '../../log/log-build'; +import { removeTempEntrypoints } from '../fs-temp-entrypoint-manager'; +import { getClientBuildDetails } from '../get-client-build-details'; +import type { EntryPointData, Options } from '../types'; +import { runBuild } from '../run-build'; +import { processI18n } from '../process-i18n'; +import { getConstants } from '@/constants'; + +export default async function clientPageBuild( + pages: BuildArtifact[], + options: Options, +): Promise { + const { LOG_PREFIX } = getConstants(); + + log(LOG_PREFIX.WAIT, 'analyzing and preparing client build...'); + + let clientBuildDetails = await getClientBuildDetails(pages, options); + + const entrypointsData = clientBuildDetails.reduce((acc, curr, index) => { + if (curr.entrypoint) acc.push({ ...curr, index }); + return acc; + }, [] as EntryPointData[]); + + const entrypoints = entrypointsData.map((p) => p.entrypoint!); + + if (entrypoints.length === 0) { + return clientBuildDetails; + } + + log( + LOG_PREFIX.WAIT, + `compiling ${entrypointsData.length} client entrypoints...`, + ); + const { success, logs, outputs } = await runBuild( + entrypoints, + options.allWebComponents, + entrypointsData[0].useWebContextPlugins, + ); + + // Remove all temp files + await removeTempEntrypoints(entrypoints); + + if (!success) { + logBuildError('Failed to compile web components', logs); + return clientBuildDetails; + } + + await Promise.all( + outputs.map(async (output, i) => { + const index = entrypointsData[i].index!; + + clientBuildDetails[index] = { + ...clientBuildDetails[index], + ...processI18n(await output.text()), + }; + }), + ); + + return clientBuildDetails; +} diff --git a/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.test.ts b/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.test.ts new file mode 100644 index 000000000..07da608ca --- /dev/null +++ b/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { + preEntrypointAnalysis, + rpcCode, + RPCLazyCode, + rpcStatic, + unsuspenseScriptCode, +} from '.'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { getConstants } from '@/constants'; + +const TEMP_DIR = path.join(import.meta.dirname, '.temp-test-files'); + +// Utility to create a unique file with Bun.hash +function createTempFileSync(content: string, extension = 'tsx') { + const fileName = `${Bun.hash(content)}.${extension}`; + const filePath = path.join(TEMP_DIR, fileName); + return { filePath, content }; +} + +// Write files concurrently to disk +async function writeTempFiles( + files: Array<{ filePath: string; content: string }>, +) { + await Promise.all( + files.map(({ filePath, content }) => writeFile(filePath, content, 'utf-8')), + ); +} + +describe('client build', () => { + describe('preEntrypointAnalysis', () => { + beforeAll(async () => { + await mkdir(TEMP_DIR, { recursive: true }); + }); + + afterAll(async () => { + await rm(TEMP_DIR, { recursive: true, force: true }); + globalThis.mockConstants = undefined; + }); + + it('should analyze the main file and detect no features', async () => { + const mainFile = createTempFileSync(` + export default function Component() { + return
hello
; + } + `); + await writeTempFiles([mainFile]); + + const result = await preEntrypointAnalysis(mainFile.filePath, {}, {}); + expect(result).toEqual({ + unsuspense: '', + rpc: '', + lazyRPC: '', + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + webComponents: {}, + useContextProvider: false, + }); + }); + + it('should detect suspense in the main file', async () => { + const mainFile = createTempFileSync(` + export default function Component() { + return
hello
; + } + + Component.suspense = () =>
loading...
; + `); + await writeTempFiles([mainFile]); + + const result = await preEntrypointAnalysis(mainFile.filePath, {}, {}); + expect(result).toEqual({ + unsuspense: unsuspenseScriptCode, + rpc: '', + lazyRPC: '', + size: unsuspenseScriptCode.length, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + webComponents: {}, + useContextProvider: false, + }); + }); + + it('should detect web components in the main file and nested components', async () => { + const mainFile = createTempFileSync(` + export default function Component() { + return hello; + } + `); + const nestedFile = createTempFileSync(` + export default function NestedComponent() { + return ; + } + `); + + await writeTempFiles([mainFile, nestedFile]); + + const allWebComponents = { + 'my-component': mainFile.filePath, + 'nested-component': nestedFile.filePath, + }; + + const entrypointWebComponents = { + 'my-component': mainFile.filePath, + }; + + const result = await preEntrypointAnalysis( + mainFile.filePath, + allWebComponents, + entrypointWebComponents, + ); + + expect(result).toEqual({ + unsuspense: '', + rpc: '', + lazyRPC: '', + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + useContextProvider: false, + webComponents: { + 'my-component': mainFile.filePath, + 'nested-component': nestedFile.filePath, + }, + }); + }); + + it('should detect hyperlinks in the main file', async () => { + const mainFile = createTempFileSync(` + export default function Component() { + return Relative Link; + } + `); + await writeTempFiles([mainFile]); + + const result = await preEntrypointAnalysis(mainFile.filePath, {}, {}); + expect(result).toEqual({ + unsuspense: '', + rpc: rpcCode, + lazyRPC: RPCLazyCode, + size: rpcCode.length, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + useContextProvider: false, + webComponents: {}, + }); + }); + + it('should detect hyperlinks in the main file and load static rpc for static app in prod', async () => { + globalThis.mockConstants = { + ...getConstants(), + IS_STATIC_EXPORT: true, + IS_PRODUCTION: true, + }; + const mainFile = createTempFileSync(` + export default function Component() { + return Relative Link; + } + `); + await writeTempFiles([mainFile]); + + const result = await preEntrypointAnalysis(mainFile.filePath, {}, {}); + expect(result).toEqual({ + unsuspense: '', + rpc: rpcStatic, + lazyRPC: RPCLazyCode, + size: rpcStatic.length, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + useContextProvider: false, + webComponents: {}, + }); + }); + + it('should detect hyperlinks in the main file and load normal rpc for static app in dev', async () => { + globalThis.mockConstants = { + ...getConstants(), + IS_STATIC_EXPORT: true, + IS_PRODUCTION: false, + }; + const mainFile = createTempFileSync(` + export default function Component() { + return Relative Link; + } + `); + await writeTempFiles([mainFile]); + + const result = await preEntrypointAnalysis(mainFile.filePath, {}, {}); + expect(result).toEqual({ + unsuspense: '', + rpc: rpcCode, + lazyRPC: RPCLazyCode, + size: rpcCode.length, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + useContextProvider: false, + webComponents: {}, + }); + }); + + it('should handle multiple nested components and aggregate metadata', async () => { + const mainFile = createTempFileSync(` + export default function Component() { + return hello; + } + `); + const nestedFile1 = createTempFileSync(` + export default function NestedComponent1() { + return nested 1; + } + `); + const nestedFile2 = createTempFileSync(` + export default function NestedComponent2() { + return nested 2; + } + `); + + await writeTempFiles([mainFile, nestedFile1, nestedFile2]); + + const allWebComponents = { + 'nested-component-1': nestedFile1.filePath, + 'nested-component-2': nestedFile2.filePath, + 'nested-component-3': 'nested-component-3.js', + }; + + const entrypointWebComponents = { + 'nested-component-1': nestedFile1.filePath, + 'nested-component-2': nestedFile2.filePath, + }; + + const result = await preEntrypointAnalysis( + mainFile.filePath, + allWebComponents, + entrypointWebComponents, + ); + + expect(result).toEqual({ + unsuspense: '', + rpc: '', + lazyRPC: '', + size: 0, + code: '', + useI18n: false, + i18nKeys: new Set(), + pagePath: mainFile.filePath, + useContextProvider: false, + webComponents: { + 'nested-component-1': nestedFile1.filePath, + 'nested-component-2': nestedFile2.filePath, + 'nested-component-3': 'nested-component-3.js', + }, + }); + }); + }); +}); diff --git a/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.ts b/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.ts new file mode 100644 index 000000000..9320ed64a --- /dev/null +++ b/packages/brisa/src/utils/client-build/pre-entrypoint-analysis/index.ts @@ -0,0 +1,108 @@ +import { getConstants } from '@/constants'; +import analyzeServerAst from '@/utils/analyze-server-ast'; +import AST from '@/utils/ast'; +import { injectUnsuspenseCode } from '@/utils/inject-unsuspense-code' with { + type: 'macro', +}; +import { + injectRPCCode, + injectRPCCodeForStaticApp, + injectRPCLazyCode, +} from '@/utils/rpc' with { type: 'macro' }; + +type WCs = Record; + +const ASTUtil = AST('tsx'); + +export const unsuspenseScriptCode = injectUnsuspenseCode() as unknown as string; +export const rpcCode = injectRPCCode() as unknown as string; +export const RPCLazyCode = injectRPCLazyCode() as unknown as string; +export const rpcStatic = injectRPCCodeForStaticApp() as unknown as string; + +/** + * Performs a comprehensive analysis of a given file path and its associated web components. + * + * This function parses the provided file's Abstract Syntax Tree (AST) to extract metadata + * about the usage of key features such as suspense, context providers, actions, and hyperlinks. + * It also recursively analyzes nested web components to aggregate their dependencies and behavior. + * + * @param path - The file path to analyze. + * @param allWebComponents - A record of all available web components, used for analysis. + * @param webComponents - A record of web components specific to the given path. + * @param layoutHasContextProvider - Indicates if the layout has a context provider. + * @returns An object containing: + * - `useSuspense`: Indicates if suspense is used. + * - `useContextProvider`: Indicates if a context provider is used. + * - `useActions`: Indicates if actions are used. + * - `useHyperlink`: Indicates if hyperlinks are used. + * - `webComponents`: An aggregated list of web components and their dependencies. + */ +export async function preEntrypointAnalysis( + path: string, + allWebComponents: WCs, + webComponents: WCs = {}, + layoutHasContextProvider?: boolean, +) { + const mainAnalysisPromise = getAstFromPath(path).then((ast) => + analyzeServerAst(ast, allWebComponents, layoutHasContextProvider), + ); + + const nestedAnalysisPromises = Object.entries(webComponents).map( + async ([, componentPath]) => + analyzeServerAst(await getAstFromPath(componentPath), allWebComponents), + ); + + // Wait for all analyses to complete + const [mainAnalysis, nestedResults] = await Promise.all([ + mainAnalysisPromise, + Promise.all(nestedAnalysisPromises), + ]); + + let { useSuspense, useContextProvider, useActions, useHyperlink } = + mainAnalysis; + + // Aggregate results + const aggregatedWebComponents = { ...webComponents }; + for (const analysis of nestedResults) { + useContextProvider ||= analysis.useContextProvider; + useSuspense ||= analysis.useSuspense; + useHyperlink ||= analysis.useHyperlink; + Object.assign(aggregatedWebComponents, analysis.webComponents); + } + + let size = 0; + const unsuspense = useSuspense ? unsuspenseScriptCode : ''; + const rpc = useActions || useHyperlink ? getRPCCode() : ''; + const lazyRPC = useActions || useHyperlink ? RPCLazyCode : ''; + + size += unsuspense.length; + size += rpc.length; + + return { + unsuspense, + rpc, + useContextProvider, + lazyRPC, + pagePath: path, + webComponents: aggregatedWebComponents, + + // Fields that need an extra analysis during/after build: + code: '', + size, + useI18n: false, + i18nKeys: new Set(), + }; +} + +async function getAstFromPath(path: string) { + return ASTUtil.parseCodeToAST( + path[0] === '{' ? '' : await Bun.file(path).text(), + ); +} + +function getRPCCode() { + const { IS_PRODUCTION, IS_STATIC_EXPORT } = getConstants(); + return (IS_STATIC_EXPORT && IS_PRODUCTION + ? rpcStatic + : rpcCode) as unknown as string; +} diff --git a/packages/brisa/src/utils/client-build/process-i18n/index.test.ts b/packages/brisa/src/utils/client-build/process-i18n/index.test.ts new file mode 100644 index 000000000..8fb95e52b --- /dev/null +++ b/packages/brisa/src/utils/client-build/process-i18n/index.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'bun:test'; +import AST from '@/utils/ast'; +import { processI18n } from '.'; +import { normalizeHTML } from '@/helpers'; + +const { parseCodeToAST, generateCodeFromAST, minify } = AST('tsx'); +const out = (c: string) => + minify(normalizeHTML(generateCodeFromAST(parseCodeToAST(c)))); + +describe('utils', () => { + describe('client-build -> process-i18n', () => { + it('should return useI18n + cleanup', () => { + const code = ` + export default function Component({i18n}) { + const { locale } = i18n; + return
{locale}
+ } + + window.useI18n = true; + `; + + const res = processI18n(code); + const resCode = normalizeHTML(res.code); + + expect(resCode).toEndWith( + out(` + export default function Component({i18n}) { + const { locale } = i18n; + return
{locale}
+ } + `), + ); + expect(res.useI18n).toBeTrue(); + expect(res.i18nKeys).toBeEmpty(); + expect(res.size).toBe(res.code.length); + + // Bridge without keys + expect(resCode).toContain('window.i18n'); + expect(resCode).not.toContain('messages()'); + }); + + it('should return useI18n + cleanup multi useI18n (entrypoint with diferent pre-analyzed files)', () => { + const code = ` + export default function Component({i18n}) { + const { locale } = i18n; + return
{locale}
+ } + + window.useI18n = true; + window.useI18n = true; + window.useI18n = true; + window.useI18n = true; + `; + + const res = processI18n(code); + const resCode = normalizeHTML(res.code); + + expect(resCode).toEndWith( + out(` + export default function Component({i18n}) { + const { locale } = i18n; + return
{locale}
+ } + `), + ); + expect(res.useI18n).toBeTrue(); + expect(res.i18nKeys).toBeEmpty(); + expect(res.size).toBe(res.code.length); + + // Bridge without keys + expect(resCode).toContain('window.i18n'); + expect(resCode).not.toContain('messages()'); + }); + + it('should return useI18n and i18nKeys + cleanup', () => { + const code = ` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + + window.useI18n = true; + window.i18nKeys = ["hello"] + `; + + const res = processI18n(code); + const resCode = normalizeHTML(res.code); + + expect(resCode).toEndWith( + out(` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + `), + ); + expect(res.useI18n).toBeTrue(); + expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res.size).toBe(res.code.length); + + // Bridge with keys + expect(resCode).toContain('window.i18n'); + expect(resCode).toContain('messages()'); + }); + + it('should return useI18n and i18nKeys + cleanup multi', () => { + const code = ` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + + window.useI18n = true; + window.i18nKeys = ["hello"] + window.useI18n = true; + window.i18nKeys = ["hello"] + `; + + const res = processI18n(code); + const resCode = normalizeHTML(res.code); + + expect(resCode).toEndWith( + out(` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + `), + ); + expect(res.useI18n).toBeTrue(); + expect(res.i18nKeys).toEqual(new Set(['hello'])); + expect(res.size).toBe(res.code.length); + + // Bridge with keys + expect(resCode).toContain('window.i18n'); + expect(resCode).toContain('messages()'); + }); + + it('should return useI18n and i18nKeys + collect and cleanup multi', () => { + const code = ` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + + window.useI18n = true; + window.i18nKeys = ["foo", "bar"] + window.i18nKeys = ["bar"] + window.i18nKeys = ["baz"] + `; + + const res = processI18n(code); + const resCode = normalizeHTML(res.code); + + expect(resCode).toEndWith( + out(` + export default function Component({i18n}) { + const { t } = i18n; + return
{t("hello")}
+ } + `), + ); + expect(res.useI18n).toBeTrue(); + expect(res.i18nKeys).toEqual(new Set(['foo', 'bar', 'baz'])); + expect(res.size).toBe(res.code.length); + + // Bridge with keys + expect(resCode).toContain('window.i18n'); + expect(resCode).toContain('messages()'); + }); + }); +}); diff --git a/packages/brisa/src/utils/client-build/process-i18n/index.ts b/packages/brisa/src/utils/client-build/process-i18n/index.ts new file mode 100644 index 000000000..ecebe2b57 --- /dev/null +++ b/packages/brisa/src/utils/client-build/process-i18n/index.ts @@ -0,0 +1,91 @@ +import AST from '@/utils/ast'; +import { build } from './inject-bridge' with { type: 'macro' }; +import { getConstants } from '@/constants'; +import transferTranslatedPagePaths from '@/utils/transfer-translated-page-paths'; +import type { ESTree } from 'meriyah'; + +const { parseCodeToAST, generateCodeFromAST, minify } = AST('tsx'); +const bridgeWithKeys = await build({ usei18nKeysLogic: true }); +const bridgeWithoutKeys = await build({ usei18nKeysLogic: false }); +const bridgeWithKeysAndFormatter = await build({ + usei18nKeysLogic: true, + useFormatter: true, +}); + +export function processI18n(code: string) { + const rawAst = parseCodeToAST(code); + const i18nKeys = new Set(); + let useI18n = false; + + const ast = JSON.parse(JSON.stringify(rawAst), (key, value) => { + if ( + isWindowProperty(value, 'i18nKeys') && + value.expression?.right?.type === 'ArrayExpression' + ) { + for (const element of value.expression.right.elements ?? []) { + i18nKeys.add(element.value); + } + return null; + } + + if (isWindowProperty(value, 'useI18n')) { + useI18n = true; + return null; + } + + // Clean null values inside arrays + if (Array.isArray(value)) return value.filter(Boolean); + + return value; + }); + + const newCode = useI18n ? astToI18nCode(ast, i18nKeys) : code; + + return { + code: newCode, + useI18n, + i18nKeys, + size: newCode.length, + }; +} + +function isWindowProperty(value: any, property: string) { + return ( + value?.type === 'ExpressionStatement' && + value.expression?.left?.object?.name === 'window' && + value.expression?.left?.property?.name === property + ); +} + +function astToI18nCode(ast: ESTree.Program, i18nKeys: Set) { + const { I18N_CONFIG } = getConstants(); + const usei18nKeysLogic = i18nKeys.size > 0; + const i18nConfig = JSON.stringify({ + ...I18N_CONFIG, + messages: undefined, + pages: transferTranslatedPagePaths(I18N_CONFIG?.pages), + }); + const formatterString = + typeof I18N_CONFIG?.interpolation?.format === 'function' + ? I18N_CONFIG.interpolation?.format.toString() + : ''; + + const bridge = + usei18nKeysLogic && formatterString + ? bridgeWithKeysAndFormatter + : usei18nKeysLogic + ? bridgeWithKeys + : bridgeWithoutKeys; + + // Note: It's important to run on the top of the AST, this way then the + // brisaElement will be able to use window.i18n + ast.body.unshift( + ...parseCodeToAST( + bridge + .replaceAll('__CONFIG__', i18nConfig) + .replaceAll('__FORMATTER__', formatterString), + ).body, + ); + + return minify(generateCodeFromAST(ast)); +} diff --git a/packages/brisa/src/utils/client-build/process-i18n/inject-bridge.ts b/packages/brisa/src/utils/client-build/process-i18n/inject-bridge.ts new file mode 100644 index 000000000..547dbd991 --- /dev/null +++ b/packages/brisa/src/utils/client-build/process-i18n/inject-bridge.ts @@ -0,0 +1,81 @@ +import { logBuildError } from '@/utils/log/log-build'; +import { join, resolve } from 'node:path'; + +type I18nBridgeConfig = { + usei18nKeysLogic?: boolean; + useFormatter?: boolean; +}; + +const translateCoreFile = resolve( + import.meta.dirname, + join('..', '..', 'translate-core', 'index.ts'), +); + +export async function build( + { usei18nKeysLogic = false, useFormatter = false }: I18nBridgeConfig = { + usei18nKeysLogic: false, + useFormatter: false, + }, +) { + const { success, logs, outputs } = await Bun.build({ + entrypoints: [translateCoreFile], + target: 'browser', + root: import.meta.dirname, + minify: true, + format: 'iife', + plugins: [ + { + name: 'i18n-bridge', + setup(build) { + const filter = /.*/; + + build.onLoad({ filter }, async ({ path, loader }) => { + const contents = ` + ${ + usei18nKeysLogic + ? // TODO: use (path).text() when Bun fix this issue: + // https://github.com/oven-sh/bun/issues/7611 + await Bun.readableStreamToText(Bun.file(path).stream()) + : '' + } + + const i18nConfig = __CONFIG__; + + window.i18n = { + ...i18nConfig, + get locale(){ return document.documentElement.lang }, + ${usei18nKeysLogic ? i18nKeysLogic(useFormatter) : ''} + } + `; + + return { contents, loader }; + }); + }, + }, + ], + }); + + if (!success) { + logBuildError('Failed to integrate i18n core', logs); + } + + return (await outputs?.[0]?.text?.()) ?? ''; +} + +function i18nKeysLogic(useFormatter: boolean) { + const formatters = useFormatter + ? `interpolation: {...i18nConfig.interpolation, format:__FORMATTER__},` + : ''; + + return ` + get t() { + return translateCore(this.locale, { ...i18nConfig, messages: this.messages, ${formatters} }); + }, + get messages() { return {[this.locale]: window.i18nMessages } }, + overrideMessages(callback) { + const p = callback(window.i18nMessages); + const a = m => Object.assign(window.i18nMessages, m); + return p.then?.(a) ?? a(p); + } + `; +} diff --git a/packages/brisa/src/utils/client-build/run-build/index.test.ts b/packages/brisa/src/utils/client-build/run-build/index.test.ts new file mode 100644 index 000000000..e57ded0c7 --- /dev/null +++ b/packages/brisa/src/utils/client-build/run-build/index.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import path from 'node:path'; +import { mkdir, rm } from 'node:fs/promises'; + +import { getConstants } from '@/constants'; +import { writeTempEntrypoint } from '../fs-temp-entrypoint-manager'; +import { runBuild } from '.'; + +const SRC = path.join(import.meta.dir, '..', '..', '..', '__fixtures__'); +const build = path.join(import.meta.dir, '.temp-test-files'); + +describe('client build -> runBuild', () => { + beforeEach(async () => { + await mkdir(path.join(build, '_brisa'), { recursive: true }); + globalThis.mockConstants = { + ...getConstants(), + SRC_DIR: SRC, + BUILD_DIR: build, + }; + }); + + afterEach(async () => { + await rm(build, { recursive: true, force: true }); + globalThis.mockConstants = undefined; + }); + + it('should run the build with a single entrypoint', async () => { + const { entrypoint } = await writeTempEntrypoint({ + webComponentsList: { + 'custom-counter': path.join( + SRC, + 'web-components', + 'custom-counter.tsx', + ), + }, + useContextProvider: false, + pagePath: 'foo', + }); + const { success, logs, outputs } = await runBuild([entrypoint], {}); + const entrypointBuild = await outputs[0].text(); + + expect(success).toBe(true); + expect(logs).toHaveLength(0); + expect(outputs).toHaveLength(1); + expect(outputs[0].size).toBeGreaterThan(0); + expect(outputs[0].text()).resolves.toContain( + ' defineElement("custom-counter"', + ); + expect(entrypointBuild).not.toContain('useI18n'); + expect(entrypointBuild).not.toContain('i18nKeys'); + }); + + it('should run the build with multiple entrypoints', async () => { + const { entrypoint: entrypoint1 } = await writeTempEntrypoint({ + webComponentsList: { + 'custom-counter': path.join( + SRC, + 'web-components', + 'custom-counter.tsx', + ), + }, + useContextProvider: false, + pagePath: 'foo', + }); + const { entrypoint: entrypoint2 } = await writeTempEntrypoint({ + webComponentsList: { + 'web-component': path.join(SRC, 'web-components', 'web-component.tsx'), + }, + useContextProvider: false, + pagePath: 'bar', + }); + + const { success, logs, outputs } = await runBuild( + [entrypoint1, entrypoint2], + {}, + ); + + const entrypoint1Build = await outputs[0].text(); + const entrypoint2Build = await outputs[1].text(); + + expect(success).toBe(true); + expect(logs).toHaveLength(0); + expect(outputs).toHaveLength(2); + expect(outputs[0].size).toBeGreaterThan(0); + expect(outputs[1].size).toBeGreaterThan(0); + expect(outputs[0].text()).resolves.toContain( + ' defineElement("custom-counter"', + ); + expect(outputs[1].text()).resolves.toContain( + ' defineElement("web-component"', + ); + + expect(entrypoint1Build).not.toContain('useI18n'); + expect(entrypoint1Build).not.toContain('i18nKeys'); + expect(entrypoint2Build).toContain('useI18n'); + expect(entrypoint2Build).toContain('i18nKeys = ["hello"]'); + }); +}); diff --git a/packages/brisa/src/utils/client-build/run-build/index.ts b/packages/brisa/src/utils/client-build/run-build/index.ts new file mode 100644 index 000000000..45d18d2a5 --- /dev/null +++ b/packages/brisa/src/utils/client-build/run-build/index.ts @@ -0,0 +1,82 @@ +import { getConstants } from '@/constants'; +import clientBuildPlugin from '@/utils/client-build-plugin'; +import getDefinedEnvVar from '@/utils/get-defined-env-var'; +import { shouldTransferTranslatedPagePaths } from '@/utils/transfer-translated-page-paths'; +import type { WCs } from '../types'; +import { logError } from '@/utils/log/log-build'; +import createContextPlugin from '@/utils/create-context/create-context-plugin'; + +export async function runBuild( + entrypoints: string[], + webComponents: WCs, + useWebContextPlugins = false, +) { + const { IS_PRODUCTION, SRC_DIR, CONFIG, I18N_CONFIG } = getConstants(); + const envVar = getDefinedEnvVar(); + const extendPlugins = CONFIG.extendPlugins ?? ((plugins) => plugins); + const webComponentsPath = Object.values(webComponents); + + return await Bun.build({ + entrypoints, + root: SRC_DIR, + format: 'iife', + target: 'browser', + minify: IS_PRODUCTION, + external: CONFIG.external, + define: { + __DEV__: (!IS_PRODUCTION).toString(), + __WEB_CONTEXT_PLUGINS__: useWebContextPlugins.toString(), + __BASE_PATH__: JSON.stringify(CONFIG.basePath ?? ''), + __ASSET_PREFIX__: JSON.stringify(CONFIG.assetPrefix ?? ''), + __TRAILING_SLASH__: Boolean(CONFIG.trailingSlash).toString(), + __USE_LOCALE__: Boolean(I18N_CONFIG?.defaultLocale).toString(), + __USE_PAGE_TRANSLATION__: shouldTransferTranslatedPagePaths( + I18N_CONFIG?.pages, + ).toString(), + // For security: + 'import.meta.dirname': '', + ...envVar, + }, + plugins: extendPlugins( + [ + { + name: 'client-build-plugin', + setup(build) { + const filter = new RegExp( + `(.*/src/web-components/(?!_integrations).*\\.(tsx|jsx|js|ts)|${webComponentsPath + .join('|') + // These replaces are to fix the regex in Windows + .replace(/\\/g, '\\\\')})$`.replace(/\//g, '[\\\\/]'), + ); + + build.onLoad({ filter }, async ({ path, loader }) => { + let code = await Bun.file(path).text(); + + try { + code = clientBuildPlugin(code, path); + } catch (error: any) { + logError({ + messages: [ + `Error transforming web component ${path}`, + error?.message, + ], + stack: error?.stack, + }); + } + + return { + contents: code, + loader, + }; + }); + }, + }, + createContextPlugin(), + ], + { + dev: !IS_PRODUCTION, + isServer: false, + }, + ), + }); +} diff --git a/packages/brisa/src/utils/client-build/types.ts b/packages/brisa/src/utils/client-build/types.ts new file mode 100644 index 000000000..04ed88903 --- /dev/null +++ b/packages/brisa/src/utils/client-build/types.ts @@ -0,0 +1,26 @@ +export type WCs = Record; +export type WCsEntrypoints = Record; + +export type Options = { + webComponentsPerEntrypoint: WCsEntrypoints; + layoutWebComponents: WCs; + allWebComponents: WCs; + integrationsPath?: string | null; + layoutHasContextProvider?: boolean; +}; + +export type EntryPointData = { + unsuspense: string; + rpc: string; + useContextProvider: boolean; + lazyRPC: string; + size: number; + useI18n: boolean; + i18nKeys: Set; + code: string; + entrypoint?: string; + useWebContextPlugins?: boolean; + pagePath: string; + index?: number; + webComponents?: WCs; +}; diff --git a/packages/brisa/src/utils/compile-files/index.test.ts b/packages/brisa/src/utils/compile-files/index.test.ts index 4aad3e5bb..c73c2f4af 100644 --- a/packages/brisa/src/utils/compile-files/index.test.ts +++ b/packages/brisa/src/utils/compile-files/index.test.ts @@ -138,7 +138,7 @@ describe('utils', () => { expect(logs).toBeEmpty(); expect(success).toBe(true); - expect(mockExtendPlugins).toHaveBeenCalledTimes(4); + expect(mockExtendPlugins).toHaveBeenCalledTimes(2); expect(mockExtendPlugins.mock.calls[0][1]).toEqual({ dev: false, isServer: true, @@ -146,17 +146,6 @@ describe('utils', () => { expect(mockExtendPlugins.mock.calls[1][1]).toEqual({ dev: false, isServer: false, - entrypoint: path.join(BUILD_DIR, 'pages', 'page-with-web-component.js'), - }); - expect(mockExtendPlugins.mock.calls[2][1]).toEqual({ - dev: false, - isServer: false, - entrypoint: path.join(BUILD_DIR, 'pages', '_404.js'), - }); - expect(mockExtendPlugins.mock.calls[3][1]).toEqual({ - dev: false, - isServer: false, - entrypoint: path.join(BUILD_DIR, 'pages', '_500.js'), }); const files = fs @@ -647,7 +636,7 @@ describe('utils', () => { ${info} ${info}Route | JS server | JS client (gz) ${info}---------------------------------------------- - ${info}λ /pages/index | 444 B | ${greenLog('4 kB')} + ${info}λ /pages/index | 444 B | ${greenLog('5 kB')} ${info}Δ /layout | 855 B | ${info}Ω /i18n | 221 B | ${info} diff --git a/packages/brisa/src/utils/compile-files/index.ts b/packages/brisa/src/utils/compile-files/index.ts index 95f680fd1..fb3da1d73 100644 --- a/packages/brisa/src/utils/compile-files/index.ts +++ b/packages/brisa/src/utils/compile-files/index.ts @@ -1,25 +1,19 @@ -import { gzipSync, type BuildArtifact } from 'bun'; -import { brotliCompressSync } from 'node:zlib'; -import fs from 'node:fs'; -import { join, sep } from 'node:path'; +import { join } from 'node:path'; import { getConstants } from '@/constants'; import byteSizeToString from '@/utils/byte-size-to-string'; -import getClientCodeInPage from '@/utils/get-client-code-in-page'; import getEntrypoints, { getEntrypointsRouter } from '@/utils/get-entrypoints'; import getImportableFilepath from '@/utils/get-importable-filepath'; import getWebComponentsList from '@/utils/get-web-components-list'; -import { logTable } from '@/utils/log/log-build'; +import { log, logTable } from '@/utils/log/log-build'; import serverComponentPlugin from '@/utils/server-component-plugin'; import createContextPlugin from '@/utils/create-context/create-context-plugin'; -import getI18nClientMessages from '@/utils/get-i18n-client-messages'; import { transpileActions, buildActions } from '@/utils/transpile-actions'; import generateStaticExport from '@/utils/generate-static-export'; import getWebComponentsPerEntryPoints from '@/utils/get-webcomponents-per-entrypoints'; import { shouldTransferTranslatedPagePaths } from '@/utils/transfer-translated-page-paths'; -import generateDynamicTypes from '@/utils/generate-dynamic-types'; +import { clientBuild } from '../client-build'; -const TS_REGEX = /\.tsx?$/; const BRISA_DEPS = ['brisa/server']; export default async function compileFiles() { @@ -76,6 +70,9 @@ export default async function compileFiles() { if (websocketPath) entrypoints.push(websocketPath); if (integrationsPath) entrypoints.push(integrationsPath); + log(LOG_PREFIX.WAIT, `compiling ${entrypoints.length} server entrypoints...`); + + const actionWrites: Promise[] = []; const { success, logs, outputs } = await Bun.build({ entrypoints, outdir: BUILD_DIR, @@ -117,9 +114,11 @@ export default async function compileFiles() { actionsEntrypoints.push(actionEntrypoint); actionIdCount += 1; - await Bun.write( - actionEntrypoint, - transpileActions(result.code), + actionWrites.push( + Bun.write( + actionEntrypoint, + transpileActions(result.code), + ), ); } @@ -148,11 +147,13 @@ export default async function compileFiles() { if (!success) return { success, logs, pagesSize: {} }; if (actionsEntrypoints.length) { - const actionResult = await buildActions({ actionsEntrypoints, define }); + const actionResult = await Promise.all(actionWrites).then(() => + buildActions({ actionsEntrypoints, define }), + ); if (!actionResult.success) logs.push(...actionResult.logs); } - const pagesSize = await compileClientCodePage(outputs, { + const pagesSize = await clientBuild(outputs, { allWebComponents, webComponentsPerEntrypoint: getWebComponentsPerEntryPoints( webComponentsPerFile, @@ -257,260 +258,3 @@ export default async function compileFiles() { return { success, logs, pagesSize: pagesSize }; } - -async function compileClientCodePage( - pages: BuildArtifact[], - { - allWebComponents, - webComponentsPerEntrypoint, - integrationsPath, - layoutPath, - pagesRoutes, - }: { - allWebComponents: Record; - webComponentsPerEntrypoint: Record>; - integrationsPath?: string | null; - layoutPath?: string | null; - pagesRoutes: ReturnType; - }, -) { - const { BUILD_DIR, I18N_CONFIG, IS_PRODUCTION } = getConstants(); - const pagesClientPath = join(BUILD_DIR, 'pages-client'); - const internalPath = join(BUILD_DIR, '_brisa'); - const layoutBuildPath = layoutPath ? getBuildPath(layoutPath) : ''; - const writes = []; - - // During hotreloading it is important to clean pages-client because - // new client files are generated with hash, this hash can change - // and many files would be accumulated during development. - // - // On the other hand, in production it will always be empty because - // the whole build is cleaned at startup. - if (fs.existsSync(pagesClientPath)) { - fs.rmSync(pagesClientPath, { recursive: true }); - } - // Create pages-client - fs.mkdirSync(pagesClientPath); - - if (!fs.existsSync(internalPath)) fs.mkdirSync(internalPath); - - const clientSizesPerPage: Record = {}; - const layoutWebComponents = webComponentsPerEntrypoint[layoutBuildPath]; - const layoutCode = layoutBuildPath - ? await getClientCodeInPage({ - pagePath: layoutBuildPath, - allWebComponents, - pageWebComponents: layoutWebComponents, - integrationsPath, - }) - : null; - - for (const page of pages) { - const route = page.path.replace(BUILD_DIR, ''); - const pagePath = page.path; - const isPage = route.startsWith(sep + 'pages' + sep); - const clientPagePath = pagePath.replace('pages', 'pages-client'); - let pageWebComponents = webComponentsPerEntrypoint[pagePath]; - - if (!isPage) continue; - - // It is necessary to add the web components of the layout before - // having the code of the page because it will add the web components - // in the following fields: code, size. - if (layoutWebComponents) { - pageWebComponents = { ...layoutWebComponents, ...pageWebComponents }; - } - - const pageCode = await getClientCodeInPage({ - pagePath, - allWebComponents, - pageWebComponents, - integrationsPath, - layoutHasContextProvider: layoutCode?.useContextProvider, - }); - - if (!pageCode) return null; - - let { size, rpc, lazyRPC, code, unsuspense, useI18n, i18nKeys } = pageCode; - - // If there are no actions in the page but there are actions in - // the layout, then it is as if the page also has actions. - if (!rpc && layoutCode?.rpc) { - size += layoutCode.rpc.length; - rpc = layoutCode.rpc; - } - - // It is not necessary to increase the size here because this - // code even if it is necessary to generate it if it does not - // exist yet, it is not part of the initial size of the page - // because it is loaded in a lazy way. - if (!lazyRPC && layoutCode?.lazyRPC) { - lazyRPC = layoutCode.lazyRPC; - } - - // If there is no unsuspense in the page but there is unsuspense - // in the layout, then it is as if the page also has unsuspense. - if (!unsuspense && layoutCode?.unsuspense) { - size += layoutCode.unsuspense.length; - unsuspense = layoutCode.unsuspense; - } - - // fix i18n when it is not defined in the page but it is defined - // in the layout - if (!useI18n && layoutCode?.useI18n) { - useI18n = layoutCode.useI18n; - } - if (layoutCode?.i18nKeys.size) { - i18nKeys = new Set([...i18nKeys, ...layoutCode.i18nKeys]); - } - - clientSizesPerPage[route] = size; - - if (!size) continue; - - const hash = Bun.hash(code); - const clientPage = clientPagePath.replace('.js', `-${hash}.js`); - clientSizesPerPage[route] = 0; - - // create _unsuspense.js and _unsuspense.txt (list of pages with unsuspense) - clientSizesPerPage[route] += addExtraChunk(unsuspense, '_unsuspense', { - pagesClientPath, - pagePath, - writes, - }); - - // create _rpc-[versionhash].js and _rpc.txt (list of pages with actions) - clientSizesPerPage[route] += addExtraChunk(rpc, '_rpc', { - pagesClientPath, - pagePath, - writes, - }); - - // create _rpc-lazy-[versionhash].js - clientSizesPerPage[route] += addExtraChunk(lazyRPC, '_rpc-lazy', { - pagesClientPath, - pagePath, - skipList: true, - writes, - }); - - if (!code) continue; - - if (useI18n && i18nKeys.size && I18N_CONFIG?.messages) { - for (const locale of I18N_CONFIG?.locales ?? []) { - const i18nPagePath = clientPage.replace('.js', `-${locale}.js`); - const messages = getI18nClientMessages(locale, i18nKeys); - const i18nCode = `window.i18nMessages={...window.i18nMessages,...(${JSON.stringify(messages)})};`; - - writes.push(Bun.write(i18nPagePath, i18nCode)); - - // Compression in production - if (IS_PRODUCTION) { - writes.push( - Bun.write( - `${i18nPagePath}.gz`, - gzipSync(new TextEncoder().encode(i18nCode)), - ), - ); - writes.push( - Bun.write(`${i18nPagePath}.br`, brotliCompressSync(i18nCode)), - ); - } - } - } - - // create page file - writes.push( - Bun.write(clientPagePath.replace('.js', '.txt'), hash.toString()), - ); - writes.push(Bun.write(clientPage, code)); - - // Compression in production - if (IS_PRODUCTION) { - const gzipClientPage = gzipSync(new TextEncoder().encode(code)); - - writes.push(Bun.write(`${clientPage}.gz`, gzipClientPage)); - writes.push(Bun.write(`${clientPage}.br`, brotliCompressSync(code))); - clientSizesPerPage[route] += gzipClientPage.length; - } - } - - writes.push( - Bun.write( - join(internalPath, 'types.ts'), - generateDynamicTypes({ allWebComponents, pagesRoutes }), - ), - ); - - // Although on Mac it can work without await, on Windows it does not and it is mandatory - await Promise.all(writes); - - return clientSizesPerPage; -} - -function addExtraChunk( - code: string, - filename: string, - { - pagesClientPath, - pagePath, - skipList = false, - writes, - }: { - pagesClientPath: string; - pagePath: string; - skipList?: boolean; - writes: Promise[]; - }, -) { - const { BUILD_DIR, VERSION, IS_PRODUCTION } = getConstants(); - const jsFilename = `${filename}-${VERSION}.js`; - - if (!code) return 0; - - if (!skipList && fs.existsSync(join(pagesClientPath, jsFilename))) { - const listPath = join(pagesClientPath, `${filename}.txt`); - - writes.push( - Bun.write( - listPath, - `${fs.readFileSync(listPath).toString()}\n${pagePath.replace(BUILD_DIR, '')}`, - ), - ); - - return 0; - } - - writes.push(Bun.write(join(pagesClientPath, jsFilename), code)); - - if (!skipList) { - writes.push( - Bun.write( - join(pagesClientPath, `${filename}.txt`), - pagePath.replace(BUILD_DIR, ''), - ), - ); - } - - if (IS_PRODUCTION) { - const gzipUnsuspense = gzipSync(new TextEncoder().encode(code)); - - writes.push( - Bun.write(join(pagesClientPath, `${jsFilename}.gz`), gzipUnsuspense), - ); - writes.push( - Bun.write( - join(pagesClientPath, `${jsFilename}.br`), - brotliCompressSync(code), - ), - ); - return gzipUnsuspense.length; - } - - return code.length; -} - -function getBuildPath(path: string) { - const { SRC_DIR, BUILD_DIR } = getConstants(); - return path.replace(SRC_DIR, BUILD_DIR).replace(TS_REGEX, '.js'); -} diff --git a/packages/brisa/src/utils/compile-wc/index.ts b/packages/brisa/src/utils/compile-wc/index.ts index 5b1ceaa46..71e4220d5 100644 --- a/packages/brisa/src/utils/compile-wc/index.ts +++ b/packages/brisa/src/utils/compile-wc/index.ts @@ -8,5 +8,5 @@ import clientBuildPlugin from '../client-build-plugin'; * Docs: https:/brisa.build/api-reference/compiler-apis/compileWC */ export default function compileWC(code: string) { - return clientBuildPlugin(code, 'web-component.tsx').code; + return clientBuildPlugin(code, 'web-component.tsx'); } diff --git a/packages/brisa/src/utils/context-provider/inject-client.ts b/packages/brisa/src/utils/context-provider/inject-client.ts index 4f58cbc1d..1263c67d4 100644 --- a/packages/brisa/src/utils/context-provider/inject-client.ts +++ b/packages/brisa/src/utils/context-provider/inject-client.ts @@ -21,7 +21,7 @@ export async function injectClientContextProviderCode() { // https://github.com/oven-sh/bun/issues/7611 await Bun.readableStreamToText(Bun.file(path).stream()), internalComponentId, - ).code, + ), loader, })); }, diff --git a/packages/brisa/src/utils/get-client-code-in-page/index.ts b/packages/brisa/src/utils/get-client-code-in-page/index.ts deleted file mode 100644 index ac6917a55..000000000 --- a/packages/brisa/src/utils/get-client-code-in-page/index.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { rm, writeFile } from 'node:fs/promises'; -import { join, sep } from 'node:path'; - -import { getConstants } from '@/constants'; -import AST from '@/utils/ast'; -import { - injectRPCCode, - injectRPCCodeForStaticApp, - injectRPCLazyCode, -} from '@/utils/rpc' with { type: 'macro' }; -import { injectUnsuspenseCode } from '@/utils/inject-unsuspense-code' with { - type: 'macro', -}; -import { injectClientContextProviderCode } from '@/utils/context-provider/inject-client' with { - type: 'macro', -}; -import { injectBrisaDialogErrorCode } from '@/utils/brisa-error-dialog/inject-code' with { - type: 'macro', -}; -import { getFilterDevRuntimeErrors } from '@/utils/brisa-error-dialog/utils'; -import clientBuildPlugin from '@/utils/client-build-plugin'; -import createContextPlugin from '@/utils/create-context/create-context-plugin'; -import snakeToCamelCase from '@/utils/snake-to-camelcase'; -import analyzeServerAst from '@/utils/analyze-server-ast'; -import { logBuildError, logError } from '@/utils/log/log-build'; -import { shouldTransferTranslatedPagePaths } from '@/utils/transfer-translated-page-paths'; -import getDefinedEnvVar from '../get-defined-env-var'; - -type TransformOptions = { - webComponentsList: Record; - useContextProvider: boolean; - integrationsPath?: string | null; - pagePath: string; -}; - -type ClientCodeInPageProps = { - pagePath: string; - allWebComponents?: Record; - pageWebComponents?: Record; - integrationsPath?: string | null; - layoutHasContextProvider?: boolean; -}; - -const ASTUtil = AST('tsx'); -const unsuspenseScriptCode = injectUnsuspenseCode() as unknown as string; -const RPCLazyCode = injectRPCLazyCode() as unknown as string; - -function getRPCCode() { - const { IS_PRODUCTION, IS_STATIC_EXPORT } = getConstants(); - return (IS_STATIC_EXPORT && IS_PRODUCTION - ? injectRPCCodeForStaticApp() - : injectRPCCode()) as unknown as string; -} - -async function getAstFromPath(path: string) { - return ASTUtil.parseCodeToAST( - path[0] === '{' ? '' : await Bun.file(path).text(), - ); -} - -export default async function getClientCodeInPage({ - pagePath, - allWebComponents = {}, - pageWebComponents = {}, - integrationsPath, - layoutHasContextProvider, -}: ClientCodeInPageProps) { - let size = 0; - let code = ''; - - const ast = await getAstFromPath(pagePath); - - let { useSuspense, useContextProvider, useActions, useHyperlink } = - analyzeServerAst(ast, allWebComponents, layoutHasContextProvider); - - // Web components inside web components - const nestedComponents = await Promise.all( - Object.values(pageWebComponents).map(async (path) => - analyzeServerAst(await getAstFromPath(path), allWebComponents), - ), - ); - - for (const item of nestedComponents) { - useContextProvider ||= item.useContextProvider; - useSuspense ||= item.useSuspense; - useHyperlink ||= item.useHyperlink; - Object.assign(pageWebComponents, item.webComponents); - } - - const unsuspense = useSuspense ? unsuspenseScriptCode : ''; - const rpc = useActions || useHyperlink ? getRPCCode() : ''; - const lazyRPC = useActions || useHyperlink ? RPCLazyCode : ''; - - size += unsuspense.length; - size += rpc.length; - - if (!Object.keys(pageWebComponents).length) { - return { - code, - unsuspense, - rpc, - useContextProvider, - lazyRPC, - size, - useI18n: false, - i18nKeys: new Set(), - }; - } - - const transformedCode = await transformToWebComponents({ - webComponentsList: pageWebComponents, - useContextProvider, - integrationsPath, - pagePath, - }); - - if (!transformedCode) return null; - - code += transformedCode?.code; - size += transformedCode?.size ?? 0; - - return { - code, - unsuspense, - rpc, - useContextProvider, - lazyRPC, - size, - useI18n: transformedCode.useI18n, - i18nKeys: transformedCode.i18nKeys, - }; -} - -export async function transformToWebComponents({ - webComponentsList, - useContextProvider, - integrationsPath, - pagePath, -}: TransformOptions) { - const { - SRC_DIR, - BUILD_DIR, - CONFIG, - I18N_CONFIG, - IS_DEVELOPMENT, - IS_PRODUCTION, - VERSION, - } = getConstants(); - - const extendPlugins = CONFIG.extendPlugins ?? ((plugins) => plugins); - const internalDir = join(BUILD_DIR, '_brisa'); - const webEntrypoint = join(internalDir, `temp-${VERSION}.ts`); - let useI18n = false; - let i18nKeys = new Set(); - const webComponentsPath = Object.values(webComponentsList); - let useWebContextPlugins = false; - const entries = Object.entries(webComponentsList); - - // Note: JS imports in Windows have / instead of \, so we need to replace it - // Note: Using "require" for component dependencies not move the execution - // on top avoiding missing global variables as window._P - let imports = entries - .map(([name, path]) => - path[0] === '{' - ? `require("${normalizePath(path)}");` - : `import ${snakeToCamelCase(name)} from "${path.replaceAll(sep, '/')}";`, - ) - .join('\n'); - - // Add web context plugins import only if there is a web context plugin - if (integrationsPath) { - const module = await import(integrationsPath); - if (module.webContextPlugins?.length > 0) { - useWebContextPlugins = true; - imports += `import {webContextPlugins} from "${integrationsPath}";`; - } - } - - const defineElement = - 'const defineElement = (name, component) => name && !customElements.get(name) && customElements.define(name, component);'; - - const customElementKeys = entries - .filter(([_, path]) => path[0] !== '{') - .map(([k]) => k); - - if (useContextProvider) { - customElementKeys.unshift('context-provider'); - } - - if (IS_DEVELOPMENT) { - customElementKeys.unshift('brisa-error-dialog'); - } - - const customElementsDefinitions = customElementKeys - .map((k) => `defineElement("${k}", ${snakeToCamelCase(k)});`) - .join('\n'); - - let code = ''; - - if (useContextProvider) { - const contextProviderCode = - injectClientContextProviderCode() as unknown as string; - code += contextProviderCode; - } - - // IS_DEVELOPMENT to avoid PROD and TEST environments - if (IS_DEVELOPMENT) { - const brisaDialogErrorCode = (await injectBrisaDialogErrorCode()).replace( - '__FILTER_DEV_RUNTIME_ERRORS__', - getFilterDevRuntimeErrors(), - ); - code += brisaDialogErrorCode; - } - - // Inject web context plugins to window to be used inside web components - if (useWebContextPlugins) { - code += 'window._P=webContextPlugins;\n'; - } - - code += `${imports}\n`; - code += `${defineElement}\n${customElementsDefinitions};`; - - await writeFile(webEntrypoint, code); - - const envVar = getDefinedEnvVar(); - - const { success, logs, outputs } = await Bun.build({ - entrypoints: [webEntrypoint], - root: SRC_DIR, - // TODO: format: "iife" when Bun support it - // https://bun.sh/docs/bundler#format - target: 'browser', - minify: IS_PRODUCTION, - external: CONFIG.external, - define: { - __DEV__: (!IS_PRODUCTION).toString(), - __WEB_CONTEXT_PLUGINS__: useWebContextPlugins.toString(), - __BASE_PATH__: JSON.stringify(CONFIG.basePath ?? ''), - __ASSET_PREFIX__: JSON.stringify(CONFIG.assetPrefix ?? ''), - __TRAILING_SLASH__: Boolean(CONFIG.trailingSlash).toString(), - __USE_LOCALE__: Boolean(I18N_CONFIG?.defaultLocale).toString(), - __USE_PAGE_TRANSLATION__: shouldTransferTranslatedPagePaths( - I18N_CONFIG?.pages, - ).toString(), - // For security: - 'import.meta.dirname': '', - ...envVar, - }, - plugins: extendPlugins( - [ - { - name: 'client-build-plugin', - setup(build) { - build.onLoad( - { - filter: new RegExp( - `(.*/src/web-components/(?!_integrations).*\\.(tsx|jsx|js|ts)|${webComponentsPath - .join('|') - // These replaces are to fix the regex in Windows - .replace(/\\/g, '\\\\')})$`.replace(/\//g, '[\\\\/]'), - ), - }, - async ({ path, loader }) => { - let code = await Bun.file(path).text(); - - try { - const res = clientBuildPlugin(code, path, { - isI18nAdded: useI18n, - isTranslateCoreAdded: i18nKeys.size > 0, - }); - code = res.code; - useI18n ||= res.useI18n; - i18nKeys = new Set([...i18nKeys, ...res.i18nKeys]); - } catch (error: any) { - logError({ - messages: [ - `Error transforming web component ${path}`, - error?.message, - ], - stack: error?.stack, - }); - } - - return { - contents: code, - loader, - }; - }, - ); - }, - }, - createContextPlugin(), - ], - { dev: !IS_PRODUCTION, isServer: false, entrypoint: pagePath }, - ), - }); - - await rm(webEntrypoint); - - if (!success) { - logBuildError('Failed to compile web components', logs); - return null; - } - - return { - code: '(() => {' + (await outputs[0].text()) + '})();', - size: outputs[0].size, - useI18n, - i18nKeys, - }; -} - -export function normalizePath(rawPathname: string, separator = sep) { - const pathname = - rawPathname[0] === '{' ? JSON.parse(rawPathname).client : rawPathname; - - return pathname.replaceAll(separator, '/'); -} diff --git a/packages/brisa/src/utils/handle-css-files/index.test.ts b/packages/brisa/src/utils/handle-css-files/index.test.ts index 7cc4a94d3..2dd9c556c 100644 --- a/packages/brisa/src/utils/handle-css-files/index.test.ts +++ b/packages/brisa/src/utils/handle-css-files/index.test.ts @@ -166,8 +166,8 @@ describe('utils/handle-css-files', () => { await handleCSSFiles(); expect(mockLog).toHaveBeenCalledTimes(2); expect(mockLog).toHaveBeenCalledWith( - LOG_PREFIX.INFO, - `Transpiling CSS with brisa-tailwindcss`, + LOG_PREFIX.WAIT, + `transpiling CSS with brisa-tailwindcss...`, ); expect(mockLog).toHaveBeenCalledWith( LOG_PREFIX.INFO, diff --git a/packages/brisa/src/utils/handle-css-files/index.ts b/packages/brisa/src/utils/handle-css-files/index.ts index 56fbac764..71d750c1c 100644 --- a/packages/brisa/src/utils/handle-css-files/index.ts +++ b/packages/brisa/src/utils/handle-css-files/index.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { getConstants } from '@/constants'; -import { logError } from '../log/log-build'; +import { log, logError } from '../log/log-build'; import { gzipSync } from 'bun'; import { brotliCompressSync } from 'node:zlib'; @@ -26,10 +26,7 @@ export default async function handleCSSFiles() { const startTime = Date.now(); if (IS_BUILD_PROCESS) { - console.log( - LOG_PREFIX.INFO, - `Transpiling CSS with ${integration.name}`, - ); + log(LOG_PREFIX.WAIT, `transpiling CSS with ${integration.name}...`); } let useDefault = true; diff --git a/packages/brisa/src/utils/log/log-build.ts b/packages/brisa/src/utils/log/log-build.ts index 323153df8..8376e5de2 100644 --- a/packages/brisa/src/utils/log/log-build.ts +++ b/packages/brisa/src/utils/log/log-build.ts @@ -44,7 +44,12 @@ export function logTable(data: { [key: string]: string }[]) { lines.forEach((line) => console.log(LOG_PREFIX.INFO, line)); } -function log(type: 'Error' | 'Warning') { +export function log(...messages: string[]) { + if (process.env.QUIET_MODE === 'true') return; + console.log(...messages); +} + +function logProblem(type: 'Error' | 'Warning') { const { LOG_PREFIX } = getConstants(); const LOG = LOG_PREFIX[ @@ -100,11 +105,11 @@ export function logError({ footer = `${docTitle ?? 'Documentation'}: ${docLink}`; } - return log('Error')(messages, footer, stack); + return logProblem('Error')(messages, footer, stack); } export function logWarning(messages: string[], footer?: string) { - return log('Warning')(messages, footer); + return logProblem('Warning')(messages, footer); } export function logBuildError( diff --git a/packages/brisa/src/utils/transpile-actions/index.ts b/packages/brisa/src/utils/transpile-actions/index.ts index 1670060b0..3b10a8144 100644 --- a/packages/brisa/src/utils/transpile-actions/index.ts +++ b/packages/brisa/src/utils/transpile-actions/index.ts @@ -6,7 +6,7 @@ import { getConstants } from '@/constants'; import type { ActionInfo } from './get-actions-info'; import getActionsInfo from './get-actions-info'; import { getPurgedBody } from './get-purged-body'; -import { logBuildError } from '@/utils/log/log-build'; +import { log, logBuildError } from '@/utils/log/log-build'; import { jsx, jsxDEV } from '../ast/constants'; type CompileActionsParams = { @@ -594,7 +594,7 @@ export async function buildActions({ actionsEntrypoints, define, }: CompileActionsParams) { - const { BUILD_DIR, IS_PRODUCTION, CONFIG } = getConstants(); + const { BUILD_DIR, IS_PRODUCTION, CONFIG, LOG_PREFIX } = getConstants(); const isNode = CONFIG.output === 'node' && IS_PRODUCTION; const rawActionsDir = join(BUILD_DIR, 'actions_raw'); const barrelFile = join(rawActionsDir, 'index.ts'); @@ -605,6 +605,9 @@ export async function buildActions({ ); const external = CONFIG.external ? [...CONFIG.external, 'brisa'] : ['brisa']; + + log(LOG_PREFIX.WAIT, `compiling server actions...`); + const res = await Bun.build({ entrypoints: [barrelFile], outdir: join(BUILD_DIR, 'actions'),