← Volver al blog
·9 min de lectura

Automatizar el scaffolding de Client Extensions en Liferay con Bash

LiferayDevOpsReactAngular

El problema real: 45 minutos de boilerplate por cada widget

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:

  • Un archivo client-extension.yaml con la definicion del Custom Element
  • Un wrapper que registre tu aplicacion como un Web Component valido con customElements.define()
  • Una configuracion de build que produzca un unico archivo JS en formato IIFE (no modulos ES)
  • Un script de post-procesamiento para limpiar el output de Vite
  • Polyfills para navegadores corporativos
  • Un package.json con los scripts correctos de build

Configurar 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.

Diseño del script: que necesita saber

El script necesita exactamente dos datos del desarrollador:

  1. Nombre del proyecto: usado para crear el directorio, el package.json y el client-extension.yaml
  2. Tag del Custom Element: el nombre de la etiqueta HTML que Liferay usara para instanciar el widget

El 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: Vite + IIFE + post-procesamiento

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.

Por que IIFE y no ESM

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 wrapper de Web Component

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.

Post-procesamiento con build-custom-element.cjs

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: @angular/elements y concatenacion

La version Angular presenta desafios diferentes. Angular no tiene un equivalente directo al modo lib de Vite, asi que la estrategia es distinta.

DoBootstrap y createCustomElement

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.

El problema del output multiple de Angular

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": []
        }
      }
    }
  }
}

El client-extension.yaml

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.

Estructura final del script

El script completo sigue este flujo:

  1. Solicitar nombre del proyecto y tag del Custom Element
  2. Validar el tag con la regex
  3. Crear la estructura de directorios
  4. Generar todos los archivos con sed reemplazando los placeholders
  5. Ejecutar npm install
  6. Verificar que npm 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

Resultados concretos

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:

  • Widgets que no se desmontaban correctamente al navegar (faltaba disconnectedCallback)
  • Builds que producian modulos ES en vez de IIFE (configuracion de Vite incorrecta)
  • CSS que se filtraba entre widgets (faltaba scoping)
  • Tags de Custom Element invalidos que fallaban silenciosamente

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.