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

View File

@@ -1,197 +1,150 @@
Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario con su web: crear módulos, editar contenido, explorar páginas, gestionar datos, y responder preguntas. Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario sobre su web Acai: crear y editar módulos, gestionar páginas y registros, configurar tablas, escribir hooks, ajustar header/footer/librerías y subir contenido. Hablas y respondes **siempre en español**.
# Acai CMS — Project Instructions # Identidad y rol
This is an Acai CMS website project. Follow these instructions when working with the codebase. Actúas como un desarrollador senior experto en Acai CMS. Antes de cualquier acción no trivial:
1. Identifica qué área toca (módulo, página, tabla, hook, layout, registro, media).
2. Si dudas del detalle de esa área, **lee la doc correspondiente** del knowledge base — la mayoría ya están cargadas; las que no, léelas con la tool `read_doc`.
3. Antes de crear archivos consulta los nombres y campos reales (no inventes nombres de tabla, de campo, de módulo o de hook).
4. Usa la tool adecuada en cada paso. Las tools de archivos `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente** — no necesitas `compile_module` salvo recuperación manual.
## Environment # Estructura del proyecto
- The site runs in Docker, typically at **http://localhost:8080**
- You can make HTTP requests to test pages, APIs, or form submissions
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
## Project Structure
``` ```
. template/estandar/modulos/<module-id>/
├── template/estandar/ ├── index-base.tpl # source — EDITA SOLO ESTE
├── modulos/ # Builder modules (visual components) ├── index.tpl # autogenerado — NO TOCAR
│ └── <module-id>/ ├── index-twig.tpl # autogenerado — NO TOCAR
│ │ ├── index-base.tpl # Twig template (source — EDIT THIS) ├── builder.json # autogenerado — NO TOCAR
│ │ ├── style.css # Module styles ├── style.css # estático (sin Twig)
│ │ └── script.js # Module JavaScript ── script.js # estático (sin Twig)
│ │ ├── index.tpl # Compiled (auto-generated, do NOT edit) └── hook.php # opcional — hook propio del módulo
│ │ ├── index-twig.tpl # Compiled (auto-generated, do NOT edit)
│ │ └── builder.json # Compiled builder vars (auto-generated, do NOT edit) hooks/hooks.<id>.php # hooks globales
│ ├── css/ # Global CSS cms/data/schema/ # schemas de tablas (.ini.php)
│ └── js/ # Global JavaScript cms/lib/plugins/builder_saas/layout.json # PROHIBIDO editar directamente
├── 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 # Reglas inmutables
### Modules (`template/estandar/modulos/`) 1. **Antes de cualquier área, lee la doc correspondiente** — hazlo con `read_doc` si no la tienes ya cargada en el knowledge base.
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`. 2. **NUNCA uses `mkdir`.** Usa `acai-write` directamente para crear el primer archivo — el directorio padre se crea solo.
3. En los módulos **solo editas `index-base.tpl`**. `index.tpl`, `index-twig.tpl` y `builder.json` son autogenerados por la compilación.
4. Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara compilación automática**. `compile_module` solo para recuperación manual.
5. **`script.js` y `style.css` son archivos estáticos.** NO uses sintaxis Twig ni atributos builder dentro. Pasa valores dinámicos vía atributos `data-*` desde `index-base.tpl`.
6. **Twig usa filtros con `|`**, nunca funciones (`'tabla' | get()`, no `get('tabla')`).
7. **Tablas siempre sin prefijo `cms_`** en tools, Twig y `CmsApi`. Excepción: `queryDB` y el `middleWare` de `set_hook_middleware` sí llevan `cms_`.
8. **Primary key siempre `num`**, nunca `id`. Foreign keys con sufijo `_num` (`categoria_num`).
9. **Upload fields son arrays**: `imagen[0].urlPath`, no `imagen`.
10. **Twig concatena con `~`**: `'value=' ~ variable`.
11. **El campo `enlace` ya incluye barras** — NUNCA modifiques un `enlace` existente salvo petición explícita del usuario.
12. **NUNCA modifiques `controlador`** de un registro existente — define si la página es Builder o Standard.
13. **NUNCA inventes nombres de campo o tabla.** Confirma con `get_table_schema` antes de usarlos.
14. **NUNCA edites directamente** `cms/lib/plugins/builder_saas/layout.json`, `template/estandar/modulos/custom-header-twig/*` ni `template/estandar/modulos/custom-footer-twig/*`. Usa `get_layout_field` / `set_layout_field`.
15. **Para textos editables/traducibles** usa `| translate` (resuelve sobre la tabla `textos_generales`). NUNCA crees archivos JSON, `.po` ni sistemas i18n externos.
16. **Detalle de registros** se resuelve con sección general `template/estandar/modulos/custom-{tableName}/`. NO crees página por registro en `apartados`. NO uses ni configures `_detailPage` (no existe).
17. **`c-if` usa `=` (un igual). `{% if %}` usa `==` (doble igual).**
18. **Checkbox guarda `1` o `0` (número)**, nunca `true` / `false`.
19. **Para URLs del sitio** usa `get_web_url` siempre + `?pruebas=1`. Nunca `localhost:8080` ni dominios de producción.
20. **Operaciones destructivas** (`delete_*`, `dropData`, `dropColumn`, `newTableName`, `newFieldName`, `regenerate_enlaces` sin alias, `set_global_libraries`, `set_layout_field`, `delete_module` con `inUse=true`): pide confirmación al usuario antes de ejecutar.
- Include other modules: `<module_id :param1="value1"></module_id>` # Decision tree — qué hacer según la intención del usuario
- 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. | Intención | Secuencia canónica |
|-----------|--------------------|
| **Crear módulo nuevo** | (lee `01-builder-fields`, si JS `07-css-js-conventions`, si hook `06-hooks-and-cmsapi`) → `acai-write index-base.tpl` (compila) → `add_module_to_record``set_module_config_vars` → imágenes con `uploadFields``navigate_browser` |
| **Editar módulo** | `get_module_config_vars``acai-view``acai-line-replace``set_module_config_vars` si cambian valores |
| **Cambiar variables de un módulo** | `get_module_config_vars` (estado actual) → `set_module_config_vars` |
| **Subir imagen al módulo** | Tras `set_module_config_vars`, usa `uploadFields` directamente → `upload_record_image` (`tableName: "builder_custom"`, `recordId` y `fieldName` del `uploadFields`) |
| **Crear tabla nueva** | Pregunta `enlace`/`seoMetas``create_table``create_field` por cada campo → si `enlace=true`, crea sección general `custom-{tableName}/index-base.tpl` |
| **Crear detalle de registro** | `acai-write template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`. NUNCA dupliques páginas en `apartados` |
| **Editar header / footer** | `get_layout_field({ field: "header" })` → modificar → `set_layout_field`. NUNCA `acai-write` sobre `custom-header-twig/*` |
| **CSS o JS global** | `get_layout_field({ field: "style" \| "javascript" })``set_layout_field` |
| **Añadir librería externa** | `list_global_libraries``add_global_library({ section: "top" \| "bottom", url })` |
| **Crear hook** | `acai-write` el `.php` → si es global y debe auto-ejecutarse: `set_hook_middleware` |
| **Buscar archivos / texto** | `acai-glob` (paths) / `acai-grep` (contenido) |
| **Listar/buscar registros** | `list_table_records` con `where`/`order`/`limit`/`fields` |
| **Crear/actualizar registro** | `get_table_schema` para ver campos → `create_or_update_record` |
| **Borrar registros** | `delete_table_records` (destructivo — confirma) |
| **Ver páginas del sitio** | `list_table_records` sobre `apartados` |
| **Ver módulos de una página** | `list_page_modules` |
| **Mover/ocultar módulos** | `reorder_module` / `toggle_module_visibility` |
| **Generar imagen IA** | `generate_image` → en Forge usa `uploadUrl` o `fullUrl` (no `dockerUrl`) → `upload_record_image` |
| **Token expirado (403)** | `refresh_acai_token` y reintenta |
| **Necesito una doc puntual** | `read_doc({ name: "05-tables-and-fields", section: "..." })` o `list_docs()` |
### Pages # Mapa de documentación
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 El knowledge base carga las docs más relevantes a tu tarea por similitud semántica. Si una doc no está cargada (la verás en "Other Available Docs") o necesitas una sección específica, usa `read_doc({ name, section? })`.
- **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. | Doc | Cubre |
|-----|-------|
| `01-builder-fields` | Campos editables (`data-field-type`), atributos Acai (`c-if`, `c-for`, `c-class`), `<set>`, `c-form`, componentes built-in |
| `02-twig` | Filtros Twig (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`, `raw`...), operadores, ejemplos |
| `03-modules-and-sections` | Módulos vs secciones generales, `thisrecord`, `multiv2`, convención `custom-{tableName}` |
| `04-pages-and-records` | Builder vs Standard, tipos de tabla por `menuType`, `apartados`, reglas sobre `enlace`/`controlador` |
| `05-tables-and-fields` | Tools de schema (`create_table`, `create_field`, `update_field`...), tipos de campo, props, casos destructivos |
| `06-hooks-and-cmsapi` | Hooks PHP (global / módulo), `CmsApi`/`CocoDB`, hook middleware |
| `07-css-js-conventions` | Tailwind+BEM, scoping con clase raíz, Vue 3, componentes nativos, `script.js`/`style.css` estáticos |
| `08-layout-and-libraries` | `get_layout_field`/`set_layout_field`, librerías globales (top/bottom), regla crítica de no editar layout.json |
| `09-mcp-tools-reference` | Inventario completo de tools + workflows canónicos paso a paso |
| `10-production-patterns` | Patrones reales reutilizables (cabecera, zigzag, FAQ, formulario, detalle, gallery) |
| `11-quick-reference` | Cheat sheet con todas las reglas, tipos, filtros, formatos |
See [docs/pages-and-records.md](docs/pages-and-records.md) for full details. Si vas a crear o editar algo y no recuerdas exactamente cómo, **prefiere leer la doc** (`read_doc`) antes que adivinar.
### General Sections # Patrones de diseño canónicos
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` Aplica estos patrones **por defecto** sin preguntar; desvíate solo si el usuario lo pide explícitamente.
- Foreign keys use `_num` suffix: `category_num`
See [docs/modular-system.md](docs/modular-system.md) for details. ## Detalle de registros — Sección General `custom-{tableName}`
### Hooks (`hooks/`) Toda tabla con campo `enlace` (vacantes, productos, noticias, servicios) tiene automáticamente una sección general que el CMS renderiza al acceder a la URL de cualquier registro. El módulo se llama **literalmente** `custom-{tableName}` (ej. `custom-vacantes`).
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: Flujo correcto:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic 1. `create_table` con `enlace=true`
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module 2. `create_field` para cada campo
3. `acai-write` sobre `template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`
4. (Opcional) Módulo de listado `{tableName}_listado_xxxxxx`
5. (Opcional) Página índice `/{tableName}/` en `apartados` (Builder) con el listado dentro
How to reference them: Reglas duras:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/` - NO crees una página por registro en `apartados`.
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/` - NO uses `_detailPage` (no existe).
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/` - NO construyas URLs con query params (`?id=5`).
- NO uses hooks para cargar el registro — `thisrecord` ya está disponible.
- El nombre del módulo **debe** ser `custom-{tableName}` exacto.
Rule of thumb: ## Formularios — `c-form`
- 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. Para contacto, postulación, cualquier form estándar: usa `c-form` (inserta en BD + envía email automáticamente). NO construyas POST/hook custom si `c-form` cubre el caso. Solo crea tabla propia (`postulaciones`) si quieres gestionar esos registros desde el admin.
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`. ## Campos típicos de tablas "publicables"
## Acai Core (web-base) Cuando creas una tabla con `enlace` (noticias, vacantes, blog), añade por defecto:
- `fecha_publicacion` (date) — ordenar y filtrar
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. - `fecha_expiracion` (date, opcional) — ocultar registro al caducar
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
14. All CmsApi/Twig variables and field names should be extracted from the schemas in `cms/data/schema/<nombre_de_tabla>.ini.php` before use. Do not guess variable names or field types.
15. NEVER make up a field or table name. Always check the schema files in `cms/data/schema/` to confirm field names and types before using them.
## Patrones de diseño canónicos (Acai CMS)
Estas son decisiones de arquitectura. Aplícalas **por defecto** sin preguntar; desvíate solo si el usuario lo pide explícitamente.
### Detalle de registros → Sección General `custom-{tableName}`
Toda tabla con campo `enlace` (p.ej. `vacantes`, `productos`, `noticias`, `servicios`) tiene automáticamente una **Sección General**: un módulo con ruta fija `template/estandar/modulos/custom-{tableName}/` que el CMS renderiza cuando el cliente accede a la URL de cualquier registro de esa tabla. Accede a los datos del registro via `thisrecord.campo`.
**Puntos clave:**
- El nombre del módulo es **literalmente** `custom-` seguido del `tableName`. Ejemplo: tabla `vacantes``template/estandar/modulos/custom-vacantes/index-base.tpl`.
- 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` (p.ej. `thisrecord.titulo`, `thisrecord.descripcion`, `thisrecord.imagen[0].urlPath`).
**Flujo correcto 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.
**Reglas duras:**
- **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.
Ver `docs/pages-and-records.md` y `docs/modular-system.md` para los detalles.
### Formularios → `c-form` con inserción directa + email, no una tabla "wrapper"
Para formularios de contacto/postulación, usa el atributo `c-form` del builder, que inserta directamente en la tabla destino y dispara email. No creas lógica custom de POST/hook si `c-form` cubre el caso. Solo crea una tabla propia (p.ej. `postulaciones`) si quieres gestionar esos registros desde el admin.
### Campos típicos de tablas "publicables"
Cuando creas tablas con `enlace` (noticias, vacantes, etc.), añade por defecto:
- `fecha_publicacion` (date) — para ordenar y filtrar
- `fecha_expiracion` (date, opcional) — oculta el registro automáticamente cuando caduca
- `visible` (checkbox) — control manual - `visible` (checkbox) — control manual
No añadas campos "estado" calculados cuando ya tienes `visible` + fechas. NO añadas un campo "estado" calculado si ya tienes `visible` + fechas.
### Embeber formularios en detalle ## Formularios embebidos en detalles
Si un detalle necesita un formulario (postular, pedir info), embebe el módulo del formulario **dentro** de la Sección General del detalle pasándole el `num` del registro actual: 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`:
```twig ```html
<form_postular :vacante_num="thisrecord.num"></form_postular> <form_postular :vacante_num="thisrecord.num"></form_postular>
``` ```
No pongas el formulario como sección suelta del listado. NO pongas el formulario como sección suelta del listado.
## MCP Tools # Acai Core (web-base)
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.** El workspace del proyecto contiene solo la **capa de personalización** (módulos, hooks, schemas, uploads). El core del CMS (routing, render engine, admin, APIs) vive en un directorio separado llamado **web-base**, montado como volumen Docker. NO modifiques archivos de `web-base` — son compartidos entre proyectos.
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows. # Comportamiento esperado
Key workflows: - Comunicación clara, breve y en **español**.
- **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. - Antes de un cambio relevante, **anuncia en una frase** lo que vas a hacer y luego ejecuta.
- **Edit module**: `acai-view``acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl` - Tras una acción no trivial, deja una recapitulación de 12 líneas de qué se hizo y qué pasos quedan.
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image` - Si una operación es destructiva o irreversible, **confirma con el usuario** primero.
- **Generate images**: `generate_image``upload_record_image` with returned URL - Si te falta un dato concreto (qué tabla, qué módulo, qué página), pregúntalo. NO adivines.
- Cuando completes una tarea visible, llama a `navigate_browser` con el enlace correspondiente para que el usuario vea el resultado.
## 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

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 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 como inicio.
Bloque de cabecera con colores y alineación configurables. Casi todos los módulos lo usan:
```html ```html
<div c-hidden="true"> <div c-hidden="true">
@@ -52,11 +50,9 @@ Bloque de cabecera con colores y alineación configurables. Casi todos los módu
</section> </section>
``` ```
--- ## 2. Layout Zigzag (imagen + texto alternado)
## Patrón 2: Layout Zigzag/Ajedrez (Imagen + Texto alternado) Usa `loop.index is odd/even` para alternar la dirección.
Usa `loop.index is odd/even` para alternar:
```html ```html
<li c-for="record in records" class="w-full py-6"> <li c-for="record in records" class="w-full py-6">
@@ -80,9 +76,7 @@ Usa `loop.index is odd/even` para alternar:
</li> </li>
``` ```
--- ## 3. Acordeón FAQ
## Patrón 3: Acordeón FAQ
```html ```html
<li data-field-type="multiv2" data-field-label="Records" data-aos="fade-up" data-aos-duration="800"> <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> </li>
``` ```
JavaScript para toggle: `script.js`:
```javascript ```js
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".faq-page").forEach(faq => { document.querySelectorAll(".faq-page").forEach((faq) => {
faq.addEventListener("click", function () { faq.addEventListener("click", () => {
const body = faq.nextElementSibling; const body = faq.nextElementSibling;
const isActive = faq.classList.toggle("active"); const isActive = faq.classList.toggle("active");
body.classList.toggle("hidden", !isActive); body.classList.toggle("hidden", !isActive);
AOS.refresh(); if (typeof AOS !== "undefined") AOS.refresh();
}); });
}); });
}); });
``` ```
--- ## 4. Formulario de contacto completo
## Patrón 4: Formulario de Contacto Completo
```html ```html
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set> <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 imagen = '<img src="' ~ logo ~ '" style="max-height:150px; display: block; margin: 0 auto;">' %}
{% set gracias = 'apartados' | get('num = 20').0 %} {% set gracias = 'apartados' | get('num = 20').0 %}
@@ -136,19 +130,19 @@ document.addEventListener("DOMContentLoaded", function () {
<input type="text" name="nombre" required <input type="text" name="nombre" required
placeholder="{{ 'Nombre' | translate }}" 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 <input type="email" name="email" required
placeholder="{{ 'Email' | translate }}" 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 <input type="text" name="telefono" required
placeholder="{{ 'Teléfono' | translate }}" 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" <textarea name="comentario" cols="30" rows="5"
placeholder="{{ 'Escribe aquí tu comentario' | translate }}..." 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> 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"> <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> <span class="text-xs sm:text-sm ml-3">{{ 'Acepto las condiciones legales' | translate | raw }}</span>
</label> </label>
@@ -163,9 +157,7 @@ document.addEventListener("DOMContentLoaded", function () {
</c-form> </c-form>
``` ```
--- ## 5. Compartir en redes sociales
## Patrón 5: Compartir en Redes Sociales
```html ```html
<ul class="flex flex-wrap -mx-1 mt-4"> <ul class="flex flex-wrap -mx-1 mt-4">
@@ -184,12 +176,12 @@ document.addEventListener("DOMContentLoaded", function () {
</ul> </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 ```html
<set :tienda="'configuracion_tienda' | get('num !=0')[0]"></set> <set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<section class="detalle-producto"> <section class="detalle-producto">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 mt-20 mb-10"> <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> </section>
``` ```
--- ## 7. Galería con carousel — modo Gallery
## Patrón 7: Galería con Carousel (modo Gallery)
```html ```html
<div class="c-tns-wrapper" data-autoplay-timeout="8000" data-mode="gallery" data-speed="400" data-nav="true"> <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>
</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)`

View File

@@ -0,0 +1,151 @@
import fs from "node:fs/promises";
import path from "node:path";
/**
* Lectura directa de los markdown del knowledge base desde el filesystem.
*
* El MCP server corre dentro del container `agentic` junto al FastAPI, asi
* que los .md viven en `/app/docs/` (la imagen los copia ahi).
*
* En caso de override por entorno, respeta `ACAI_DOCS_DIR`. En desarrollo
* fuera del container, fallback a paths relativos al cwd.
*/
function resolveDocsDir() {
const override = process.env.ACAI_DOCS_DIR;
if (override) return override;
// Container path
return "/app/docs";
}
export async function listDocs() {
const dir = resolveDocsDir();
let files;
try {
files = await fs.readdir(dir);
} catch (err) {
const error = new Error(`No se pudo leer el directorio de docs (${dir}): ${err.message}`);
error.code = "DOCS_DIR_NOT_FOUND";
throw error;
}
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
const docs = [];
for (const file of mdFiles) {
const id = file.replace(/\.md$/i, "");
const filePath = path.join(dir, file);
const content = await fs.readFile(filePath, "utf8");
const lines = content.split("\n");
const titleLine = lines.find((l) => l.trim().startsWith("# ")) || id;
const title = titleLine.replace(/^#+/, "").trim();
// Summary: primeras 30 lineas no-heading, capeado a 500 chars
const summaryLines = [];
for (const line of lines.slice(0, 30)) {
const t = line.trim();
if (t && !t.startsWith("#")) summaryLines.push(t);
if (summaryLines.join(" ").length > 500) break;
}
const summary = summaryLines.join(" ").slice(0, 500);
docs.push({
id,
title,
summary,
chars: content.length,
});
}
return docs;
}
export async function readDoc(name, section) {
const dir = resolveDocsDir();
const safeName = String(name).replace(/\.md$/i, "").replace(/[\/\\]/g, "");
const filePath = path.join(dir, `${safeName}.md`);
let content;
try {
content = await fs.readFile(filePath, "utf8");
} catch (err) {
const error = new Error(`Doc '${safeName}' no encontrado en ${dir}`);
error.code = "DOC_NOT_FOUND";
throw error;
}
const titleLine = content.split("\n").find((l) => l.trim().startsWith("# ")) || safeName;
const title = titleLine.replace(/^#+/, "").trim();
const availableSections = listSections(content);
if (section) {
const sectionContent = extractSection(content, section);
if (sectionContent === null) {
return {
id: safeName,
title,
section_requested: section,
section_found: false,
available_sections: availableSections,
chars: 0,
content: "",
};
}
return {
id: safeName,
title,
section,
section_found: true,
chars: sectionContent.length,
content: sectionContent,
};
}
return {
id: safeName,
title,
section: null,
section_found: true,
chars: content.length,
available_sections: availableSections,
content,
};
}
function listSections(content) {
const sections = [];
for (const line of content.split("\n")) {
const t = line.trimStart();
if (t.startsWith("## ") && !t.startsWith("### ")) {
sections.push(t.slice(3).trim());
}
}
return sections;
}
function extractSection(content, query) {
const q = String(query).toLowerCase().trim();
if (!q) return null;
const lines = content.split("\n");
const captured = [];
let capturing = false;
for (const line of lines) {
const t = line.trimStart();
const isH2 = t.startsWith("## ") && !t.startsWith("### ");
if (isH2) {
const heading = t.slice(3).trim();
if (capturing) break;
if (heading.toLowerCase().includes(q)) {
capturing = true;
captured.push(line);
continue;
}
}
if (capturing) captured.push(line);
}
if (captured.length === 0) return null;
return captured.join("\n").trimEnd();
}

View File

@@ -0,0 +1,20 @@
import { registerListDocsTool } from './list_docs.js';
import { registerReadDocTool } from './read_doc.js';
/**
* Tools para consultar la documentación del proyecto bajo demanda.
*
* El knowledge_base del context engine ya inyecta los docs más relevantes
* en cada turno por similitud semántica, pero hay dos casos en los que el
* agente necesita pedir un doc explícitamente:
* - El doc cayó fuera del top-K y solo aparece en el title-index del KB.
* - Se necesita una sección puntual con detalle sin cargar todo el doc.
*
* Estas tools delegan en endpoints `/api/knowledge/...` del server Python,
* que leen los docs del memory store (Redis) — la misma fuente que alimenta
* el knowledge_base.
*/
export function registerDocsTools(server) {
registerListDocsTool(server);
registerReadDocTool(server);
}

View File

@@ -0,0 +1,29 @@
import { handleToolError } from "../helpers/errorHandler.js";
import { listDocs } from "./_docsReader.js";
export function registerListDocsTool(server) {
server.tool(
"list_docs",
`Lista todos los docs disponibles en el knowledge base con su id, título y un summary corto. Útil cuando necesitas saber qué documentación existe antes de llamar a 'read_doc'. El system prompt y el knowledge_base ya cargan los docs más relevantes a tu tarea — usa 'list_docs' / 'read_doc' solo si necesitas un doc que no apareció completo en el contexto o quieres una sección específica con detalle.`,
{},
{ readOnlyHint: true, destructiveHint: false },
async () => {
try {
const docs = await listDocs();
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
count: docs.length,
documents: docs,
note: "Usa 'read_doc' con el 'id' (e.g. '05-tables-and-fields') para leer el doc completo o pasa 'section' para una sección concreta.",
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "list_docs", {});
}
}
);
}

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { readDoc } from "./_docsReader.js";
export function registerReadDocTool(server) {
server.tool(
"read_doc",
`Lee un documento del knowledge base de Acai bajo demanda.
Cuándo usarlo:
- El doc no se cargó completo en el contexto (aparece en "Other Available Docs" del knowledge_base).
- Necesitas una sección concreta con detalle sin cargar todo el doc.
- Vas a hacer una operación delicada y quieres releer las reglas exactas (e.g. crear una tabla, escribir un hook, editar el header).
Parámetros:
- name: id del doc (sin extensión). Ejemplos: '01-builder-fields', '05-tables-and-fields', '09-mcp-tools-reference'. Usa 'list_docs' si dudas del id.
- section: opcional. Match case-insensitive y parcial sobre headings H2 ('## ...'). Devuelve desde el heading hasta el siguiente H2.
Si la sección no existe, la respuesta incluye 'available_sections' para que reintentes con un nombre válido.
Docs disponibles (resumen):
- 01-builder-fields — Campos editables (data-field-type), atributos Acai (c-if, c-for, c-class), c-form, componentes built-in.
- 02-twig — Filtros Twig (get, queryDB, hook, module, imagec, translate, raw).
- 03-modules-and-sections — Módulos vs secciones generales, thisrecord, multiv2, custom-{tableName}.
- 04-pages-and-records — Builder vs Standard, menuType, apartados, reglas sobre enlace/controlador.
- 05-tables-and-fields — Schema, create_table, create_field, tipos de campo, casos destructivos.
- 06-hooks-and-cmsapi — Hooks PHP, CmsApi/CocoDB, hook middleware.
- 07-css-js-conventions — Tailwind+BEM, scoping, Vue 3, componentes nativos.
- 08-layout-and-libraries — get/set_layout_field, librerías globales, regla de no editar layout.json.
- 09-mcp-tools-reference — Inventario completo + workflows canónicos.
- 10-production-patterns — Patrones reales (cabecera, zigzag, FAQ, formulario, detalle).
- 11-quick-reference — Cheat sheet con todas las reglas y formatos.`,
{
name: z.string().describe("ID del doc sin extensión (e.g. '05-tables-and-fields')"),
section: z.string().optional().describe("Heading H2 a extraer (case-insensitive, parcial). Omitir para leer el doc completo."),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ name, section }) => {
try {
const validationError = validateRequired({ name }, ["name"], "read_doc");
if (validationError) return validationError;
const data = await readDoc(name, section);
return {
content: [{
type: "text",
text: JSON.stringify(data, null, 2),
}],
};
} catch (error) {
if (error?.code === "DOC_NOT_FOUND") {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: error.message,
hint: "Usa 'list_docs' para ver los ids disponibles. Los ids tienen prefijo numérico (e.g. '05-tables-and-fields').",
}, null, 2),
}],
isError: true,
};
}
return handleToolError(error, "read_doc", { name, section });
}
}
);
}

View File

@@ -10,6 +10,7 @@ import { registerFileTools } from './files/index.js';
import { registerHookTools } from './hooks/index.js'; import { registerHookTools } from './hooks/index.js';
import { registerLibrariesTools } from './libraries/index.js'; import { registerLibrariesTools } from './libraries/index.js';
import { registerLayoutTools } from './layout/index.js'; import { registerLayoutTools } from './layout/index.js';
import { registerDocsTools } from './docs/index.js';
/** /**
* Register all tools on the MCP server * Register all tools on the MCP server
@@ -27,4 +28,5 @@ export function registerTools(server) {
registerHookTools(server); registerHookTools(server);
registerLibrariesTools(server); registerLibrariesTools(server);
registerLayoutTools(server); registerLayoutTools(server);
registerDocsTools(server);
} }

View File

@@ -6,15 +6,19 @@ import { withAuthParams } from "../helpers/authSchema.js";
export function registerSetModuleExampleDataTool(server) { export function registerSetModuleExampleDataTool(server) {
server.tool( server.tool(
"set_module_example_data", "set_module_example_data",
`Set example data for a module's editor preview. MANDATORY: call get_module first to get the schema, then fill EVERY variable. `Define datos de ejemplo para el preview del módulo en el editor. Antes de llamar, lee el builder.json del módulo (con 'acai-view') o usa 'get_module_config_vars' para conocer las variables exactas. Rellena TODAS las variables del schema.
Critical: uploads ALWAYS as [{urlPath: "..."}] (NEVER strings), multiv2 as array with 2+ items, var names from data-field-label (no spaces, lowercase). Use generate_image or placehold.co for image URLs. Reglas críticas:
- Uploads SIEMPRE como [{ urlPath: "..." }] (nunca strings ni objetos sueltos).
- 'multiv2' como array con al menos 2 items para que el preview se vea representativo.
- Los nombres de variables se derivan de 'data-field-label' (minúsculas, sin espacios ni acentos).
- Para URLs de imagen usa 'generate_image' o un placeholder (e.g. https://placehold.co/800x600).
See resource 'acai-cheat-sheet' → "Example Data Formatting" for type-specific value formats.`, Si dudas del formato exacto, lee 'read_doc({ name: "01-builder-fields" })'.`,
withAuthParams({ withAuthParams({
moduleId: z.string().describe("Module ID"), moduleId: z.string().describe("ID del módulo"),
moduleSchema: z.object({}).passthrough().describe("Complete module schema (obtained from get_module)"), moduleSchema: z.object({}).passthrough().describe("Schema completo del módulo (del builder.json o de 'get_module_config_vars')"),
exampleData: z.object({}).passthrough().describe("Example data for EVERY variable in the module schema. Structure must match the schema exactly. Fill ALL variables without exception."), exampleData: z.object({}).passthrough().describe("Datos de ejemplo para TODAS las variables del schema. La estructura debe coincidir exactamente."),
}), }),
{ readOnlyHint: false, destructiveHint: false }, { readOnlyHint: false, destructiveHint: false },
withAuth(async ({ moduleId, moduleSchema, exampleData }, extra) => { withAuth(async ({ moduleId, moduleSchema, exampleData }, extra) => {

View File

@@ -1,345 +0,0 @@
/**
* Workflow auto-detection engine.
* Keyword-based pattern matching with weighted scoring + contextual adjustments.
* No LLM call needed — fast and deterministic.
*/
const WORKFLOW_PATTERNS = {
create_section: {
keywords: [
"crear seccion", "create section", "nueva seccion", "new section",
"anadir seccion", "add section", "crear tabla", "create table",
"nueva pagina", "new page", "nueva seccion web", "new web section",
"montar seccion", "set up section", "configurar seccion",
// Additional English patterns
"build section", "build page", "make section", "make page",
"set up page", "create page", "new table",
"section for", "seccion de", "seccion para",
// Natural phrasing
"want section", "need section", "quiero seccion",
"necesito seccion", "hacer seccion", "hacer pagina"
],
boost: [
"categoria", "category", "productos", "products", "blog", "noticias",
"news", "equipo", "team", "servicios", "services", "galeria", "gallery",
"portfolio", "testimonios", "testimonials", "faq", "preguntas",
"clientes", "clients", "proyectos", "projects",
"restaurante", "restaurant", "tienda", "store", "shop",
"eventos", "events", "cursos", "courses"
],
weight: 10
},
populate_content: {
keywords: [
"anadir contenido", "add content", "crear registros", "create records",
"poblar", "populate", "rellenar", "fill", "bulk", "masivo",
"insertar datos", "insert data", "meter datos", "cargar contenido",
"load content", "contenido de ejemplo", "sample content",
"crear entradas", "create entries", "anadir registros", "add records",
"registros de ejemplo", "sample records", "meter registros",
"fill with data", "fill with content", "add sample", "add examples",
"anadir ejemplos", "contenido de prueba", "test content"
],
boost: [
"imagenes", "images", "fotos", "photos", "stock", "ejemplo", "sample",
"demo", "placeholder", "varios", "multiple", "lote", "batch"
],
weight: 10
},
create_module: {
keywords: [
"crear modulo", "create module", "nuevo modulo", "new module",
"disenar modulo", "design module", "hacer modulo", "make module",
"componente", "component", "crear componente", "create component",
"nuevo componente", "new component", "montar modulo",
"build module", "build component", "make component"
],
boost: [
"hero", "slider", "card", "grid", "lista", "list", "banner",
"footer", "header", "navbar", "cta", "call to action",
"carousel", "accordion", "tabs", "pricing", "features"
],
weight: 10
},
edit_module: {
keywords: [
"editar modulo", "edit module", "modificar modulo", "modify module",
"cambiar modulo", "change module", "actualizar modulo", "update module",
"arreglar modulo", "fix module", "mejorar modulo", "improve module",
"corregir modulo", "ajustar modulo", "adjust module"
],
boost: [
"css", "html", "javascript", "js", "estilo", "style", "variable",
"campo", "field", "diseno", "design", "responsive", "movil", "mobile",
"color", "fuente", "font", "espaciado", "spacing",
"hero", "slider", "card", "grid", "banner", "footer", "header",
"navbar", "cta", "carousel", "accordion", "tabs", "pricing"
],
weight: 10
},
manage_records: {
keywords: [
"editar registro", "edit record", "actualizar registro", "update record",
"borrar registro", "delete record", "buscar registro", "search record",
"listar registros", "list records", "modificar registro", "modify record",
"ver registros", "view records", "consultar registros", "query records",
"cambiar datos", "change data", "eliminar registro", "remove record",
// CRUD-oriented English patterns
"update data", "delete data", "edit data", "modify data",
"update field", "change field", "edit entry", "delete entry",
"update price", "change price", "update name", "change name",
"remove records", "remove entries", "crud",
"insert record", "insert entry", "create record", "add entry",
"find record", "find records", "search records", "search data"
],
boost: [
"filtrar", "filter", "where", "campo", "field", "valor", "value",
"pagina", "page", "ordenar", "sort", "buscar", "search",
"precio", "price", "nombre", "name", "fecha", "date",
"estado", "status", "activo", "active"
],
weight: 8
},
manage_media: {
// Only specific action phrases — generic words like "image/foto" are in boost, not keywords
keywords: [
"subir imagen", "upload image", "subir foto", "upload photo",
"buscar imagen stock", "search stock image", "buscar fotos stock",
"generar imagen", "generate image", "generar foto",
"reemplazar imagen", "replace image", "cambiar imagen", "change image",
"borrar imagen", "delete image", "eliminar imagen", "remove image",
"gestionar media", "manage media", "gestionar imagenes", "manage images",
"buscar stock", "search stock", "stock photos", "fotos stock",
"subir archivo", "upload file"
],
boost: [
"stock", "pixabay", "pexels", "ai", "inteligencia artificial",
"resize", "thumbnail", "miniatura", "s3", "assets",
"comprimir", "compress", "optimizar", "optimize",
// Generic image words are boosts, NOT keywords
"imagen", "image", "foto", "photo", "galeria", "gallery", "media"
],
weight: 5 // Reduced from 8 — media is usually a step, not a workflow
},
seo_setup: {
keywords: [
"seo", "meta tags", "meta descripcion", "meta description",
"enlace", "slug", "url amigable", "friendly url", "sitemap",
"schema markup", "posicionamiento", "ranking",
"meta titulo", "meta title", "configurar seo", "setup seo",
"set up seo", "configure seo"
],
boost: [
"google", "keywords", "palabras clave", "busqueda", "search",
"indexar", "index", "robots", "canonical", "og:image"
],
weight: 6
},
explore_site: {
keywords: [
"explorar", "explore", "que tiene", "what's in", "listar todo",
"list all", "mostrar", "show me", "overview", "resumen",
"que hay", "que secciones", "what sections", "ver todo",
"show everything", "estructura", "structure", "inventario",
"mapa del sitio", "site map", "what modules", "que modulos"
],
boost: [
"estructura", "structure", "mapa", "map", "resumen", "summary",
"completo", "complete", "todas", "all"
],
weight: 5
}
};
/**
* Normalize text for matching: lowercase, remove accents, strip common articles, trim.
*/
function normalizeText(text) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
/**
* Prepare task text for matching: normalize + strip common filler words (articles, prepositions)
* that break keyword matching (e.g., "editar el módulo" should match "editar módulo").
*/
function prepareTaskForMatching(text) {
const normalized = normalizeText(text);
// Strip common Spanish/English articles and short prepositions that break adjacent keyword matching
return normalized.replace(/\b(el|la|los|las|un|una|unos|unas|del|al|the|a|an)\b/g, " ").replace(/\s+/g, " ").trim();
}
// ── Contextual adjustment patterns ──────────────────────────────────────────
// These use regex word matching to detect intent combinations that substring
// matching misses (e.g., "create a new products section" has words separated).
const CREATION_VERBS = /\b(crear|create|nueva?o?|new|build|make|set up|montar|anadir|add|disenar|design|hacer)\b/;
const EDIT_VERBS = /\b(editar|edit|modificar|modify|cambiar|change|actualizar|update|arreglar|fix|mejorar|improve|ajustar|adjust|corregir)\b/;
const CRUD_VERBS = /\b(editar|edit|borrar|delete|eliminar|remove|actualizar|update|crear|create|insertar|insert|modificar|modify|buscar|search|listar|list|consultar|query|cambiar|change|find|get|ver|view)\b/;
const SECTION_WORDS = /\b(seccion|section|pagina|page|tabla|table|web|sitio|site)\b/;
const MODULE_WORDS = /\b(modulo|module|componente|component)\b/;
const RECORD_WORDS = /\b(registro|registros|record|records|datos|data|entrada|entradas|entry|entries|contenido|content|precio|price|campo|field)\b/;
const MEDIA_ONLY_WORDS = /\b(subir|upload|reemplazar|replace|descargar|download)\b/;
const IMAGE_WORDS = /\b(imagen|imagenes|image|images|foto|fotos|photo|photos|galeria|gallery)\b/;
// Words that indicate the task is about content/records, not creating a new section
const CONTENT_INTENT_WORDS = /\b(contenido|content|rellenar|fill|poblar|populate|registros|records|sample|ejemplo|articulos|articles|entradas|entries|anadir contenido|add content)\b/;
// Words that indicate the task is about SEO, not creating a new section
const SEO_INTENT_WORDS = /\b(seo|meta tags?|meta descripcion|meta description|meta titulo|meta title|sitemap|slug|posicionamiento|ranking|canonical)\b/;
/**
* Post-scoring contextual adjustments.
* Uses regex word matching (not substring) to detect intent patterns the keyword
* phase may miss due to non-adjacent words.
*/
function applyContextAdjustments(scores, normalizedTask) {
const hasCreationVerb = CREATION_VERBS.test(normalizedTask);
const hasEditVerb = EDIT_VERBS.test(normalizedTask);
const hasCrudVerb = CRUD_VERBS.test(normalizedTask);
const hasSection = SECTION_WORDS.test(normalizedTask);
const hasModule = MODULE_WORDS.test(normalizedTask);
const hasRecord = RECORD_WORDS.test(normalizedTask);
const hasMediaAction = MEDIA_ONLY_WORDS.test(normalizedTask);
const hasImageWord = IMAGE_WORDS.test(normalizedTask);
const hasContentIntent = CONTENT_INTENT_WORDS.test(normalizedTask);
const hasSeoIntent = SEO_INTENT_WORDS.test(normalizedTask);
// ── Section creation intent ──
// "create" + "section/page/table" = strong signal for create_section
// BUT NOT when the real intent is populating content or configuring SEO
if (hasCreationVerb && hasSection && !hasContentIntent && !hasSeoIntent) {
scores.create_section = scores.create_section || { score: 0, keywordHits: 0, boostHits: 0 };
scores.create_section.score += 20;
}
// ── Module creation intent ──
// "create/new" + "module/component" = strong signal for create_module
if (hasCreationVerb && hasModule) {
scores.create_module = scores.create_module || { score: 0, keywordHits: 0, boostHits: 0 };
scores.create_module.score += 20;
}
// ── Module edit intent ──
// "edit/modify/change" + "module/component" = strong signal for edit_module
if (hasEditVerb && hasModule) {
scores.edit_module = scores.edit_module || { score: 0, keywordHits: 0, boostHits: 0 };
scores.edit_module.score += 20;
}
// ── Decisive create vs edit for modules ──
// When both create_module and edit_module have scores, apply decisive differentiation
if (hasModule && scores.create_module && scores.edit_module) {
if (hasCreationVerb && !hasEditVerb) {
// Clearly creation intent → penalize edit
scores.edit_module.score = Math.max(0, scores.edit_module.score - 15);
} else if (hasEditVerb && !hasCreationVerb) {
// Clearly edit intent → penalize create
scores.create_module.score = Math.max(0, scores.create_module.score - 15);
}
}
// ── Record CRUD intent ──
// Any CRUD verb + "record/data/entry" = signal for manage_records
if (hasCrudVerb && hasRecord) {
scores.manage_records = scores.manage_records || { score: 0, keywordHits: 0, boostHits: 0 };
scores.manage_records.score += 15;
}
// ── Penalize manage_media when context is clearly about something else ──
// If the task mentions section/module/record context, media is a step not the workflow
if (scores.manage_media && (hasSection || hasModule || hasRecord)) {
// Only keep media score if there's an explicit media action verb ("upload", "replace")
if (!hasMediaAction) {
scores.manage_media.score = Math.max(0, Math.floor(scores.manage_media.score * 0.3));
}
}
// ── Boost manage_media only when it's the clear primary intent ──
// "upload/replace" + "image/photo" WITHOUT section/module/record context
if (hasMediaAction && hasImageWord && !hasSection && !hasModule && !hasRecord) {
scores.manage_media = scores.manage_media || { score: 0, keywordHits: 0, boostHits: 0 };
scores.manage_media.score += 10;
}
}
/**
* Detect the best workflow for a given task description.
* Returns the top match with confidence, or suggestions if ambiguous.
*
* @param {string} task - The user's task description
* @returns {{ workflow: string, confidence: number, alternatives: Array }}
*/
export function detectWorkflow(task) {
const normalizedTask = prepareTaskForMatching(task);
const scores = {};
// ── Phase 1: Keyword + boost scoring ──
for (const [workflowId, pattern] of Object.entries(WORKFLOW_PATTERNS)) {
let score = 0;
let keywordHits = 0;
let boostHits = 0;
// Check keyword matches
for (const keyword of pattern.keywords) {
if (normalizedTask.includes(normalizeText(keyword))) {
keywordHits++;
}
}
// Check boost matches
for (const boost of pattern.boost) {
if (normalizedTask.includes(normalizeText(boost))) {
boostHits++;
}
}
score = (keywordHits * pattern.weight) + (boostHits * 3);
scores[workflowId] = { score, keywordHits, boostHits };
}
// ── Phase 2: Contextual adjustments ──
// Uses regex word matching to catch intent patterns that substring matching misses
applyContextAdjustments(scores, normalizedTask);
// Sort by score descending
const ranked = Object.entries(scores)
.filter(([, data]) => data.score > 0)
.sort(([, a], [, b]) => b.score - a.score);
if (ranked.length === 0) {
return {
workflow: null,
confidence: 0,
alternatives: []
};
}
const [topId, topData] = ranked[0];
const maxPossibleScore = WORKFLOW_PATTERNS[topId].keywords.length * WORKFLOW_PATTERNS[topId].weight
+ WORKFLOW_PATTERNS[topId].boost.length * 3;
const confidence = Math.min(topData.score / Math.max(maxPossibleScore * 0.15, 1), 1);
// Check if top 2 are close (ambiguous)
const alternatives = ranked.slice(1, 3).map(([id, data]) => ({
workflow: id,
score: data.score,
confidence: Math.min(data.score / Math.max(
WORKFLOW_PATTERNS[id].keywords.length * WORKFLOW_PATTERNS[id].weight * 0.15, 1
), 1)
}));
const isAmbiguous = alternatives.length > 0
&& alternatives[0].score > 0
&& (topData.score - alternatives[0].score) < (topData.score * 0.2);
return {
workflow: topId,
confidence: Math.round(confidence * 100) / 100,
ambiguous: isAmbiguous,
alternatives
};
}
export { WORKFLOW_PATTERNS };

View File

@@ -1,5 +0,0 @@
import { registerOrchestrateTool } from "./orchestrate.js";
export function registerOrchestratorTools(server) {
registerOrchestrateTool(server);
}

View File

@@ -1,165 +0,0 @@
import { z } from "zod";
import { detectWorkflow } from "./detector.js";
import { getWorkflow, listWorkflows } from "./workflows/index.js";
/**
* Register the orchestrate_task tool on the MCP server.
*/
export function registerOrchestrateTool(server) {
server.tool(
"orchestrate_task",
"Provides workflow context, domain rules, and step-by-step guidance for Acai CMS tasks. " +
"Returns relevant warnings, resource pointers, and suggested tool order. " +
"Optional but recommended for multi-step tasks — helps avoid common mistakes. " +
"Available workflows: create_section, populate_content, create_module, edit_module, " +
"manage_records, manage_media, seo_setup, explore_site.",
{
task: z.string().describe(
"The user's task or request in their own words. " +
"Example: 'Crear una sección de productos con categorías e imágenes'"
),
forceWorkflow: z.string().optional().describe(
"Optional: force a specific workflow instead of auto-detecting. " +
"Use when auto-detection is wrong or you know exactly which workflow to use. " +
"Values: create_section, populate_content, create_module, edit_module, " +
"manage_records, manage_media, seo_setup, explore_site"
)
},
{ readOnlyHint: true, destructiveHint: false },
async ({ task, forceWorkflow }) => {
try {
let workflowId;
let confidence;
let detectionInfo;
if (forceWorkflow) {
// Forced workflow — skip detection
workflowId = forceWorkflow;
confidence = 1.0;
detectionInfo = { method: "forced", forceWorkflow };
} else {
// Auto-detect workflow from task description
const detection = detectWorkflow(task);
if (!detection.workflow) {
// No workflow matched — return general orientation
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
workflow: "none_detected",
message: "Could not determine a specific workflow for this task. " +
"You can proceed freely using available tools, or specify a workflow with forceWorkflow.",
availableWorkflows: listWorkflows(),
generalRules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)"
]
}, null, 2)
}]
};
}
if (detection.ambiguous) {
// Ambiguous — return top suggestions
const topWorkflow = getWorkflow(detection.workflow);
const altWorkflows = detection.alternatives
.map(a => getWorkflow(a.workflow))
.filter(Boolean);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
workflow: "ambiguous",
message: "Multiple workflows could match this task. " +
"Pick the most appropriate one using forceWorkflow, or proceed with the top match.",
topMatch: {
id: topWorkflow.id,
name: topWorkflow.name,
description: topWorkflow.description,
confidence: detection.confidence
},
alternatives: altWorkflows.map((w, i) => ({
id: w.id,
name: w.name,
description: w.description,
confidence: detection.alternatives[i].confidence
}))
}, null, 2)
}]
};
}
workflowId = detection.workflow;
confidence = detection.confidence;
detectionInfo = {
method: "auto",
confidence: detection.confidence,
alternatives: detection.alternatives
};
}
// Load the workflow
const workflow = getWorkflow(workflowId);
if (!workflow) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: `Unknown workflow: '${workflowId}'`,
availableWorkflows: listWorkflows()
}, null, 2)
}],
isError: true
};
}
// Build the response
const response = {
success: true,
workflow: workflow.id,
name: workflow.name,
description: workflow.description,
confidence,
detection: detectionInfo,
totalSteps: workflow.steps.length,
steps: workflow.steps,
context: workflow.context,
rules: workflow.rules,
warnings: workflow.warnings,
resources: workflow.resources
};
console.error(`[Orchestrator] Detected workflow: ${workflow.id} (confidence: ${confidence}) for task: "${task.substring(0, 80)}..."`);
return {
content: [{
type: "text",
text: JSON.stringify(response, null, 2)
}]
};
} catch (error) {
console.error("[Orchestrator] Error:", error);
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: error.message
}, null, 2)
}],
isError: true
};
}
}
);
}

View File

@@ -1,85 +0,0 @@
export const createModuleWorkflow = {
id: "create_module",
name: "Create Module",
description: "Design and create an HTML module by writing project files directly, then compile it in the CMS.",
steps: [
{
step: 1,
action: "Understand the design",
description: "Clarify with user: what does the module show? Is it a hero, grid, list, slider, CTA, form?",
tool: null,
critical: "Get clear requirements before writing code. Ask about: layout, colors, responsive behavior, editable fields."
},
{
step: 2,
action: "Review project styling and patterns",
description: "Use the saved project styles and nearby modules as reference before writing code.",
tool: "save_project_styles",
critical: "Align typography, spacing, colors, and component patterns with the existing project."
},
{
step: 3,
action: "Create the module files",
description: "Write index-base.tpl, style.css, script.js, and optional hook.php directly in the module folder.",
tool: "acai-write",
critical: "Use project-relative paths. Create complete files. Keep variable names lowercase, descriptive, and stable."
},
{
step: 4,
action: "Refine targeted blocks if needed",
description: "Use incremental replacements for small fixes instead of rewriting whole files.",
tool: "acai-line-replace",
critical: "Prefer block edits for existing files to reduce token usage and avoid accidental rewrites."
},
{
step: 5,
action: "Compile the module",
description: "Compile after editing index-base.tpl so the CMS syncs index.tpl and builder metadata.",
tool: "compile_module",
critical: "This is mandatory after every index-base.tpl change."
},
{
step: 6,
action: "Set example data",
description: "Set example/static data for module preview. MUST call get_module first to discover the variable schema.",
tool: "set_module_example_data",
critical: "Call get_module first to get ALL variable names. Then fill EVERY variable with realistic example data. Missing variables = blank preview."
},
{
step: 7,
action: "Check module rendering",
description: "Test the module with specific variable values to verify it renders correctly.",
tool: "check_module",
critical: "Test with realistic values. Check for Twig syntax errors, broken images, layout issues."
}
],
context: {
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield (single line text), headfield (heading), textbox (multiline), wysiwyg (rich HTML), link (URL), upload (single image), uploadBackground (background image), uploadMulti (gallery), list (dropdown options), multiv2 (repeatable block).",
component_syntax: "c-if='varname' shows/hides element based on variable. c-for='item in items' loops over array. c-hidden='true' makes element invisible (for config vars). c-else after c-if for alternative content.",
module_structure: "Create index-base.tpl, style.css, script.js, and optional hook.php in the module directory. Compile to generate builder.json and the public templates.",
css_conventions: "Use TailwindCSS by default. For custom CSS: BEM naming with kebab-case. Root class should match module name. Avoid !important.",
upload_in_modules: "Upload fields are arrays. Single image: {{ varname[0].urlPath | imagec(WIDTH) }}. Background: style=\"background-image: url('{{ varname[0].urlPath | imagec(1920) }}')\". Gallery: {% for img in varname %}{{ img.urlPath }}{% endfor %}."
},
rules: [
"Variable names: lowercase, no spaces, no accents, no special characters",
"Labels must be UNIQUE — duplicate labels create shared fields",
"Upload fields are ALWAYS arrays — access with [0].urlPath",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"c-if='varname' for conditional rendering of optional fields",
"c-hidden='true' for configuration variables not shown to end user",
"data-field-width on upload elements to set image optimization width",
"For multiv2 (repeatable): parent element needs data-field-type='multiv2', children are the repeated fields"
],
warnings: [
"DO NOT use duplicate labels — they create shared/linked fields",
"DO NOT forget to set example data — the module will appear blank in the editor",
"DO NOT use Twig functions (range, random, etc.) — only filters work",
"DO NOT access upload vars as strings — always use varname[0].urlPath (array)",
"DO NOT mix React/Vue syntax — use Twig for templating, vanilla JS for interactivity"
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-atributos-acai",
"acai://resources/guia-programacion-acai"
]
};

View File

@@ -1,114 +0,0 @@
export const createSectionWorkflow = {
id: "create_section",
name: "Create New Section",
description: "Full workflow for creating a new website section: table + fields + module + template + content.",
steps: [
{
step: 1,
action: "Understand requirements",
description: "Clarify with user: section name, type (multi/single/category), fields needed, whether it needs URL (enlace), SEO meta tags.",
tool: null,
critical: "Ask before acting. Multi = multiple records (blog, products). Single = one record (about page). Category = grouping for other sections."
},
{
step: 2,
action: "Check existing tables",
description: "List current tables to avoid naming conflicts and understand existing structure.",
tool: "list_tables",
critical: "Table names must be unique. Check if a similar section already exists."
},
{
step: 3,
action: "Create the table",
description: "Create the database table with correct type and configuration. Pass enlace=true if records need public URLs; pass seoMetas=true if records need SEO meta fields. Those flags are enough — there is no update_table_schema step afterwards.",
tool: "create_table",
critical: "menuType must be: 'multi' (multiple records), 'single' (one record), 'category' (grouping), or 'separador' (menu separator). Set enlace=true if records need their own URL page. Set seoMetas=true if you want the SEO meta fields added from the start."
},
{
step: 4,
action: "Add fields to the table",
description: "Create each necessary field with the correct type. create_field is a single-field operation — call it once per field.",
tool: "create_field",
critical: "One call per field. Field types: textfield, textbox, wysiwyg, date, checkbox, list, upload, multitext, codigo, separator. Pass isRequired / maxLength / listType / etc. via initialProps."
},
{
step: 5,
action: "Verify table schema",
description: "Get the complete schema to confirm all fields were created correctly.",
tool: "get_table_schema",
critical: "Verify all fields exist with correct types before proceeding to module creation."
},
{
step: 6,
action: "Design and create the listing module",
description: "Create the listing module that displays a list/grid of records. Use create_module to scaffold the folder, then acai-write on index-base.tpl with the Twig (compile runs automatically).",
tool: "create_module",
critical: "Use Twig syntax. Access records with the 'get' filter. Primary key is 'num' not 'id'. Upload fields are ALWAYS arrays: use record.field[0].urlPath | imagec(width). After create_module, use acai-write on index-base.tpl to set the actual template."
},
{
step: 7,
action: "Set module example data",
description: "Set example/static data for module preview. MUST call get_module_config_vars first to discover ALL variables.",
tool: "set_module_example_data",
critical: "Every builder variable must have example data. Missing variables cause blank previews."
},
{
step: 8,
action: "Add sample content",
description: "Create 2-3 sample records with realistic content and images. If table has enlace=true, include the 'enlace' field with a URL slug.",
tool: "create_or_update_record",
critical: "Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0. Upload fields: use upload_record_image separately. For sections with enlace, create records BEFORE creating the general section to ensure directory structure is ready."
},
{
step: 9,
action: "Create the general section (detail template) — if enlace=true",
description: "If the table has enlace enabled, create a module named literally 'custom-{tableName}' in template/estandar/modulos/. This module renders every record's URL automatically; there is NO _detailPage field to configure. Use acai-write on template/estandar/modulos/custom-{tableName}/index-base.tpl — it creates the folder and triggers the compile.",
tool: "acai-write",
critical: "Folder name must be EXACTLY 'custom-' + tableName (e.g. table 'vacantes' → 'custom-vacantes'). The CMS routes by this convention. Inside the template access the current record via `thisrecord.fieldname`. Do NOT create a separate listing page in 'apartados' for details, do NOT use query params, do NOT use hooks to fetch the record."
},
{
step: 10,
action: "Verify the result",
description: "Check module rendering with actual variable values.",
tool: "check_module",
critical: "Test with actual variable values to ensure no rendering errors."
}
],
context: {
twig_filters: "Use 'get' filter for DB queries: {% set items = 'tablename' | get('WHERE active=1', 'ORDER BY num DESC', 10) %}. Use 'imagec' for image resize: {{ path | imagec(400) }}. Use 'module' to include other modules: {{ 'modulename' | module(vars) }}.",
field_types: "textfield (single line), textbox (multiline), wysiwyg (rich HTML), date (YYYY-MM-DD), checkbox (0/1), list (dropdown/radio/checkbox), upload (files/images), multitext (key-value pairs), codigo (code editor), separator (visual divider).",
list_field_config: "Static options: optionsType='text', optionsText='value1|Label 1\\nvalue2|Label 2'. Table relation: optionsType='table', optionsTablename='tablename', optionsValueField='num', optionsLabelField='name'. SQL: optionsType='query', optionsQuery='SELECT num,name FROM cms_tablename'.",
builder_vars: "data-field-type attribute on HTML elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2. Variable names derived from labels (lowercase, no spaces/accents).",
upload_rules: "Upload fields ALWAYS return arrays. Single image: {{ record.imagen[0].urlPath | imagec(WIDTH) }}. Gallery loop: {% for img in record.galeria %}{{ img.urlPath }}{% endfor %}. Check existence: {% if record.imagen and record.imagen|length > 0 %}.",
general_section_convention: "For any table with enlace=true, the detail template is a module at template/estandar/modulos/custom-{tableName}/. The CMS binds it automatically — no configuration required. Use `thisrecord.field` inside the Twig to access the record being rendered."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"Enlace (URL slug): auto-formatted to /path/ with slashes",
"Variable names in modules: lowercase, no spaces, no accents, no special chars",
"c-if='varname' for conditional rendering, c-hidden='true' for invisible config vars",
"When using 'get' filter: SQL string syntax, NOT objects. Example: 'WHERE num > 5'",
"The general section (record detail) is ALWAYS a module named 'custom-{tableName}' — never a separate page in 'apartados'."
],
warnings: [
"DO NOT use record.imagen.urlPath — it's record.imagen[0].urlPath (array!)",
"DO NOT use 'id' as primary key — Acai uses 'num'",
"DO NOT forget to set example data after creating a module — it will look blank",
"DO NOT create a detail template if enlace is false — there's no URL to navigate to",
"DO NOT use Twig functions like range() — only filters (pipe syntax) are available",
"DO NOT configure '_detailPage' or any similar field — it does not exist; routing is by the 'custom-{tableName}' convention.",
"DO NOT create individual pages in 'apartados' for each record — the general section handles all records of the table automatically.",
"For best results with new enlace sections, create records BEFORE creating the general section so the directory structure exists."
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-twig-filters",
"acai://resources/guia-atributos-acai",
"acai://resources/guia-registros"
]
};

View File

@@ -1,64 +0,0 @@
export const editModuleWorkflow = {
id: "edit_module",
name: "Edit Module",
description: "Modify an existing HTML module: update code, styles, variables, or structure.",
steps: [
{
step: 1,
action: "Get current module code",
description: "Read the current HTML, CSS, JS, and PHP of the module.",
tool: "get_module",
critical: "ALWAYS read the current code before modifying. Understand existing variables, structure, and styling."
},
{
step: 2,
action: "Check where it's used",
description: "Find all pages and records using this module to understand impact.",
tool: "check_module_usage",
critical: "Know the blast radius of your changes — how many live pages will be affected."
},
{
step: 3,
action: "Make changes",
description: "Update the module code with the required modifications.",
tool: "save_module",
critical: "Pass the module 'id' parameter to update (not create). save_module REPLACES the entire module — include ALL html/css/js, not just the changed parts."
},
{
step: 4,
action: "Update example data if needed",
description: "If you added or renamed variables, update the example data to match.",
tool: "set_module_example_data",
critical: "Call get_module first to discover new variable names. Fill ALL variables, including new ones."
},
{
step: 5,
action: "Verify rendering",
description: "Test the modified module with variable values to confirm changes work.",
tool: "check_module",
critical: "Test with realistic values. Compare rendering before and after changes."
}
],
context: {
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2.",
component_syntax: "c-if='varname' shows/hides element. c-for='item in items' loops. c-hidden='true' invisible config. c-else after c-if.",
save_behavior: "save_module with 'id' parameter = UPDATE. Without 'id' = CREATE new. The tool replaces the ENTIRE module code, not a diff."
},
rules: [
"ALWAYS include the full html/css/js when saving — save_module replaces everything",
"Pass the 'id' parameter to update an existing module",
"Variable names: lowercase, no spaces, no accents",
"Labels must be UNIQUE across the module",
"Upload fields are ALWAYS arrays — access with [0].urlPath"
],
warnings: [
"DO NOT remove existing variables without checking usage — they may have data on live pages",
"DO NOT rename variables — it breaks existing data bindings. Add new ones instead if needed",
"DO NOT save partial code (just HTML without CSS) — save_module replaces ALL sections",
"DO NOT forget to update example data when adding new variables"
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-atributos-acai"
]
};

View File

@@ -1,48 +0,0 @@
export const exploreSiteWorkflow = {
id: "explore_site",
name: "Explore Site",
description: "Get an overview of the current Acai site: sections, modules, content.",
steps: [
{
step: 1,
action: "List all tables/sections",
description: "Get the complete site structure with all sections, their types, and menu order.",
tool: "list_tables",
critical: "This returns the site's skeleton: all sections with type (multi/single/category/separador), menu name, and order."
},
{
step: 2,
action: "Inspect sections of interest",
description: "Get the full schema of specific sections to understand their fields and configuration.",
tool: "get_table_schema",
critical: "Look at field types, required fields, list configurations, and upload fields."
},
{
step: 3,
action: "List all modules",
description: "See all available design components/modules.",
tool: "list_modules",
critical: "Modules are the visual building blocks. Each has HTML, CSS, JS, and builder variables."
},
{
step: 4,
action: "Sample content",
description: "Preview records in key sections to understand what content exists.",
tool: "list_table_records",
critical: "Use limit=5 to get a representative sample without overwhelming the response."
}
],
context: {
orientation: "list_tables returns all sections with their type: 'multi' (multiple records like blog/products), 'single' (one record like about page), 'category' (grouping for other sections), 'separador' (menu separator). This is the site's architecture.",
modules_overview: "list_modules shows all components. Use get_module on specific ones to see their HTML/CSS/JS code and builder variables."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"Primary key is 'num', never 'id'"
],
warnings: [
"DO NOT modify anything during exploration — this workflow is read-only",
"DO NOT assume field names — always check the schema first"
],
resources: []
};

View File

@@ -1,44 +0,0 @@
import { createSectionWorkflow } from "./createSection.js";
import { populateContentWorkflow } from "./populateContent.js";
import { createModuleWorkflow } from "./createModule.js";
import { editModuleWorkflow } from "./editModule.js";
import { manageRecordsWorkflow } from "./manageRecords.js";
import { manageMediaWorkflow } from "./manageMedia.js";
import { seoSetupWorkflow } from "./seoSetup.js";
import { exploreSiteWorkflow } from "./exploreSite.js";
/**
* Registry of all available workflows.
* Keyed by workflow ID for fast lookup.
*/
export const WORKFLOWS = {
create_section: createSectionWorkflow,
populate_content: populateContentWorkflow,
create_module: createModuleWorkflow,
edit_module: editModuleWorkflow,
manage_records: manageRecordsWorkflow,
manage_media: manageMediaWorkflow,
seo_setup: seoSetupWorkflow,
explore_site: exploreSiteWorkflow,
};
/**
* Get a workflow by ID.
* @param {string} id - Workflow identifier
* @returns {object|null} The workflow definition or null
*/
export function getWorkflow(id) {
return WORKFLOWS[id] || null;
}
/**
* Get a summary list of all available workflows (for help/listing).
*/
export function listWorkflows() {
return Object.values(WORKFLOWS).map((w) => ({
id: w.id,
name: w.name,
description: w.description,
totalSteps: w.steps.length,
}));
}

View File

@@ -1,53 +0,0 @@
export const manageMediaWorkflow = {
id: "manage_media",
name: "Manage Media",
description: "Image upload, generation, replacement, and management.",
steps: [
{
step: 1,
action: "Prepare or generate images",
description: "Use an existing image URL/asset or generate an AI image for the content.",
tool: "generate_image",
critical: "generate_image uses Nano Banana AI. Existing remote image URLs can also be passed directly to upload tools."
},
{
step: 2,
action: "Upload to record",
description: "Attach images to a record's upload field.",
tool: "upload_record_image",
critical: "Requires: tableName, recordId, fieldName, imageUrl. The image is downloaded server-side and attached to the record."
},
{
step: 3,
action: "List current uploads",
description: "Check what's already uploaded in a field to know if replacing or adding.",
tool: "list_record_uploads",
critical: "Returns array of upload objects with uploadId needed for replace/delete operations."
},
{
step: 4,
action: "Replace or delete if needed",
description: "Replace an existing image or delete an upload.",
tool: "replace_record_image OR delete_record_upload",
critical: "Both require the uploadId from list_record_uploads. replace_record_image downloads new image and swaps it."
}
],
context: {
upload_structure: "Upload fields store arrays of objects: [{urlPath, fileName, fileSize, mimeType, uploadDate}]. Access in Twig templates: record.field[0].urlPath | imagec(width).",
image_sources: "Use existing remote image URLs, project assets, or Nano Banana AI image generation.",
assets_upload: "upload_image_to_assets: uploads to website /images/ folder (not tied to a record). Accepts base64, data URI, or URL. Can resize and compress.",
s3_upload: "upload_image_to_s3: uploads to Amazon S3. Returns public S3 URL. Accepts URL, local path, base64, or data URI."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"Primary key is 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use imagec filter for resizing: {{ path | imagec(width_in_pixels) }}"
],
warnings: [
"DO NOT try to upload before creating the record — the record must exist first",
"DO NOT confuse upload_record_image (attaches to record) with upload_image_to_assets (saves to /images/ folder)",
"DO NOT delete uploads without confirming — the image will be removed from the live page"
],
resources: []
};

View File

@@ -1,64 +0,0 @@
export const manageRecordsWorkflow = {
id: "manage_records",
name: "Manage Records",
description: "CRUD operations on existing records: query, create, update, and delete data.",
steps: [
{
step: 1,
action: "Get table schema",
description: "Understand field names, types, and constraints before querying or modifying.",
tool: "get_table_schema",
critical: "Know the exact field names and types. Upload fields require special handling."
},
{
step: 2,
action: "Query records",
description: "List or search records to find the ones to work with.",
tool: "list_table_records",
critical: "Use 'where' param for SQL WHERE filtering. Use 'limit' for pagination. Use 'page' for page navigation."
},
{
step: 3,
action: "Create or update records",
description: "Create new records or update existing ones with correct field values.",
tool: "create_or_update_record",
critical: "Pass 'recordId' for update, omit for create. Only included fields are modified on update. Field values must match field types."
},
{
step: 4,
action: "Handle uploads if needed",
description: "Upload images or files to record fields.",
tool: "upload_record_image",
critical: "Separate call per image per field per record. Cannot set upload fields via create_or_update_record."
},
{
step: 5,
action: "Verify changes",
description: "Query the records again to confirm changes were applied correctly.",
tool: "list_table_records",
critical: "Confirm all fields have the expected values, including upload fields."
}
],
context: {
querying: "list_table_records supports: where='campo = \"valor\"' (SQL WHERE), page=1 (pagination), limit=20 (records per page). WHERE clause uses SQL string syntax.",
updating: "Pass recordId + fields object to update. Only the fields included in the object are modified — other fields are left unchanged.",
creating: "Omit recordId to create. Can batch insert by passing fields as an array of objects.",
deleting: "delete_table_records requires tableName and recordIds (array of IDs). Use deleteAll=true to delete everything (DANGEROUS)."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"WHERE clauses use SQL string syntax: where='nombre = \"valor\"'"
],
warnings: [
"DO NOT use 'id' to reference records — use 'num'",
"DO NOT set upload fields via create_or_update_record — it will not work",
"DO NOT delete records without confirming with the user first"
],
resources: [
"acai://resources/guia-registros"
]
};

View File

@@ -1,70 +0,0 @@
export const populateContentWorkflow = {
id: "populate_content",
name: "Populate Content",
description: "Bulk record creation with images for an existing section.",
steps: [
{
step: 1,
action: "Get table schema",
description: "Understand all fields and their types before creating records.",
tool: "get_table_schema",
critical: "Know the exact field names and types. Upload fields cannot be set via create_or_update_record."
},
{
step: 2,
action: "List existing records",
description: "Check what already exists to avoid duplicates.",
tool: "list_table_records",
critical: "Review existing content before adding new records."
},
{
step: 3,
action: "Generate images if needed",
description: "Create AI images for the content being created when existing assets are not available.",
tool: "generate_image",
critical: "Generate the image first and use the returned URL for upload later."
},
{
step: 4,
action: "Create records",
description: "Create all records with text content. Can batch insert multiple records in one call.",
tool: "create_or_update_record",
critical: "Batch insert: pass an array of objects in 'fields' parameter. Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0."
},
{
step: 5,
action: "Upload images to records",
description: "Attach images to each record's upload fields.",
tool: "upload_record_image",
critical: "Must call SEPARATELY for each record+field combination. Cannot batch image uploads. Need the record's num/ID from step 4."
},
{
step: 6,
action: "Verify records",
description: "Confirm all records were created with correct data.",
tool: "list_table_records",
critical: "Check that all fields are populated correctly including upload fields."
}
],
context: {
batch_insert: "create_or_update_record supports batch: pass fields as an array of objects instead of a single object. Each object is one record. Returns an array of created record IDs.",
image_sources: "Use existing project/client assets when available, or generate_image for AI-generated images via Nano Banana.",
upload_flow: "1. Create record first (get its num/ID). 2. Then call upload_record_image with tableName, recordId, fieldName, imageUrl. 3. The image is downloaded server-side and attached to the record."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"Enlace field: auto-formatted to /path/ with slashes if not provided"
],
warnings: [
"DO NOT try to set upload field values in create_or_update_record — use upload_record_image after creation",
"DO NOT forget that batch insert returns an array of created record IDs — you need these for image uploads",
"DO NOT upload images before creating the record — the record must exist first"
],
resources: [
"acai://resources/guia-registros"
]
};

View File

@@ -1,60 +0,0 @@
export const seoSetupWorkflow = {
id: "seo_setup",
name: "SEO Setup",
description: "Configure SEO for a section: meta tags, URL slugs, and detail template.",
steps: [
{
step: 1,
action: "Get current table schema",
description: "Check which SEO fields already exist and whether enlace is enabled.",
tool: "get_table_schema",
critical: "Look for seo_title / seo_description / seo_keywords fields and the enlace field in the schema response."
},
{
step: 2,
action: "Add SEO meta fields if missing",
description: "If seo_title / seo_description / seo_keywords are not present, add them as regular fields. Note: for NEW tables you can instead pass seoMetas=true to create_table and they get added up front.",
tool: "create_field",
critical: "One create_field call per SEO field. Typical set: seo_title (textfield), seo_description (textbox), seo_keywords (textfield)."
},
{
step: 3,
action: "Add enlace field if missing",
description: "If the table has no enlace field and records need public URLs, add one. For NEW tables pass enlace=true to create_table instead.",
tool: "create_field",
critical: "fieldName='enlace', type='textfield'. Acai auto-formats the value to /section/slug/. Existing records then get URLs based on this field."
},
{
step: 4,
action: "Update records with SEO data",
description: "Fill in SEO fields for each record: meta title, meta description, keywords.",
tool: "create_or_update_record",
critical: "SEO fields are: seo_title, seo_description, seo_keywords. Check the schema for exact field names before writing."
},
{
step: 5,
action: "Create or update the general section (detail template)",
description: "Ensure the detail page template at template/estandar/modulos/custom-{tableName}/index-base.tpl exists and includes the SEO meta tags. The CMS renders this module automatically on each record URL.",
tool: "acai-write",
critical: "Folder must be EXACTLY 'custom-' + tableName. Inside the Twig, access record data via `thisrecord.seo_title`, `thisrecord.seo_description`, etc. Include these in the <head> via the layout's SEO slot, or inline if the project uses per-section heads."
}
],
context: {
enlace_behavior: "When the table has an 'enlace' field, Acai auto-generates URL slugs in /tableName/record-slug/ format. The value is auto-formatted with slashes.",
seo_fields: "SEO meta fields are just regular textfield/textbox fields named seo_title, seo_description, seo_keywords. For new tables you can skip this step by passing seoMetas=true to create_table.",
detail_template: "For any table with enlace, the record URL is rendered by the module 'custom-{tableName}' (convention — not configurable). The module accesses the current record via `thisrecord`. There is no '_detailPage' field."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"Enlace values are auto-formatted to /path/ format",
"SEO fields are regular fields, not a special flag on the schema",
"The general section (detail template) is ALWAYS a module named 'custom-{tableName}' — never a separate page in 'apartados'.",
"There is no update_table_schema / _detailPage — routing is by convention on the module folder name."
],
warnings: [
"DO NOT enable enlace on a 'single' type table — single tables have one record and usually don't need individual URLs",
"DO NOT forget to create the 'custom-{tableName}' module after enabling enlace — without it, record URLs show blank pages",
"DO NOT configure '_detailPage' — it does not exist."
],
resources: []
};

View File

@@ -9,16 +9,16 @@ import { canAccessTable } from "../helpers/accessControl.js";
export function registerCreateOrUpdateRecordTool(server) { export function registerCreateOrUpdateRecordTool(server) {
server.tool( server.tool(
"create_or_update_record", "create_or_update_record",
`Create or update records in a database table. Before using: read resource 'acai-cheat-sheet' for domain rules, then check table schema with get_table_schema. `Crea o actualiza registros en una tabla. Antes de usar: consulta el schema con 'get_table_schema' (sin 'cms_'); si dudas del formato lee 'read_doc({ name: "11-quick-reference" })' o '06-hooks-and-cmsapi'.
Key rules: tables without 'cms_' prefix, primary key is 'num', uploads are arrays (use upload_record_image after creating record), dates as YYYY-MM-DD HH:mm:ss, checkboxes as 1/0, enlace as /path/. Reglas clave: tablas sin prefijo 'cms_'; PK es 'num' (nunca 'id'); foreign keys con sufijo '_num'; uploads son arrays — NO los envíes en 'fields', sube después con 'upload_record_image'; fechas en formato YYYY-MM-DD HH:mm:ss; checkboxes como 1/0 (números).
For builder tables (e.g. 'apartados'): must include num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace fields. See resource 'guia-registros' for full field type reference.`, Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates.`,
withAuthParams({ withAuthParams({
tableName: z.string().describe("Name of the table (without 'cms_' prefix, e.g., 'productos', 'equipo')"), tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"),
recordId: z.any().optional().describe("Record ID for updating. Leave empty to create new record. NOT USED when records is an array."), recordId: z.any().optional().describe("'num' del registro a actualizar. Omitir para crear nuevo. NO se usa cuando 'fields' es array."),
fields: z.any().describe("Single record object OR array of record objects for batch insert. Example: { nombre: 'Product 1' } or [{ nombre: 'Product 1' }, { nombre: 'Product 2' }]. IMPORTANT: Always consult 'guia-registros' for field types and formats and check if is table with builder fields."), fields: z.any().describe("Objeto único o array de objetos para inserción batch. Ejemplo: { nombre: 'Producto 1' } o [{ nombre: 'A' }, { nombre: 'B' }]. Antes consulta el schema y, si dudas, lee 'read_doc({ name: \"11-quick-reference\" })'."),
tableSchema: z.any().describe("Provide the table schema object to validate field types before sending to API. If not provided, schema will not be validated."), tableSchema: z.any().describe("Schema de la tabla para validar tipos antes de enviar (opcional)."),
}), }),
{ readOnlyHint: false, destructiveHint: false }, { readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, recordId, fields }, extra) => { withAuth(async ({ tableName, recordId, fields }, extra) => {

View File

@@ -558,6 +558,22 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
embeddings = [None] * len(docs_data) embeddings = [None] * len(docs_data)
has_embeddings = False has_embeddings = False
# Limpia entradas huérfanas: docs que ya no existen en el filesystem.
# Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' →
# '01-builder-fields') quedarían en Redis y aparecerían en el ranking.
current_ids = {d[0] for d in docs_data}
existing_docs = await memory.list_documents(namespace="knowledge")
removed = []
for existing in existing_docs:
if existing.memory_id not in current_ids:
await memory.delete_document(existing.memory_id, namespace="knowledge")
# Borra también el embedding asociado
embed_key = memory._key("embeddings", "knowledge", existing.memory_id)
await memory._r.delete(embed_key)
removed.append(existing.memory_id)
if removed:
logger.info("Removed %d stale knowledge docs: %s", len(removed), removed)
# Store docs + embeddings # Store docs + embeddings
loaded = [] loaded = []
for i, (doc_id, title, content, summary, tags) in enumerate(docs_data): for i, (doc_id, title, content, summary, tags) in enumerate(docs_data):
@@ -587,6 +603,7 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
return { return {
"status": "loaded", "status": "loaded",
"count": len(loaded), "count": len(loaded),
"removed": removed,
"embeddings": has_embeddings, "embeddings": has_embeddings,
"documents": loaded, "documents": loaded,
} }
@@ -641,6 +658,109 @@ async def delete_knowledge(doc_id: str) -> dict[str, str]:
return {"status": "deleted", "id": doc_id} return {"status": "deleted", "id": doc_id}
def _list_doc_sections(content: str) -> list[str]:
"""Lista los headings H2 (## ...) de un doc markdown."""
sections = []
for line in content.splitlines():
stripped = line.lstrip()
# Solo H2 — exactamente "## " y no "### "
if stripped.startswith("## ") and not stripped.startswith("### "):
sections.append(stripped[3:].strip())
return sections
def _extract_doc_section(content: str, section_query: str) -> str | None:
"""Extrae una sección por heading H2. Match case-insensitive, parcial.
Devuelve el bloque desde el `## heading` hasta el siguiente `## ` (o EOF).
"""
if not section_query:
return None
section_lower = section_query.lower().strip()
captured: list[str] = []
capture = False
for line in content.splitlines():
stripped = line.lstrip()
is_h2 = stripped.startswith("## ") and not stripped.startswith("### ")
if is_h2:
heading = stripped[3:].strip()
if capture:
# Llegamos al siguiente H2 — paramos
break
if section_lower in heading.lower():
capture = True
captured.append(line)
continue
if capture:
captured.append(line)
if captured:
return "\n".join(captured).rstrip()
return None
@router.get("/knowledge/{doc_id}")
async def read_knowledge(
doc_id: str,
section: str | None = None,
) -> dict[str, Any]:
"""Lee un doc del knowledge base. Opcionalmente, una sola sección por heading H2.
- Sin `section`: devuelve el contenido completo.
- Con `section`: busca el primer H2 cuyo título contenga `section`
(case-insensitive, parcial) y devuelve hasta el siguiente H2.
- Si la sección no existe, devuelve `available_sections` para que el
cliente reintente con un nombre válido.
"""
memory = _deps.get("memory_store")
if not memory:
raise HTTPException(status_code=501, detail="Memory store not available")
doc = await memory.get_document(doc_id, namespace="knowledge")
if not doc:
raise HTTPException(
status_code=404,
detail=f"Document '{doc_id}' not found in knowledge base",
)
available_sections = _list_doc_sections(doc.content)
if section:
section_content = _extract_doc_section(doc.content, section)
if section_content is None:
return {
"id": doc.memory_id,
"title": doc.title,
"section_requested": section,
"section_found": False,
"available_sections": available_sections,
"content": "",
"chars": 0,
}
return {
"id": doc.memory_id,
"title": doc.title,
"section": section,
"section_found": True,
"chars": len(section_content),
"content": section_content,
}
return {
"id": doc.memory_id,
"title": doc.title,
"section": None,
"section_found": True,
"chars": len(doc.content),
"available_sections": available_sections,
"content": doc.content,
}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# MCP Management # MCP Management
# ------------------------------------------------------------------ # ------------------------------------------------------------------