Cuando trabajas en un proyecto empresarial con Liferay DXP 7.4, es comun que necesites crear multiples Client Extensions de tipo Custom Element. En un proyecto reciente teniamos que desarrollar mas de quince widgets independientes -- cada uno como una aplicacion React o Angular empaquetada como Web Component.
El problema es que cada Client Extension requiere una estructura de archivos muy especifica para funcionar correctamente dentro de Liferay. No basta con crear un proyecto de React con Vite y desplegarlo. Necesitas:
client-extension.yaml con la definicion 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, estabamos hablando de mas de diez horas solo en scaffolding. Y lo peor: cualquier inconsistencia entre proyectos generaba bugs sutiles en produccion.
La solucion 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 mas critico porque debe cumplir con la especificacion de Web Components. Si el tag no es valido, el navegador lo ignora silenciosamente y el widget nunca se renderiza. La validacion que implementamos usa esta expresion 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 especificacion de Custom Elements para evitar colisiones con elementos HTML nativos) y solo permite minusculas y numeros. Es una restriccion deliberada: aunque la spec permite mas caracteres, limitarla asi evita problemas con Liferay y mantiene consistencia entre equipos.
La version React del script genera un proyecto basado en Vite, pero con una configuracion muy diferente a la que Vite produce por defecto. La razon es que Liferay necesita cargar el widget como un script clasico, no como un modulo ES.
Cuando Liferay renderiza una pagina que contiene un Custom Element, inyecta un tag <script src="..."> apuntando al JS de tu extension. Este script se ejecuta en el contexto global de la pagina, que ya tiene su propio bundle de Liferay, jQuery, y potencialmente otros widgets. Si tu build produce modulos ES con import/export, el navegador los rechaza porque no estan en un <script type="module">.
Ademas, Liferay no soporta code splitting para Client Extensions. Necesitas un unico archivo JS que contenga todo: React, tu aplicacion, y el registro del Web Component.
La configuracion 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 proporciono. La verificacion customElements.get() antes de define() previene errores si el script se carga dos veces (algo que puede ocurrir en Liferay cuando se navega entre paginas sin recarga completa).
El disconnectedCallback es crucial y a menudo se omite en tutoriales. Sin el, cuando un usuario navega fuera de una pagina 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 despues 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 version Angular presenta desafios diferentes. Angular no tiene un equivalente directo al modo lib de Vite, asi 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 libreria oficial de Angular para crear Web Components. El patron DoBootstrap le dice a Angular que no intente renderizar un componente root en el DOM automaticamente, sino que registre el Custom Element y espere a que el navegador lo instancie cuando encuentre el tag en el HTML.
Angular CLI produce multiples archivos: runtime.js, polyfills.js, main.js, y potencialmente chunks adicionales. Liferay espera un unico archivo JS. La solucion es un script de concatenacion:
#!/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 module loader de webpack (Angular CLI usa webpack internamente), luego polyfills para las APIs del navegador, y finalmente main con la aplicacion. Invertir el orden causa errores cripticos de __webpack_require__ is not defined.
El angular.json se genera con outputHashing: "none" para que los archivos no tengan hashes en el nombre, simplificando la concatenacion:
{
"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 multiples veces en una pagina. portletCategoryName determina en que seccion del panel de widgets aparece al editar una pagina.
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
Despues de implementar estos scripts, el tiempo de scaffolding bajo de 45 minutos a menos de 30 segundos por widget. Pero el beneficio mas importante no fue la velocidad sino la consistencia: todos los proyectos tenian exactamente la misma estructura, las mismas configuraciones de build, y los mismos patrones de Web Component.
Esto elimino una clase entera de bugs que antes eran recurrentes:
disconnectedCallback)La leccion mas 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 generacion se paga en la primera semana de desarrollo.
Si tu equipo esta empezando con Client Extensions, te recomiendo invertir un dia en crear scripts similares adaptados a tu stack. La documentacion oficial de Liferay sobre Client Extensions es buena, pero asume que crearas uno o dos widgets. Cuando necesitas quince o veinte, necesitas automatizacion.