This commit is contained in:
Jordan Diaz
2026-04-25 10:27:51 +00:00
parent e84a36c83d
commit 6881d64a08
42 changed files with 3207 additions and 3413 deletions

444
docs/01-builder-fields.md Normal file
View File

@@ -0,0 +1,444 @@
# Builder Fields — Campos editables del index-base.tpl
Este documento define los campos editables que el usuario rellena desde el panel del builder de Acai. Cubre el atributo `data-field-type` con todos sus tipos (`textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`), la regla `data-field-label` → nombre de variable, los atributos Acai (`c-if`, `c-else`, `c-for`, `c-class`, `c-hidden`, `c-required`), el tag `<set>`, la inclusión de módulos, los formularios `c-form` y los componentes built-in. Léelo antes de crear o modificar cualquier `index-base.tpl`.
## Reglas de nomenclatura de variables
El atributo `data-field-label` se convierte automáticamente en el nombre de variable Twig: se ponen minúsculas y se eliminan espacios y caracteres especiales.
| Label | Variable resultante |
|-------|---------------------|
| `Categoría Noticia` | `categoranoticia` |
| `Color Principal` | `colorprincipal` |
| `Título Producto` | `ttuloproducto` |
Reglas obligatorias:
- Todo elemento con `data-field-type` DEBE incluir también `data-field-label`.
- Sin `data-field-label`, el builder genera variables temporales o incorrectas y el módulo queda mal configurado.
- Usa labels descriptivos y estables; no dejes labels vacíos ni genéricos como "Campo" o "Texto".
- En `index-base.tpl` evita clases Tailwind con valores arbitrarios (`text-[44px]`, `font-['Cinzel']`, `leading-[1.1]`) — pueden romper el parseo. Muévelas a `style.css`.
## Tipos de campo (`data-field-type`)
| Tipo | Elemento HTML | Devuelve |
|------|---------------|----------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>``<h6>` | String + variable extra `_tag` con la etiqueta elegida |
| `textbox` | `<div>` | String multilínea |
| `wysiwyg` | `<div class="wysiwyg">` | String HTML |
| `link` | `<a>` | URL string (ya incluye barras) |
| `upload` | `<img>` | **Array** de `{urlPath, info1, info2, info3, info4}` |
| `uploadMulti` | `<li>` | Itera sobre archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos repetibles |
| `checkbox` | `<div>` o `<input>` | `1` o `0` (número) |
| `colorpicker` | `<div>` | Hex color string |
### textfield
```html
<p data-field-type="textfield" data-field-label="Título">
Elemento editable
</p>
```
### headfield
Genera 2 variables: la estándar y `_tag` con la etiqueta elegida (h1…h6).
```html
<{{ titulo_tag | default('h2') }}
data-field-type="headfield"
data-field-label="Título Sección"
class="text-3xl font-bold">
Título de la sección
</{{ titulo_tag | default('h2') }}>
```
### textbox
```html
<div data-field-type="textbox" data-field-label="Descripción">
Texto largo editable
</div>
```
### wysiwyg
Editor de texto enriquecido. Acceder con `| raw` para no escapar el HTML.
```html
<div class="wysiwyg" data-field-type="wysiwyg" data-field-label="Contenido Enriquecido">
<p>Texto con <strong>estilos</strong> editables</p>
</div>
```
### link
El campo `enlace` de Acai ya incluye las barras necesarias — nunca añadas barras extra.
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
Haz clic aquí
</a>
```
### upload
Devuelve un array. Acceso en Twig: `{{ imagen[0].urlPath }}`.
```html
<div class="p-1/6 relative">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-field-type="upload"
data-field-label="Imagen Principal"
data-lazy="true"
data-field-info1="titulo"
data-field-width="1400"
alt="">
</div>
```
Atributos disponibles:
- `data-lazy="true"` — carga perezosa
- `data-field-width="1400"` — ancho máximo sugerido
- `data-field-info1="titulo"` — campo de información adicional (típicamente alt)
### uploadMulti
Itera sobre todas las imágenes subidas. Variable iteradora: `uploadMulti`.
```html
<li data-field-type="uploadMulti" data-field-label="Galería" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
```
### list (opciones fijas)
```html
<div data-field-type="list"
data-field-label="Color Producto"
data-list-options="Rojo,Azul,|Verde,3|Amarillo">
</div>
```
Formato `data-list-options`:
- `opcion1,opcion2` → la opción es etiqueta y valor a la vez
- `|valor3,etiqueta3` → separa valor de etiqueta con `|`
### list (tabla)
Selecciona un registro de otra tabla. Devuelve el `num`.
```html
<div data-field-type="list"
data-field-label="Noticia Destacada"
data-list-table="noticias"
data-list-value="num"
data-list-label="titulo">
{{ record.titulo }}
</div>
```
- `data-list-table` — nombre de tabla **sin prefijo `cms_`**
- `data-list-value` — campo a usar como valor (normalmente `num`)
- `data-list-label` — campo a mostrar como label
### multiv2 — Campos repetibles
Crea grupos de campos repetibles. La variable resultante es un array de objetos.
```html
<ul>
<li data-field-type="multiv2" data-field-label="Productos">
<div data-field-type="textfield" data-field-label="Nombre">
Nombre del producto
</div>
<div data-field-type="textbox" data-field-label="Descripción">
Descripción del producto
</div>
<div class="p-1/6 relative">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-field-type="upload"
data-field-label="Imagen"
data-lazy="true"
data-field-width="800"
alt="">
</div>
</li>
</ul>
```
Uso en Twig:
```twig
{% for record in productos %}
<div class="producto">
<h3>{{ record.nombre }}</h3>
<p>{{ record.descripcion }}</p>
<img src="{{ record.imagen[0].urlPath }}" alt="">
</div>
{% endfor %}
```
### checkbox
Devuelve `1` o `0` (número), nunca `true`/`false`.
### colorpicker
Devuelve un string hexadecimal (`#ff0000`). Almacenado en config-vars (no en `builder_custom`).
## Atributos Acai
### `c-if` — Renderizado condicional
Usa `=` (un solo igual) para comparaciones, no `==`.
```html
<div c-if="subtitle">{{ subtitle }}</div>
<div c-if="layout = 'grid'">Grid layout</div>
```
### `c-else`
Va inmediatamente después del elemento `c-if`.
```html
<div c-if="image">
<img src="{{ image[0].urlPath }}">
</div>
<div c-else>
<p>No image available</p>
</div>
```
### `c-for` — Iteración sobre array
```html
<div c-for="item in record.features">
<h3>{{ item.title }}</h3>
</div>
```
### `c-for` — Iteración sobre tabla de BD
```html
<ul>
<li c-for="producto in productos"
c-where="'visible=1'"
c-order="'num desc'"
c-limit="10">
{{ producto.title }}
</li>
</ul>
```
Parámetros opcionales: `c-where` (string SQL), `c-order` (string de orden), `c-limit` (entero).
Equivalente Twig:
```twig
{% for producto in 'productos' | get('visible=1','num desc',10) %}
<li>{{ producto.title }}</li>
{% endfor %}
```
Variables del loop: `loop.index` (1-based), `loop.index is odd`, `loop.index is even`.
### `c-class` — Clases CSS condicionales
```html
<!-- Simple -->
<div c-class="{ 'text-center': alineacion == '1', 'text-right': alineacion == '2' }">
<!-- Múltiples condiciones -->
<div c-class="{
'flex-row-reverse': orden == '1',
'cursor-pointer click-a-child': record.enlace_anchor,
'rounded-xl': radioborde == '4'
}">
<!-- Con loop -->
<div c-class="{
'md:order-1': loop.index is odd,
'md:pl-6': loop.index is even
}">
<!-- Combinado con clases estáticas -->
<div class="flex items-center" c-class="{ 'justify-center': centrado }">
```
### `c-hidden` — Variables ocultas
Elemento que NO se renderiza pero SÍ declara variables builder. Patrón típico para colores y opciones de configuración.
```html
<div c-hidden="true">
<input data-field-type="textfield" data-field-label="Color de fondo" value="">
<div data-field-type="list"
data-field-label="Color titulo resaltado"
data-list-options="|Main color,1|Main color light,2|Main color dark"></div>
</div>
```
### `c-required` — Validación condicional
```html
<input type="text" name="telefono"
c-required="'2' not in camposquitar"
placeholder="Teléfono">
```
## Tag `<set>` — Definir variables
```html
<!-- Obtener configuración de la BD -->
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<!-- Construir URLs dinámicas -->
<set :logo="tienda.logo.0.urlPath
? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath
: 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'">
</set>
<!-- Twig set para expresiones complejas -->
{% set gracias = 'apartados' | get('num = 20').0 %}
```
## Incluir módulos
Para incluir un módulo dentro de otro módulo o dentro de una sección general, usa el `moduleId` como etiqueta HTML:
```html
<module_id :param1="value1" :param2="'string value'"></module_id>
```
Ejemplos:
```html
<header_menu :showLogo="true" :menuItems="items"></header_menu>
<product_card :product="selectedProduct" :showPrice="true"></product_card>
```
El módulo hijo recibe los parámetros como variables en su contexto.
## Formularios — `c-form`
Maneja automáticamente validación, almacenamiento en BD y envío de emails.
```html
<c-form
class="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow"
tableName="'solicitudes'"
mailRecord="['correos', 'CONTACTO']"
sendTo="'contacto@empresa.com'"
sendToClient="'email'"
captcha="true"
honeypot="true"
messageOK="'¡Gracias! Te contactaremos pronto'"
messageKO="'Por favor, completa todos los campos'"
redirect="'/gracias'"
attachFiles="true">
<input name="nombre" type="text" required class="w-full p-2 border rounded">
<input name="email" type="email" required class="w-full p-2 border rounded">
<textarea name="mensaje" required class="w-full p-2 border rounded" rows="5"></textarea>
<label class="flex items-center">
<input name="acepto_politica" type="checkbox" class="mr-2" required>
<span>Acepto la política de privacidad</span>
</label>
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded">Enviar</button>
<captcha/>
</c-form>
```
### Atributos `c-form`
| Atributo | Descripción |
|----------|-------------|
| `tableName="'tabla'"` | Tabla destino (sin `cms_`) |
| `mailRecord="['correos', 'ID']"` | Template de email en tabla `correos` |
| `sendTo="'email@dominio.com'"` | Destinatarios (separados por coma) |
| `sendToClient="'campo_email'"` | Campo del formulario con email del cliente para auto-reply |
| `captcha="true"` | Activa Google reCAPTCHA |
| `honeypot="true"` | Campo oculto anti-spam |
| `messageOK="'texto'"` | Mensaje al enviar correctamente |
| `messageKO="'texto'"` | Mensaje al fallar validación |
| `redirect="'/ruta/'"` | Redirección tras envío correcto |
| `attachFiles="true"` | Adjuntar archivos al email |
| `showImages="true"` | Mostrar thumbnails en email |
| `emailMode="'twig'"` | Email en formato Twig |
| `header="'<div>...'"` | HTML cabecera del email |
| `footer="'<div>...'"` | HTML footer del email |
| `styles="'body { ... }'"` | CSS del email |
Para formularios estándar (contacto, postulación), prefiere `c-form` antes que crear lógica custom de POST/hook. Solo crea una tabla propia si necesitas gestionar esos registros desde el admin.
## Componentes built-in
### Carousel — `c-tns-wrapper`
```html
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-speed="400"
data-nav="true"
data-autoplay-timeout="3000">
<div c-for="slide in record.slides">
<img src="{{ slide.image[0].urlPath }}">
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}">
</a>
```
### Breadcrumb
```html
<breadCrumb/>
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
```
### Animate On Scroll (AOS)
```html
<div data-aos="fade-up" data-aos-delay="200" data-aos-duration="800">
Contenido animado
</div>
```
Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `zoom-in-up`, `fade-up-right`, `fade-up-left`. Tras cambios dinámicos en JS: `AOS.refresh()`.
### Lazy loading
```html
<img class="lazyload" data-src="{{ image[0].urlPath }}">
<!-- O en builder field: -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true">
```
## Reglas críticas
1. Todo `data-field-type` exige `data-field-label`.
2. `data-field-label` se transforma a variable: minúsculas, sin espacios ni caracteres especiales.
3. Campos `upload` retornan **arrays** — usa `imagen[0].urlPath`, nunca `imagen`.
4. Variables dentro de `multiv2` son propiedades del objeto iterado (`record.nombre`).
5. `c-if` usa `=` (un igual). `{% if %}` usa `==` (doble igual).
6. `c-for` con tabla: nombre **sin prefijo `cms_`**.
7. `enlace` ya incluye las barras — no añadas slashes extra.
8. Checkbox guarda `1` o `0` (número), nunca `true`/`false`.
9. Evita Tailwind arbitrary-value en `index-base.tpl` — muévelos a `style.css`.
10. `script.js` y `style.css` son estáticos: NO uses sintaxis Twig dentro. Pasa valores dinámicos vía `data-*`.

260
docs/02-twig.md Normal file
View File

@@ -0,0 +1,260 @@
# Twig — Filtros personalizados de Acai
Este documento describe los filtros Twig propios de Acai (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`) y los filtros estándar más usados (`raw`, `truncate`, `json_decode`, `split`, `filter`). Acai usa **filtros con pipe `|`**, nunca funciones. Léelo antes de escribir cualquier expresión Twig dentro de `index-base.tpl` o de una sección general. Cubre también la concatenación con `~`, los ternarios, el operador `default` y la diferencia entre `c-if` (=) y `{% if %}` (==).
## `get` — Consultar tabla de BD
```twig
{{ 'table_name' | get(where, order, limit) }}
```
- `table_name`: **sin prefijo `cms_`**
- `where`: string SQL u objeto (opcional)
- `order`: string de orden (opcional)
- `limit`: entero (opcional)
```twig
{# Todos los registros #}
{% set products = 'productos' | get() %}
{# Con WHERE string #}
{% set active = 'productos' | get('activo=1') %}
{# Con WHERE objeto #}
{% set active = 'productos' | get({activo: 1}) %}
{# Con WHERE + ORDER + LIMIT #}
{% set latest = 'noticias' | get('publicado=1', 'fecha DESC', 6) %}
{# Single record (primer resultado) #}
{% set product = 'productos' | get({num: 42}) %}
{{ product[0].nombre }}
```
Iterar resultados:
```twig
{% for producto in 'productos' | get('activo=1', 'num DESC', 10) %}
<h3>{{ producto.titulo }}</h3>
{% endfor %}
```
Concatenar valor dinámico en WHERE — usa el operador `~`:
```twig
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
```
## `queryDB` — SQL directo
Usa el nombre de tabla **completo CON prefijo `cms_`**. Solo cuando `get` no sea suficiente (JOINs, agregaciones complejas).
```twig
{% set results = 'SELECT * FROM cms_productos WHERE precio > 100 ORDER BY precio ASC' | queryDB() %}
{# JOIN complejo #}
{% set top = 'SELECT p.*, COUNT(v.num) as ventas
FROM cms_productos p
LEFT JOIN cms_ventas v ON v.producto_num = p.num
GROUP BY p.num
ORDER BY ventas DESC
LIMIT 5' | queryDB() %}
```
## `hook` — Ejecutar PHP hook
```twig
{# Llamar y mostrar resultado #}
{{ 'hooks/module_id/' | hook({param1: 'value', param2: variable}) }}
{# Capturar en variable #}
{% set result = 'hooks/calcular_precio/' | hook({cantidad: 5, tipo: 'mayoreo'}) %}
<p>Total: ${{ result.total }}</p>
```
El primer argumento es la ruta del endpoint del hook (`hooks/<id>/`). El objeto pasa los parámetros, que el PHP recibe como variables (`$cantidad`, `$tipo`).
## `module` — Renderizar otro módulo
```twig
{{ 'other_module_id' | module({param1: value1}) }}
{# Capturar en variable #}
{% set carrito = 'carrito_compras' | module({usuario_id: 123}) %}
```
Equivale a `<other_module_id :param1="value1"></other_module_id>`.
## `imagec` — Optimizar imágenes
```twig
{# Redimensionar a ancho específico #}
<img src="{{ record.image[0].urlPath | imagec(400) }}">
{# Con srcset #}
<img src="{{ record.image[0].urlPath | imagec(800) }}"
srcset="{{ record.image[0].urlPath | imagec(400) }} 400w,
{{ record.image[0].urlPath | imagec(800) }} 800w">
```
Acai genera versiones optimizadas (webp + tamaños) y las cachea. Usa siempre `imagec` para imágenes en producción.
## `translate` — Texto editable y traducción
Cualquier string con `| translate` se resuelve contra la tabla `textos_generales` del proyecto. Cumple **dos funciones a la vez**:
1. **Traducción**: cada fila guarda la versión del texto por cada idioma habilitado.
2. **Edición de contenidos**: es el canal oficial para que el usuario final modifique esos textos sin tocar código. `| translate` no es solo i18n — es el mecanismo por el que un texto "hardcodeado" se vuelve editable desde el CMS.
```twig
{{ 'Bienvenido' | translate }}
{{ variable | translate }}
{{ 'Contáctanos' | translate | raw }}
```
Cómo funciona:
- Los strings envueltos en `| translate` se buscan en `textos_generales`.
- Si existe la fila, devuelve el valor guardado (en el idioma activo).
- Si no existe, devuelve el texto original tal cual (fallback).
- Las filas se editan desde el admin del CMS o vía `CmsApi` (update sobre `textos_generales`).
Reglas críticas:
- **NO crees archivos JSON de traducciones, `.po`, ni ningún sistema i18n externo.** El único sistema de textos traducibles/editables es la tabla `textos_generales`.
- **NO hardcodees textos en el código del módulo** si el usuario debe poder editarlos. Envuélvelos en `| translate`.
- Para **cambiar un texto** (traducir o editar), edita la fila correspondiente en `textos_generales` — nunca modifiques el código.
- Para **añadir un texto nuevo editable**, basta con escribir el string con `| translate` en el código; el sistema lo recoge y el usuario lo puede editar desde el admin.
## Filtros estándar
### `raw` — Renderizar HTML sin escapar
```twig
{{ record.description | raw }}
```
Imprescindible para `wysiwyg` y para HTML construido en variables.
### `truncate` — Truncar texto
```twig
{{ record.description | truncate(150) }}
```
### `json_decode` — Parsear JSON
```twig
{% set data = jsonString | json_decode %}
{{ data.key }}
```
### `split`, `filter`, `length`, `default`, `lower`, `upper`, `trim`, `replace`
Funcionan igual que en Twig estándar.
```twig
{{ title | default('Sin título') }}
{{ items | length }}
{{ name | upper }}
```
## Operadores y sintaxis
### Concatenación con `~`
Twig usa `~` (no `.` ni `+`):
```twig
{{ 'Hello ' ~ name ~ '!' }}
{% set url = '/products/' ~ product.slug ~ '/' %}
```
En filtros:
```twig
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
```
### Ternario
```twig
{{ isActive ? 'active' : 'inactive' }}
{{ title | default('Default Title') }}
```
### Comparaciones
| Contexto | Igualdad |
|----------|----------|
| `c-if` | `=` (un solo igual) |
| `{% if %}` | `==` (doble igual) |
```twig
{# Atributo Acai - un igual #}
<div c-if="layout = 'grid'">
{# Twig estándar - doble igual #}
{% if type == 'premium' %}
{% if items | length > 0 %}
{% if name is not empty %}
```
## Ejemplos complejos
### Galería con productos y stock
```twig
{% for producto in 'productos' | get('destacado=1', 'num DESC', 12) %}
<div class="producto-card">
<img src="{{ producto.imagen[0].urlPath | imagec(400) }}" alt="{{ producto.titulo }}">
<h3>{{ producto.titulo }}</h3>
<p>{{ producto.descripcion | truncate(100) }}</p>
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
<span>Stock: {{ stock[0].cantidad }}</span>
</div>
{% endfor %}
```
### Múltiples filtros combinados
```twig
{% set categorias = 'categorias' | get() %}
{% set productos = 'productos' | get('activo=1', 'titulo ASC', 20) %}
{% set stats = 'hooks/obtener_stats/' | hook({fecha_inicio: '2024-01-01'}) %}
<h1>{{ stats.titulo | translate }}</h1>
<nav>
{% for cat in categorias %}
<a href="{{ cat.enlace }}">{{ cat.nombre }}</a>
{% endfor %}
</nav>
{% for prod in productos %}
<div>
<img src="{{ prod.imagen[0].urlPath | imagec(300) }}" alt="">
<h3>{{ prod.titulo }}</h3>
</div>
{% endfor %}
```
### Composición con `<set>` y configuración global
```twig
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
{% set logoUrl = tienda.logo.0.urlPath
? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath
: 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png' %}
<img src="{{ logoUrl }}" alt="{{ tienda.nombre }}">
```
## Reglas críticas
1. **Solo filtros, nunca funciones.** `'tabla' | get()`, no `get('tabla')`.
2. **Tablas sin prefijo `cms_`** en `get()`. **Con prefijo `cms_`** en `queryDB()`.
3. **Upload fields son arrays.** `record.imagen[0].urlPath`, no `record.imagen`.
4. **Concatenación con `~`**, no con `.` ni `+`.
5. **`c-if` usa `=`**, **`{% if %}` usa `==`**.
6. **Foreign keys con sufijo `_num`**: `categoria_num`, no `categoria_id`.
7. **`enlace` ya tiene barras** — no las añadas.
8. **PK siempre es `num`**, nunca `id`.
9. **`| translate` para textos editables** — nunca crees JSONs de i18n.
10. Usa `imagec(width)` para imágenes en producción.

View File

@@ -0,0 +1,200 @@
# Módulos y Secciones Generales
Este documento explica el sistema modular de Acai: la diferencia entre **módulos** (componentes visuales reutilizables que el usuario coloca en páginas Builder) y **secciones generales** (plantillas ligadas a una tabla que se renderizan automáticamente al acceder al `enlace` de un registro). Cubre la estructura de archivos de un módulo, las reglas obligatorias sobre `index-base.tpl`, las variables globales (`section_id`, `interno`, `server.HTTP_HOST`, `loop`), la convención `custom-{tableName}` para detalles de registro, la inclusión de un módulo dentro de otro y el uso de `thisrecord` en secciones generales. Léelo antes de crear, mover o editar cualquier carpeta dentro de `template/estandar/modulos/`.
## Módulos
Componentes visuales reutilizables. Viven en `template/estandar/modulos/<module-id>/`. El usuario los arrastra al builder de una página y rellena sus variables.
### Estructura de archivos
```
<module-id>/
├── index-base.tpl # Plantilla source (Twig + atributos Acai) — EDITA ESTE
├── index.tpl # Compilado (auto-generado) — NO TOCAR
├── index-twig.tpl # Compilado Twig (auto-generado) — NO TOCAR
├── builder.json # Variables del builder (auto-generado) — NO TOCAR
├── style.css # CSS del módulo (estático)
├── script.js # JS del módulo (estático)
└── hook.php # Hook PHP propio del módulo (opcional)
```
Reglas duras:
- **Solo se edita `index-base.tpl`.** `index.tpl`, `index-twig.tpl` y `builder.json` los genera el compilador y se sobrescriben automáticamente.
- Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara la compilación automática**.
- `script.js` y `style.css` son **estáticos** — NO uses sintaxis Twig dentro. Pasa valores dinámicos vía atributos `data-*`.
- `index-base.tpl` solo contiene HTML/Twig. **Nunca** embebas etiquetas `<script>` con lógica del módulo, **nunca** PHP.
### Sintaxis del template
Híbrido Twig + atributos Acai. Ver `01-builder-fields.md` para el catálogo completo de campos editables (`data-field-type`) y atributos (`c-if`, `c-for`, etc.).
```html
<section class="hero-section" id="{{ section_id }}">
<div class="container mx-auto px-4">
<h2 data-field-type="headfield" data-field-label="Titulo" class="text-3xl font-bold">
Title here
</h2>
<p data-field-type="textbox" data-field-label="Descripcion" class="text-lg text-gray-600">
Description text
</p>
<img data-field-type="upload" data-field-label="Imagen" src="placeholder.jpg" class="w-full rounded-lg">
<a data-field-type="link" data-field-label="Boton" href="#" class="btn">Call to action</a>
</div>
</section>
```
### Incluir un módulo dentro de otro
```html
<module_id :param1="value1" :param2="'string value'"></module_id>
```
El módulo hijo recibe los parámetros como variables en su contexto. Ejemplos típicos: incluir un formulario dentro del detalle de un registro, anidar un `bloqueproducto` dentro de un listado.
```html
<bloqueproducto_i7aunn :producto="selectedProduct" :showPrice="true"></bloqueproducto_i7aunn>
<form_postular :vacante_num="thisrecord.num"></form_postular>
```
### Variables globales
| Variable | Descripción |
|----------|-------------|
| `section_id` | ID único por instancia del módulo. Úsalo para anchors, IDs HTML, scoping de JS |
| `interno` | `true` cuando se renderiza en el editor del CMS, `false` en el sitio público |
| `server.HTTP_HOST` | Dominio actual (sin protocolo) |
| `loop.index` | Índice 1-based dentro de un `c-for` o `{% for %}` |
| `loop.index is odd` / `is even` | Para layouts alternados (zigzag) |
| `thisrecord` | El registro actual (solo en secciones generales / detalle) |
Patrón canónico para `section_id`:
```html
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido -->
</section>
```
## Secciones Generales
Plantillas ligadas a una tabla. Cuando el usuario navega al `enlace` de un registro, Acai busca la sección general correspondiente y la renderiza pasándole el registro como `thisrecord`.
Se construyen como **módulos especiales** dentro de `template/estandar/modulos/`. La diferencia clave: NO se colocan vía drag-and-drop; el CMS las enlaza por convención de nombre.
### Convención `custom-{tableName}` (REGLA DURA)
Toda tabla con campo `enlace` tiene automáticamente una sección general en:
```
template/estandar/modulos/custom-{tableName}/
```
El nombre es **literalmente** `custom-` seguido del `tableName`. Cuando el cliente accede a la URL de cualquier registro de esa tabla, Acai renderiza esa sección pasándole el registro como `thisrecord`.
Ejemplos:
- Tabla `vacantes``template/estandar/modulos/custom-vacantes/index-base.tpl`
- Tabla `productos``template/estandar/modulos/custom-productos/index-base.tpl`
- Tabla `noticias``template/estandar/modulos/custom-noticias/index-base.tpl`
Reglas duras:
- El CMS lo enlaza **automáticamente por convención de nombre**. NO existe ni se configura `_detailPage`.
- Se crea/edita como cualquier otro módulo: `acai-write` sobre `index-base.tpl` dispara el compile.
- Dentro del Twig, el registro actual está en `thisrecord` (`thisrecord.titulo`, `thisrecord.descripcion`, `thisrecord.imagen[0].urlPath`).
- **NO crees una página por registro en `apartados`** ni una página "detalle" genérica. El detalle ya lo resuelve la sección general.
- **NO uses ni configures `_detailPage`** — no existe.
- **NO construyas URLs con query params** (`?id=5`) ni hagas fetch desde JS para cargar el registro.
- **NO uses hooks para cargar el registro** — `thisrecord` ya está disponible.
- **NO inventes otro nombre de módulo** para el detalle: debe ser `custom-{tableName}` exacto.
### Acceso a datos via `thisrecord`
```html
<article class="product-card">
<img src="{{ thisrecord.imagen[0].urlPath }}"
alt="{{ thisrecord.imagen[0].info1 }}"
class="w-full h-64 object-cover">
<h3 class="text-xl font-semibold">{{ thisrecord.nombre }}</h3>
<p class="text-gray-600">{{ thisrecord.descripcion | raw }}</p>
<span class="text-2xl font-bold">{{ thisrecord.precio }}€</span>
</article>
```
Particularidades:
- Upload fields retornan **arrays**: `thisrecord.imagen[0].urlPath`
- Metadatos del upload: `info1` (alt típico), `info2`, `info3`, `info4`
- Foreign keys con sufijo `_num`: `thisrecord.categoria_num`
- Si la FK tiene relación cargada, también aparece como objeto: `thisrecord.categoria_bd[0].nombre`
### Embeber formularios en el detalle
Si un detalle necesita un formulario (postular, pedir info), **embebe el módulo del formulario dentro de la sección general** pasándole el `num` del registro actual:
```html
<form_postular :vacante_num="thisrecord.num"></form_postular>
```
NO pongas el formulario como sección suelta del listado.
### Definir variables con `<set>`
```html
<set :categories="'categorias' | get()"></set>
<set :featured="'productos' | get('destacado=1', 'orden ASC', 3)"></set>
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
```
## Flujo canónico para una funcionalidad tipo "vacantes"
1. **Crear la tabla** con `enlace=true` (`create_table`) y añadir los campos (`create_field`).
2. **Crear la sección general** `template/estandar/modulos/custom-{tableName}/index-base.tpl` con el Twig que renderiza `thisrecord.*`. Añade `style.css` y `script.js` si hace falta.
3. (Opcional) **Crear un módulo de listado** `template/estandar/modulos/{tableName}_listado/` que consulte los registros y enlace a cada `enlace`.
4. (Opcional) **Crear la página índice** `/{tableName}/` como registro normal en `apartados` (tipo Builder) y añadirle el módulo de listado.
## multiv2 — Contenido repetible dentro del módulo
El tipo `multiv2` permite que el usuario repita un grupo de campos editables dentro de un mismo módulo. Útil para FAQs, listados de servicios, slides, etc.
```html
<ul>
<li data-field-type="multiv2" data-field-label="Records">
<h3 data-field-type="textfield" data-field-label="Titulo">Título por defecto</h3>
<p data-field-type="textbox" data-field-label="Descripcion">Descripción</p>
<img data-field-type="upload" data-field-label="Imagen" src="">
</li>
</ul>
```
Acceso en Twig:
```twig
{% for record in records %}
<h3>{{ record.titulo }}</h3>
<p>{{ record.descripcion }}</p>
<img src="{{ record.imagen[0].urlPath }}">
{% endfor %}
```
Las variables son **propiedades del objeto iterado**, no variables sueltas.
## Layout global vs módulos
`header`, `footer`, `style` global y `javascript` global NO son módulos normales. Viven en `cms/lib/plugins/builder_saas/layout.json` y se editan con tools dedicadas (`get_layout_field` / `set_layout_field`). Ver `08-layout-and-libraries.md`.
NUNCA edites directamente:
- `cms/lib/plugins/builder_saas/layout.json`
- `template/estandar/modulos/custom-header-twig/*`
- `template/estandar/modulos/custom-footer-twig/*`
- `template/estandar/modulos/custom-header/*`
- `template/estandar/modulos/custom-footer/*`
Estos son artefactos generados a partir de `layout.json`.
## Reglas críticas
1. Solo se edita `index-base.tpl`. Los demás archivos `.tpl` y `builder.json` son auto-generados.
2. Editar `index-base.tpl` con `acai-write` / `acai-line-replace` compila automáticamente.
3. `script.js` y `style.css` son estáticos: nunca uses Twig ni atributos builder dentro.
4. Detalle de registro = `template/estandar/modulos/custom-{tableName}/`. Nada de `_detailPage`, nada de páginas duplicadas.
5. `thisrecord` solo existe en secciones generales — en módulos normales no aparece.
6. Para incluir un módulo: `<module_id :param="value"></module_id>` o `'module_id' | module({param: value})`.
7. Layout global (header/footer) NO se edita por archivos — usa `get_layout_field`/`set_layout_field`.

View File

@@ -0,0 +1,186 @@
# Páginas y Registros
Este documento explica cómo Acai modela las páginas del sitio: toda fila con campo `enlace` es una página, y según el campo `controlador` puede ser **Builder** (modular, contenido por módulos) o **Standard** (contenido directo en los campos del registro). Cubre los tipos de tabla por `menuType` (`category`, `multi`, `single`, `separador`), las particularidades de la tabla `apartados`, los campos de visibilidad (`visible_en_el_menu` vs `visible`), las reglas inviolables sobre `enlace` y `controlador`, y el patrón canónico para implementar el detalle de un registro vía sección general `custom-{tableName}`. Léelo antes de crear, modificar o eliminar cualquier registro de tabla con `enlace`.
## Tipos de página
Todo registro con campo `enlace` es una **página pública**. El campo `controlador` decide cómo se renderiza.
### Builder (modular)
- `controlador` = `cms/lib/plugins/builder_saas/controlador.php`
- El contenido se construye con **módulos** (drag & drop)
- El campo `builder` contiene un array JSON de instancias de módulos
- Tools: `add_module_to_record`, `set_module_config_vars`, `list_page_modules`, `reorder_module`, `toggle_module_visibility`
- La página renderiza los módulos en orden
### Standard (campos directos)
- `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php`
- El contenido vive en los campos del registro (`content`, `titulo_alternativo`, etc.)
- El campo `content` es HTML (wysiwyg)
- Tool: `create_or_update_record` para editar el contenido directamente
- No se usan módulos del builder
### Cómo detectar el tipo
**Siempre comprueba el campo `controlador` del registro**:
- Contiene `controlador.php` (sin `_tabla`) → **Builder**
- Contiene `controlador_tabla.php`**Standard**
## Tipos de tabla con páginas (sections)
Las tablas con páginas se llaman **secciones**. El campo `menuType` del schema define el tipo:
### `menuType = "category"`
- **Jerárquico** — páginas con relaciones padre/hijo
- Campos especiales: `parentNum`, `depth`, `globalOrder`, `lineage`, `siblingOrder`, `breadcrumb`
- Ejemplo: `apartados` (páginas principales del sitio)
- Visibilidad: campo `visible_en_el_menu` (1 visible, 0 oculto)
- Orden: `globalOrder`
### `menuType = "multi"`
- **Lista plana** — sin jerarquía
- Orden: `dragSortOrder`
- Ejemplos: `blog`, `noticias`, `productos`, `vacantes`, `travesias`
- Visibilidad: campo `visible` (1 visible, 0 oculto)
### `menuType = "single"`
- Página única (un solo registro)
- Ejemplos: home, about us
- No requiere campos de orden
### `menuType = "separador"`
- Separador visual en el menú del admin (no es una tabla con datos)
## La tabla `apartados`
La tabla principal de páginas en la mayoría de sitios Acai. Características:
| Campo | Descripción |
|-------|-------------|
| `num` | Primary key |
| `name` | Nombre de la página (campo título) |
| `enlace` | URL pública (ya con barras: `/servicios/`) |
| `controlador` | Define Builder vs Standard |
| `menuType` | `"category"` (jerárquico) |
| `parentNum` | `num` del padre (`0` = raíz) |
| `depth` | Nivel de anidamiento (`0` raíz, `1` hijo, `2` nieto) |
| `globalOrder` | Orden global en el árbol |
| `visible_en_el_menu` | `1` visible, `0` oculto |
| `breadcrumb` | Auto-generado |
| `builder` | JSON con instancias de módulos (si es Builder) |
| `content` | HTML del contenido (si es Standard) |
Cada registro de `apartados` puede ser Builder o Standard — comprueba `controlador` por registro.
## Reglas críticas para páginas
### NUNCA cambies el campo `enlace`
A menos que el usuario te lo pida explícitamente, **nunca modifiques `enlace`**. Cambiarlo:
- Rompe enlaces externos (SEO, marcadores, otros sitios)
- Rompe enlaces internos del propio sitio
- Genera 404 hasta que se regeneren los aliases
Si el usuario sí pide cambiarlo, considera usar la tool `regenerate_enlaces` con `generateAlias=true` para mantener los enlaces antiguos como redirects.
### NUNCA cambies el campo `controlador`
`controlador` define si la página es Builder o Standard. Cambiarlo a posteriori rompe el render. Solo se setea durante la creación.
### Campos de visibilidad
Comprueba siempre qué campo tiene la tabla antes de cambiar visibilidad:
- `apartados` y otras categorías → `visible_en_el_menu`
- `blog`, `travesias`, `noticias` y otras multi → `visible`
### Campos de título
- Algunas tablas usan `name` (e.g. `apartados`)
- Otras usan `title` (e.g. `blog`, `travesias`)
- Consulta el schema (`get_table_schema`) antes de asumir.
## Trabajar con páginas Builder
### Añadir contenido a una página Builder nueva
1. Listar módulos disponibles con `acai-glob` (`template/estandar/modulos/*/builder.json`).
2. `add_module_to_record` (uno cada vez, en orden). Devuelve `sectionId`.
3. `set_module_config_vars` con el `sectionId` para rellenar variables. Devuelve `uploadFields`.
4. Imágenes: `upload_record_image` o `generate_image` usando los `recordNum` y `fieldName` de `uploadFields`.
5. `navigate_browser` para que el usuario vea el resultado.
### Editar una página Builder existente
1. `list_page_modules` — ver el layout actual (sectionIds, posiciones, visibilidades).
2. `get_module_config_vars` — leer las variables actuales del módulo a modificar.
3. `set_module_config_vars` — actualizar valores.
4. O editar el template del módulo: `acai-view` + `acai-line-replace` sobre `index-base.tpl` (compila automáticamente).
5. `reorder_module` para mover módulos, `toggle_module_visibility` para ocultar/mostrar.
## Trabajar con páginas Standard
Usa `create_or_update_record`. Campos típicos:
| Campo | Descripción |
|-------|-------------|
| `content` | HTML del contenido (wysiwyg) |
| `titulo_alternativo` | Título mostrado en la página |
| `titulo_de_pagina` | `<title>` del navegador (SEO) |
| `metatag_descripcion` | Meta description (SEO) |
```
create_or_update_record:
tableName: "apartados"
recordId: "87"
fields:
content: "<h2>Nuestros Servicios</h2><p>Ofrecemos…</p>"
titulo_de_pagina: "Servicios | Mi Sitio"
metatag_descripcion: "Descubre nuestros servicios…"
```
## Patrón canónico — Detalle de registro
Para cualquier tabla con campo `enlace` (productos, noticias, vacantes, servicios), **el detalle se resuelve por convención** vía sección general `custom-{tableName}`. Ver `03-modules-and-sections.md` para detalles.
Reglas duras:
- **NO** crees una página por registro en `apartados`.
- **NO** uses ni configures `_detailPage` — no existe.
- **NO** uses query params (`?id=5`) en URLs.
- **NO** uses hooks para cargar el registro — `thisrecord` ya existe.
- El nombre del módulo es **literalmente** `custom-` + `tableName`.
Flujo para una funcionalidad tipo "vacantes":
1. `create_table` con `enlace=true`
2. `create_field` para los campos necesarios
3. Crear `template/estandar/modulos/custom-vacantes/index-base.tpl` que use `thisrecord.*`
4. (Opcional) Módulo de listado `vacantes_listado` que consulte y enlace
5. (Opcional) Página índice `/vacantes/` en `apartados` con el módulo de listado
## Campos típicos en tablas "publicables"
Cuando creas una tabla con `enlace` (noticias, vacantes, blog), añade por defecto:
| Campo | Tipo | Uso |
|-------|------|-----|
| `fecha_publicacion` | date | Ordenar y filtrar |
| `fecha_expiracion` | date (opcional) | Oculta el registro automáticamente cuando caduca |
| `visible` | checkbox | Control manual |
NO añadas un campo "estado" calculado si ya tienes `visible` + fechas.
## Reglas críticas
1. **Toda tabla con `enlace` produce páginas públicas.** Comprueba el `controlador` para saber si son Builder o Standard.
2. **NUNCA modifiques `enlace`** salvo petición explícita del usuario.
3. **NUNCA modifiques `controlador`** de un registro existente.
4. **PK siempre es `num`**, nunca `id`. Foreign keys con sufijo `_num`.
5. **Visibilidad**: `visible_en_el_menu` para `category`, `visible` para `multi`. Comprueba el schema.
6. **Detalle de registro = `custom-{tableName}`**, nunca página duplicada.
7. **Tablas sin prefijo `cms_`** en todas las llamadas a tools.
8. **Para formularios estándar usa `c-form`**, no construyas POST/hook custom.

View File

@@ -0,0 +1,272 @@
# Tablas y Campos
Este documento explica cómo gestionar tablas y campos en Acai usando las tools del MCP. Cubre: cómo se almacena el schema (`cms/data/schema/{tabla}.ini.php`), los `menuType` (`multi`, `single`, `category`, `separador`), el flag `enlace` para tablas públicas, todos los tipos de campo (`textfield`, `textbox`, `wysiwyg`, `codigo`, `date`, `list`, `checkbox`, `upload`, `multitext`, `separator`), los props comunes (`isRequired`, `defaultValue`, `optionsType`, etc.), la diferencia entre operaciones reversibles e irreversibles (`dropData`, `dropColumn`, rename), y el flujo correcto para crear una funcionalidad nueva. Léelo antes de usar cualquier tool del grupo `tables/`.
## Schemas
Cada tabla tiene un schema en `cms/data/schema/{tabla}.ini.php`. Define:
- Nombres y tipos de campo
- Reglas de validación
- Relaciones (foreign keys)
- Configuración de display (orden, ancho, etc.)
- Bloque `[meta]` con `menuName`, `menuType`, `menuOrder`, `controller`, etc.
Antes de operar sobre una tabla, **siempre** consulta el schema:
- `list_tables` — inventario rápido del proyecto
- `get_table_schema` con `tableName` (sin `cms_`) — schema completo
- `get_table_schema` con `minimal=true` — solo nombres + tipos + labels (ahorra tokens)
- `get_table_schema` con `filterFields="galeria|foto|image"` — filtra por palabras clave
**NUNCA inventes nombres de campos o tablas.** Siempre confirma con el schema.
## Convenciones inmutables
| Regla | Valor correcto |
|-------|----------------|
| Nombres de tabla en tools/Twig/CmsApi | sin prefijo `cms_` |
| Nombres en `queryDB` | con prefijo `cms_` |
| Primary key | `num` (siempre) |
| Foreign key | `<entidad>_num` (e.g. `categoria_num`) |
| Upload field | array `[{urlPath, info1, info2, info3, info4}]` |
## Crear una tabla — `create_table`
```
create_table({
tableName: "vacantes", // sin cms_, lowercase + underscores
menuName: "Vacantes", // display en sidebar admin
menuType: "multi", // multi | single | category | separador
enlace: true, // ¿es tabla pública con URLs?
seoMetas: true, // añade campos SEO meta (default false)
menuOrder: 5 // opcional, orden en sidebar
})
```
### Decisiones obligatorias antes de llamar
- **`enlace: true|false`** es una decisión de arquitectura. **PREGUNTA AL USUARIO** antes de llamar:
- `true` → la tabla genera URLs públicas, automáticamente añade campo `enlace` + slug. Cada registro será una página y puede tener detalle vía `custom-{tableName}`.
- `false` → tabla puramente administrativa (categorías internas, configuraciones, logs).
- **`menuType`**:
- `multi` → lista plana (productos, noticias, vacantes)
- `single` → un único registro (home, configuración, about us)
- `category` → contenedor jerárquico que agrupa otras tablas en el menú
- `separador` → solo un separador visual
### Después de crear la tabla
1. Añade los campos necesarios con `create_field` (uno por uno).
2. Si la tabla tiene `enlace: true`, considera crear la sección general `custom-{tableName}` para el detalle (ver `03-modules-and-sections.md`).
3. Si la tabla quiere ordenar/filtrar por fechas, añade campos `fecha_publicacion`, `fecha_expiracion`, `visible` (ver `04-pages-and-records.md`).
## Crear un campo — `create_field`
```
create_field({
tableName: "vacantes",
fieldName: "salario_minimo", // identificador SQL-safe
label: "Salario Mínimo", // display en formulario admin
type: "textfield",
initialProps: { // opcional — overrides de defaults
isRequired: 1,
maxLength: 100
}
})
```
### Tipos de campo
| Tipo | Uso |
|------|-----|
| `textfield` | Texto de una línea |
| `textbox` | Texto multilínea plano |
| `wysiwyg` | Editor de texto enriquecido |
| `codigo` | Editor de código (HTML/JS/CSS snippet) |
| `date` | Selector de fecha o datetime |
| `list` | Select / radio / checkboxes (necesita `listType` + `optionsType` en `initialProps`) |
| `checkbox` | Booleano (1/0) |
| `upload` | Subida de archivos (imágenes, docs) |
| `multitext` | Repetidor de entradas de texto |
| `separator` | Separador visual en el formulario (sin columna en BD) |
### Props comunes (`initialProps`)
Pasa solo los que quieres sobrescribir; el resto usa defaults.
| Prop | Aplica a | Descripción |
|------|----------|-------------|
| `isRequired` | todos | `1` o `0` |
| `isUnique` | textfield, textbox | `1` o `0` |
| `defaultValue` | todos | Valor por defecto |
| `description` | todos | Texto de ayuda en el formulario |
| `minLength` / `maxLength` | textfield, textbox, wysiwyg | Longitud min/max |
| `listType` | list | `select`, `radio`, `checkboxes` |
| `optionsType` | list | `text` (opciones fijas) o `tablename` (opciones desde otra tabla) |
| `optionsText` | list (text) | `"opcion1,opcion2,|valor3,etiqueta3"` |
| `optionsTablename` | list (tablename) | Tabla origen (sin `cms_`) |
| `optionsValueField` | list (tablename) | Campo del valor (típico: `num`) |
| `optionsLabelField` | list (tablename) | Campo de la etiqueta |
| `optionsQuery` | list (tablename) | Filtro WHERE adicional |
| `filterField` | list (tablename) | Filtro dinámico por valor de otro campo |
| `allowedExtensions` | upload | `"jpg,png,webp,pdf"` |
| `maxUploads` | upload | Número máximo de archivos |
| `createThumbnails` | upload | `1` o `0` |
| `maxThumbnailWidth` / `maxThumbnailHeight` | upload | px |
| `fieldWidth` / `fieldHeight` | upload | px sugeridos al builder |
| `adminOnly` | todos | `1` oculta el campo en formularios públicos |
| `charsetRule` | textfield | Restricciones de caracteres |
| `tipoTags` | wysiwyg | Tags HTML permitidos |
### Ejemplo: lista desde tabla
```
create_field({
tableName: "vacantes",
fieldName: "categoria_num",
label: "Categoría",
type: "list",
initialProps: {
listType: "select",
optionsType: "tablename",
optionsTablename: "categorias",
optionsValueField: "num",
optionsLabelField: "nombre"
}
})
```
## Actualizar un campo — `update_field`
```
update_field({
tableName: "vacantes",
fieldName: "descripcion",
newFieldName: "descripcion_corta", // OPCIONAL — renombra columna MySQL
props: {
label: "Descripción Corta",
maxLength: 200
}
})
```
### Casos destructivos
- **`newFieldName`** renombra la columna MySQL. Los datos se preservan, pero **rompe cualquier referencia hardcodeada** (Twig, hooks, JS, queryDB). Audita el código antes de renombrar.
- **Cambiar `type`** puede coercer/truncar datos (ej. `wysiwyg``textfield` elimina HTML). El backend devuelve `warnings` en la respuesta — **muéstralos al usuario**.
## Borrar un campo — `delete_field`
```
delete_field({
tableName: "vacantes",
fieldName: "campo_obsoleto",
dropColumn: false // default
})
```
- `dropColumn: false` → solo elimina del schema. Si la columna MySQL tiene datos, el backend rechaza y devuelve `dataCount` para que avises al usuario.
- `dropColumn: true``ALTER TABLE DROP COLUMN`. **Los datos de esa columna se pierden permanentemente.**
## Borrar una tabla — `delete_table`
```
delete_table({
tableName: "tabla_obsoleta",
dropData: false, // default
dryRun: true // pre-flight check
})
```
- `dryRun: true` → no borra nada, solo reporta `recordCount`. **Úsalo siempre antes de pedir confirmación al usuario.**
- `dropData: false` → solo borra el schema (`.ini.php`). Si la tabla MySQL tiene registros, el backend rechaza.
- `dropData: true``DROP TABLE` + delete schema. **Datos perdidos permanentemente.**
## Reordenar — `reorder_tables`, `reorder_fields`
Pasa la lista completa ordenada de nombres. Solo cambia el orden visual, los datos no se tocan.
```
reorder_tables({ order: ["apartados", "blog", "productos", "vacantes"] })
reorder_fields({
tableName: "vacantes",
order: ["titulo", "descripcion", "salario_minimo", "fecha_publicacion", "visible"]
})
```
Los campos del sistema (`num`, `creationDate`, etc.) se ignoran automáticamente.
## Actualizar metadata — `update_table_metadata`
Modifica el bloque `[meta]` del schema.
```
update_table_metadata({
tableName: "vacantes",
newTableName: "ofertas_empleo", // OPCIONAL — renombra tabla MySQL
meta: {
menuName: "Ofertas de Empleo",
menuOrder: 3,
listPageFields: "titulo,fecha_publicacion,visible",
breadcrumbField: "titulo"
}
})
```
Keys aceptadas en `meta`:
`menuName`, `menuDesc`, `menuType`, `menuOrder`, `menuDisplay`, `menuHidden`, `controller`, `breadcrumbField`, `breadcrumbByLink`, `breadcrumbParentNum`, `listPageFields` (csv), `listPageOrder`, `listPageSearchFields`.
**`newTableName` renombra la tabla MySQL** y rompe cualquier referencia hardcodeada (controllers custom, módulos con SQL embebido, queryDB en plantillas). Audita el código antes y avisa al usuario.
## Regenerar enlaces — `regenerate_enlaces`
Regenera el campo `enlace` (slug) de todos los registros de una tabla. **Cambia URLs públicas** — todo lo que apunte a las antiguas dará 404 a menos que actives los aliases.
```
regenerate_enlaces({
tableName: "vacantes",
generateAlias: true // recomendado si la tabla ya es pública
})
```
- `generateAlias: false` (default) — solo actualiza `enlace`. URLs antiguas → 404.
- `generateAlias: true` — escribe entradas en `alias_urls` para redirigir las URLs antiguas a las nuevas. Más seguro.
## Listar tablas — `list_tables`
Devuelve todas las tablas con su `menuName`, `menuType`, `menuOrder` y `tableName`. Sin prefijo `cms_`. Úsalo cuando necesites un inventario rápido.
## Tipos de campo y formato al insertar/actualizar registros
Al usar `create_or_update_record`, cada tipo espera un formato específico:
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| `textfield` | String | `"Texto"` |
| `textbox` | String multilínea | `"Línea 1\nLínea 2"` |
| `date`/datetime | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| `wysiwyg` | String HTML | `"<p>Texto</p>"` |
| `list` | String o número | `"activo"` o `"1"` (num si es FK) |
| `checkbox` | Número 1/0 | `1` o `0` |
| `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` |
| `upload` | **NO enviar** | Usa `upload_record_image` después de crear el registro |
Ver `06-hooks-and-cmsapi.md` para los detalles de `CmsApi::insert` / `update`.
## Flujo canónico — Funcionalidad nueva tipo "vacantes"
1. `create_table({ tableName: "vacantes", menuType: "multi", enlace: true, seoMetas: true })` — pregunta al usuario si quiere `enlace` y `seoMetas`.
2. `create_field` para cada campo: `titulo`, `descripcion` (wysiwyg), `salario_minimo` (textfield), `categoria_num` (list desde tabla), `fecha_publicacion` (date), `fecha_expiracion` (date), `visible` (checkbox), `imagen_destacada` (upload).
3. Crear sección general `template/estandar/modulos/custom-vacantes/index-base.tpl` con `acai-write` (compila automáticamente).
4. (Opcional) Módulo de listado `vacantes_listado_xxxxxx` que liste registros con `'vacantes' | get('visible=1', 'fecha_publicacion DESC', 20)`.
5. (Opcional) Página índice `/vacantes/` en `apartados` con el módulo de listado.
## Reglas críticas
1. **Tabla sin prefijo `cms_`** en todas las tools. PK siempre `num`.
2. **Antes de cualquier operación**: `get_table_schema` para confirmar nombres y tipos de campo.
3. **Pregunta al usuario antes de `create_table`** sobre `enlace` y `seoMetas` (decisiones de arquitectura).
4. **`dropData`, `dropColumn`, `newFieldName`, `newTableName`** son destructivos o irreversibles — pide confirmación explícita.
5. **`regenerate_enlaces`**: usa `generateAlias: true` si la tabla ya tiene tráfico público.
6. **Surfacea los `warnings`** que el backend devuelve (cambios de tipo, renames, conteos de datos en riesgo).
7. **Upload fields no se setean en insert/update** — usa `upload_record_image` después.

383
docs/06-hooks-and-cmsapi.md Normal file
View File

@@ -0,0 +1,383 @@
# Hooks y CmsApi (server-side)
Este documento describe cómo crear y consumir hooks PHP en Acai (lógica server-side) y cómo usar `CmsApi` (alias de `CocoDB`) para acceder a la base de datos. Cubre las dos ubicaciones válidas para un hook (global en `hooks/hooks.<id>.php` o propio de módulo en `template/estandar/modulos/<id>/hook.php`), las cuatro formas de invocarlo (filtro Twig, etiqueta `<hook>`, JS `CmsApi.hook`, `c-form`), las reglas obligatorias (devolver array, no `echo`, no `exit`), la API completa de `CmsApi::get/insert/update/delete` con sus opciones (`uploads`, `relations`, `translates`, `groupBy`, `aggregates`), y la tool `set_hook_middleware` para que un hook global se ejecute automáticamente antes de renderizar páginas. Léelo antes de crear cualquier `.php` de hook.
## Hooks — qué son y dónde viven
Hooks son archivos PHP que ejecutan lógica server-side. Hay dos ubicaciones válidas:
### Hook global
- Archivo: `hooks/hooks.<hook-id>.php`
- Endpoint: `/hooks/<hook-id>/`
- Úsalo cuando la lógica se reutiliza entre módulos, páginas o formularios.
Ejemplo: `hooks/hooks.calcular_precio.php` → endpoint `/hooks/calcular_precio/`
### Hook propio de módulo
- Archivo: `template/estandar/modulos/<module-id>/hook.php`
- Endpoint: `/hooks/<module-id>/`
- Úsalo cuando la lógica pertenece solo a ese módulo.
Ejemplo: `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` → endpoint `/hooks/buscadorapartados_hjd8s/`
### Regla práctica
- Si la lógica solo sirve a un módulo → `hook.php` dentro del módulo.
- Si varias piezas la consumen → hook global en `hooks/`.
## Reglas obligatorias
- Devuelve datos con `return [...]`. **NUNCA** uses `echo json_encode(...)` ni `exit`.
- Para leer parámetros usa `$_REQUEST[...]` o las variables ya inyectadas (los parámetros pasados al llamar el hook se convierten en variables PHP del mismo nombre).
- En hooks usa `CmsApi::get()` o `CocoDB::get()` como primera opción.
- **NO uses `CocoDB::getInstance()`** salvo necesidad excepcional.
- **NO escribas SQL manual con `prepare()/bind_param()`** salvo que de verdad no haya alternativa con `CmsApi` o `CocoDB`.
### Estructura de un hook
```php
<?php
// Si llamas el hook con { cantidad: 10, tipo: 'mayoreo' },
// recibes $cantidad = 10 y $tipo = 'mayoreo' como variables.
$precioUnitario = 50;
if ($tipo === 'mayoreo' && $cantidad > 10) {
$precioUnitario *= 0.85; // 15% descuento
}
return [
"success" => true,
"precioUnitario" => round($precioUnitario, 2),
"total" => round($precioUnitario * $cantidad, 2),
"descuento" => $tipo === 'mayoreo' ? 15 : 0
];
```
## Cómo invocar un hook
### Desde Twig (filtro `hook`)
```twig
{% set resultado = 'hooks/calcular_precio/' | hook({cantidad: 5, tipo: 'mayoreo'}) %}
<p>Total: {{ resultado.total }}€</p>
```
### Desde HTML (etiqueta `<hook>`)
```html
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>{{ precio.message }}</p>
```
### Desde JavaScript
```js
CmsApi.hook('/hooks/calcular_precio/', { cantidad: 10, tipo: 'mayoreo' }, (data) => {
console.log(data.total);
});
```
Si llamas a un hook propio del módulo desde su `script.js`, usa el `module-id` real (`/hooks/buscadorapartados_hjd8s/`). NO construyas el endpoint con Twig dentro de `script.js` — pásalo desde `index-base.tpl` vía `data-hook-endpoint`.
### Desde otro hook PHP
```php
$result = hook("/hooks/calcular_precio/", ["cantidad" => 5, "tipo" => "mayoreo"]);
$mensaje = $result["message"];
```
### Desde `c-form`
Los hooks se ejecutan automáticamente al enviar el formulario si están configurados en los atributos del `c-form`.
### Testing manual
Con Docker corriendo:
```bash
curl {ACAI_WEB_URL}/hooks/calcular_precio/
```
Usa la URL real del proyecto (devuélvela con `get_web_url`), no `localhost:8080`. En desarrollo local no necesitas `X-Hooks-Token`.
## CmsApi (PHP)
API server-side para BD. Disponible en todos los hooks. Alias de `CocoDB`.
### Read — `CmsApi::get()`
```php
// Todos los registros
$products = CmsApi::get("productos");
// Con WHERE string
$active = CmsApi::get("productos", "activo=1");
// Con orden y límite
$latest = CmsApi::get("noticias", "", "fecha DESC", 5);
// Condiciones complejas
$caros = CmsApi::get("productos", "precio > 100");
$resultados = CmsApi::get("productos", "activo = 1 AND stock > 0");
// Operadores
$expensive = CmsApi::get("productos", "precio >= 100");
$search = CmsApi::get("productos", "nombre LIKE '%keyword%'");
$inList = CmsApi::get("productos", "categoria_num IN (1, 2, 3)");
// Con opciones
$datos = CmsApi::get("productos", "", "", "", [
'translates' => true,
'uploads' => true,
'relations' => true,
'relationsDepth' => 2
]);
```
#### Opciones
| Option | Tipo | Default | Descripción |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['categoria']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | idioma actual | Código de idioma para `| translate` |
| `groupBy` | string | null | Cláusula GROUP BY |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo ciertos campos |
| `debug` | bool | false | Imprime el SQL generado |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL del cache (segundos) |
### Insert — `CmsApi::insert()`
```php
// Un registro
CmsApi::insert('contacto', [
["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hola"]
]);
// Múltiples registros
CmsApi::insert('productos', [
["nombre" => "Producto A", "precio" => 100],
["nombre" => "Producto B", "precio" => 200]
]);
// Retornar el last id
$result = CmsApi::insert('productos',
[["nombre" => "Nuevo", "precio" => 150]],
[],
['return_last_id' => true]
);
$nuevoNum = $result['lastId'];
```
#### Opciones de insert
| Option | Descripción |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
| `return_last_id` | Devuelve el `num` del último insert |
### Update — `CmsApi::update()`
```php
// Con WHERE string
CmsApi::update('productos', ["precio" => 150], "num=1");
// Con WHERE array
CmsApi::update('productos',
["activo" => 1],
[["column" => "num", "operator" => "=", "value" => 1]]
);
// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");
```
### Delete — `CmsApi::delete()`
```php
CmsApi::delete('productos', "num=5");
CmsApi::delete('productos',
[["column" => "activo", "operator" => "=", "value" => 0]]
);
```
### Reglas CmsApi
- Nombres de tabla **sin prefijo `cms_`**
- Primary key siempre es **`num`**
- Foreign keys: **`<entidad>_num`**
- Upload fields **NO se setean** vía insert/update — usa `upload_record_image` después.
- Operadores soportados: `=`, `!=`, `>`, `>=`, `<`, `<=`, `LIKE`, `IN`
## CocoDB (low-level)
Capa de BD que usa internamente `CmsApi`. Misma API que `CmsApi` pero con métodos verbosos:
- `CocoDB::get($table, $where, $order, $limit, $options)` — idéntico a `CmsApi::get`
- `CocoDB::insertRecords($table, $records, $functions, $options)` — idéntico a `CmsApi::insert`
- `CocoDB::updateRecords($table, $records, $where, $functions, $options)` — idéntico a `CmsApi::update`
- `CocoDB::deleteRecords($table, $where, $options)` — idéntico a `CmsApi::delete`
Para uso normal, `CmsApi` es suficiente. Acude a `CocoDB` solo para SQL más bajo nivel cuando `CmsApi` no cubra el caso.
## CmsApi (JavaScript — Client-Side)
```js
// Llamar hook
CmsApi.hook('/hooks/calcular_precio/', { cantidad: 10 }, (response) => {
console.log(response);
});
// Leer registros (si está expuesto vía hooks)
CmsApi.get('productos', { where: 'activo=1' }, (records) => {
console.log(records);
});
```
## Hook middleware — auto-ejecutar antes de páginas
Un hook global puede configurarse como **middleware**: se ejecuta automáticamente antes de renderizar ciertas páginas (o todas). Útil para inyección de variables, redirecciones condicionales, control de acceso, parseo de URLs, etc.
### Tools
- `get_hook_middleware({ hookEndPoint: "/hooks/parse_styles/" })` — leer config actual
- `set_hook_middleware({ hookEndPoint: "/hooks/parse_styles/", middleWare: [...] })` — escribir config
### Valores de `middleWare`
| Valor | Comportamiento |
|-------|----------------|
| `[]` | Hook solo se ejecuta cuando se llama explícitamente (default). |
| `["allurls"]` | Se ejecuta antes de **cada página** del sitio. |
| `["<tableName>-<num>", ...]` | Se ejecuta antes de **registros específicos**. Ejemplo: `["cms_apartados-2"]` para la home. |
> Nota: en el array de `middleWare` los `tableName` van **con** prefijo `cms_` (es la representación interna de `layout.json`).
### Ejemplos
```
// Lógica de redirect que solo afecta a la home (apartados num=2)
set_hook_middleware({
hookEndPoint: "/hooks/redirect_home/",
middleWare: ["cms_apartados-2"]
})
// Inyección global (analytics, vars de sitio)
set_hook_middleware({
hookEndPoint: "/hooks/global_vars/",
middleWare: ["allurls"]
})
// Hook utilitario que se llama desde módulos — sin middleware
set_hook_middleware({
hookEndPoint: "/hooks/calcular_precio/",
middleWare: []
})
```
### Reglas
- El middleware se aplica **solo a hooks globales**, no a hooks de módulo.
- Crear el `.php` con `acai-write` **NO** activa middleware automáticamente — hay que llamar `set_hook_middleware` explícitamente.
- Lee `get_hook_middleware` antes de modificar para no sobrescribir configuraciones existentes.
## Schemas y formato de datos al insertar
Antes de un `CmsApi::insert`/`update` o de un `create_or_update_record` desde MCP, consulta el schema (`get_table_schema`). Tipos de campo y formato esperado:
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| `textfield` | String | `"Texto"` |
| `textbox` | String multilínea | `"Línea 1\nLínea 2"` |
| `date`/datetime | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| `wysiwyg` | HTML string | `"<p class=\"font-bold\">Texto</p>"` |
| `list` | String o número | `"activo"` o `"1"` |
| `checkbox` | Número 1/0 | `1` o `0` |
| `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` |
| `upload` | NO enviar | Usa `upload_record_image` después |
## Ejemplos prácticos
### Hook con operaciones de BD
```php
<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get("productos", "num=" . intval($producto_id));
if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"];
}
$total = $producto[0]['precio'] * $cantidad;
// Crear venta
$result = CmsApi::insert('ventas', [[
"usuario_num" => $usuario_id,
"producto_num" => $producto_id,
"cantidad" => $cantidad,
"total" => $total,
"fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);
// Actualizar stock
$stock = CmsApi::get("stocks", "producto_num=" . intval($producto_id));
if (!empty($stock)) {
CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad],
"producto_num=$producto_id"
);
}
return ["success" => true, "ventaNum" => $result['lastId'], "total" => $total];
```
### Hook con relaciones cargadas
```php
<?php
$productos = CmsApi::get("productos", "activo=1", "globalOrder ASC", 10, [
'uploads' => true,
'relations' => true,
'relationsDepth' => 1
]);
return [
"success" => true,
"productos" => $productos
];
```
### Hook con búsqueda dinámica
```php
<?php
$where = "1=1";
if (!empty($termino)) {
$termino = addslashes($termino);
$where .= " AND (titulo LIKE '%$termino%' OR descripcion LIKE '%$termino%')";
}
if (!empty($categoria)) {
$where .= " AND categoria_num=" . intval($categoria);
}
$resultados = CmsApi::get("productos", $where, "fecha DESC", 20);
return ["success" => true, "count" => count($resultados), "resultados" => $resultados];
```
## Reglas críticas
1. Hook devuelve `return [...]`**nunca** `echo`/`exit`.
2. Tablas **sin prefijo `cms_`** en `CmsApi`/`CocoDB`. PK siempre `num`.
3. Foreign keys con sufijo `_num` (`categoria_num`, `usuario_num`).
4. Upload fields **no** se setean por insert/update.
5. Sanea variables externas — `intval()`, `addslashes()` o usa parámetros tipados antes de concatenar en SQL.
6. Antes de usar `set_hook_middleware`, lee con `get_hook_middleware` para no sobrescribir.
7. Hooks de módulo: endpoint = `/hooks/<module-id>/` (no `/hooks/hook.<id>/`).

View File

@@ -0,0 +1,283 @@
# CSS y JavaScript — Convenciones del Módulo
Este documento define cómo escribir CSS, JavaScript y, cuando hace falta, Vue 3 dentro de un módulo Acai. Cubre la regla "Tailwind first" + BEM para CSS custom, las clases utilitarias propias de Acai (`transition3s`, `click-a-child`, `line-clamp2`, `lazyload`, `bg-main-color`, etc.), las CSS variables del tema (`--main-color`), el patrón obligatorio de **scoping** vía la clase raíz del módulo, la regla dura de que `script.js` y `style.css` son **archivos estáticos** (sin Twig dentro), cómo pasar valores dinámicos desde `index-base.tpl` a JS vía `data-*`, cuándo usar Vue 3 y cómo integrarlo evitando conflicto de delimiters con Twig, y los componentes nativos del builder (Carousel `c-tns-wrapper`, Lightbox, Breadcrumb, AOS, Lazy loading). Léelo antes de escribir cualquier `style.css` o `script.js`.
## Estructura del módulo
- Cada módulo genera HTML + CSS + JS (y opcionalmente Vue 3).
- Define una **clase raíz en kebab-case** específica del módulo: `product-card`, `hero-section`, `buscador-apartados`.
- **Todo el CSS y JS deben quedar scopeados bajo esa clase raíz.**
## CSS
### Tailwind first
Usa TailwindCSS como método principal. Reserva CSS custom solo cuando Tailwind no cubra el caso (estados complejos, transiciones específicas, animaciones).
```html
<div class="flex items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900">Title</h2>
</div>
```
### BEM para CSS custom
Cuando necesites CSS propio, scopealo bajo la clase raíz con BEM:
```css
.hero-section { }
.hero-section__title { }
.hero-section__image { }
.hero-section--dark { }
```
Nunca uses clases globales sin prefijo de módulo.
### CSS variables del tema
| Variable | Descripción |
|----------|-------------|
| `var(--main-color)` | Color de marca principal |
| `var(--main-color-light)` | Variante clara |
| `var(--main-color-dark)` | Variante oscura |
### Estilos inline con fallbacks
Patrón estándar para colores configurables por el usuario (variables del builder):
```html
<div style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<p style="color: {{ colordeltexto ? colordeltexto : '#111827' }}">
```
### Clases utilitarias de Acai
| Clase | Descripción |
|-------|-------------|
| `transition3s` | Transición suave 0.3s |
| `click-a-child` | Hace al padre clickeable vía el primer `<a>` hijo |
| `line-clamp2` / `line-clamp3` / `line-clamp5` | Truncar texto a N líneas |
| `filter-white` | Filtro CSS para teñir imágenes/iconos en blanco |
| `lazyload` | Activa lazy loading (usar con `data-src`) |
| `text-shadow` | Sombra de texto para legibilidad sobre imágenes |
| `wysiwyg` | Wrapper para contenido rico (estilos coherentes) |
| `bg-main-color` / `bg-main-color-light` / `bg-main-color-dark` | Fondos con color primario |
| `text-main-color` / `text-main-color-light` / `text-main-color-dark` | Texto con color primario |
| `titulo-main-color` / `titulo-white` | Estilos de título resaltado |
| `c-tns-wrapper` / `c-tns-container` / `c-tns-nav-container` | Carousel built-in |
| `glightbox` | Activa lightbox |
### Reglas para `style.css`
`style.css` es **estático**. Reglas duras:
- **NO** uses sintaxis Twig (`{{ ... }}`, `{% ... %}`) ni atributos builder (`c-if`, `c-for`).
- **NO** escribas selectores que dependan de `{{ section_id }}` — scopea con la clase raíz del módulo.
```css
/* CORRECTO — scopeado con clase raíz */
.product-card { }
.product-card__title { color: var(--main-color); }
/* INCORRECTO — Twig inside */
#{{ section_id }} h3 { }
```
## JavaScript
### `script.js` es estático
Igual que `style.css`: **NO** uses sintaxis Twig dentro. Si necesitas valores dinámicos, pásalos desde `index-base.tpl` vía atributos `data-*`.
#### Patrón correcto
`index-base.tpl`:
```html
<section class="buscador-apartados"
id="{{ section_id }}"
data-section-id="{{ section_id }}"
data-hook-endpoint="/hooks/buscadorapartados_hjd8s/"
data-limit="{{ limite | default('10') }}">
<input class="buscador-apartados__input" type="text">
<ul class="buscador-apartados__results"></ul>
</section>
```
`script.js`:
```js
document.querySelectorAll('.buscador-apartados').forEach((section) => {
const sectionId = section.dataset.sectionId;
const hookEndpoint = section.dataset.hookEndpoint;
const limit = parseInt(section.dataset.limit, 10);
const input = section.querySelector('.buscador-apartados__input');
const results = section.querySelector('.buscador-apartados__results');
input.addEventListener('input', (e) => {
CmsApi.hook(hookEndpoint, { termino: e.target.value, limite: limit }, (data) => {
results.innerHTML = data.items.map((it) => `<li>${it.titulo}</li>`).join('');
});
});
});
```
NUNCA hagas `CmsApi.hook('/hooks/{{ moduleId }}/', ...)` dentro de `script.js`. Si necesitas el endpoint, pásalo por `data-hook-endpoint`.
### CmsApi (cliente)
```js
// Llamar hook
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, (response) => {
console.log(response);
});
// Si llamas un hook propio del módulo, usa el module-id real:
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, (response) => {
console.log(response);
});
```
### Embeber `<script>` directamente — NO
No embebas `<script>` con lógica del módulo dentro de `index-base.tpl`. La lógica vive en `script.js`. La excepción: scripts mínimos para inicializar Vue 3 (ver más abajo) que sí necesitan los delimitadores `{{ section_id }}` para mountear sobre IDs únicos.
## Cuándo usar Vue 3
Usa Vue 3 (vía CDN) cuando la lógica requiera:
- Doble binding / reactividad
- Solicitudes asíncronas complejas con UI reactiva
- Componentes reutilizables dentro del módulo
- Gestión de estado local
- Ciclos de vida (mounted, unmounted, etc.)
Para lógica simple (listeners, fetch único, toggle de clases), usa JavaScript vanilla.
### Integración Vue 3
```html
<div id="app-{{ section_id }}">
<p>${ message }</p>
<button @click="increment">${ count }</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
delimiters: ['${', '}'], // OBLIGATORIO — evita conflicto con Twig {{ }}
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
}
}).mount('#app-{{ section_id }}');
</script>
```
Reglas:
- **Siempre** redefine `delimiters` a `['${', '}']` para no chocar con Twig.
- Mountea sobre un id único usando `section_id`: `#app-{{ section_id }}`.
- Vue se carga como librería global vía `add_global_library` o ya incluida en el proyecto (ver `08-layout-and-libraries.md`).
## Variables globales disponibles en JS / Twig
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| `section_id` | ID único por instancia del módulo | `<div id="{{section_id}}">` |
| `server.HTTP_HOST` | Dominio actual | `https://{{ server.HTTP_HOST }}/path` |
| `loop.index` | Índice 1-based en `c-for`/`{% for %}` | `{{ loop.index }}` |
| `loop.index is odd` / `is even` | Para layouts alternados | zigzag |
| `interno` | `true` dentro del editor CMS | `c-class="{'editor-mode': interno}"` |
### Patrón `section_id`
Cada instancia de un módulo recibe un `section_id` único. Úsalo para anchors, IDs HTML y selector raíz de JS:
```html
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido -->
</section>
```
## Componentes nativos
### Carousel — `c-tns-wrapper`
```html
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-autoplay-timeout="5000"
data-mode="carousel"
data-speed="400"
data-nav="true">
<ul class="c-tns-container">
<li data-field-type="multiv2" data-field-label="Slides" class="px-2">
<!-- contenido del slide -->
</li>
</ul>
</div>
```
| Atributo | Descripción |
|----------|-------------|
| `data-responsive` | Items por breakpoint. JSON `{"0":1,"768":2,"1024":3}` o sintaxis corta `"sm:2, md:3, lg:4"` |
| `data-autoplay-timeout` | Intervalo autoplay (ms) |
| `data-mode` | `"gallery"` o `"carousel"` |
| `data-speed` | Velocidad de transición (ms) |
| `data-nav` | `"true"` para mostrar dots |
Dots de navegación custom:
```html
<div class="c-tns-nav-container absolute bottom-4 left-0 w-full flex justify-center items-end z-20">
<div c-for="item in records"
class="pointer-events-auto cursor-pointer rounded-full border-2 border-white w-4 h-4 mx-1 bg-black bg-opacity-50">
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}">
</a>
```
### Breadcrumb
```html
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
```
### Animate On Scroll (AOS)
```html
<div data-aos="fade-up" data-aos-duration="800">Contenido</div>
```
Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `zoom-in-up`, `fade-up-right`, `fade-up-left`.
Tras cambios dinámicos en JS:
```js
AOS.refresh();
```
### Lazy loading
```html
<!-- Builder var con lazy automático -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-width="800" alt="">
<!-- Manual en plantillas -->
<img class="lazyload" data-src="{{ record.imagen[0].urlPath | imagec(800) }}" alt="">
```
## Reglas críticas
1. `script.js` y `style.css` son **estáticos** — sin sintaxis Twig dentro.
2. Para valores dinámicos en JS, pásalos desde `index-base.tpl` vía atributos `data-*`.
3. Define una **clase raíz en kebab-case** por módulo. Scopea TODO el CSS/JS bajo ella.
4. Tailwind first; CSS custom solo donde Tailwind no llegue, siempre con BEM.
5. Vue 3: redefine `delimiters: ['${', '}']` para evitar conflicto con Twig.
6. Mountea Vue sobre `#app-{{ section_id }}`.
7. Usa las clases utilitarias de Acai (`transition3s`, `lazyload`, `bg-main-color`, etc.) antes de inventar utilidades.
8. NO embebas lógica `<script>` dentro de `index-base.tpl` (Vue init es la única excepción común).

View File

@@ -0,0 +1,212 @@
# Layout Global y Librerías Globales
Este documento explica cómo gestionar los **4 campos globales del proyecto** (`style` CSS global, `javascript` JS global, `header` Twig del header del sitio, `footer` Twig del footer) y las **librerías globales** (CSS/JS/fonts inyectadas en `<head>` o antes de `</body>`). Cubre la regla crítica de NO editar nunca `cms/lib/plugins/builder_saas/layout.json` ni los `.tpl` de `custom-header-twig` / `custom-footer-twig` directamente, las tools `get_layout_field` / `set_layout_field` (única vía válida para editar header/footer/style/javascript) y las tools `list_global_libraries` / `add_global_library` / `remove_global_library` / `set_global_libraries` para gestionar las URLs de librerías. Léelo antes de tocar cualquier cosa relacionada con header, footer, CSS global o librerías externas (jQuery, Vue CDN, Google Fonts, etc.).
## Layout global
Los 4 campos globales del proyecto viven en `cms/lib/plugins/builder_saas/layout.json`:
| Campo | Contenido |
|-------|-----------|
| `style` | CSS global del proyecto (se inyecta en todas las páginas) |
| `javascript` | JS global del proyecto (se inyecta en todas las páginas) |
| `header` | Twig del header del sitio (se renderiza arriba de cada página) |
| `footer` | Twig del footer del sitio (se renderiza al final de cada página) |
Los campos `header` y `footer` son **Twig** — se beneficias de filtros (`| get`, `| translate`, etc.) y atributos (`c-if`, `c-for`).
### REGLA CRÍTICA — Nunca edites estos archivos directamente
**Está prohibido** usar `acai-view`, `acai-line-replace`, `acai-write` ni `acai-delete` sobre:
- `cms/lib/plugins/builder_saas/layout.json`
- `template/estandar/modulos/custom-header-twig/*`
- `template/estandar/modulos/custom-footer-twig/*`
- `template/estandar/modulos/custom-header/*`
- `template/estandar/modulos/custom-footer/*`
Estos ficheros son **artefactos generados** a partir de `layout.json`. Editarlos directamente provoca:
- Desincronización con `layout.json.{header,footer}ModuleCustom.htmlParsed`.
- Sobrescritura de tus cambios cuando el usuario abre el builder visual y guarda.
- Comportamiento inconsistente entre el render público y el builder.
**El backend protege estas rutas** — las tools de archivo devolverán error si intentas tocarlas.
### Workflow correcto
#### Leer
```
get_layout_field({ field: "header" }) // Twig source del header
get_layout_field({ field: "footer" }) // Twig source del footer
get_layout_field({ field: "style" }) // CSS global
get_layout_field({ field: "javascript" }) // JS global
```
#### Escribir
```
set_layout_field({
field: "footer",
content: "<footer class='bg-gray-900 text-white py-10'>…nuevo HTML/Twig…</footer>"
})
```
`set_layout_field` ejecuta una pipeline atómica:
1. Escribe el source en `layout.json.{field}`.
2. Sincroniza `layout.json.{field}ModuleCustom.htmlParsed`.
3. Regenera los `.tpl` de `custom-{field}-twig/`.
4. Compila el Twig a PHP.
Es **destructivo** — sobrescribe el contenido completo. **Pair con `get_layout_field` primero** para leer el actual y modificarlo, no escribirlo desde cero.
### Ejemplos de uso
#### Cambiar el copyright del footer
```
// 1. Leer
get_layout_field({ field: "footer" })
// devuelve: <footer><p>© 2024 Mi Sitio</p>…</footer>
// 2. Modificar localmente y reescribir entero
set_layout_field({
field: "footer",
content: "<footer><p>© 2025 Mi Sitio. Todos los derechos reservados.</p>…</footer>"
})
```
#### Añadir un menú al header
```
// 1. Leer source actual
get_layout_field({ field: "header" })
// 2. Escribir nueva versión con el menú añadido
set_layout_field({
field: "header",
content: "<header>…<nav>{% for item in 'apartados' | get('parentNum=0 AND visible_en_el_menu=1', 'globalOrder ASC') %}<a href='{{ item.enlace }}'>{{ item.name }}</a>{% endfor %}</nav>…</header>"
})
```
#### Añadir CSS global
```
// 1. Leer
get_layout_field({ field: "style" })
// 2. Reescribir con la regla añadida
set_layout_field({
field: "style",
content: ":root { --main-color: #2563eb; }\n.btn-primary { … }\n…"
})
```
## Librerías globales
`layout.json["libraries"]` define una lista de URLs (CSS, JS, fonts) que el CMS inyecta en cada página. Hay **dos secciones**:
| Sección | Posición | Uso típico |
|---------|----------|------------|
| `top` | Dentro de `<head>` | CSS, fonts (Google Fonts), JS crítico (preload) |
| `bottom` | Antes de `</body>` | La mayoría de JS (jQuery, Vue, sliders, etc.) |
### Tools
#### Listar — `list_global_libraries`
```
list_global_libraries()
```
Devuelve:
```json
{
"top": [
{ "num": 1, "url": "https://fonts.googleapis.com/css2?family=Inter" },
{ "num": 2, "url": "/css/extras.css" }
],
"bottom": [
{ "num": 1, "url": "https://unpkg.com/vue@3/dist/vue.global.prod.js" },
{ "num": 2, "url": "/js/main.js" }
],
"layoutExists": true
}
```
Llama a esta tool **antes** de añadir/quitar para no duplicar entradas.
#### Añadir — `add_global_library`
Idempotente: si la URL ya existe en esa sección, devuelve `added: false`.
```
add_global_library({
section: "bottom",
url: "https://unpkg.com/vue@3/dist/vue.global.prod.js"
})
add_global_library({
section: "top",
url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
})
// Rutas relativas al proyecto también valen
add_global_library({
section: "bottom",
url: "/js/custom.js"
})
```
#### Eliminar — `remove_global_library`
Idempotente: si la URL no existe, devuelve `removed: false`.
```
remove_global_library({
section: "bottom",
url: "https://unpkg.com/vue@3/dist/vue.global.prod.js"
})
```
#### Reemplazar entera — `set_global_libraries`
**Destructivo** — sobrescribe la lista completa de la sección. Úsalo solo para reordenar masivamente o reemplazar el conjunto. Para cambios incrementales prefiere `add_global_library` / `remove_global_library`.
```
set_global_libraries({
section: "bottom",
libraries: [
{ url: "/js/jquery.min.js" },
{ url: "/js/main.js" },
{ url: "https://unpkg.com/vue@3/dist/vue.global.prod.js" }
]
})
```
### Convenciones
- Para **librerías populares**, prefiere CDN oficial (`unpkg.com`, `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`).
- Para **assets propios** del proyecto, usa rutas relativas (`/js/main.js`, `/css/extras.css`).
- Si añades una librería que ya tiene equivalente cargada (ej. dos versiones de jQuery), elimina la antigua antes de añadir la nueva.
- El **orden** importa: las dependencias deben ir antes que sus consumidores. `set_global_libraries` permite reordenar.
## Decisión: ¿layout global o módulo?
| Caso | Solución |
|------|----------|
| Header/footer del sitio | Layout global (`set_layout_field` con `header`/`footer`) |
| CSS aplicado a todo el sitio | Layout global (`set_layout_field` con `style`) o librería global |
| JS aplicado a todo el sitio | Layout global (`set_layout_field` con `javascript`) o librería global |
| Componente reutilizable en páginas | Módulo en `template/estandar/modulos/` |
| Detalle de un registro | Sección general `custom-{tableName}` |
| Bloque visual específico | Módulo |
## Reglas críticas
1. **NUNCA edites directamente** `layout.json`, `custom-header-twig/*`, `custom-footer-twig/*`, `custom-header/*`, `custom-footer/*`. Las tools de archivo te devolverán error si lo intentas.
2. Para header/footer/style/javascript globales, **única vía**: `get_layout_field` + `set_layout_field`.
3. `set_layout_field` es **destructivo** — siempre lee primero, modifica, escribe.
4. Para librerías globales: `list_global_libraries``add_global_library` / `remove_global_library`. `set_global_libraries` solo para reordenar/reemplazar masivamente.
5. Inyección automática: `top` va en `<head>`, `bottom` antes de `</body>`. Decide según el tipo de asset.
6. El orden de las librerías importa para dependencias.

View File

@@ -0,0 +1,245 @@
# MCP Tools — Referencia Completa
Este documento es el **inventario canónico** de todas las tools MCP disponibles para el agente Acai. Está agrupado por categoría (archivos, módulos, registros, tablas, layout, librerías, hooks, media, navegación, proyecto, git, autenticación, docs) y describe para cada tool su propósito, parámetros clave, qué devuelve y cuándo usarla. Incluye además los **workflows canónicos** para las operaciones más comunes (crear módulo, editar módulo, crear funcionalidad nueva con tabla + detalle, gestionar imágenes de un módulo, editar header/footer, configurar middleware de hook). Léelo antes de cualquier tarea para elegir la secuencia correcta de tools.
## Inventario por categoría
### Archivos
| Tool | Acción | Notas |
|------|--------|-------|
| `acai-view` | Lee un archivo (con rango de líneas opcional) | Ahorra tokens — usa `start_line`/`end_line` |
| `acai-glob` | Encuentra archivos por patrón glob | `template/estandar/modulos/**/index-base.tpl` |
| `acai-grep` | Busca texto/regex en archivos | Soporta filtro por glob |
| `acai-write` | Crea o reescribe un archivo completo | **Editar `index-base.tpl` compila automáticamente** |
| `acai-line-replace` | Reemplaza un bloque de líneas validado | Preferida sobre `acai-write` para edits puntuales |
| `acai-delete` | Elimina un archivo | Destructivo — confirma primero |
Reglas:
- Rutas siempre relativas al proyecto.
- Editar `index-base.tpl` → compila automáticamente. No necesitas llamar a `compile_module`.
- **NO toques** `index.tpl`, `index-twig.tpl`, `builder.json` (autogenerados) ni los archivos de layout protegidos (ver `08-layout-and-libraries.md`).
### Módulos
| Tool | Acción | Notas |
|------|--------|-------|
| `create_module` | Crea un módulo nuevo (carpeta + archivos + compile) | Alternativa a hacer `acai-write` manual del `index-base.tpl`. Genera `moduleId` único añadiendo sufijo aleatorio. |
| `compile_module` | Recompila manualmente (rescate) | Solo si los archivos generados están desincronizados. Editar `index-base.tpl` con tools de archivo ya compila. |
| `check_module` | Preview del render con datos de ejemplo | Devuelve preview (50 líneas) por defecto; `fullRender:true` para todo |
| `check_module_usage` | Lista páginas que usan el módulo | **OBLIGATORIO antes de `delete_module`** |
| `delete_module` | Elimina la carpeta del módulo | Destructivo. Si `inUse=true`, deniega — el usuario debe quitarlo de las páginas primero |
| `set_module_example_data` | Define datos de ejemplo para preview en el editor | Pasar valores para TODAS las variables del schema |
### Registros (records)
| Tool | Acción | Notas |
|------|--------|-------|
| `list_table_records` | Lista registros con filtros, paginación | Usa `fields` y `truncateText` para ahorrar tokens |
| `get_record` | Obtiene un registro completo por `num` | Carga uploads + relations por defecto |
| `create_or_update_record` | Crea o actualiza registros | Sin `recordId` → crea. Con `recordId` → actualiza. Acepta array para batch |
| `delete_table_records` | Elimina registros (por IDs o `deleteAll`) | **Destructivo permanente** |
| `list_page_modules` | Lista módulos colocados en una página Builder | Devuelve sectionIds, posiciones, visibilidad, configVars |
| `add_module_to_record` | Añade módulo a una página Builder | Devuelve `sectionId` — úsalo en `set_module_config_vars` |
| `remove_module_from_record` | Quita módulo de la página | Por `sectionId` (preferido) o `modulePosition` |
| `reorder_module` | Mueve módulo a otra posición | `fromPosition``toPosition` |
| `toggle_module_visibility` | Muestra/oculta sin borrar | Por `sectionId` |
| `get_module_config_vars` | Lee valores actuales de las variables | Por `tableName` + `recordNum` + `sectionId` |
| `set_module_config_vars` | Escribe variables del módulo | Devuelve `uploadFields` con `recordNum`+`fieldName` listos para subir imágenes |
### Tablas y campos (schema)
Ver `05-tables-and-fields.md` para detalles. Tools:
| Tool | Acción |
|------|--------|
| `list_tables` | Inventario de tablas (sin `cms_`) |
| `get_table_schema` | Schema completo. Soporta `minimal:true` y `filterFields:"..."` para ahorrar tokens |
| `create_table` | Crea tabla nueva. PREGUNTA al usuario sobre `enlace`/`seoMetas` antes |
| `update_table_metadata` | Actualiza el `[meta]`. `newTableName` renombra MySQL (destructivo) |
| `delete_table` | Borra tabla. Usa `dryRun:true` primero. `dropData:true` borra datos |
| `reorder_tables` | Reordena sidebar admin |
| `create_field` | Añade campo a tabla |
| `update_field` | Actualiza props. `newFieldName` renombra columna (destructivo). Cambios de `type` pueden truncar datos — surfacea `warnings` |
| `delete_field` | Borra campo. `dropColumn:true` borra datos permanentemente |
| `reorder_fields` | Reordena los campos del formulario admin |
| `regenerate_enlaces` | Regenera URLs de la tabla. `generateAlias:true` para preservar redirects |
### Layout global
Ver `08-layout-and-libraries.md`.
| Tool | Acción |
|------|--------|
| `get_layout_field` | Lee `style`/`javascript`/`header`/`footer` del `layout.json` |
| `set_layout_field` | Escribe el campo (atómico: actualiza json + regenera tpl + compila). Destructivo |
### Librerías globales
| Tool | Acción |
|------|--------|
| `list_global_libraries` | Lista las URLs `top` (head) y `bottom` (antes de `</body>`) |
| `add_global_library` | Añade URL idempotente |
| `remove_global_library` | Quita URL idempotente |
| `set_global_libraries` | Reemplaza la lista de la sección. Destructivo |
### Hooks (middleware)
| Tool | Acción |
|------|--------|
| `get_hook_middleware` | Lee la config `middleWare` de un hook global |
| `set_hook_middleware` | Configura cuándo se ejecuta automáticamente: `[]`, `["allurls"]`, `["<tableName>-<num>"]` |
Ver `06-hooks-and-cmsapi.md` para uso. Crear/editar el `.php` del hook se hace con `acai-write`.
### Media
| Tool | Acción | Notas |
|------|--------|-------|
| `generate_image` | Genera imagen con IA y la guarda en `cms/uploads/generated/` | Devuelve `dockerUrl` y `uploadUrl`/`fullUrl`. **En Forge prefiere `uploadUrl`/`fullUrl`** sobre `dockerUrl` para `upload_record_image` |
| `upload_record_image` | Sube imagen a un campo de un registro | Necesita `tableName`, `recordId` (num), `fieldName` real (de relations o `uploadFields`) |
| `upload_image_to_assets` | Sube imagen a `/images/` del template (assets globales) | Acepta base64, data URI, URL. Permite resize/quality/format |
### Navegación
| Tool | Acción |
|------|--------|
| `navigate_browser` | Navega el browser preview del usuario a un `enlace` (e.g. `/servicios/`) |
### Proyecto
| Tool | Acción |
|------|--------|
| `get_web_url` | URL del sitio en desarrollo. **OBLIGATORIO** antes de fetch/Playwright. Acuérdate de añadir `?pruebas=1` |
| `save_project_styles` | Guarda resumen de estilos en `docs/project-styles.md` |
### Git
| Tool | Acción |
|------|--------|
| `list_git_log` | Lista los últimos commits para que el usuario elija un id de rollback |
| (rollback) | Tool de rescate; pide confirmación al usuario |
### Autenticación
| Tool | Acción |
|------|--------|
| `refresh_acai_token` | Renueva el JWT cuando expira (errores 403) |
### Documentación
| Tool | Acción |
|------|--------|
| `read_doc` | Lee un doc del knowledge base completo o por sección. Útil cuando un doc no fue cargado por relevancia o necesitas una sección puntual |
| `list_docs` | Lista los docs disponibles con sus títulos y summaries |
## Workflows canónicos
### 1. Crear un módulo nuevo
1. **Estilo del proyecto**: si existe `docs/project-styles.md` léelo. Si no, explora 3-4 módulos representativos (no `custom-*`) y guarda con `save_project_styles`.
2. **Lee la doc relevante** según contenido: `01-builder-fields.md` siempre; `07-css-js-conventions.md` si lleva JS; `06-hooks-and-cmsapi.md` si lleva hook PHP; `02-twig.md` si usa filtros.
3. **`acai-write`** sobre `template/estandar/modulos/<moduleId>_xxxxxx/index-base.tpl` con el HTML/Twig. Si necesita CSS/JS/PHP, escribe también `style.css`, `script.js`, `hook.php`.
4. **Compilación automática** al escribir `index-base.tpl`. Si por algún motivo necesitas forzarla sin tocar el archivo: `compile_module`.
5. **`add_module_to_record`** para colocarlo en una página. Devuelve `sectionId`.
6. **`set_module_config_vars`** para rellenar variables (textos, listas, etc.). Devuelve `uploadFields` con `recordNum`+`fieldName` por cada upload.
7. **Imágenes**: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del paso 6.
8. **`navigate_browser`** al `enlace` de la página para que el usuario vea el resultado.
### 2. Editar un módulo existente
1. `get_module_config_vars` — leer estado actual (vars + recordNums).
2. `acai-view` — leer el rango concreto de `index-base.tpl` que vas a tocar.
3. `acai-line-replace` — modificar el bloque (compila automáticamente). Usa `acai-write` solo si el cambio es masivo.
4. Si cambian variables: `set_module_config_vars` para actualizar valores.
### 3. Gestionar imágenes de un módulo
**Tras `set_module_config_vars`** (recomendado):
1. La respuesta incluye `uploadFields` con `{ fieldName, recordNum }` por cada variable upload.
2. Para multi vars con uploads: `uploadFields["records.imagen"]` es array `[{index, fieldName, recordNum}]`.
3. `upload_record_image` con `tableName: "builder_custom"`, `recordId` y `fieldName` de `uploadFields`.
**Sin `set_module_config_vars` previo**:
1. `get_module_config_vars` — obtiene `recordNum` de builder_custom.
2. Lee `builder.json` del módulo para encontrar el `fieldName` real (de `vars.NOMBRE.relations.builder_custom`, ej. `image1`**NO** uses el nombre de la variable).
3. `upload_record_image` con `tableName: "builder_custom"`, `recordId` (recordNum del paso 1), `fieldName` (de relations).
Generar imagen primero:
1. `generate_image` con `prompt` + `style`.
2. Usa la URL recomendada que devuelve (`uploadUrl` o `fullUrl` en Forge; `dockerUrl` solo en local).
3. `upload_record_image` con esa URL.
### 4. Crear funcionalidad nueva con tabla + detalle
Ejemplo: implementar "Vacantes".
1. **`create_table`** con `tableName: "vacantes"`, `menuType: "multi"`, `enlace: true`, `seoMetas: true`. Pregunta al usuario antes los flags.
2. **`create_field`** para cada campo: `titulo`, `descripcion` (wysiwyg), `salario_minimo` (textfield), `categoria_num` (list desde tabla), `fecha_publicacion` (date), `fecha_expiracion` (date), `visible` (checkbox), `imagen_destacada` (upload).
3. **`acai-write`** sobre `template/estandar/modulos/custom-vacantes/index-base.tpl` con el Twig que usa `thisrecord.*` (sección general que renderiza el detalle de cada registro).
4. (Opcional) **Módulo de listado** `vacantes_listado_xxxxxx` con `'vacantes' | get('visible=1', 'fecha_publicacion DESC', 20)`.
5. (Opcional) **Página índice** `/vacantes/` en `apartados` (Builder) con el módulo de listado dentro.
6. **`navigate_browser`** a un detalle creado para verificar.
### 5. Editar header / footer del sitio
Ver `08-layout-and-libraries.md`.
1. `get_layout_field({ field: "header" })` — lee Twig actual.
2. Modifica localmente.
3. `set_layout_field({ field: "header", content: "..." })` — atómico. Sobrescribe `layout.json`, regenera `.tpl` y compila.
**NUNCA** uses `acai-write` sobre `custom-header-twig/index-base.tpl` ni `layout.json`.
### 6. Añadir librería global (jQuery, Vue CDN, Google Fonts)
1. `list_global_libraries` — comprueba si ya existe.
2. `add_global_library({ section: "bottom", url: "..." })` para JS, o `top` para CSS/fonts.
Para reordenar dependencias: `set_global_libraries` con la lista completa.
### 7. Hook con middleware (auto-ejecutar antes de páginas)
1. **`acai-write`** sobre `hooks/hooks.<id>.php` con la lógica.
2. **`get_hook_middleware`** sobre `/hooks/<id>/` para ver config actual.
3. **`set_hook_middleware`** con el nuevo `middleWare`:
- `[]` → solo cuando se llama explícitamente
- `["allurls"]` → antes de cada página
- `["cms_apartados-2"]` → solo antes del registro num=2 de apartados
### 8. Gestionar registros de una tabla
1. `list_table_records` con `where`/`order`/`limit`/`fields` (sin `cms_`).
2. `get_record` para uno completo (`tableName`+`recordNum`).
3. `create_or_update_record` para crear/actualizar. Antes consulta el schema con `get_table_schema`.
4. `delete_table_records` para borrar (destructivo permanente).
### 9. Explorar el sitio
- `list_table_records` sobre `apartados` para ver páginas.
- `list_page_modules` sobre una página para ver módulos.
- `get_module_config_vars` para ver datos de un módulo.
- `check_module` para preview con datos custom.
### 10. Consultar la documentación bajo demanda
Si el knowledge_base no cargó un doc relevante (lo verás en "Other Available Docs") o necesitas una sección puntual con detalle:
```
list_docs() // todos los docs con summary
read_doc({ name: "05-tables-and-fields" }) // doc completo
read_doc({ name: "06-hooks-and-cmsapi", section: "Hook middleware" }) // sección por heading H2
```
## Reglas globales para todas las tools
1. **`tableName` siempre SIN prefijo `cms_`** (excepto en `queryDB` Twig y en el `middleWare` de `set_hook_middleware`).
2. **PK siempre `num`**, nunca `id`. Foreign keys con sufijo `_num`.
3. **Upload fields son arrays** — accede con `[0].urlPath`.
4. **`fieldName` para imágenes** viene de `builder.json``vars.NOMBRE.relations.builder_custom` (ej. `image1`), NO del nombre de la variable.
5. **`recordId` para imágenes de módulo** es el `num` de `builder_custom`, NO el `sectionId`.
6. Tras `set_module_config_vars`, **TODAS** las variables (incluidos uploads) reciben `recordNum`+`fieldName` en `uploadFields`.
7. Si un token JWT expira (error 403): `refresh_acai_token` y reintentar.
8. Al pedir URLs del sitio: `get_web_url` SIEMPRE primero. Añade `?pruebas=1` para modo desarrollo. Nunca uses dominios de producción ni `localhost:8080`.
9. Antes de crear archivos, **lee la doc** relevante (`read_doc` si no está en el KB cargado).
10. Operaciones destructivas (`delete_*`, `dropColumn`, `dropData`, `dropTable`, `newTableName`, `newFieldName`, `regenerate_enlaces` sin alias, `set_global_libraries`, `set_layout_field`): **pide confirmación al usuario** si no es trivial.

View File

@@ -1,12 +1,10 @@
# Patrones de Producción
Patrones reales usados en módulos y secciones generales de producción. Usar como referencia al crear nuevos módulos.
Este documento recoge patrones reales usados en módulos y secciones generales de proyectos Acai en producción. Cada patrón incluye el HTML/Twig listo para reutilizar y notas sobre cuándo aplicarlo. Cubre: cabecera de sección con colores configurables, layout zigzag (imagen + texto alternado), acordeón FAQ, formulario de contacto completo con `c-form`, compartir en redes sociales, sección general de detalle de producto, galería con carousel modo `gallery`. Léelo cuando vayas a crear un módulo y quieras evitar reinventar patrones que ya tienen una versión canónica testeada en producción.
---
## 1. Cabecera de sección (Pretítulo + Título + Subtítulo)
## Patrón 1: Cabecera de Sección (Pretítulo + Título + Subtítulo)
Bloque de cabecera con colores y alineación configurables. Casi todos los módulos lo usan:
Bloque de cabecera con colores y alineación configurables. Casi todos los módulos lo usan como inicio.
```html
<div c-hidden="true">
@@ -52,11 +50,9 @@ Bloque de cabecera con colores y alineación configurables. Casi todos los módu
</section>
```
---
## 2. Layout Zigzag (imagen + texto alternado)
## Patrón 2: Layout Zigzag/Ajedrez (Imagen + Texto alternado)
Usa `loop.index is odd/even` para alternar:
Usa `loop.index is odd/even` para alternar la dirección.
```html
<li c-for="record in records" class="w-full py-6">
@@ -80,9 +76,7 @@ Usa `loop.index is odd/even` para alternar:
</li>
```
---
## Patrón 3: Acordeón FAQ
## 3. Acordeón FAQ
```html
<li data-field-type="multiv2" data-field-label="Records" data-aos="fade-up" data-aos-duration="800">
@@ -101,27 +95,27 @@ Usa `loop.index is odd/even` para alternar:
</li>
```
JavaScript para toggle:
```javascript
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".faq-page").forEach(faq => {
faq.addEventListener("click", function () {
`script.js`:
```js
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".faq-page").forEach((faq) => {
faq.addEventListener("click", () => {
const body = faq.nextElementSibling;
const isActive = faq.classList.toggle("active");
body.classList.toggle("hidden", !isActive);
AOS.refresh();
if (typeof AOS !== "undefined") AOS.refresh();
});
});
});
```
---
## Patrón 4: Formulario de Contacto Completo
## 4. Formulario de contacto completo
```html
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
<set :logo="tienda.logo.0.urlPath
? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath
: 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
{% set imagen = '<img src="' ~ logo ~ '" style="max-height:150px; display: block; margin: 0 auto;">' %}
{% set gracias = 'apartados' | get('num = 20').0 %}
@@ -136,19 +130,19 @@ document.addEventListener("DOMContentLoaded", function () {
<input type="text" name="nombre" required
placeholder="{{ 'Nombre' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1">
<input type="email" name="email" required
placeholder="{{ 'Email' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1">
<input type="text" name="telefono" required
placeholder="{{ 'Teléfono' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1">
<textarea name="comentario" cols="30" rows="5"
placeholder="{{ 'Escribe aquí tu comentario' | translate }}..."
class="w-full bg-white border border-neutral-400 rounded-xl resize-none px-6 py-2 my-1"></textarea>
<label class="w-full flex items-start mt-4">
<input required type="checkbox" class="mt-1" />
<input required type="checkbox" class="mt-1">
<span class="text-xs sm:text-sm ml-3">{{ 'Acepto las condiciones legales' | translate | raw }}</span>
</label>
@@ -163,9 +157,7 @@ document.addEventListener("DOMContentLoaded", function () {
</c-form>
```
---
## Patrón 5: Compartir en Redes Sociales
## 5. Compartir en redes sociales
```html
<ul class="flex flex-wrap -mx-1 mt-4">
@@ -184,12 +176,12 @@ document.addEventListener("DOMContentLoaded", function () {
</ul>
```
---
## 6. Sección general — Detalle de producto
## Patrón 6: Sección General — Detalle de Producto
Patrón completo para `template/estandar/modulos/custom-productos/index-base.tpl`:
```html
<set :tienda="'configuracion_tienda' | get('num !=0')[0]"></set>
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<section class="detalle-producto">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 mt-20 mb-10">
@@ -238,9 +230,7 @@ document.addEventListener("DOMContentLoaded", function () {
</section>
```
---
## Patrón 7: Galería con Carousel (modo Gallery)
## 7. Galería con carousel — modo Gallery
```html
<div class="c-tns-wrapper" data-autoplay-timeout="8000" data-mode="gallery" data-speed="400" data-nav="true">
@@ -260,3 +250,49 @@ document.addEventListener("DOMContentLoaded", function () {
</div>
</div>
```
## 8. Listado con filtros y paginación
```html
<set :categorias="'categorias' | get('visible=1', 'orden ASC')"></set>
{% set perPage = 12 %}
{% set page = pagina | default(1) %}
{% set offset = (page - 1) * perPage %}
<div class="container mx-auto max-w-7xl px-6 py-10">
<!-- Filtros -->
<nav class="flex flex-wrap gap-2 mb-8">
<a href="?cat=" class="px-4 py-2 rounded-full bg-gray-200">{{ 'Todas' | translate }}</a>
{% for cat in categorias %}
<a href="?cat={{ cat.num }}" class="px-4 py-2 rounded-full bg-gray-200">{{ cat.nombre }}</a>
{% endfor %}
</nav>
<!-- Listado -->
{% set where = 'visible=1' %}
{% if cat_filter %}
{% set where = where ~ ' AND categoria_num=' ~ cat_filter %}
{% endif %}
{% set productos = 'productos' | get(where, 'orden ASC', perPage) %}
<ul class="grid grid-cols-1 md:grid-cols-3 gap-6">
{% for prod in productos %}
<li class="bg-white rounded-xl shadow p-4">
<a href="{{ prod.enlace }}">
<img src="{{ prod.imagen[0].urlPath | imagec(400) }}" alt="{{ prod.nombre }}" class="w-full aspect-video object-cover rounded">
<h3 class="text-lg font-semibold mt-3">{{ prod.nombre }}</h3>
<p class="text-gray-600">{{ prod.descripcion | truncate(100) }}</p>
</a>
</li>
{% endfor %}
</ul>
</div>
```
## Reglas de aplicación
- Estos patrones son **referencia** — adáptalos al estilo del proyecto (ver `docs/project-styles.md` si existe).
- Reutiliza **clases utilitarias de Acai** (`bg-main-color`, `transition3s`, `lazyload`, `glightbox`, `c-tns-wrapper`) antes de inventar.
- Para campos editables siempre añade `data-field-label` (ver `01-builder-fields.md`).
- Para `c-form`, prefiere usarlo antes de construir POST/hook custom.
- Para detalle de registro usa **siempre** la convención `custom-{tableName}/`.

128
docs/11-quick-reference.md Normal file
View File

@@ -0,0 +1,128 @@
# Quick Reference — Cheat sheet
Este documento es un **resumen ejecutable** de las reglas críticas, los tipos de campo, los filtros Twig, los formatos de datos para insert/update y las variables globales. Es la **fuente única de verdad** para resolver dudas rápidas sin tener que abrir los docs largos. Léelo antes de cualquier operación cuando quieras refrescar las reglas; el resto de docs (`01``10`) profundizan en cada tema.
## Reglas inmutables
| Regla | Correcto | Incorrecto |
|-------|----------|------------|
| Nombres de tabla en tools/Twig/CmsApi | `'productos'` | `'cms_productos'` |
| Nombres en `queryDB` | `cms_productos` | `productos` |
| Primary key | `record.num` | `record.id` |
| Foreign keys | `categoria_num` | `categoria_id` |
| Upload fields | `record.imagen[0].urlPath` | `record.imagen` |
| Optimizar imagen | `imagen[0].urlPath \| imagec(800)` | `imagen.url` |
| Filtros Twig | `{{ 'tabla' \| get() }}` | `{{ get('tabla') }}` |
| Campo enlace | `{{ producto.enlace }}` (ya tiene barras) | `"/{{ producto.enlace }}/"` |
| Builder var name | `data-field-label` → minúsculas, sin espacios | Mantener casing original |
| Checkbox | `1` o `0` (número) | `true` / `false` |
| Formato fecha | `YYYY-MM-DD HH:mm:ss` | Cualquier otro |
| `c-if` igualdad | `c-if="x = 'valor'"` (un `=`) | `c-if="x == 'valor'"` |
| Twig `{% if %}` | `{% if x == 'valor' %}` (doble `==`) | `{% if x = 'valor' %}` |
| Concatenación Twig | `'value=' ~ variable` | `'value=' + variable` |
## Tipos de builder field (`data-field-type`)
| Tipo | Elemento | Devuelve |
|------|----------|----------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>``<h6>` | String + variable `_tag` |
| `textbox` | `<div>` | String multilínea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string |
| `upload` | `<img>` | Array `[{urlPath, info1, info2, info3, info4}]` |
| `uploadMulti` | `<li>` | Itera archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
| `checkbox` | `<input>` o `<div>` | `1` / `0` |
| `colorpicker` | `<div>` | Hex color |
## Atributos Acai
| Atributo | Uso | Ejemplo |
|----------|-----|---------|
| `c-if` | Condicional | `<p c-if="activo = 1">` |
| `c-else` | Rama else | `<p c-else>` |
| `c-for` | Loop array | `<li c-for="item in items">` |
| `c-for` (tabla) | Loop BD | `<li c-for="p in productos" c-where="'activo=1'" c-limit="10">` |
| `c-hidden` | Variable oculta | `<div c-hidden="true">` |
| `c-class` | Clase condicional | `<div c-class="{ 'bg-red': color == '1' }">` |
| `c-required` | Required condicional | `c-required="'2' not in camposquitar"` |
| `c-form` | Formulario | `<c-form tableName="'contacto'" captcha="true">` |
## Filtros Twig
| Filtro | Uso |
|--------|-----|
| `get` | `'tabla' \| get(where, order, limit)` |
| `queryDB` | `'SELECT ... FROM cms_tabla' \| queryDB()` |
| `hook` | `'hooks/module_id/' \| hook({params})` |
| `module` | `'module_id' \| module({params})` |
| `imagec` | `path \| imagec(width)` |
| `translate` | `'texto' \| translate` (tabla `textos_generales`) |
| `raw` | `variable \| raw` |
| `truncate` | `text \| truncate(100)` |
| `json_decode` | `'json_string' \| json_decode` |
| `default` | `variable \| default('fallback')` |
| `length`, `upper`, `lower`, `trim`, `replace`, `split`, `filter` | Estándar Twig |
## Formato de datos para insert/update
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| `textfield` | String | `"Texto"` |
| `textbox` | String multilínea | `"Línea 1\nLínea 2"` |
| `date`/datetime | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| `wysiwyg` | HTML string | `"<p>Texto</p>"` |
| `list` | String o número | `"activo"` o `"1"` |
| `checkbox` | Número 1/0 | `1` o `0` |
| `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` |
| `upload` | NO enviar — usar `upload_record_image` después |
## Variables globales
| Variable | Descripción |
|----------|-------------|
| `section_id` | ID único por instancia del módulo |
| `interno` | `true` dentro del editor CMS |
| `server.HTTP_HOST` | Dominio actual (sin protocolo) |
| `loop.index` | Índice 1-based en `c-for`/`{% for %}` |
| `loop.index is odd` / `is even` | Layouts alternados |
| `thisrecord` | Registro actual (solo en secciones generales) |
## Decisión rápida — qué tool usar
| Intención | Tool / workflow |
|-----------|-----------------|
| Crear módulo nuevo | `acai-write` `index-base.tpl``add_module_to_record``set_module_config_vars` |
| Editar template de módulo | `acai-view``acai-line-replace` |
| Ver datos de un módulo en una página | `get_module_config_vars` |
| Cambiar valores de un módulo | `set_module_config_vars` |
| Subir imagen a un módulo | Usa `uploadFields` de `set_module_config_vars``upload_record_image` (`tableName: "builder_custom"`) |
| Crear tabla nueva | `create_table` (pregunta `enlace`/`seoMetas` antes) → `create_field` |
| Crear detalle de registro | Sección general en `template/estandar/modulos/custom-{tableName}/` |
| Editar header / footer | `get_layout_field``set_layout_field` (NUNCA edites los `.tpl` directamente) |
| Añadir librería global | `list_global_libraries``add_global_library` (`top` o `bottom`) |
| Hook que se ejecuta antes de cada página | `acai-write` el `.php``set_hook_middleware({ middleWare: ["allurls"] })` |
| Generar imagen IA | `generate_image``upload_record_image` con `uploadUrl`/`fullUrl` |
| Buscar archivos | `acai-glob` |
| Buscar texto en archivos | `acai-grep` |
| URL del proyecto | `get_web_url` (añade `?pruebas=1`) |
| Navegar el preview del usuario | `navigate_browser` |
| Token JWT expirado (403) | `refresh_acai_token` |
| Necesito un doc no cargado | `read_doc({ name: "..." })` |
| Listado de docs | `list_docs()` |
## Errores comunes a evitar
- Editar `index.tpl`, `index-twig.tpl` o `builder.json` (autogenerados).
- Editar `layout.json` o `custom-header-twig/*` directamente (usa `set_layout_field`).
- Usar el `sectionId` como `recordId` para subir imágenes (es el `num` de `builder_custom`).
- Usar el nombre de la variable como `fieldName` (es el campo de relations: `image1`, no `imagenes`).
- Crear página por registro en `apartados` para detalles (usa `custom-{tableName}/`).
- Cambiar `enlace` o `controlador` de un registro existente.
- Usar `localhost:8080` o dominios de producción (siempre `get_web_url` + `?pruebas=1`).
- Crear archivos JSON de i18n (usa `| translate` + tabla `textos_generales`).
- Usar Twig dentro de `script.js` o `style.css` (estáticos — pasa valores via `data-*`).
- Llamar `mkdir` (usa `acai-write` directamente — crea el directorio padre).

View File

@@ -1,481 +0,0 @@
# Builder Fields & Acai Attributes
## Nombres de variables
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
Reglas obligatorias:
- Todo elemento editable con `data-field-type` debe incluir también `data-field-label`
- Si falta `data-field-label`, el builder puede generar variables temporales o incorrectas y el módulo queda mal configurado
- Usa labels descriptivos y estables; no dejes labels vacíos ni genéricos como "Campo" o "Texto"
Evita también en `index-base.tpl` las clases Tailwind con valores arbitrarios como `text-[44px]`, `font-['Cinzel']` o `leading-[1.1]`. En este stack pueden romper el parseo/compilación del template. Muévelas a `style.css`.
| Label | Variable |
|-------|----------|
| Categoría Noticia | `categoranoticia` |
| Color Principal | `colorprincipal` |
| Título Producto | `ttuloproducto` |
---
## Field Types (`data-field-type`)
| Type | Element | Returns |
|------|---------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + variable `_tag` con la etiqueta elegida |
| `textbox` | `<div>` | String multi-línea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string (ya incluye barras) |
| `upload` | `<img>` | **Array** de `{urlPath, info1, info2, info3, info4}` |
| `uploadMulti` | `<li>` | Itera sobre archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
### textfield
```html
<p data-field-type="textfield" data-field-label="Título">
Elemento editable
</p>
```
### headfield
Genera 2 variables: la estándar y otra con sufijo `_tag` con la etiqueta elegida por el usuario.
```html
<{{ title_tag | default('h2') }} data-field-type="headfield" data-field-label="Título Sección" class="text-3xl font-bold">
Título de la sección
</{{ title_tag | default('h2') }}>
```
### textbox
```html
<div data-field-type="textbox" data-field-label="Descripción">
Texto largo editable
</div>
```
### wysiwyg
```html
<div class="wysiwyg" data-field-type="wysiwyg" data-field-label="Contenido Enriquecido">
<p>Texto con <strong>estilos</strong> editables</p>
</div>
```
### link
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
Haz clic aquí
</a>
```
### upload
```html
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover object-center lazyload"
data-field-type="upload"
data-field-label="Imagen Principal"
data-lazy="true"
data-field-info1="titulo"
data-field-width="1400"
alt=""
>
</div>
```
Atributos disponibles:
- `data-lazy="true"`: Carga perezosa
- `data-field-width="1400"`: Ancho máximo sugerido
- `data-field-info1="titulo"`: Campo de información adicional (usado como alt)
Acceso en Twig: `{{ imagen[0].urlPath }}`, `{{ imagen[0].info1 }}`
### uploadMulti
Itera sobre todas las imágenes subidas:
```html
<li data-field-type="uploadMulti" data-field-label="Galería" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
```
### list (opciones fijas)
```html
<div
data-field-type="list"
data-field-label="Color Producto"
data-list-options="Rojo,Azul,|Verde,3|Amarillo"
>
</div>
```
Formato de opciones: `opcion1,opcion2,|opcion3,valor3|opcion4`
### list (tabla)
```html
<div
data-field-type="list"
data-field-label="Noticia Destacada"
data-list-table="noticias"
data-list-value="num"
data-list-label="titulo"
>
{{ record.titulo }}
</div>
```
- `data-list-table`: Nombre de tabla sin prefijo `cms_`
- `data-list-value`: Campo a usar como valor (generalmente `num`)
- `data-list-label`: Campo a mostrar como label
### multiv2 — Campos repetibles
```html
<ul>
<li data-field-type="multiv2" data-field-label="Productos">
<div data-field-type="textfield" data-field-label="Nombre">
Nombre del producto
</div>
<div data-field-type="textbox" data-field-label="Descripción">
Descripción del producto
</div>
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-field-type="upload"
data-field-label="Imagen"
data-lazy="true"
data-field-width="800"
alt=""
>
</div>
</li>
</ul>
```
Uso en Twig — las variables son propiedades del objeto iterado:
```twig
{% for record in productos %}
<div class="producto">
<h3>{{ record.nombre }}</h3>
<p>{{ record.descripcion }}</p>
<img src="{{ record.imagen[0].urlPath }}" alt="">
</div>
{% endfor %}
```
---
## Acai Attributes
### `c-if` — Renderizado condicional
```html
<!-- Verificar existencia de variable -->
<div c-if="subtitle">{{ subtitle }}</div>
<!-- Comparación de valores (usa = no ==) -->
<div c-if="layout = 'grid'">Grid layout</div>
```
### `c-else`
Debe ir inmediatamente después del elemento `c-if`:
```html
<div c-if="image">
<img src="{{ image[0].urlPath }}" />
</div>
<div c-else>
<p>No image available</p>
</div>
```
### `c-for` — Iteración sobre array
```html
<div c-for="item in record.features">
<h3>{{ item.title }}</h3>
</div>
```
### `c-for` — Iteración sobre tabla de BD
```html
<ul>
<li c-for="producto in productos" c-where="'visible=1'" c-order="'num desc'" c-limit="10">
{{ producto.title }}
</li>
</ul>
```
Parámetros opcionales: `c-where` (condición SQL), `c-order` (orden), `c-limit` (límite).
Equivalente en Twig:
```twig
{% for producto in 'productos' | get('visible=1','num desc',10) %}
<li>{{ producto.title }}</li>
{% endfor %}
```
Dentro del loop: `loop.index` (1-based), `loop.index is odd`, `loop.index is even`
### `c-class` — Clases CSS condicionales
```html
<!-- Simple -->
<div c-class="{ 'text-center': alineacion == '1', 'text-right': alineacion == '2' }">
<!-- Múltiples condiciones -->
<div c-class="{
'flex-row-reverse': orden == '1',
'cursor-pointer click-a-child': record.enlace_anchor,
'rounded-xl': radioborde == '4'
}">
<!-- Con expresiones Twig (loop) -->
<div c-class="{
'md:order-1': loop.index is odd,
'md:pl-6': loop.index is even
}">
<!-- Combinado con clases estáticas -->
<div class="flex items-center" c-class="{ 'justify-center': centrado }">
```
### `c-hidden` — Elementos ocultos
Elemento que no se renderiza pero puede declarar variables builder:
```html
<div c-hidden="true">
<input data-field-type="textfield" data-field-label="Config" value="default" />
</div>
```
### `c-required` — Campos requeridos condicionales
```html
<input type="text" name="telefono" c-required="'2' not in camposquitar" placeholder="Teléfono">
```
---
## Definiendo variables con `<set>`
```html
<!-- Obtener configuración de la BD -->
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<!-- Construir URLs dinámicas -->
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
<!-- Twig set para expresiones complejas -->
{% set gracias = 'apartados' | get('num = 20').0 %}
```
---
## Incluyendo módulos
Para incluir un módulo dentro de otro módulo o sección general, usa el ID del módulo como etiqueta HTML:
```html
<module_id :param1="value1" :param2="value2"></module_id>
```
Ejemplo:
```html
<header_menu :showLogo="true" :menuItems="items"></header_menu>
<product_card :product="selectedProduct" :showPrice="true"></product_card>
```
El módulo hijo recibe los parámetros como variables en su contexto.
---
## Formularios (`c-form`)
Manejo automático de validación, almacenamiento en BD y envío de emails.
```html
<c-form
class="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow"
tableName="'solicitudes'"
mailRecord="['correos', 'CONTACTO']"
sendTo="'contacto@empresa.com'"
sendToClient="'email'"
captcha="true"
honeypot="true"
messageOK="'¡Gracias! Te contactaremos pronto'"
messageKO="'Por favor, completa todos los campos'"
redirect="'/gracias'"
attachFiles="true"
>
<div class="mb-4">
<label class="block mb-2">Nombre</label>
<input name="nombre" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Email</label>
<input name="email" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Mensaje</label>
<textarea name="mensaje" class="w-full p-2 border rounded" rows="5" required></textarea>
</div>
<div class="mb-4">
<label class="flex items-center">
<input name="acepto_politica" type="checkbox" class="mr-2" required>
<span>Acepto la política de privacidad</span>
</label>
</div>
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">Enviar</button>
<captcha/>
</c-form>
```
### Atributos de c-form
| Atributo | Descripción |
|----------|-------------|
| `tableName="'table'"` | Tabla donde almacenar registros |
| `mailRecord="['correos', 'ID']"` | Template de email de la tabla `correos` |
| `sendTo="'email@domain.com'"` | Destinatarios (separados por coma) |
| `sendToClient="'campo_email'"` | Campo con email del cliente para auto-reply |
| `captcha="true"` | Google reCAPTCHA |
| `honeypot="true"` | Campo oculto anti-spam |
| `messageOK="'texto'"` | Mensaje de éxito |
| `messageKO="'texto'"` | Mensaje de error |
| `redirect="'/path/'"` | Redirección tras envío exitoso |
| `attachFiles="true"` | Adjuntar archivos al email |
| `showImages="true"` | Mostrar thumbnails en email |
| `emailMode="'twig'"` | Email en formato Twig |
| `header="'<div>...</div>'"` | HTML cabecera del email |
| `footer="'<div>...</div>'"` | HTML footer del email |
| `styles="'body { ... }'"` | CSS para el email |
---
## Componentes Built-in
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-speed="400"
data-nav="true"
data-autoplay-timeout="3000">
<div c-for="slide in record.slides">
<img src="{{ slide.image[0].urlPath }}" />
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb/>
```
### Animate On Scroll (AOS)
```html
<div data-aos="fade-up" data-aos-delay="200">
Animated content
</div>
```
### Lazy Loading
```html
<img class="lazyload" data-src="{{ image[0].urlPath }}" />
<!-- o -->
<img data-lazy="true" src="{{ image[0].urlPath }}" />
```
---
## Puntos importantes
1. **Nombres de variables:** `data-field-label` → sin espacios ni caracteres especiales, minúsculas
2. **Variables en multiv2:** Son propiedades del objeto iterado (`record.nombre`)
3. **Campos upload:** Retornan arrays, no strings (`imagen[0].urlPath`, no `imagen`)
4. **c-if usa `=` no `==`:** `c-if="layout = 'grid'"` (un solo igual)
5. **c-for tabla:** El nombre de tabla va sin prefijo `cms_`
6. **Enlace:** Ya incluye barras, no añadir extras
7. **Checkbox:** Valores `1` o `0`, no `true`/`false`
---
## MCP Tools: Config Vars e Imágenes de Módulos
### Regla importante: Siempre rellenar variables al añadir un módulo
Cuando se añade un módulo a una página (con `add_module_to_record`), este queda vacío y no muestra nada visible. **SIEMPRE** hay que llamar a `set_module_config_vars` inmediatamente después para rellenar las variables con contenido de ejemplo coherente con el contexto del sitio. Incluir:
- Textos (títulos, descripciones, pretítulos) con contenido relevante al sitio
- Valores de listas/selects con una opción válida
- Para variables multi (records), crear al menos 2-3 items de ejemplo
- Para variables de imagen (upload), usar `generate_image` o `upload_record_image` para que el módulo se vea completo
Un módulo sin variables configuradas es invisible en la web.
### Leer variables de un módulo
Antes de modificar cualquier módulo, usar `get_module_config_vars` para conocer el estado actual:
- **tableName**: tabla del registro padre (ej: `apartados`), SIN prefijo `cms_`
- **recordNum**: campo `num` del registro padre (ej: `2`)
- **sectionId**: el `section_id` de la instancia del módulo (ej: `6c6d8`)
### Escribir variables de un módulo
Usar `set_module_config_vars` con los mismos tableName, recordNum y sectionId. Pasar todos los valores como strings.
La respuesta incluye `configVars` con el `recordNum` del registro `builder_custom` creado/actualizado y `uploadFields` para imágenes.
**Tipos de almacenamiento (manejado automáticamente):**
- `headfield`, `textfield`, `link`, `textbox`, `wysiwyg`, `upload` → se guardan en tabla `builder_custom`
- `list`, `checkbox`, `colorpicker` → se guardan directamente en el JSON config-vars (no en builder_custom)
No necesitas preocuparte por esto — `set_module_config_vars` lo maneja internamente. Solo pasa los valores como strings.
### Subir imágenes a un módulo
El nombre del campo de imagen viene de `builder.json``vars.NOMBRE.relations.builder_custom` (ej: `"image1"`). NO es el nombre de la variable (ej: NO `"imagenes"`).
**Flujo correcto:**
1. `get_module_config_vars` → obtener el `recordNum` en builder_custom de la variable de imagen
2. `upload_record_image` con:
- `tableName`: `"builder_custom"` (siempre, sin prefijo cms_)
- `recordId`: el `recordNum` del paso 1 (ej: `"778"`)
- `fieldName`: el campo de relations del builder.json (ej: `"image1"`)
- `imageUrl`: URL completa accesible desde Docker
3. `reorder_record_uploads` si es necesario — pasar array de upload IDs en el orden deseado
4. `list_record_uploads` para verificar
**Errores comunes a evitar:**
- NO usar el sectionId como recordId — usar el `num` de builder_custom
- NO usar el nombre de la variable como fieldName — usar el campo de relations del builder.json (ej: `image1`, no `imagenes`)
- NO poner prefijo `cms_` en tableName

View File

@@ -1,261 +0,0 @@
# CSS & JavaScript Conventions
## Estructura del módulo
- Genera HTML + CSS + JS (o Vue 3 si es necesario)
- Define una clase raíz en kebab-case: `product-card`, `hero-section`, etc.
- Todo el CSS y JS scopeado bajo esa clase raíz
---
## CSS
### Tailwind First
Usar TailwindCSS como método principal. Solo CSS custom cuando Tailwind no cubra el estilo o se necesiten estados complejos/transiciones específicas.
```html
<div class="flex items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900">Title</h2>
</div>
```
### BEM para CSS Custom
Cuando se necesite CSS personalizado, siempre scopeado bajo la clase raíz con BEM:
```css
.hero-section { }
.hero-section__title { }
.hero-section__image { }
.hero-section--dark { }
```
Nunca usar clases globales sin prefijo de módulo.
### CSS Variables del tema
```css
var(--main-color) /* Color de marca primario */
var(--main-color-light) /* Variante clara */
var(--main-color-dark) /* Variante oscura */
```
### Estilos inline con fallbacks
Patrón para colores configurables por el usuario:
```html
<div style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<p style="color: {{ colordeltexto ? colordeltexto : '#111827' }}">
```
### Clases utilitarias de Acai
| Clase | Descripción |
|-------|-------------|
| `transition3s` | Transición suave 0.3s |
| `click-a-child` | Hace el padre clickeable via primer `<a>` hijo |
| `line-clamp2` / `line-clamp3` / `line-clamp5` | Truncar texto a N líneas |
| `filter-white` | Filtro CSS para hacer imágenes/iconos blancos |
| `lazyload` | Lazy loading (usar con `data-src`) |
| `text-shadow` | Sombra de texto para legibilidad sobre imágenes |
| `wysiwyg` | Wrapper para contenido de texto enriquecido |
| `bg-main-color` / `bg-main-color-light` / `bg-main-color-dark` | Fondos con color primario |
| `text-main-color` / `text-main-color-light` / `text-main-color-dark` | Texto con color primario |
---
## JavaScript
### Module Scripts (`script.js`)
JavaScript scopeado al módulo usando `section_id`:
This is the default and expected place for module JavaScript.
Do NOT embed `<script>` tags directly inside `index-base.tpl` for normal module behavior.
Keep `index-base.tpl` for HTML/Twig markup and put interactive logic in `script.js`.
`script.js` is a static file: do NOT use Twig syntax inside it.
Do NOT write `{{ section_id }}`, `{{ variable }}`, `{% if ... %}`, or builder attributes inside `script.js`.
If JavaScript needs dynamic values, pass them from `index-base.tpl` through `data-*` attributes:
```html
<section
class="buscador-apartados"
data-section-id="{{ section_id }}"
data-hook-endpoint="/hooks/buscadorapartados_hjd8s/">
</section>
```
```js
document.querySelectorAll('.buscador-apartados').forEach((section) => {
const sectionId = section.dataset.sectionId;
const hookEndpoint = section.dataset.hookEndpoint;
const buttons = section.querySelectorAll('.btn');
// ...
});
```
### CmsApi (Client-Side)
```js
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, function(response) {
console.log(response);
});
```
If you are calling a hook that belongs to the current module, the endpoint must use the real module id:
```js
// Module folder: template/estandar/modulos/buscadorapartados_hjd8s/
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, function(response) {
console.log(response);
});
```
Do not try to build this endpoint with Twig inside `script.js`. Put the final endpoint in a `data-hook-endpoint` attribute in `index-base.tpl` if needed.
### Module Styles (`style.css`)
`style.css` is also a static file.
Do NOT use Twig syntax or builder attributes inside it.
Do NOT write selectors or values that depend on `{{ section_id }}`.
Scope styles with the module root class instead of dynamic Twig ids.
### Cuándo usar Vue 3
Usar Vue 3 CDN cuando la lógica requiera:
- Doble binding / reactividad
- Solicitudes asíncronas complejas
- Componentes reutilizables
- Gestión de estado local
- Ciclos de vida
Para lógica simple, usar JavaScript vanilla.
### Vue 3 Integration
```html
<div id="app-{{ section_id }}">
<p>${ message }</p>
<button @click="increment">${ count }</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
delimiters: ['${', '}'], // Evitar conflicto con Twig {{ }}
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
}
}).mount('#app-{{ section_id }}');
</script>
```
Siempre usar `'${'` y `'}'` como delimitadores Vue para evitar conflicto con Twig.
---
## Variables Globales Disponibles
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| `section_id` | ID único por instancia del módulo | `<div id="{{section_id}}">` |
| `server.HTTP_HOST` | Dominio actual | `https://{{ server.HTTP_HOST }}/path` |
| `loop.index` | Índice de iteración (1-based) en c-for/for | `{{ loop.index }}` |
| `loop.index is odd` | True en iteraciones impares | Layouts alternados |
| `loop.index is even` | True en iteraciones pares | Patrones zigzag |
| `interno` | True dentro del editor CMS | `c-class="{'editor-mode': interno}"` |
### Patrón section_id
Cada instancia de módulo recibe un `section_id` único. Usar para navigation anchor e IDs:
```html
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido del módulo -->
</section>
```
---
## Componentes Nativos
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper" data-responsive="sm:2, md:3, lg:4" data-speed="1000" data-nav="true">
<ul class="c-tns-container">
<li data-field-type="multiv2" data-field-label="Slides" class="px-2">
<!-- contenido del slide -->
</li>
</ul>
</div>
```
| Atributo | Descripción | Ejemplo |
|----------|-------------|---------|
| `data-responsive` | Items por breakpoint | `"sm:2, md:3, lg:4"` |
| `data-autoplay-timeout` | Intervalo autoplay (ms) | `"5000"` |
| `data-mode` | Modo de transición | `"gallery"` o `"carousel"` |
| `data-speed` | Velocidad de transición (ms) | `"400"` |
| `data-nav` | Puntos de navegación | `"true"` |
Dots de navegación custom:
```html
<div class="c-tns-nav-container absolute bottom-4 left-0 w-full flex justify-center items-end z-20">
<div c-for="item in records"
class="pointer-events-auto cursor-pointer rounded-full border-2 border-white w-4 h-4 mx-1 bg-black bg-opacity-50">
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
```
### AOS (Animate On Scroll)
```html
<div data-aos="fade-up" data-aos-duration="800">Contenido</div>
```
Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `zoom-in-up`, `fade-up-right`, `fade-up-left`
Después de cambios dinámicos: `AOS.refresh()` en JavaScript.
### Lazy Loading
```html
<!-- Builder var con lazy loading -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-width="800" alt="">
<!-- Manual en templates -->
<img class="lazyload" data-src="{{ record.imagen[0].urlPath | imagec(800) }}" alt="">
```
---
## Buenas prácticas
- HTML/Twig semántico
- Código limpio y organizado
- Evitar dependencias externas innecesarias
- Evitar estilos inline salvo casos justificados (colores dinámicos del usuario)
- No usar clases globales sin prefijo de módulo

View File

@@ -1,389 +0,0 @@
# Hooks & Server-Side API
## Hooks
Hooks son archivos PHP que ejecutan lógica server-side. Pueden existir en dos sitios:
- Hooks globales en `hooks/hooks.<hook-id>.php`
- Hooks propios de módulo en `template/estandar/modulos/<module-id>/hook.php`
### Tipos de hooks
**1. Hook global**
- Archivo: `hooks/hooks.<hook-id>.php`
- Endpoint: `/hooks/<hook-id>/`
- Úsalo cuando la lógica se reutiliza entre módulos, páginas o formularios
**2. Hook propio de módulo**
- Archivo: `template/estandar/modulos/<module-id>/hook.php`
- Endpoint: `/hooks/<module-id>/`
- Úsalo cuando la lógica pertenece solo a ese módulo
Ejemplos:
- `hooks/hooks.buscar_barcos.php` -> `/hooks/buscar_barcos/`
- `template/estandar/modulos/hero_banner/hook.php` -> `/hooks/hero_banner/`
Regla práctica:
- Si el hook solo sirve a un módulo, créalo dentro del módulo
- Si varias piezas del proyecto lo van a consumir, créalo como hook global
## Reglas obligatorias para hooks
- Un hook debe devolver datos con `return [...]`
- No uses `echo json_encode(...)`
- No uses `exit`
- Para leer parámetros, usa `$_REQUEST[...]` o las variables ya inyectadas por el sistema
- En hooks, usa `CmsApi::get()` o `CocoDB::get()` como primera opción
- No uses `CocoDB::getInstance()` salvo necesidad real muy excepcional
- No escribas SQL manual con `prepare()/bind_param()` salvo que no exista forma razonable de resolverlo con `CmsApi` o `CocoDB`
### Estructura de un Hook
```php
<?php
// Los parámetros se reciben como variables directamente
// Ejemplo: Si llamas hook con {param1: 100}, tendrás $param1 = 100
$resultado = $param1 * 2;
// Retornar un array (se convierte a JSON)
return [
"success" => true,
"message" => "Valor procesado: " . $resultado,
"value" => $resultado
];
?>
```
### Testing Hooks
El Docker debe estar corriendo. Hacer curl al endpoint del hook:
```bash
curl {ACAI_WEB_URL}/hooks/example_hook/
```
(Use the project's actual URL, never localhost:8080)
No usar X-Hooks-Token en desarrollo local.
### Cómo Llamar Hooks
La referencia siempre se hace con el endpoint `/hooks/<id>/`:
- Para hooks globales, `<id>` es el nombre lógico del hook sin `hooks.` ni `.php`
- Para hooks de módulo, `<id>` es el `module-id` de la carpeta del módulo
**Desde HTML (recomendado para módulos):**
```html
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
<p>{{ myVar.message }}</p>
```
**Desde Twig:**
```twig
{% set resultado = 'hooks/mimodulo/' | hook({param1: 100, param2: 'texto'}) %}
<p>{{ resultado.message }}</p>
```
**Desde JavaScript:**
```js
CmsApi.hook('/hooks/mimodulo/', {param1: 100, param2: 'texto'}, (data) => {
console.log(data.message);
});
```
**Ejemplo real para hook de módulo:**
```js
// Módulo: template/estandar/modulos/buscadorapartados_hjd8s/
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, (data) => {
console.log(data);
});
```
**Desde otro Hook PHP:**
```php
<?php
$result = hook("/hooks/mimodulo/", ["param1" => 100, "param2" => "texto"]);
$mensaje = $result["message"];
?>
```
**Desde c-form:** Los hooks se ejecutan automáticamente al enviar el formulario si están configurados.
---
## CmsApi (PHP)
API server-side para operaciones de base de datos. Disponible en todos los hooks.
### Read — `CmsApi::get()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schema/<nombre_de_tabla>.ini.php
```php
// Todos los registros
$products = CmsApi::get("productos");
// Con condición WHERE en string
$active = CmsApi::get("productos", "active=1");
// Con orden y límite
$latest = CmsApi::get("noticias", "", "fecha DESC", 5);
// Con condición string
$activos = CmsApi::get("productos", "activo=1");
// Condición compleja
$caros = CmsApi::get("productos", "precio > 100");
// Múltiples condiciones (AND)
$resultados = CmsApi::get("productos", "activo = 1 AND stock > 0");
// Con operadores
$expensive = CmsApi::get("productos", "precio >= 100");
$search = CmsApi::get("productos", "nombre LIKE '%keyword%'");
$inList = CmsApi::get("productos", "categoria_num IN (1, 2, 3)");
#### Opciones de `get()`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['category']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | current lang | Código de idioma |
| `groupBy` | string | null | GROUP BY clause |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo campos específicos |
| `debug` | bool | false | Mostrar SQL query |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL de cache Redis (segundos) |
// Con opciones
$datos = CmsApi::get("productos", "", "", "", [
'translates' => true,
'uploads' => true,
'relations' => true,
'relationsDepth' => 2
]);
```
### Insert — `CmsApi::insert()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schema/<nombre_de_tabla>.ini.php
```php
// Un registro
CmsApi::insert('contacto', [
["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hello"]
]);
// Múltiples registros
CmsApi::insert('productos', [
["nombre" => "Producto A", "precio" => 100],
["nombre" => "Producto B", "precio" => 200]
]);
// Con retorno del último ID
CmsApi::insert('productos',
[["nombre" => "Nuevo", "precio" => 150]],
[],
['return_last_id' => true]
);
```
#### Opciones de insert
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### Update — `CmsApi::update()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schema/<nombre_de_tabla>.ini.php
```php
// Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1");
// Con condición array
CmsApi::update('productos',
["activo" => 1],
[["column" => "num", "operator" => "=", "value" => 1]]
);
// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");
```
#### Opciones de update
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### Delete — `CmsApi::delete()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schema/<nombre_de_tabla>.ini.php
```php
CmsApi::delete('productos', "num=5");
CmsApi::delete('productos',
[["column" => "activo", "operator" => "=", "value" => 0]]
);
```
### Reglas importantes
- Nombres de tabla **sin** prefijo `cms_`
- Primary key siempre es `num`, nunca `id`
- Foreign keys: `categoria_num`, no `categoria_id`
- Upload fields: no se manejan via insert/update
- Operadores: `=`, `!=`, `>`, `>=`, `<`, `<=`, `LIKE`, `IN`
---
## CmsApi (JavaScript — Client-Side)
```js
// Llamar hook
CmsApi.hook('/hooks/module_id/', { param: 'value' }, function(response) {
// response es la salida del hook
});
// Leer registros (si está expuesto via hooks)
CmsApi.get('tableName', { where: conditions }, function(records) {
// records array
});
```
---
## CocoDB
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
Para búsquedas y lecturas habituales, prioriza:
1. `CmsApi::get()`
2. `CocoDB::get()`
3. SQL manual solo si de verdad no hay alternativa razonable
### `CocoDB::get($table, $where, $order, $limit, $options)`
## Funcionalidad exactamente igual a CmsApi::get ( ver referencia CmsApi::get )
### `CocoDB::insertRecords($table, $records, $functions, $options)`
## Funcionalidad exactamente igual a CmsApi::insert ( ver referencia CmsApi::insert )
### `CocoDB::updateRecords($table, $records, $where, $functions, $options)`
## Funcionalidad exactamente igual a CmsApi::update ( ver referencia CmsApi::update )
### `CocoDB::deleteRecords($table, $where, $options)`
## Funcionalidad exactamente igual a CmsApi::delete ( ver referencia CmsApi::delete )
---
## Creación y Actualización de Registros
### Flujo correcto
1. Consultar el esquema de la tabla (leer `cms/data/schema/{tabla}.ini.php`)
2. Revisar los tipos de campo
3. Rellenar según el tipo de dato
4. Enviar con la estructura correcta
### Tipos de campo y formato
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| **Text field** | String | `"Texto"` |
| **Text box** | String multilínea | `"Línea 1\nLínea 2"` |
| **Date/time** | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| **Wysiwyg** | String HTML | `"<p class=\"font-bold\">Texto</p>"` |
| **List** | String o número | `"activo"` o `"1"` (num si es foreign key) |
| **Checkbox** | Número 1/0 | `1` o `0` |
| **Multivalores** | String JSON | `"[{\"producto\":\"1\"}]"` |
| **Upload** | **NO enviar** — usar `upload_record_image` después de crear el registro |
---
## Table Schemas
Los schemas están en `cms/data/schema/` como archivos `.ini.php`. Definen:
- Nombres y tipos de campo
- Reglas de validación
- Relaciones (foreign keys)
- Configuración de display
---
## Ejemplos Prácticos
### Hook de Cálculo de Precio
```php
<?php
// hook.php del módulo "calcular_precio"
$precioUnitario = 50;
if ($tipo === 'mayoreo' && $cantidad > 10) {
$precioUnitario *= 0.85; // 15% descuento
}
return [
"success" => true,
"precioUnitario" => round($precioUnitario, 2),
"total" => round($precioUnitario * $cantidad, 2),
"descuento" => $tipo === 'mayoreo' ? 15 : 0
];
?>
```
```html
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>Total: ${{ precio.total }}</p>
```
En este ejemplo, el endpoint usa `calcular_precio` porque el archivo vive en:
`template/estandar/modulos/calcular_precio/hook.php`
### Hook con Operaciones de BD
```php
<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get("productos", "num=" . $producto_id);
if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"];
}
$total = $producto[0]['precio'] * $cantidad;
// Crear venta
CmsApi::insert('ventas', [[
"usuario_num" => $usuario_id,
"producto_num" => $producto_id,
"cantidad" => $cantidad,
"total" => $total,
"fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);
// Actualizar stock
$stock = CmsApi::get("stocks", "producto_num=" . $producto_id);
if (!empty($stock)) {
CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad],
"producto_num=$producto_id"
);
}
return ["success" => true, "total" => $total];
?>
```

View File

@@ -1,159 +0,0 @@
# Acai CMS — Project Instructions
This is an Acai CMS website project. Follow these instructions when working with the codebase.
## Environment
- The site runs in Docker. **Before using fetch or Playwright, call the `get_web_url` tool** to get the correct development URL. Never hardcode or guess URLs.
- **ALWAYS append `?pruebas=1`** to any URL you visit (e.g. `http://.../?pruebas=1`). This query param is required to view the site in development mode.
- **NEVER navigate to the production domain** (e.g. `keepsailing.es`, `tienda.com`). The production site is NOT your development environment.
- **NEVER use localhost:8080 or https://** — use HTTP (not HTTPS) with the URL from `get_web_url`.
## Project Structure
```
.
├── template/estandar/
│ ├── modulos/ # Builder modules (visual components)
│ │ └── <module-id>/
│ │ ├── index-base.tpl # Twig template (source — EDIT THIS)
│ │ ├── style.css # Module styles
│ │ └── script.js # Module JavaScript
│ │ ├── index.tpl # Compiled (auto-generated, do NOT edit)
│ │ ├── index-twig.tpl # Compiled (auto-generated, do NOT edit)
│ │ └── builder.json # Compiled builder vars (auto-generated, do NOT edit)
│ ├── css/ # Global CSS
│ └── js/ # Global JavaScript
├── hooks/ # PHP hooks (server-side logic)
├── cms/
│ ├── data/schema/ # Database table schemas (JSON)
│ ├── lib/plugins/ # CMS plugins
│ └── uploads/ # Uploaded media files
├── .acai # Project config (domain, tokens, DB credentials)
├── .docker/
│ ├── .env # Docker environment (DB credentials)
│ ├── docker-compose.yml
│ ├── tunnel-url.txt # Public tunnel URL (if active)
│ └── bore-db-url.txt # Database tunnel URL (if active)
└── database.sql # Database dump
```
## Key Concepts
### Modules (`template/estandar/modulos/`)
Visual components that the site builder uses. Each module is a self-contained unit with its own template (Twig + Acai attributes), CSS, and JS. Modules are placed on pages via the drag-and-drop builder. The editable file is always `index-base.tpl`.
- Include other modules: `<module_id :param1="value1"></module_id>`
- Each module instance gets a unique `section_id` variable for anchors/scoping
- Use `interno` variable to detect CMS editor mode vs public view
See [docs/modular-system.md](docs/modular-system.md) for detailed rules.
### Pages
Every record with an `enlace` field is a page. Pages are either **Builder** (modular) or **Standard**:
- **Builder**: `controlador` = `cms/lib/plugins/builder_saas/controlador.php` — content via modules
- **Standard**: `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php` — content in record fields
**Critical**: Never change `enlace` or `controlador` of existing pages unless explicitly asked.
See [docs/pages-and-records.md](docs/pages-and-records.md) for full details.
### General Sections
Database-backed templates (headers, footers, record views) that use the `thisrecord` variable to access record fields. They use the same Twig + Acai attribute engine as modules.
- Upload fields return arrays: `thisrecord.image[0].urlPath`
- Foreign keys use `_num` suffix: `category_num`
See [docs/modular-system.md](docs/modular-system.md) for details.
### Hooks (`hooks/`)
PHP files that execute server-side logic. Triggered by:
- Twig filter: `'hooks/module_id/' | hook({param: value})`
- HTML tag: `<hook result="var" endpoint="/hooks/module_id/" :param="value"></hook>`
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute
There are two valid hook locations:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
How to reference them:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
Rule of thumb:
- If the logic is only used by one module, prefer that module's `hook.php`
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
## Database Access
When the site is running in Docker, you can connect to the database:
- **Host:** `127.0.0.1`
- **Port:** Check `.docker/docker-compose.yml` for the mapped port (usually 3307+)
- **Credentials:** Read from `.docker/.env`:
- `DB_USERNAME`
- `DB_PASSWORD`
- `DB_DATABASE`
```bash
docker exec -it dw-<project-name>-db mysql -u root -p<password> <database>
```
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`.
## Acai Core (web-base)
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate directory called **web-base** that is mounted as a Docker volume.
The web-base path can be obtained via: `GET http://localhost:9090/api/web-base-path`
Do NOT modify web-base files — they are shared across all projects.
## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
6. Use Twig **filters** (with `|`), never Twig functions
7. Table names without `cms_` prefix everywhere
8. Primary key is `num`, never `id`
9. Upload fields are arrays — access with `[0].urlPath`
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
## MCP Tools
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.**
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
Key workflows:
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl``add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
- **Edit module**: `acai-view``acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image``upload_record_image` with returned URL
## Documentation
- [docs/modular-system.md](docs/modular-system.md) — Modules, general sections, global variables
- [docs/builder-fields.md](docs/builder-fields.md) — Builder field types, Acai attributes, c-form, components
- [docs/twig-filters.md](docs/twig-filters.md) — Twig filters reference (get, hook, module, queryDB, etc.)
- [docs/hooks-and-api.md](docs/hooks-and-api.md) — PHP hooks, CmsApi, CocoDB, record creation
- [docs/css-js-conventions.md](docs/css-js-conventions.md) — CSS/JS/Vue 3, Tailwind, BEM, native components
- [docs/quick-reference.md](docs/quick-reference.md) — Cheat sheet: domain rules, field types, filters
- [docs/production-patterns.md](docs/production-patterns.md) — Real production patterns (header, zigzag, FAQ, forms)
- [docs/vue-builder-rules.md](docs/vue-builder-rules.md) — CMS-VUE rules (tabs, colorpicker, components)
- [docs/vue-builder-examples.md](docs/vue-builder-examples.md) — Vue builder examples (Banner Slideshow, etc.)
- [docs/pages-and-records.md](docs/pages-and-records.md) — Page types (Builder vs Standard), sections, visibility, critical rules
- [docs/module-creation-guide.md](docs/module-creation-guide.md) — Module creation workflow, style reference, field types
- [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) — MCP tools reference, available tools, workflows

View File

@@ -1,159 +0,0 @@
# MCP Tools Reference
## Quick Reference
| Tool | Categoría | Acción |
|------|-----------|--------|
| `compile_module` | Módulos | Recompilación manual de rescate cuando necesitas forzar la compilación sin editar el archivo |
| `check_module` | Módulos | Preview de cómo renderiza un módulo |
| `check_module_usage` | Módulos | Qué páginas usan un módulo |
| `acai-view` | Archivos | Lee un archivo del proyecto por tramos |
| `acai-glob` | Archivos | Busca rutas de archivos por patrón |
| `acai-grep` | Archivos | Busca texto en archivos del proyecto |
| `acai-line-replace` | Archivos | Reemplaza un bloque concreto en un archivo existente |
| `acai-write` | Archivos | Crea o reescribe un archivo completo. Antes de usarlo, lee la doc correspondiente según el tipo de archivo (`module-creation-guide`, `builder-fields`, `css-js-conventions`, `hooks-and-api`) |
| `acai-delete` | Archivos | Borra un archivo del proyecto |
| `list_page_modules` | Registros | Lista módulos de una página |
| `add_module_to_record` | Registros | Añade módulo a una página |
| `remove_module_from_record` | Registros | Elimina módulo de una página |
| `reorder_module` | Registros | Cambia posición de un módulo |
| `toggle_module_visibility` | Registros | Muestra/oculta módulo |
| `get_module_config_vars` | Registros | Lee variables de un módulo |
| `set_module_config_vars` | Registros | Escribe variables de un módulo |
| `list_table_records` | Registros | Buscar/listar registros con filtros |
| `get_record` | Registros | Obtener un registro por num |
| `create_or_update_record` | Registros | Crear o actualizar registros |
| `delete_table_records` | Registros | Eliminar registros (destructivo) |
| `upload_record_image` | Media | Subir imagen a campo de registro (desde URL) |
| `generate_image` | Media | Generar imagen con IA y guardar en uploads |
| `upload_image_to_assets` | Media | Subir imagen a /images/ del template |
| `list_record_uploads` | Media | Listar uploads de un campo |
| `replace_record_image` | Media | Reemplazar imagen existente |
| `delete_record_upload` | Media | Borrar upload |
| `reorder_record_uploads` | Media | Reordenar imágenes de un campo |
| `refresh_acai_token` | Auth | Renovar token JWT expirado |
| `navigate_browser` | Navegación | Navegar el browser del frontend a una URL |
| `save_project_styles` | Proyecto | Guardar resumen de estilos en docs/project-styles.md |
| `rollback_git` | Git | Recuperar cambios de git remoto |
| `get_layout_field` | Layout | Lee el source de los campos globales del layout.json: style, javascript, header, footer |
| `set_layout_field` | Layout | Reemplaza un campo global del layout.json. **USA ESTA TOOL** para editar header/footer — NO toques los .tpl directos |
## Flujos de trabajo
### Crear un módulo nuevo desde cero
1. `acai-write` — Crea `index-base.tpl`, `style.css`, `script.js` y cualquier hook necesario con rutas relativas al proyecto
2. `acai-write` o `acai-line-replace` compilan automáticamente al tocar `index-base.tpl`
3. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`)
4. `set_module_config_vars` — Rellena las variables con contenido (textos, colores, opciones). **OBLIGATORIO** — sin esto el módulo no muestra nada. Devuelve:
- `configVars`: mapa de variables → recordNums
- `uploadFields`: mapa de variables upload → `{ fieldName, recordNum }`**usa estos directamente** para subir imágenes sin necesidad de leer builder.json
- Para vars multi con uploads: `uploadFields["varName.subVarName"]` es un array con `[{ index, fieldName, recordNum }]`
5. Para imágenes: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del `uploadFields` devuelto en el paso 4
6. Verificar con `check_module` o recargando la página
### Editar un módulo existente
1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums)
2. `acai-view` — Leer solo el tramo de `index-base.tpl` que se va a modificar
3. `acai-line-replace` — Editar el bloque concreto. Usa `acai-write` solo si el archivo es nuevo o el cambio es masivo
4. La compilación es automática tras cada edición de `index-base.tpl`
5. Si cambias variables: `set_module_config_vars` para actualizar valores
### Editar archivos del proyecto con bajo consumo de tokens
1. `acai-view` — Leer el archivo o un rango de líneas
2. `acai-glob` — Encontrar archivos relevantes por ruta cuando no conoces el path exacto
3. `acai-grep` — Buscar texto o atributos concretos dentro de archivos del proyecto
4. `acai-line-replace` — Reemplazar el bloque exacto en archivos existentes
5. `acai-write` — Crear archivos nuevos o reescribirlos por completo si es necesario
6. `acai-delete` — Borrar archivos solo cuando sea explícitamente necesario
Reglas:
- Usa siempre rutas relativas al proyecto
- No edites `index.tpl`, `index-twig.tpl` ni `builder.json` — son auto-generados
- Tras editar cualquier `index-base.tpl` con las file tools, la compilación se ejecuta automáticamente
### Añadir/modificar imágenes de un módulo
**Tras `set_module_config_vars`** (método recomendado — sin pasos extra):
1. El response de `set_module_config_vars` incluye `uploadFields` con los `recordNum` y `fieldName` de cada variable upload
2. `upload_record_image` con `tableName: "builder_custom"`, `recordId` y `fieldName` del `uploadFields`
3. Para uploads dentro de vars multi: `uploadFields["records.imagen"]` devuelve array con `{ index, fieldName, recordNum }` por cada record
**Sin haber llamado a `set_module_config_vars`**:
1. `get_module_config_vars` — Obtener el `recordNum` de builder_custom
2. Leer `builder.json` del módulo para encontrar el `fieldName` real (ej: `image1`, NO el nombre de la variable)
3. `upload_record_image` con:
- `tableName`: `"builder_custom"` (siempre sin cms_)
- `recordId`: el recordNum del paso 1
- `fieldName`: el campo de relations del builder.json (ej: `image1`)
- `imageUrl`: usa la URL recomendada por la tool que generó/subió la imagen. En Forge, si `generate_image` devuelve `uploadUrl` o `fullUrl`, priorízala frente a `dockerUrl`
### Generar imagen con IA
1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...)
2. La imagen se guarda en `cms/uploads/generated/` y devuelve una URL local de preview (`dockerUrl`) y, cuando aplica, una URL recomendada para subida (`uploadUrl` / `fullUrl`)
3. Para `upload_record_image`, usa la URL recomendada por la tool. En Forge, prioriza `uploadUrl` o `fullUrl` si están presentes
### Gestionar registros de una tabla
1. `list_table_records` — Buscar registros con filtros (`where`, `order`, `limit`)
2. `get_record` — Obtener un registro completo por num
3. `create_or_update_record` — Crear o actualizar (la tabla sin prefijo `cms_`, PK es `num`)
4. `delete_table_records` — Eliminar por IDs
### Explorar el sitio
1. `list_page_modules` — Ver qué módulos tiene cada página
2. `get_module_config_vars` — Ver los datos de cada módulo
3. `check_module` — Preview de cómo renderiza
## Reglas importantes para todas las tools
1. **tableName** siempre SIN prefijo `cms_` (ej: `apartados`, no `cms_apartados`)
2. **Primary key** es siempre `num`, nunca `id`
3. **Uploads** son arrays — acceder con `[0].urlPath`
4. **fieldName de imágenes** viene de `builder.json``vars.NOMBRE.relations.builder_custom` (ej: `image1`), NO del nombre de la variable
5. **recordId para imágenes** es el `num` de `builder_custom`, NO el sectionId del módulo
6. Tras `set_module_config_vars`, TODAS las variables del módulo (incluyendo upload) reciben config-vars automáticamente
7. Si el token expira (error 403), usar `refresh_acai_token`
## Layout global (header, footer, style, javascript)
Los 4 campos globales del proyecto (`style.css`, `script.js`, `header`, `footer`) viven en `cms/lib/plugins/builder_saas/layout.json`.
### REGLA CRÍTICA
**NUNCA uses `acai-view`, `acai-line-replace`, `acai-write` ni `acai-delete` sobre**:
- `cms/lib/plugins/builder_saas/layout.json`
- `template/estandar/modulos/custom-header-twig/*`
- `template/estandar/modulos/custom-footer-twig/*`
- `template/estandar/modulos/custom-header/*`
- `template/estandar/modulos/custom-footer/*`
Esos ficheros son **artefactos generados** a partir del `layout.json`. Editarlos directamente provoca:
- Desincronización con `layout.json.{header,footer}ModuleCustom.htmlParsed`.
- Sobrescritura de tus cambios cuando el usuario abre el builder visual y guarda.
- Comportamiento inconsistente entre el render público y el builder.
### Workflow correcto
Para leer:
```
get_layout_field({ field: "header" }) // devuelve el source Twig del header
get_layout_field({ field: "footer" })
get_layout_field({ field: "style" }) // CSS global
get_layout_field({ field: "javascript" }) // JS global
```
Para editar:
```
set_layout_field({ field: "footer", content: "<footer>...nuevo HTML/Twig...</footer>" })
```
El backend:
1. Escribe el source en `layout.json.{field}`.
2. Sincroniza `layout.json.{field}ModuleCustom.htmlParsed`.
3. Regenera los `.tpl` del módulo `custom-{field}-twig/`.
4. Compila el Twig a PHP.

View File

@@ -1,105 +0,0 @@
# Acai Modular System
## Modules
Modules are the visual building blocks of Acai websites. Each module lives in `template/estandar/modulos/<module-id>/`.
### File Structure
```
<module-id>/
├── index-base.tpl # Source template (EDIT THIS)
├── index.tpl # Compiled output (auto-generated, do NOT edit)
├── index-twig.tpl # Compiled Twig output (auto-generated, do NOT edit)
├── builder.json # Compiled builder vars (auto-generated, do NOT edit)
├── style.css # Module-scoped styles
└── script.js # Module JavaScript
```
### Template Syntax
Templates use a hybrid of **Twig** and **Acai attributes**. The source file is always `index-base.tpl`.
```html
<section class="hero-section" id="{{ section_id }}">
<div class="container mx-auto px-4">
<h2 data-field-type="headfield" class="text-3xl font-bold">
Title here
</h2>
<p data-field-type="textbox" class="text-lg text-gray-600">
Description text
</p>
<img data-field-type="upload" src="placeholder.jpg" class="w-full rounded-lg" />
<a data-field-type="link" href="#" class="btn">Call to action</a>
</div>
</section>
```
### Including Modules from Other Modules
```html
<module_id :param1="value1" :param2="'string value'"></module_id>
```
Parameters are received as variables inside the included module.
### Global Variables
| Variable | Description |
|----------|-------------|
| `section_id` | Unique ID per module instance (use for anchors, JS scoping) |
| `interno` | `true` when viewing in CMS editor, `false` on public site |
| `server.HTTP_HOST` | Current domain |
| `loop.index` | 1-based iteration index (inside `c-for`) |
| `loop.index is odd` / `loop.index is even` | For alternating layouts |
## General Sections
General sections are database-backed templates used for record views, headers, footers, and reusable layouts. They use the same template engine as modules.
### Key Differences from Modules
- Access record data via the `thisrecord` variable
- Upload fields return **arrays**: `thisrecord.image[0].urlPath`
- Additional upload metadata: `info1` (alt text), `info2`, `info3`, `info4`
- Foreign key fields use `_num` suffix: `thisrecord.category_num`
- Saved via `save_general_section()` (not `save_module()`)
- Parser type 2 = Twig (recommended), 0 = Acai legacy syntax
### Example: Record Template
```html
<article class="product-card">
<img src="{{ thisrecord.imagen[0].urlPath }}"
alt="{{ thisrecord.imagen[0].info1 }}"
class="w-full h-64 object-cover" />
<h3 class="text-xl font-semibold">{{ thisrecord.nombre }}</h3>
<p class="text-gray-600">{{ thisrecord.descripcion | raw }}</p>
<span class="text-2xl font-bold">{{ thisrecord.precio }}€</span>
</article>
```
### Variable Assignment
Use `<set>` tag to create variables from queries:
```html
<set :categories="'categorias' | get()"></set>
<set :featured="'productos' | get({destacado: 1}, 'orden ASC', 3)"></set>
```
## Repeatable Content (multiv2)
The `multiv2` builder field type creates repeatable groups of fields:
```html
<div c-for="item in record.items">
<h3 data-field-type="textfield">{{ item.title }}</h3>
<p data-field-type="textbox">{{ item.description }}</p>
<img data-field-type="upload" src="{{ item.image }}" />
</div>
```
Access individual items: `record.items[0].title`, `record.items[1].image`, etc.

View File

@@ -1,100 +0,0 @@
# Module Creation Guide
## Style Reference
When creating new modules, you MUST match the visual style of the existing project. Follow these steps IN ORDER:
### Step 1: Check for `docs/project-styles.md`
- If the file exists → read it and use it as your style reference. DONE — skip to module creation.
- If the file does NOT exist → continue to Step 2.
### Step 2: Determine if exploration is needed
- Count modules in `template/estandar/modulos/` that have `builder.json` and do NOT start with `custom-`
- If 3+ qualifying modules exist → continue to Step 3
- If fewer than 3 → skip exploration, create the module based on the user's description. The style will be defined as modules are created.
### Step 3: Explore and GENERATE the style guide (MANDATORY)
- Read `index-base.tpl` and `style.css` of 3-4 representative modules (only those with `builder.json`, skip `custom-*`)
- **You MUST then call `save_project_styles`** with a markdown summary including:
- Primary/secondary/accent colors (hex values)
- Font families and sizes used
- Spacing scale (padding/margin patterns)
- Common Tailwind classes and custom CSS patterns
- Button styles, card styles, section layouts
- Any recurring design patterns (gradients, shadows, borders, etc.)
- This saves `docs/project-styles.md` which will be read by future module creation tasks — no re-exploration needed.
**After creating a module:** if `docs/project-styles.md` does not exist yet and there are now 3+ modules, call `save_project_styles`.
## Module Structure
Each module lives in `template/estandar/modulos/<moduleId>/` with:
- `index-base.tpl` — Twig template (source — EDIT THIS)
- `style.css` — Module styles
- `script.js` — Module JavaScript
- `builder.json` — Compiled builder vars (auto-generated, do NOT edit)
- `index.tpl` / `index-twig.tpl` — Compiled (auto-generated, do NOT edit)
## Creating a Module — Full Workflow
If the module needs JavaScript, you MUST read `docs/css-js-conventions.md` before writing `index-base.tpl` or `script.js`.
If the module needs server-side logic, dynamic data processing, form handling, or reusable backend behavior, you MUST read `docs/hooks-and-api.md` before creating `hook.php` or any global hook.
If the module will call hooks from Twig, also review `docs/twig-filters.md` for the `hook` filter syntax.
If `index-base.tpl` contains builder fields (`data-field-type`), you MUST review `docs/builder-fields.md` before writing the template.
Hard rules for module files:
- `index-base.tpl` is for HTML/Twig only
- `script.js` is for module JavaScript
- `style.css` is for module CSS
- `hook.php` is for server-side logic
- Do NOT embed `<script>` tags in `index-base.tpl`
- Do NOT put PHP logic in `index-base.tpl`
- `script.js` and `style.css` are static files: do NOT use Twig syntax inside them
- Do NOT use `{{ ... }}`, `{% ... %}`, `c-if`, `c-for`, or builder attributes inside `script.js` or `style.css`
- If JavaScript needs dynamic values such as `section_id` or a hook endpoint, expose them from `index-base.tpl` via `data-*` attributes
- Every editable builder field with `data-field-type` MUST also define `data-field-label`
- Avoid Tailwind arbitrary-value syntax such as `text-[...]`, `font-[...]`, `leading-[...]`, `bg-[...]` inside `index-base.tpl`; move those styles to `style.css`
1. **Read style reference** (steps above)
2. **`acai-write`** — Create the module files directly (`index-base.tpl`, `style.css`, `script.js`, optional `hook.php`) using project-relative paths and complete file contents.
- If the server-side logic belongs only to that module, create `template/estandar/modulos/<module-id>/hook.php`
- If the logic should be reused across modules/pages, create a global hook in `hooks/hooks.<hook-id>.php`
- Inside the module, reference its own hook with `/hooks/<module-id>/`
- Example: module folder `template/estandar/modulos/buscadorapartados_hjd8s/` -> hook endpoint `/hooks/buscadorapartados_hjd8s/`
3. **Automatic compile** — Writing `index-base.tpl` automatically creates the generated template placeholders and triggers compilation. `compile_module` is only a manual recovery tool if you need to force a recompile without changing the file.
4. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step.
5. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable.
6. **Upload images** — Use `generate_image` then `upload_record_image` with the `recordNum` and `fieldName` from step 5's `uploadFields`. No need to read builder.json or call get_module_config_vars.
7. **`navigate_browser`** — Navigate to the page so the user can see the result.
## HTML Field Types
Use these `data-field-type` attributes in `index-base.tpl`:
| Attribute | Purpose | Example |
|-----------|---------|---------|
| `headfield` | Editable heading | `<h2 data-field-type="headfield">Title</h2>` |
| `textfield` | Short editable text | `<span data-field-type="textfield">Text</span>` |
| `wysiwyg` | Rich text editor | `<div data-field-type="wysiwyg">Content</div>` |
| `upload` | Image upload | `<img data-field-type="upload" src="...">` |
| `list` | Select dropdown | `<div data-field-type="list" data-options="opt1,opt2">` |
| `multiv2` | Repeater/records | `<div data-field-type="multiv2">...</div>` |
| `checkbox` | Toggle | `<div data-field-type="checkbox">` |
| `colorpicker` | Color picker | `<div data-field-type="colorpicker">` |
## MJML Modules
Modules with `MJMLModule: true` in their schema are email modules:
- Only appear when the page table is `mail_marketing`
- For `mail_marketing` tables, only MJML modules are shown
- Use MJML markup instead of standard HTML
## Key Rules
- Always use Tailwind CSS as primary styling
- Use `section_id` variable for unique anchors/scoping
- Use `interno` variable to detect CMS editor vs public view
- Include other modules with: `<module_id :param1="value1"></module_id>`
- Editing `index-base.tpl` with `acai-write` or `acai-line-replace` compiles automatically
- Twig uses filters (with `|`), never functions
- Twig concatenation uses `~`: `'value=' ~ variable`

View File

@@ -1,104 +0,0 @@
# Pages & Records Guide
## Page Types
Every CMS record that has an `enlace` (URL) field is a **page**. Pages come in two types determined by the `controlador` field:
### Builder (Modular) Pages
- `controlador` = `cms/lib/plugins/builder_saas/controlador.php`
- Content is built from **modules** (drag & drop components)
- The `builder` field contains a JSON array of module instances
- Use MCP tools: `add_module_to_record`, `set_module_config_vars`, etc.
- The page template renders modules in order from the builder JSON
### Standard Pages
- `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php`
- Content lives directly in the record fields (`content`, `titulo_alternativo`, etc.)
- The `content` field is HTML (wysiwyg)
- Use `create_or_update_record` to edit content directly
- No modules involved
### How to determine page type
**Always check the `controlador` field** of the record:
- Contains `controlador.php` (without `_tabla`) → **Builder**
- Contains `controlador_tabla.php`**Standard**
## Table Types (Sections)
Tables with pages are called **sections**. There are two section types defined by `menuType` in the schema:
### Category (`menuType = "category"`)
- **Hierarchical** — pages have parent/child relationships
- Fields: `parentNum`, `depth`, `globalOrder`, `lineage`, `siblingOrder`
- Example: `apartados` (main site pages)
- Uses `visible_en_el_menu` field for menu visibility
- Ordered by `globalOrder`
### Multi (`menuType = "multi"`)
- **Flat list** — no hierarchy
- Uses `dragSortOrder` for ordering
- Example: `blog`, `travesias`
- Typically uses `visible` field (not `visible_en_el_menu`)
## Critical Rules for Pages
### NEVER change the `enlace` field
Unless the user explicitly asks to change a page URL, **never modify the `enlace` field**. Changing it breaks existing links, SEO, and navigation. The enlace is set when the page is created and should remain stable.
### NEVER change the `controlador` field
The controlador defines whether the page is Builder or Standard. Changing it breaks the page rendering. Only set it during page creation.
### Visibility fields
- `apartados` and other category tables use: `visible_en_el_menu` (1 = visible, 0 = hidden)
- `blog`, `travesias` and other multi tables use: `visible` (1 = visible, 0 = hidden)
- Always check which field the table has before toggling visibility
### Name/Title fields
- Some tables use `name` (e.g. `apartados`)
- Others use `title` (e.g. `blog`, `travesias`)
- Check the schema to know which one to use
## Working with Builder Pages
### Adding content to a new Builder page
1. List available modules: `list_available_modules`
2. Add modules: `add_module_to_record` (one at a time, in order)
3. Configure each module: `set_module_config_vars` with content
4. Add images if needed: `upload_record_image` or `generate_image`
### Editing an existing Builder page
1. List current modules: `list_page_modules`
2. Get module vars: `get_module_config_vars`
3. Update vars: `set_module_config_vars`
4. Or edit the module template: edit `index-base.tpl` with `acai-line-replace` or `acai-write` → compilation runs automatically
## Working with Standard Pages
### Adding content to a Standard page
Use `create_or_update_record` to set:
- `content` — HTML content (main body)
- `titulo_alternativo` — alternative title shown on the page
- `titulo_de_pagina` — browser tab title (SEO)
- `metatag_descripcion` — meta description (SEO)
### Example: Update a standard page
```
create_or_update_record with:
tableName: "apartados"
recordNum: "87"
fields:
content: "<h2>Our Services</h2><p>We offer...</p>"
titulo_de_pagina: "Services | My Site"
metatag_descripcion: "Discover our services..."
```
## The `apartados` Table (Special)
The `apartados` table is the main pages table in most Acai sites. Key characteristics:
- `menuType = "category"` — hierarchical with parent/child
- `parentNum` — the num of the parent page (0 = root level)
- `depth` — nesting level (0 = root, 1 = child, 2 = grandchild)
- `globalOrder` — display order across the entire tree
- `visible_en_el_menu` — whether the page shows in the navigation menu
- `breadcrumb` — auto-generated breadcrumb path
- Pages can be either Builder or Standard (check `controlador` field per record)

View File

@@ -1,85 +0,0 @@
# Quick Reference
## Reglas Críticas
| Regla | Correcto | Incorrecto |
|-------|----------|------------|
| Nombres de tabla | `'productos'` | `'cms_productos'` |
| Primary key | `record.num` | `record.id` |
| Foreign keys | `categoria_num` | `categoria_id` |
| Upload fields | `record.imagen[0].urlPath` | `record.imagen` |
| Optimizar imagen | `record.imagen[0].urlPath \| imagec(800)` | `record.imagen.url` |
| Filtros Twig | `{{ 'table' \| get() }}` | `{{ get('table') }}` |
| Campo enlace | `{{ producto.enlace }}` (ya tiene barras) | `"/{{ producto.enlace }}/"` |
| Nombres builder vars | `data-field-label` → sin espacios/especiales, minúsculas | Mantener casing original |
| Checkbox | `1` o `0` (número) | `true`/`false` |
| Formato fecha | `YYYY-MM-DD HH:mm:ss` | Cualquier otro formato |
| c-if igualdad | `c-if="x = 'valor'"` (un `=`) | `c-if="x == 'valor'"` |
| Twig if igualdad | `{% if x == 'valor' %}` (doble `==`) | `{% if x = 'valor' %}` |
| queryDB tablas | `SELECT * FROM cms_tabla` (con prefijo) | `SELECT * FROM tabla` |
| get tablas | `'tabla' \| get()` (sin prefijo) | `'cms_tabla' \| get()` |
## Builder Variable Types
| Type | Elemento | Retorna |
|------|----------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + var `_tag` |
| `textbox` | `<div>` | String multilínea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string |
| `upload` | `<img>` | Array de `{urlPath, info1}` |
| `uploadMulti` | `<li>` | Itera archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
## Acai HTML Attributes
| Atributo | Uso | Ejemplo |
|----------|-----|---------|
| `c-if` | Condicional | `<p c-if="activo = 1">` |
| `c-else` | Rama else | `<p c-else>` |
| `c-for` | Loop array | `<li c-for="item in items">` |
| `c-for` | Loop tabla | `<li c-for="p in productos" c-where="'activo=1'" c-limit="10">` |
| `c-hidden` | Variable oculta | `<p c-hidden="true" data-field-type="textfield">` |
| `c-class` | Clase condicional | `<div c-class="{ 'bg-red': color == '1' }">` |
| `c-form` | Formulario | `<c-form tableName="'contacto'" captcha="true">` |
## Twig Filters
| Filtro | Uso |
|--------|-----|
| `get` | `'table' \| get(where, order, limit)` |
| `hook` | `'hooks/module_id/' \| hook({params})` |
| `module` | `'module_id' \| module({params})` |
| `queryDB` | `'SELECT ...' \| queryDB()` |
| `imagec` | `path \| imagec(width)` |
| `translate` | `'text' \| translate` — tabla `textos_generales` (editar/traducir), no crear JSONs i18n |
| `json_decode` | `'json_string' \| json_decode` |
| `raw` | `variable \| raw` |
| `truncate` | `text \| truncate(100)` |
## Formato de datos para registros
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| Text field | String | `"Texto"` |
| Text box | String multilínea | `"Línea 1\nLínea 2"` |
| Date/time | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| Wysiwyg | HTML string | `"<p class=\"font-bold\">Texto</p>"` |
| List | String o número | `"activo"` o `"1"` |
| Checkbox | Número 1/0 | `1` o `0` |
| Multivalores | String JSON | `"[{\"producto\":\"1\"}]"` |
| Upload | NO enviar — subir imagen después de crear registro |
## Variables globales
| Variable | Descripción |
|----------|-------------|
| `section_id` | ID único por instancia del módulo |
| `server.HTTP_HOST` | Dominio actual |
| `loop.index` | Índice de iteración (1-based) |
| `loop.index is odd/even` | Para layouts alternados |
| `interno` | True dentro del editor CMS |
| `thisrecord` | Registro actual (en secciones generales) |

View File

@@ -1,242 +0,0 @@
# Twig Filters Reference
Acai usa filtros Twig con sintaxis `|`. No usar funciones Twig — solo filtros.
## `get` — Consultar tabla de BD
```twig
{{ 'table_name' | get(where, order, limit) }}
```
- `table_name`: sin prefijo `cms_`
- `where`: string SQL o objeto (opcional)
- `order`: string de orden (opcional)
- `limit`: int (opcional)
```twig
{# Todos los registros #}
{% set products = 'productos' | get() %}
{# Con WHERE string #}
{% set active = 'productos' | get('activo=1') %}
{# Con WHERE objeto #}
{% set active = 'productos' | get({activo: 1}) %}
{# Con WHERE + ORDER + LIMIT #}
{% set latest = 'noticias' | get('publicado=1', 'fecha DESC', 6) %}
{# Completo #}
{% set caros = 'productos' | get('precio > 100', 'precio DESC', 20) %}
{# Single record (primer resultado) #}
{% set product = 'productos' | get({num: 42}) %}
{{ product[0].nombre }}
```
Iterar resultados:
```twig
{% for producto in 'productos' | get('activo=1', 'num DESC', 10) %}
<h3>{{ producto.titulo }}</h3>
{% endfor %}
```
## `queryDB` — SQL directo
Usa nombre de tabla completo WITH prefijo `cms_`.
```twig
{% set results = 'SELECT * FROM cms_productos WHERE precio > 100 ORDER BY precio ASC' | queryDB() %}
{# JOIN complejo #}
{% set top = 'SELECT p.*, COUNT(v.num) as ventas
FROM cms_productos p
LEFT JOIN cms_ventas v ON v.producto_num = p.num
GROUP BY p.num
ORDER BY ventas DESC
LIMIT 5' | queryDB() %}
```
Usar solo cuando `get` no sea suficiente.
## `hook` — Ejecutar PHP Hook
```twig
{# Llamar y mostrar resultado #}
{{ 'hooks/module_id/' | hook({param1: 'value', param2: variable}) }}
{# Capturar en variable #}
{% set result = 'hooks/calcular_precio/' | hook({cantidad: 5, tipo: 'mayoreo'}) %}
<p>Total: ${{ result.total }}</p>
```
## `module` — Renderizar otro módulo
```twig
{{ 'other_module_id' | module({param1: value1}) }}
{# Capturar en variable #}
{% set carrito = 'carrito_compras' | module({usuario_id: 123}) %}
```
## `imagec` — Optimizar/redimensionar imágenes
```twig
{# Redimensionar a ancho #}
<img src="{{ record.image[0].urlPath | imagec(400) }}" />
{# En srcset #}
<img src="{{ record.image[0].urlPath | imagec(800) }}"
srcset="{{ record.image[0].urlPath | imagec(400) }} 400w,
{{ record.image[0].urlPath | imagec(800) }} 800w" />
```
## `translate` — Texto editable y traducción
Cualquier string con `| translate` se resuelve contra la tabla
`textos_generales` del proyecto. Esta tabla cumple **dos funciones a la vez**:
1. **Traducción**: cada fila guarda la versión del texto por cada idioma
habilitado del proyecto.
2. **Edición de contenidos**: es el canal oficial para que el usuario final
(o el agente) **modifique esos textos sin tocar código**. El filtro
`| translate` no es solo i18n — es el mecanismo por el que un texto
"hardcodeado" en una plantilla se vuelve editable desde el CMS.
```twig
{{ 'Bienvenido' | translate }}
{{ variable | translate }}
```
**Cómo funciona:**
- Los strings envueltos en `| translate` en las plantillas o en el código de
los módulos se buscan en `textos_generales`.
- Si existe la fila, devuelve el valor guardado (en el idioma activo).
- Si no existe, devuelve el texto original tal cual (fallback).
- Las filas se editan desde el admin del CMS o via `cmsApi` (update sobre
`textos_generales`).
**Reglas críticas para el agente:**
- **No crees archivos JSON de traducciones, `.po`, ni ningún sistema i18n
externo**. El único sistema de textos traducibles/editables es la tabla
`textos_generales`.
- **No hardcodees los textos en el código del módulo** si se espera que el
usuario pueda cambiarlos. Envuélvelos siempre en `| translate`:
`{{ 'Contáctanos' | translate }}` en vez de `Contáctanos`.
- Para **cambiar un texto** (traducirlo o editarlo), el flujo correcto es
editar la fila correspondiente en `textos_generales` — nunca modificar
el código de la plantilla.
- Para **añadir un texto nuevo editable**, basta con escribir el string en
el código con `| translate`; el sistema lo recogerá y el usuario podrá
editarlo desde el admin. No hace falta insertar la fila manualmente
(aunque se puede via `cmsApi` si quieres pre-cargar traducciones).
## `raw` — Renderizar HTML sin escapar
```twig
{{ record.description | raw }}
```
## `truncate` — Truncar texto
```twig
{{ record.description | truncate(150) }}
```
## `json_decode` — Parsear JSON
```twig
{% set data = jsonString | json_decode %}
{{ data.key }}
```
## `split`, `filter` — Filtros estándar Twig
Misma funcionalidad que Twig estándar.
---
## Operadores y Sintaxis
### Concatenación
Twig usa `~` (no `.` ni `+`):
```twig
{{ 'Hello ' ~ name ~ '!' }}
{% set url = '/products/' ~ product.slug ~ '/' %}
```
### Concatenar en filtros
```twig
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
```
### Ternario / Default
```twig
{{ title | default('Default Title') }}
{{ isActive ? 'active' : 'inactive' }}
```
### Comparaciones
```twig
{% if items | length > 0 %}
{% if type == 'premium' %}
{% if name is not empty %}
```
En `c-if` usar `=` (simple). En `{% if %}` usar `==` (doble).
---
## Ejemplos complejos
### Galería con productos y stock
```twig
{% for producto in 'productos' | get('destacado=1', 'num DESC', 12) %}
<div class="producto-card">
<img src="{{ producto.imagen[0].urlPath | imagec(400) }}" alt="{{ producto.titulo }}">
<h3>{{ producto.titulo }}</h3>
<p>{{ producto.descripcion | truncate(100) }}</p>
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
<span>Stock: {{ stock[0].cantidad }}</span>
</div>
{% endfor %}
```
### Múltiples filtros combinados
```twig
{% set categorias = 'categorias' | get() %}
{% set productos = 'productos' | get('activo=1', 'titulo ASC', 20) %}
{% set stats = 'hooks/obtener_stats/' | hook({fecha_inicio: '2024-01-01'}) %}
<h1>{{ stats.titulo | translate }}</h1>
<nav>
{% for cat in categorias %}
<a href="">{{ cat.nombre }}</a>
{% endfor %}
</nav>
{% for prod in productos %}
<div>
<img src="{{ prod.imagen[0].urlPath | imagec(300) }}" alt="">
<h3>{{ prod.titulo }}</h3>
</div>
{% endfor %}
```
---
## Puntos importantes
1. **Solo filtros, no funciones:** `'tabla' | get()` no `get('tabla')`
2. **Upload fields son arrays:** `record.imagen[0].urlPath`, no `record.imagen`
3. **Tablas sin prefijo `cms_`** en `get()`. Con prefijo en `queryDB()`
4. **Concatenar con `~`:** `'stocks' | get('producto_num=' ~ producto.num)`