Cuando trabajas en un proyecto empresarial con Liferay DXP 7.4, es común que necesites crear múltiples Client Extensions de tipo Custom Element. En un proyecto reciente teníamos que desarrollar más de quince widgets independientes -- cada uno como una aplicación React o Angular empaquetada como Web Component.
El problema es que cada Client Extension requiere una estructura de archivos muy específica para funcionar correctamente dentro de Liferay. No basta con crear un proyecto de React con Vite y desplegarlo. Necesitas:
client-extension.yaml con la definición del Custom ElementcustomElements.define()package.json con los scripts correctos de buildConfigurar todo esto manualmente tomaba entre 30 y 45 minutos por widget. Con quince widgets, estábamos hablando de más de diez horas solo en scaffolding. Y lo peor: cualquier inconsistencia entre proyectos generaba bugs sutiles en producción.
La solución fue crear lo que internamente llamamos 1script: un conjunto de scripts Bash que generan un proyecto completo de Client Extension listo para desarrollar, con un solo comando.
El script necesita exactamente dos datos del desarrollador:
package.json y el client-extension.yamlEl tag del Custom Element es el dato más crítico porque debe cumplir con la especificación de Web Components. Si el tag no es válido, customElements.define() lanza un SyntaxError (DOMException) y el registro se aborta ahí mismo: el widget nunca se renderiza y el único rastro es una excepción en la consola del navegador, jamás un error de build. La validación que implementamos usa esta expresión regular:
PATTERN='^[a-z0-9]+(-[a-z0-9]+)+$'
if [[ ! "$ELEMENT_TAG" =~ $PATTERN ]]; then
echo "Error: El tag '$ELEMENT_TAG' no es valido."
echo "Debe contener al menos un guion y solo letras minusculas/numeros."
echo "Ejemplo: mi-widget, panel-datos-usuario"
exit 1
fi
Esta regex exige al menos un guion (requisito de la especificación de Custom Elements para evitar colisiones con elementos HTML nativos) y solo permite minúsculas y números. Es una restricción deliberada: la spec admite bastantes más caracteres, y limitarla así evita problemas con Liferay y mantiene consistencia entre equipos. Con un matiz importante: la spec también exige que el primer carácter sea una letra minúscula, y este patrón no lo comprueba, así que conviene endurecerlo a ^[a-z][a-z0-9]*(-[a-z0-9]+)+$ para que no pase un tag como 1-widget, que customElements.define() rechazaría con un SyntaxError.
La versión React del script genera un proyecto basado en Vite, pero con una configuración muy diferente a la que Vite produce por defecto. La razón es que Liferay necesita cargar el widget como un script clásico, no como un módulo ES.
Cuando Liferay renderiza una página que contiene un Custom Element, inyecta un tag <script src="..."> apuntando al JS de tu extensión. Este script se ejecuta en el contexto global de la página, que ya tiene su propio bundle de Liferay, jQuery, y potencialmente otros widgets. Si tu build produce módulos ES con import/export, el navegador los rechaza porque no están en un <script type="module">.
Además, Liferay no soporta code splitting para Client Extensions. Necesitas un único archivo JS que contenga todo: React, tu aplicación, y el registro del Web Component.
La configuración de Vite que genera el script:
// vite.config.js generado por el script
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
build: {
lib: {
entry: 'src/index.jsx',
formats: ['iife'],
name: 'CustomElement',
fileName: () => 'main.js',
},
rollupOptions: {
output: {
entryFileNames: 'main.js',
assetFileNames: 'main.[ext]',
},
},
cssCodeSplit: false,
minify: 'terser',
},
plugins: [react()],
})
Los puntos clave son formats: ['iife'] para producir un bundle auto-ejecutable, cssCodeSplit: false para consolidar todo el CSS en un archivo, y nombres de archivo fijos (main.js, main.css) porque el client-extension.yaml referencia estos nombres exactos.
El archivo src/index.jsx generado no es un App.jsx convencional de React. Es una clase que extiende HTMLElement y gestiona el ciclo de vida del Web Component:
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
class CustomElementComponent extends HTMLElement {
constructor() {
super()
this._root = null
}
connectedCallback() {
if (!this._root) {
this._root = createRoot(this)
}
this._root.render(<App />)
}
disconnectedCallback() {
if (this._root) {
this._root.unmount()
this._root = null
}
}
}
const TAG_NAME = '__ELEMENT_TAG__'
if (!customElements.get(TAG_NAME)) {
customElements.define(TAG_NAME, CustomElementComponent)
}
El script reemplaza __ELEMENT_TAG__ con el tag que el desarrollador proporcionó. La verificación customElements.get() antes de define() previene errores si el script se carga dos veces (algo que puede ocurrir en Liferay cuando se navega entre páginas sin recarga completa).
El disconnectedCallback es crucial y a menudo se omite en tutoriales. Sin él, cuando un usuario navega fuera de una página que contiene el widget, React no desmonta el componente correctamente, causando memory leaks acumulativos.
Vite en modo IIFE a veces produce un output que incluye un wrapper innecesario o referencias a document.currentScript que fallan en ciertos contextos de Liferay. El script genera un archivo build-custom-element.cjs que se ejecuta después del build de Vite:
// build-custom-element.cjs (version simplificada)
const fs = require('fs')
const path = require('path')
const distDir = path.join(__dirname, 'build')
const jsFile = path.join(distDir, 'main.js')
let content = fs.readFileSync(jsFile, 'utf-8')
// Eliminar IIFE wrapper externo si Vite lo duplico
content = content.replace(/^\(function\(\)\{/, '')
content = content.replace(/\}\)\(\);?\s*$/, '')
// Re-envolver limpiamente
content = `(function(){${content}})();`
fs.writeFileSync(jsFile, content, 'utf-8')
console.log('Post-processing completed: build/main.js')
El package.json generado encadena ambos pasos:
{
"scripts": {
"dev": "vite",
"build": "vite build --outDir build && node build-custom-element.cjs",
"preview": "vite preview"
}
}
La versión Angular presenta desafíos diferentes. Angular no tiene un equivalente directo al modo lib de Vite, así que la estrategia es distinta.
En Angular, el script genera un AppModule que implementa la interfaz DoBootstrap en lugar de usar el bootstrap convencional:
import { Injector, NgModule, DoBootstrap } from '@angular/core'
import { createCustomElement } from '@angular/elements'
import { BrowserModule } from '@angular/platform-browser'
import { AppComponent } from './app.component'
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
})
export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {}
ngDoBootstrap() {
const element = createCustomElement(AppComponent, {
injector: this.injector,
})
const TAG = '__ELEMENT_TAG__'
if (!customElements.get(TAG)) {
customElements.define(TAG, element)
}
}
}
@angular/elements es la librería oficial de Angular para crear Web Components. El patrón DoBootstrap le dice a Angular que no intente renderizar un componente root en el DOM automáticamente, sino que registre el Custom Element y espere a que el navegador lo instancie cuando encuentre el tag en el HTML.
Angular CLI produce múltiples archivos: runtime.js, polyfills.js, main.js, y potencialmente chunks adicionales. Liferay espera un único archivo JS. La solución es un script de concatenación:
#!/bin/bash
# concat-build.sh generado por el script
BUILD_DIR="dist/__PROJECT_NAME__"
OUTPUT="build/main.js"
mkdir -p build
cat "$BUILD_DIR/runtime."*.js \
"$BUILD_DIR/polyfills."*.js \
"$BUILD_DIR/main."*.js \
> "$OUTPUT"
# Copiar CSS
cat "$BUILD_DIR/styles."*.css > build/main.css 2>/dev/null
echo "Concatenation complete: $OUTPUT"
El orden importa: runtime primero porque contiene el cargador de módulos que genera webpack (el builder browser de Angular CLI lo emite como chunk aparte con runtimeChunk: 'single'), luego polyfills -- que instala zone.js y las APIs que la aplicación da por disponibles -- y finalmente main. Si concatenas en otro orden el fallo rara vez es explícito: lo habitual es que la aplicación simplemente no arranque, o que main reviente al ejecutarse antes de que zone.js esté cargado. Y ojo con la versión: desde Angular 17 el builder por defecto es application (esbuild), que no emite runtime.js y escribe en dist/<proyecto>/browser, así que este script asume el builder browser basado en webpack.
El angular.json se genera con outputHashing: "none" para que los archivos no tengan hashes en el nombre, simplificando la concatenación:
{
"architect": {
"build": {
"configurations": {
"production": {
"outputHashing": "none",
"budgets": []
}
}
}
}
}
Ambas versiones generan el mismo client-extension.yaml, que es lo que Liferay lee para registrar el Custom Element:
assemble:
- from: build
into: static
__PROJECT_NAME__:
cssURLs:
- main.css
htmlElementName: __ELEMENT_TAG__
instanceable: true
name: __PROJECT_NAME__
portletCategoryName: category.client-extensions
type: customElement
urls:
- main.js
El campo instanceable: true permite que el mismo widget se coloque múltiples veces en una página. portletCategoryName determina en qué sección del panel de widgets aparece al editar una página.
El script completo sigue este flujo:
sed reemplazando los placeholdersnpm installnpm run build produce los archivos esperados#!/bin/bash
# Estructura simplificada del 1script (version React)
set -euo pipefail
read -p "Nombre del proyecto: " PROJECT_NAME
read -p "Tag del Custom Element: " ELEMENT_TAG
# Validacion
PATTERN='^[a-z0-9]+(-[a-z0-9]+)+$'
if [[ ! "$ELEMENT_TAG" =~ $PATTERN ]]; then
echo "Tag invalido. Debe contener al menos un guion."
exit 1
fi
mkdir -p "$PROJECT_NAME/src"
cd "$PROJECT_NAME"
# Generar archivos (cada funcion crea un archivo)
generate_package_json "$PROJECT_NAME"
generate_vite_config
generate_index_jsx "$ELEMENT_TAG"
generate_app_jsx
generate_build_script
generate_client_extension_yaml "$PROJECT_NAME" "$ELEMENT_TAG"
# Instalar y verificar
npm install
npm run build
if [[ -f "build/main.js" && -f "build/main.css" ]]; then
echo "Proyecto '$PROJECT_NAME' creado exitosamente."
echo "Tag: <$ELEMENT_TAG>"
echo "Build: build/main.js + build/main.css"
else
echo "Error: el build no produjo los archivos esperados."
exit 1
fi
Después de implementar estos scripts, el tiempo de scaffolding bajó de 45 minutos a menos de 30 segundos por widget. Pero el beneficio más importante no fue la velocidad sino la consistencia: todos los proyectos tenían exactamente la misma estructura, las mismas configuraciones de build, y los mismos patrones de Web Component.
Esto eliminó una clase entera de bugs que antes eran recurrentes:
disconnectedCallback)SyntaxError en tiempo de ejecuciónLa lección más importante fue que en proyectos empresariales con Liferay, donde puedes tener decenas de Client Extensions, automatizar el scaffolding no es un lujo: es una necesidad. El tiempo que inviertes en crear buenos scripts de generación se paga en la primera semana de desarrollo.
Si tu equipo está empezando con Client Extensions, te recomiendo invertir un día en crear scripts similares adaptados a tu stack. La documentación oficial de Liferay sobre Client Extensions es buena, pero asume que crearás uno o dos widgets. Cuando necesitas quince o veinte, necesitas automatización.