ajustes
This commit is contained in:
@@ -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 1–2 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
444
docs/01-builder-fields.md
Normal 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
260
docs/02-twig.md
Normal 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.
|
||||||
200
docs/03-modules-and-sections.md
Normal file
200
docs/03-modules-and-sections.md
Normal 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`.
|
||||||
186
docs/04-pages-and-records.md
Normal file
186
docs/04-pages-and-records.md
Normal 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.
|
||||||
272
docs/05-tables-and-fields.md
Normal file
272
docs/05-tables-and-fields.md
Normal 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
383
docs/06-hooks-and-cmsapi.md
Normal 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>/`).
|
||||||
283
docs/07-css-js-conventions.md
Normal file
283
docs/07-css-js-conventions.md
Normal 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).
|
||||||
212
docs/08-layout-and-libraries.md
Normal file
212
docs/08-layout-and-libraries.md
Normal 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.
|
||||||
245
docs/09-mcp-tools-reference.md
Normal file
245
docs/09-mcp-tools-reference.md
Normal 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.
|
||||||
@@ -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,9 +176,9 @@ 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>
|
||||||
@@ -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
128
docs/11-quick-reference.md
Normal 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).
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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];
|
|
||||||
?>
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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`
|
|
||||||
@@ -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)
|
|
||||||
@@ -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) |
|
|
||||||
@@ -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)`
|
|
||||||
151
mcp-server/tools/docs/_docsReader.js
Normal file
151
mcp-server/tools/docs/_docsReader.js
Normal 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();
|
||||||
|
}
|
||||||
20
mcp-server/tools/docs/index.js
Normal file
20
mcp-server/tools/docs/index.js
Normal 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);
|
||||||
|
}
|
||||||
29
mcp-server/tools/docs/list_docs.js
Normal file
29
mcp-server/tools/docs/list_docs.js
Normal 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", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
68
mcp-server/tools/docs/read_doc.js
Normal file
68
mcp-server/tools/docs/read_doc.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { registerOrchestrateTool } from "./orchestrate.js";
|
|
||||||
|
|
||||||
export function registerOrchestratorTools(server) {
|
|
||||||
registerOrchestrateTool(server);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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: []
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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: []
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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: []
|
|
||||||
};
|
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user