docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)

Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp,
GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt.

- 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter)
- 01: Elementor classico (paginas, layout, widgets, templates, globals)
- 02: Elementor Atomic v4 + Gutenberg
- 03: WordPress core (conteudo, media, settings, temas)
- 04: Themer (CPT, condicoes, render, PHP templates)
- 05: Redirects + change ledger unificado (rollback)
- 06: Sandbox PHP snippets + custom widgets
- 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco)
- 08: Integracoes terceiros (ACF, Meta Box, forms, SEO)
- 09: Stock images + Cloud + OAuth
- 10: Sistema de modulos + inventario Pro-only (30 classes)
- INDEX: sintese, sequencia de construcao, tabela de risco

Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de
consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
This commit is contained in:
Claude Code
2026-08-19 06:41:04 +01:00
commit 8ada367bd0
12 changed files with 6301 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
# 00 — Arquitectura do EMCP Tools e blueprint da réplica
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Autor terceiro: Mian Shahzad Raza (`msrbuilds.com`), repo público
`github.com/msrbuilds/elementor-mcp`, licença **GPL-2.0-or-later**.
## 0. Achado mais importante: a peça MCP não é do fornecedor — é do WordPress core team
O plugin **não implementa o protocolo MCP**. Depende de duas bibliotecas Composer, ambas
**projectos oficiais da equipa de IA do WordPress.org** (`WordPress AI Team`,
`make.wordpress.org/ai`), GPL-2.0-or-later, públicas:
| Pacote | Namespace PHP | Repo | O que faz |
|---|---|---|---|
| `wordpress/mcp-adapter` | `WP\MCP\` | `github.com/wordpress/mcp-adapter` | Expõe abilities da Abilities API como servidor MCP (`McpAdapter`, `McpServer`, `HttpTransport`, `SessionManager`, `RequestRouter`, `HttpSessionValidator`, `McpErrorFactory`) — cria o endpoint REST `/wp-json/mcp/{route}` |
| `wordpress/php-mcp-schema` | `WP\McpSchema\` | `github.com/WordPress/php-mcp-schema` | DTOs PHP para o schema oficial do protocolo MCP |
A **Abilities API em si é núcleo do WordPress 6.9+/7.0** (`wp_register_ability()`,
`wp_get_ability()`, `wp_get_abilities()`, hooks `wp_abilities_api_categories_init` /
`wp_abilities_api_init`) — não é código do plugin nem do adapter.
**Consequência directa para a réplica:** não há nada de protocolo para reimplementar.
`composer require wordpress/mcp-adapter` (ou vendorizar uma cópia, como o EMCP faz — ver §1)
dá-nos a mesma máquina JSON-RPC/sessão/transporte. O trabalho real do "clone" é: (a) registar
as nossas próprias abilities via `wp_register_ability()`, com o nosso próprio prefixo (ex.
`descomplicar-tools/*`), e (b) `$mcp_adapter->create_server(...)` com o nosso `server_id`. Tudo o
resto — catálogo de ~165 tools, módulos, sandbox, change-ledger, content-mirror — é trabalho de
aplicação, não de protocolo.
**Nota de licenciamento:** por ser GPL-2.0-or-later, nada impede legalmente copiar/adaptar o
código-fonte do EMCP Tools directamente para um plugin próprio (fork), mantendo a licença GPL a
jusante. "Réplica 100% nossa" pode ser lida como (a) fork com rebranding + remoção do Freemius +
namespace próprio (muito menos esforço, reutiliza ~78k linhas já testadas), ou (b) reimplementação
limpa a partir desta especificação (mais esforço, zero dependência de código de terceiros,
liberdade total de redesenho). Esta série de documentos serve ambos os caminhos — é a
especificação funcional; a decisão de fork-vs-reescrita é independente dela.
## 1. Estrutura de directórios do plugin
```
emcp-tools/
├── emcp-tools.php # bootstrap: header, guarda Free⇄Pro, Freemius, handoff
├── includes/
│ ├── class-bootstrap.php # boot(): dependências, carrega classes, arranca Plugin
│ ├── class-plugin.php # EMCP_Tools_Plugin (singleton orquestrador) — ver §3
│ ├── class-mcp-adapter-bootstrap.php # preload do WP\MCP\ vendorizado — ver §2
│ ├── class-schema-compat.php # emcp_tools_register_ability() + normalização de schema
│ ├── abilities/
│ │ ├── class-ability-registrar.php # regista TODOS os grupos de abilities — índice mestre
│ │ ├── class-dispatcher-abilities.php # compact tool mode (list-tools/get-tool-schema/call-tool)
│ │ ├── class-{grupo}-abilities.php # ~40 classes de abilities (uma por grupo funcional)
│ │ ├── forms/class-{plugin}-integration.php # CF7 (free) + dispatcher genérico
│ │ └── seo/class-{plugin}-integration.php # SlimSEO (free) + dispatcher genérico
│ ├── modules/ # os 9 módulos opcionais + registry — ver doc 10
│ ├── themer/ # Themer CPT/render/condições/blocos/widgets/PHP — ver doc 04
│ ├── sandbox/ # armazenamento de artefactos sandbox (bundle/paths/store) — ver doc 06
│ ├── security/ # 4 auditores + finding — ver doc 07
│ ├── performance/ # 3 auditores + finding — ver doc 07
│ ├── redirects/ # handler + store — ver doc 05
│ ├── cloud/ # ligação/sync EMCP Cloud — ver doc 09
│ ├── oauth/ # servidor OAuth para autenticação MCP remota — ver doc 09
│ ├── wpcli/ # runner/validator/jobs para run-wp-cli — ver doc 07
│ ├── widgets/ # catálogos de widgets Elementor (free/woo/pro) — ver doc 02
│ ├── blocks-catalog/ # catálogos Kadence Blocks / Spectra — ver doc 03
│ ├── schemas/ # gerador de JSON Schema a partir de controls Elementor
│ ├── validators/ # validador de settings/elementos
│ ├── admin/ # UI de admin: class-admin.php (6115 linhas!), views/*, mcpb-builder
│ └── vendors/fremius/ # SDK Freemius (licenciamento/billing) — ~50k linhas, IRRELEVANTE p/ réplica
├── vendor/wordpress/ # mcp-adapter + php-mcp-schema vendorizados (ver §0)
├── vendor/enshrined/svg-sanitize/ # sanitização SVG (módulo svg-support)
├── vendor/automattic/jetpack-autoloader/ # autoloader partilhado entre plugins que usam o adapter
└── prompts/, assets/, bin/, languages/
```
**Total:** ~128 465 linhas PHP fora de `vendor/`/`languages/` (medido 19-08-2026); ~50k dessas
são o SDK Freemius bundled (irrelevante para a réplica — ver §6). Ficheiro maior de longe:
`includes/admin/class-admin.php`, **6115 linhas** (catálogo de tools para a UI, aplicação do
deny-list incremental de 34 versões, todas as views de admin — ver doc dedicado se necessário).
## 2. Cadeia de arranque (ordem real de hooks)
```
emcp-tools.php carrega no arquivo (topo do request)
↓ guarda Free⇄Pro (§7), depois Freemius init
↓ require class-bootstrap.php
add_action('plugins_loaded', [Bootstrap, 'boot'], 20)
↓
plugins_loaded:20 → EMCP_Tools_Bootstrap::boot()
↓ EMCP_Tools_Adapter_Bootstrap::ensure() — preload do WP\MCP\ vendorizado (ver §2.1)
↓ require de todas as classes includes/**/*.php
↓ EMCP_Tools_Plugin::instance() → init()
add_action('wp_abilities_api_categories_init', register_category)
add_action('wp_abilities_api_init', register_abilities) # regista ~165 abilities
add_action('mcp_adapter_init', register_mcp_server, 20) # cria o servidor MCP
add_filter('emcp_tools_ability_names', filter_disabled_tools) # aplica o deny-list
add_filter('rest_pre_dispatch', MCP_Host_Guard::guard, 5) # valida Host header
add_filter('rest_pre_dispatch'/'rest_post_dispatch', mcp_log_*) # log de pedidos MCP
↓
(Abilities API é lazy — só inicializa no primeiro wp_get_ability())
O PRÓPRIO adapter, ao arrancar (mcp_adapter_init prioridade 10), chama wp_get_ability()
internamente para descobrir tools → dispara wp_abilities_api_init → register_abilities() corre
ANTES do nosso register_mcp_server (prioridade 20) — por isso $this->ability_names já está
preenchido quando register_mcp_server lê $this->ability_names.
↓
mcp_adapter_init:20 → EMCP_Tools_Plugin::register_mcp_server($mcp_adapter)
→ $mcp_adapter->create_server('emcp-tools-server', 'mcp', 'emcp-tools-server', …, $tools, …)
→ endpoint fica disponível em /wp-json/mcp/emcp-tools-server
```
### 2.1 O preload "authoritative namespace" (`class-mcp-adapter-bootstrap.php`)
Problema real documentado no código (issue #99): quando **vários plugins activos** vendorizam
`wordpress/mcp-adapter` (ex. WooCommerce + EMCP Tools) em versões diferentes, e nem todos usam o
mesmo autoloader partilhado (Jetpack Autoloader), PHP carrega **classes individuais** de cópias
diferentes consoante qual plugin referencia cada classe primeiro — resultado: um `McpAdapter` da
v0.5.0 a par de um `HttpTransport` da v0.4.1 no mesmo request ("sheared namespace"), que falha
com `McpServerError: Session terminated` (JSON-RPC -32600).
**Mitigação do EMCP:** regista um autoloader `spl_autoload_register($cb, true, true)` (throw,
**prepend**) logo no arranque, servindo TODO o namespace `WP\MCP\` a partir da SUA cópia
vendorizada — antes que qualquer outro plugin possa referenciar uma classe do adapter. Reafirma-se
depois de carregar o Jetpack Autoloader do próprio adapter (que também se regista com prepend).
**Implicação para a réplica:** se formos "mais um plugin" a vendorizar `wordpress/mcp-adapter`
lado a lado com EMCP Tools/WooCommerce no mesmo WordPress, herdamos este mesmo risco de colisão.
Duas opções limpas: (a) usar o Jetpack Autoloader nós também (para participar na arbitragem de
versão-mais-alta em vez de competir com autoloaders simples), ou (b) não vendorizar — depender de
uma cópia `wordpress/mcp-adapter` partilhada ao nível do site (plugin dedicado só ao adapter,
todos os outros plugins declaram a dependência) — mais correcto a prazo mas exige coordenação com
todos os plugins que hoje bundlam a sua própria cópia.
## 3. `EMCP_Tools_Plugin` — orquestrador singleton
Ficheiro: `includes/class-plugin.php`. Contrato mínimo para a réplica:
- **`register_category()`** — `wp_register_ability_category('<prefixo>', ['label'=>…,
'description'=>…])`, chamado em `wp_abilities_api_categories_init`.
- **`register_abilities()`** — `$this->ability_names = $registrar->register_all($elementor_active)`,
chamado em `wp_abilities_api_init`. `$elementor_active` vem de
`EMCP_Tools_Bootstrap::elementor_active()` (guarda de dependência — grupos Elementor-dependentes
ficam todos atrás desta flag, ver doc 01/02 e a lista completa em `class-ability-registrar.php`
reproduzida no doc 10).
- **`get_active_ability_names()`** — força a inicialização lazy da Abilities API
(`wp_get_ability('emcp-tools/list-pages')` como gatilho) e devolve o array já filtrado pelo
deny-list; usado pelo dispatcher (compact mode) e por qualquer superfície externa (ex. um chat
de admin) que precise de saber "o que está mesmo disponível agora".
- **`filter_disabled_tools($names)`** — `array_values(array_diff($names,
get_option('emcp_tools_disabled_tools', [])))`, hook em `emcp_tools_ability_names`. **Este é o
único ponto de aplicação do deny-list** — mecanismo do deny-list incremental (34 migrações) já
documentado em `skill://emcp-tools` §8; aqui confirma-se o hook exacto que o aplica.
- **`register_mcp_server($mcp_adapter)`** — chamado em `mcp_adapter_init` prioridade 20 (depois da
Abilities API já ter corrido). Comportamento condicional:
- **Server gate** (`emcp_tools_server_enabled`, on por omissão): se off, abilities continuam
registadas no core mas **nenhum endpoint MCP é criado** — kill-switch total sem desregistar
nada.
- **Compact tool mode** (`emcp_tools_dispatcher_mode`, off por omissão): quando ligado, o
`$tools` passado a `create_server()` é só os 3 nomes do dispatcher
(`emcp-tools/{list-tools,get-tool-schema,call-tool}`) — o resto continua **registado e
invocável via `call-tool`**, só não aparece em `tools/list`. Quando desligado, `$tools` é a
lista cheia (+ as 3 abilities de contexto do core: `core/get-site-info`,
`core/get-user-info`, `core/get-environment-info`, sempre incluídas quando existirem).
- `create_server()` recebe: `server_id='emcp-tools-server'`, `route_namespace='mcp'`,
`route='emcp-tools-server'` (→ endpoint final `/wp-json/mcp/emcp-tools-server`), nome/descrição
(descrição compõe um resumo do ambiente do site via `EMCP_Tools_Site_Context`), versão,
`transports=[HttpTransport::class]`, `tools=$tools`, `resources=[]`, `prompts=[]`, e um
`transport_permission_callback` opcional: se o módulo OAuth (`EMCP_Tools_OAuth_Server`) está
activo, usa Bearer OAuth; senão cai no default do adapter (Application Password / cookie
admin).
## 4. Compact tool mode — o dispatcher de 3 tools
`includes/abilities/class-dispatcher-abilities.php` (ver excerto completo lido nesta sessão).
Sempre **registado** (para `wp_get_ability()` resolver os 3 nomes), mas só **exposto no servidor**
quando `emcp_tools_dispatcher_mode=1` (§3). Este é exactamente o padrão que os 3 servidores
`emcp-tools`/`emcp-emanuelalmeida`/`emcp-descomplicar` deste ecossistema já usam pelo lado do
cliente (system prompt: "Compact tool mode... Discover tools with list-tools, fetch inputs with
get-tool-schema, run with call-tool").
| Tool | O que faz | Enforcement |
|---|---|---|
| `list-tools` | Devolve `{name, description, category, destructive}` para cada ability activa (pós-deny-list), filtrável por `search`/`category`. Inclui `context` (resumo do ambiente via `Site_Context`). | `permission_callback`: `current_user_can('edit_posts')` — é só metadata/routing |
| `get-tool-schema` | Devolve `{description, inputSchema}` por nome, em lote (`names: string[]`). Nomes fora do set activo entram em `unavailable`. | idem |
| `call-tool` | Resolve `wp_get_ability($name)`, corre `check_permissions($args)` **da ability alvo** (nunca do dispatcher), depois `validate_input()` se existir, depois `execute($args)`. | **A gate real é sempre a da tool alvo** — o dispatcher nunca contorna `permission_callback` individual |
**Blueprint para a réplica:** replicar este padrão exactamente — um dispatcher de 3 tools que só
resolve/chama `wp_get_ability()`, nunca duplica lógica de permissão. É ~250 linhas triviais de
reescrever do zero (schema simples, sem estado) — não vale a pena tentar simplificar mais.
## 5. `emcp_tools_register_ability()` — o único ponto de entrada de registo
`includes/class-schema-compat.php`. Toda classe de abilities chama esta função global (nunca
`wp_register_ability()` directamente). Faz 3 coisas antes de delegar:
1. **`sanitize()`** — normaliza o JSON Schema para clientes exigentes: remove valores vazios de
`enum`, força `properties: {}` (objecto, nunca array vazio) — Gemini/Antigravity rejeitam a
forma "errada". Recursivo em `items`/`allOf`/`oneOf`/`anyOf`.
2. **`strictify()`** *(opt-in, `emcp_tools_strict_schemas`, off por omissão)* — reescreve o schema
para o modo "strict function calling" da OpenAI: toda a propriedade em `required`, as
originalmente opcionais tornam-se nullable, `additionalProperties: false` em objectos com
propriedades declaradas. Existe porque CrewAI e stacks OpenAI-compatíveis exigem esta forma; um
schema "normal" (opcionais fora de `required`) é rejeitado por eles.
3. **`wrap_execute_callback()`** — envolve o callback da ability em dois cuidados:
- **Veto de escrita** (`emcp_tools_before_write` filter, só para abilities não-read-only):
um listener pode devolver `WP_Error` para bloquear a escrita antes de correr — mecanismo real
de enforcement de guardrails (usado pela feature Pro "Memory Enforcer"). Seam útil para nós:
dá um ponto único onde qualquer política de governança (ex. CARL) pode vetar uma escrita MCP
sem tocar em cada ability individualmente.
- **`normalize_result()`** — garante que o retorno cabe em `structuredContent` do MCP (que o
schema tipa como objecto `{[key:string]:unknown}`, nunca lista). Arrays associativos e
objectos passam直; listas/escalares/`null` são embrulhados em `{data: …}`; `array()` vazio
TAMBÉM é embrulhado (PHP não distingue lista vazia de mapa vazio — desembrulhado serializa
para `[]`, forma inválida). **Bug real que isto evita:** a integração WooCommerce devolve
`WP_REST_Response::get_data()` verbatim, e várias rotas `wc/v3` respondem com array de topo
(ex. `report-products-totals`) — sem este wrapper, clientes MCP estritos rejeitam a resposta.
**Blueprint para a réplica:** replicar exactamente este envelope (sanitize + normalize_result no
mínimo; strictify só se formos alvo de clientes OpenAI-strict). É a peça que evita uma classe
inteira de bugs "funciona no Claude, parte no CrewAI" — não pular esta camada achando-a
acessória.
## 6. Guardas de infraestrutura (fora do fluxo funcional, mas obrigatórias)
- **`EMCP_Tools_MCP_Host_Guard::guard`** (`rest_pre_dispatch`, prioridade 5) — recusa pedidos MCP
cujo header `Host` já não bate com o `home` do site (conector apontado para um domínio
antigo/temporário). `no_store_headers` (`rest_pre_serve_request`) força `Cache-Control:
no-store` nas respostas MCP — proxies como LiteSpeed/QUIC descartam respostas suspeitas de cache
sem isto (issue nomeada no código).
- **`EMCP_Tools_MCP_Request_Log`** — grava tool/status/duração/`x-request-id` de cada pedido MCP
via `rest_pre_dispatch`(6)/`rest_post_dispatch`(10), só para rotas que começam por
`/mcp/emcp-tools-server` — alimenta a tab "MCP Log" do admin.
## 7. Guarda Free⇄Pro (single-instance)
`emcp-tools.php`, topo do ficheiro (antes de qualquer `require`). Free e Pro são **o mesmo código**
em duas pastas de plugin (`emcp-tools/` vs `emcp-pro/`), distinguidas só por um marcador
`.emcp-pro` no directório. Como WordPress trata as duas pastas como plugins distintos,
`require_once` não deduplica entre elas — activar ambas redeclara todas as classes → fatal error.
A guarda corre **antes de qualquer require**, detecta se já correu neste request
(`defined('EMCP_TOOLS_VERSION')`) e decide: Premium sempre ganha — a cópia Free cede (mostra aviso,
`return` antes de declarar nada) se a Premium estiver activa; a cópia Premium desactiva a Free se
esta já tiver arrancado primeiro. **Não relevante para a réplica** (é housekeeping específico do
modelo de distribuição Free/Pro deste vendor) — mas explica porque é seguro assumir que só UMA
"visão" do código corre por request, mesmo no build Free.
## 8. Superfície total (dimensionamento)
Ver `class-ability-registrar.php::register_groups()` para a lista EXACTA de classes/condições —
reproduzida integralmente no doc 10 (módulos) como referência cruzada, porque a ordem de registo
ali É a fonte de verdade de que grupo depende de que módulo/plugin/licença. Resumo por doc:
| Doc | Assunto | Nº aprox. de abilities |
|---|---|---|
| 01 | Elementor clássico (páginas, layout, widgets, templates, globals, custom code, composite) | ~35 |
| 02 | Elementor Atomic v4 + Gutenberg | ~25 |
| 03 | WordPress core (conteúdo, media, settings, plugins, temas, users, menus) + temas/frameworks | ~30 |
| 04 | Themer (CPT header/footer/single/archive/search/404 + Themer PHP) | ~13 |
| 05 | Redirects + Search + Snapshot + Change-ledger + Content-mirror | ~14 |
| 06 | Sandbox (PHP snippets, custom widgets/blocks, cloud bundle) | ~20 |
| 07 | Filesystem + DB + WP-CLI + Security + Performance | ~15 |
| 08 | Integrações terceiros (ACF, Meta Box, forms, SEO) | ~30 |
| 09 | Stock images + Cloud sync + OAuth | ~17 |
| 10 | Módulos (lifecycle) + inventário Pro-only (metadata) | n/a (estrutural) |
Total bate aproximadamente com os 162-165 confirmados ao vivo em `skill://emcp-tools` §7 — a
diferença entre sites reflecte quais integrações de terceiros estão de facto instaladas.
## Fonte
Leitura directa (19-08-2026) de: `emcp-tools.php`, `includes/class-mcp-adapter-bootstrap.php`,
`includes/class-plugin.php`, `includes/class-schema-compat.php`,
`includes/abilities/class-ability-registrar.php`,
`includes/abilities/class-dispatcher-abilities.php`,
`vendor/wordpress/mcp-adapter/composer.json`, `vendor/wordpress/php-mcp-schema/composer.json`;
inventário completo de ficheiros (`find … -name '*.php' -exec wc -l`, 128 465 linhas). Cruzado com
`skill://emcp-tools` (auditoria de postura de segurança, 16-08-2026) para os números de abilities
activas/desligadas por site.
+632
View File
@@ -0,0 +1,632 @@
# 01 — Elementor Clássico (não-atómico): páginas, layout, widgets, templates, globals, custom code, composite
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cobre as classes de abilities que operam sobre o modelo de dados Elementor
**clássico** (`_elementor_data`, árvore `container`/`widget`/`section`/`column`), incluindo os
grupos de leitura/escrita das **Global Classes** do Elementor 4.0+ (que já não são "clássicas"
mas vivem no mesmo `_elementor_data`/kit e não fazem parte do sistema de props atómicas
tratado no doc 02). Todas as classes aqui descritas registam-se dentro do bloco
`if ( $elementor_active )` de `class-ability-registrar.php` (linha ~452), sem nenhum outro gate
de módulo — só o Elementor (free) precisa de estar activo.
## ⚠️ Nota importante para a batch — onde vive o CRUD de widgets custom
`class-widget-abilities.php` (`EMCP_Tools_Widget_Abilities`) **NÃO contém** nenhuma tool de
criação/edição/eliminação de widgets custom (`create-custom-widget`, `update-custom-widget`,
`get-custom-widget`, `list-custom-widgets`, `set-widget-status`, `delete-custom-widget` — a
lista de 16 tools "Widget/Block Builder" identificada em `skill://emcp-tools` §2.14). Esta
classe cobre **só colocação/actualização de instâncias de widget numa página** — três tools:
`add-free-widget`, `add-pro-widget` (ambas catalog-backed, inserem um widget *já existente* no
registo do Elementor) e `update-widget` (edita definições de uma instância já colocada). O CRUD
de definição de widgets custom (a "fábrica" que cria um NOVO tipo de widget PHP/JS a partir de
um spec) vive noutro módulo — **confirmado por grep ao `class-ability-registrar.php`, não faz
parte de nenhuma das 10 classes desta tarefa** — quase certamente no sandbox de widgets/blocos
custom tratado no doc 06 (`EMCP_Tools_Sandbox_*`). Doc06 deve confirmar isto ao ler o registrar
completo; aqui fica o achado negativo registado para não haver dupla cobertura nem lacuna.
---
## 1. `EMCP_Tools_Page_Abilities` — `includes/abilities/class-page-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre (sem gate adicional).
Construtor recebe `EMCP_Tools_Data $data` e `EMCP_Tools_Element_Factory $factory` (injectados
pelo registrar). 5 tools, todas prefixadas `emcp-tools/`.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `create-page` | `title*` (string), `status` (`draft`\|`publish`, default draft), `post_type` (`page`\|`post`, default page), `template` (slug), `content` (array de elementos, opcional) | `wp_insert_post()` com `_elementor_edit_mode=builder` + `_elementor_template_type=wp-{post_type}`; grava `content` (ou `[]` se omitido) via `EMCP_Tools_Data::save_page_data()`; devolve `post_id`, `edit_url`, `preview_url`. | `check_create_permission`: `publish_pages` \|\| `edit_pages` | não-readonly, não-destructive, não-idempotente |
| `update-page-settings` | `post_id*`, `settings*` (objecto livre) | Delegado 1:1 a `EMCP_Tools_Data::save_page_settings()` — grava definições ao nível de página (background, padding, custom CSS, layout) via `Document::save(['settings'=>...])` com fallback a merge em `_elementor_page_settings`. | `check_edit_permission`: `edit_posts` + (se `post_id`) `edit_post` desse post | não-readonly, não-destructive, idempotente |
| `delete-page-content` | `post_id*` | `save_page_data($post_id, [])` — **limpa TODO o conteúdo Elementor da página**, mantendo a página em si (post continua a existir). | `check_delete_permission`: `edit_posts` **E** `delete_posts`, mais (se `post_id`) `edit_post` **E** `delete_post` desse post — a única classe do doc que exige capability de eliminação para uma operação que tecnicamente só edita meta, porque o efeito é irreversível sem o change-ledger | não-readonly, **destructive**, idempotente |
| `import-template` | `post_id*`, `template_json*` (array de elementos Elementor), `position` (default -1 = append) | Lê a página actual, `reassign_ids()` a todo o `template_json` (evita colisão de IDs), insere no array (append ou `array_splice` na posição) e grava. Devolve `elements_count` (contagem recursiva via `count_elements()`). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `export-page` | `post_id*` | Devolve `get_page_data($post_id)` verbatim como `json` — export completo da árvore Elementor da página, reimportável via `import-template`/`apply-template`. | `check_edit_permission` | **readonly**, idempotente |
**Achado de design:** `create-page` grava sempre `_elementor_data` mesmo quando `content` é
omitido (`save_page_data($post_id, [])`) — isto **inicializa** a meta em vez de a deixar por
criar, o que é relevante porque `EMCP_Tools_Data::get_page_data()` trata "meta ausente" e "meta
`[]`" da mesma forma (array vazio), mas só a segunda garante que o Elementor reconhece a página
como "Editada com Elementor" desde o primeiro save.
---
## 2. `EMCP_Tools_Layout_Abilities` — `includes/abilities/class-layout-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. Mesmo par de
dependências injectadas (`$data`, `$factory`). 9 tools — o grupo com mais ferramentas do
Elementor clássico, cobrindo toda a manipulação estrutural da árvore de elementos.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `add-container` | `post_id*`, `parent_id` (vazio = topo), `position` (-1=append), `settings` (flex/grid completo), `full_bleed` (bool) | Cria um `container` via `EMCP_Tools_Element_Factory::create_container()` e insere na árvore. `full_bleed=true` faz merge do preset `full_bleed_preset()` (content_width=full, width 100%, padding/gap zero, column+stretch) **antes** de aplicar `settings` do chamador (que sempre ganham). **Bloqueia** com erro accionável se `EMCP_Tools_Atomic_Props::is_container_supported()` for falso (ver "Gotchas" abaixo). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `update-container` | `post_id*`, `element_id*`, `settings*` (merge parcial) | Valida que o elemento alvo é mesmo um container (`is_container_type()`) antes de aplicar `update_element_settings()` — devolve erro `not_container` se for widget (aponta para `update-widget`). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `update-element` | `post_id*`, `element_id*`, `settings*` | Versão **universal** de update — funciona em qualquer elType, container ou widget, sem o caller precisar de saber qual é. Aceita também `styles`/`editor_settings` no payload (roteados para a raiz do elemento pela camada de dados — ver §Elementor_Data). Recomendado como default em vez de `update-container`/`update-widget` separados. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `batch-update` | `post_id*`, `operations*` (array de `{element_id, settings}`) | Aplica múltiplos updates **num único save** (uma leitura + uma escrita da página inteira) — muito mais eficiente que N chamadas a `update-element`. Continua a processar mesmo com falhas parciais; devolve `{success, updated, failed:[{element_id,reason}]}`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `set-element-label` | `post_id*`, `element_id*`, `title*` | Wrapper de conveniência sobre `update_element_settings()` com `editor_settings.title` — define só o rótulo do Navigator (útil sobretudo em elementos atómicos v4, mas funciona em qualquer elType). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `reorder-elements` | `post_id*`, `container_id*`, `element_ids*` (ordem desejada) | Reordena os filhos DIRECTOS de um container. Valida que todos os `element_ids` são de facto filhos directos (erro se não). Filhos existentes não mencionados na lista são acrescentados no fim (preservados, não perdidos). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `move-element` | `post_id*`, `element_id*`, `target_parent_id*` (vazio=topo), `position*` | Remove o elemento da posição actual e reinsere no destino — implementado como `remove_element()` + `insert_element()` sequenciais sobre a mesma árvore em memória, um único save no fim. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `remove-element` | `post_id*`, `element_id*` | Remove o elemento e **todos os filhos** da árvore. | `check_edit_permission` | não-readonly, **destructive**, idempotente |
| `duplicate-element` | `post_id*`, `element_id*` | Clona profundamente o elemento (`reassign_element_ids()` — novos IDs em toda a subárvore, incluindo remapeamento de classes de estilo locais v4 se aplicável) e insere logo a seguir ao original. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
### Gotchas de design documentados no código (Layout)
- **`is_container_type()` inclui os tipos atómicos** (`container`, `e-flexbox`, `e-div-block`) —
não é só legado; `update-container`/`reorder-elements` reconhecem containers v4 também
(comentário no código refere issues #104/#72 como o mesmo problema raiz).
- **`add-container` recusa-se a criar um `container` legado se a experiência "Flexbox
Container" do Elementor estiver desligada** (`EMCP_Tools_Atomic_Props::is_container_supported()`).
Antes desta guarda (issue #111), o elemento era gravado com sucesso mas o Elementor
simplesmente **não o renderiza** em runtime — página fica vazia sem qualquer erro visível ao
agente. Este é o tipo de falha silenciosa mais perigosa do plugin: a tool "funciona" (devolve
`success:true`) mas o resultado visual é nada. A mesma guarda está em `build-page` (ver §10).
- **`full_bleed` preset (#83):** em páginas com template Canvas, os defaults "boxed" do
Elementor deixam faixas brancas nas margens de secções full-width (headers/footers). O preset
resolve isto de forma reutilizável em vez de o agente ter de descobrir os 6 campos certos por
tentativa e erro.
---
## 3. `EMCP_Tools_Widget_Abilities` — `includes/abilities/class-widget-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`. `add-pro-widget` só regista se
`defined('ELEMENTOR_PRO_VERSION')` (gate interno na própria classe, não no registrar). 3 tools.
Construtor recebe `$data`, `$factory`, `$schema_generator` (`EMCP_Tools_Schema_Generator`,
usado por `get-widget-schema` noutra classe P0, não aqui) e `$validator`
(`EMCP_Tools_Settings_Validator`, usado para validar settings contra o schema do widget alvo).
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `add-free-widget` | `post_id*`, `parent_id*`, `widget_type*`, `position`, `settings` | Valida tier via `EMCP_Tools_Widget_Catalog::is_pro($widget_type)` — **rejeita** (`wrong_tier`) se o tipo pedido for Pro/Woo. Faz merge dos `defaults` do catálogo (`entry['defaults']`) por baixo do `settings` do chamador (chamador sempre ganha). Delega ao motor comum `execute_add_widget()`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `add-pro-widget` | idem `add-free-widget` | Espelho exacto, mas com o tier invertido — rejeita widgets free (aponta para `add-free-widget`). Só registada quando Elementor Pro está activo (gate natural: não faz sentido oferecer a tool se não há widgets Pro para colocar). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `update-widget` | `post_id*`, `element_id*`, `settings*` | Universal para instâncias de widget já colocadas: encontra o elemento, valida `elType==='widget'` (erro `not_a_widget` caso contrário), faz merge parcial de `settings`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
### Motor comum: `execute_add_widget()` (privado, partilhado pelas duas tools de inserção)
Passos: (1) valida que `widget_type` existe de facto no registo Elementor
(`Plugin::$instance->widgets_manager->get_widget_types($widget_type)` — erro
`invalid_widget_type` se não), (2) se `settings` não vazio, chama
`EMCP_Tools_Settings_Validator::validate($widget_type, $settings)` (mesmo validador de
`includes/validators/`, mas para controls de widget — distinto do `Element_Validator` que
valida a *forma* estrutural do elemento, ver §Elementor_Data), (3) `factory->create_widget()`,
(4) `data->insert_element()`, (5) `data->save_page_data()`.
**Achado de design:** o catálogo (`EMCP_Tools_Widget_Catalog`, não lido nesta tarefa — pertence
provavelmente ao doc 02 ou doc 10) é a fonte de verdade de tier E de defaults por widget — as
duas tools de inserção são finas camadas de gate+merge sobre um motor único; **não há lógica de
posicionamento/inserção duplicada entre free e pro**.
---
## 4. `EMCP_Tools_Template_Abilities` — `includes/abilities/class-template-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`. `save-as-template` e
`apply-template` sempre registadas; as outras 6 só se `defined('ELEMENTOR_PRO_VERSION')`
(gate interno, `get_ability_names()` e `register()` espelham a mesma condição). 8 tools no
total (2 free + 6 Pro).
| Tool | Tier | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|---|
| `save-as-template` | free | `post_id*`, `element_id` (omitir=página inteira), `title*`, `template_type` (`page`\|`section`\|`container`, default page) | Cria um post `elementor_library` com `_elementor_template_type`, define a taxonomia `elementor_library_type`, grava os elementos (página inteira ou só o elemento indicado) como `_elementor_data` desse novo post-template. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `apply-template` | free | `post_id*`, `template_id*`, `parent_id`, `position` | Lê o template, `reassign_ids()`, insere na página alvo (dentro de `parent_id` ou ao nível de topo). Devolve `elements_added` (contagem via `count_elements()`). | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `create-elementor-theme-template` | **Pro** | `title*`, `template_type*` (enum: header/footer/single/single-post/single-page/archive/search-results/error-404/loop-item) | Cria post `elementor_library` do tipo indicado, inicializa com `_elementor_data=[]`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `set-elementor-template-conditions` | **Pro** | `post_id*`, `conditions*` (array de arrays de partes, ex. `["include","singular","post"]`) | Ver "Gotcha crítico #38" abaixo — usa `save_elementor_conditions()`. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `list-dynamic-tags` | **Pro** | `group` (filtro opcional) | Enumera `Plugin::instance()->dynamic_tags->get_tags()`, filtra por grupo se indicado, devolve `{name, title, group, categories}` por tag. | `check_edit_permission` | **readonly**, idempotente |
| `set-dynamic-tag` | **Pro** | `post_id*`, `element_id*`, `setting_key*`, `tag_name*`, `tag_settings` | Constrói o valor `[elementor-tag id="…" name="…" settings="…"]` (formato interno do Elementor, `settings` urlencoded como JSON) e escreve-o em `element.settings.__dynamic__[setting_key]`, tornando essa definição dinâmica. | `check_edit_permission` | não-readonly, não-destructive, idempotente |
| `create-popup` | **Pro** | `title*` | Cria post `elementor_library` tipo `popup`. | `check_edit_permission` | não-readonly, não-destructive, não-idempotente |
| `set-popup-settings` | **Pro** | `post_id*`, `triggers`, `conditions`, `timing` | Grava `_elementor_popup_triggers`/`_elementor_popup_timing` em post meta directamente; `conditions` reutiliza o MESMO `save_elementor_conditions()` seguro que o template (não é um caminho separado). | `check_edit_permission` | não-readonly, não-destructive, idempotente |
### Gotcha crítico documentado (#38) — `save_elementor_conditions()`
Método privado partilhado por `set-elementor-template-conditions` e `set-popup-settings`.
**A abordagem anterior** (`update_post_meta()` + `delete_option()` na cache global
`elementor_pro_theme_builder_conditions`) **invalidava a localização de TODOS os templates**
sem os reconstruir — definir condições num template partia silenciosamente headers/footers não
relacionados até um rebuild completo. A abordagem correcta passa pelo **conditions manager** do
próprio Elementor Pro (`ThemeBuilder::get_conditions_manager()->save_conditions()`), que
regenera a cache correctamente; só cai para escrita directa de meta (sem tocar na cache global)
se o gestor Pro não estiver disponível. **Blueprint:** nunca fazer bypass da API de alto nível
de um plugin de terceiros para "poupar uma chamada" quando essa API mantém uma cache
side-effectful — o preço é corromper estado partilhado fora do escopo da própria operação.
---
## 5. `EMCP_Tools_Global_Abilities` — `includes/abilities/class-global-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. Construtor só recebe
`$data` (não usa `$factory`). 2 tools — actuam sobre o **kit activo** do Elementor
(`Plugin::$instance->kits_manager->get_active_kit()`), que é o post que guarda paleta global de
cores e tipografia site-wide.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `update-global-colors` | `colors*` (array de `{_id*, title*, color*}` hex) | Lê `kit_settings['custom_colors']`, faz merge por `_id` (actualiza existentes, acrescenta novos), `kit->update_settings(['custom_colors'=>...])`. Regista snapshot no change-ledger ANTES de escrever (`snapshot_kit_settings()`/`record_kit_change()` — captura `_elementor_page_settings` do kit para rollback). | `check_manage_permission`: `manage_options` | não-readonly, não-destructive, idempotente |
| `update-global-typography` | `typography*` (array de `{_id*, title*, typography_font_family, typography_font_size, typography_font_weight, typography_line_height, typography_letter_spacing}`) | Mesmo padrão merge-por-`_id` sobre `kit_settings['custom_typography']`, mas com **allowlist explícita de chaves** (`allowed_keys`) — qualquer campo fora dessa lista é descartado silenciosamente. Força sempre `typography_typography='custom'` (activa o override; sem isto o Elementor ignora os campos custom). | `check_manage_permission` | não-readonly, não-destructive, idempotente |
**Achado de design — a única classe do doc com change-ledger integrado directamente no fluxo de
escrita** (não via o hook central de `save_page_data()`, porque estas duas tools não passam por
`_elementor_data` nenhuma — escrevem `_elementor_page_settings` do post do kit). `snapshot_kit_settings()`
+ `record_kit_change()` chamam `EMCP_Tools_Change_Recorder::record_meta()` explicitamente antes
do `update_settings()`, porque de outra forma uma mudança de cor/tipografia global — que afecta
**todas as páginas do site simultaneamente** — não teria rollback nenhum via o ledger genérico
(esse só cobre `_elementor_data` de um post individual, ver `class-elementor-data.php`).
**Blueprint:** qualquer mutação "global" que não passe pelo caminho de escrita comum de página
precisa do seu PRÓPRIO ponto de integração com o change-ledger — não é automático.
---
## 6. `EMCP_Tools_Global_Classes_Abilities` — `includes/abilities/class-global-classes-abilities.php`
**Condição de registo:** classe auto-gated — `is_available()` verifica
`class_exists('\Elementor\Modules\GlobalClasses\Global_Classes_Repository')` (Elementor 4.0+).
O registrar só instancia se `class_exists('EMCP_Tools_Global_Classes_Abilities')` E chama
sempre `register()`, que internamente re-verifica `is_available()`. 1 tool, **read-only**.
Resolve a peça mais opaca do sistema de design do Elementor 4.0+: elementos referenciam classes
CSS globais só pelo ID opaco `g-xxxxxxx`; sem esta tool, um agente que lê um elemento vê o ID
mas não sabe o que ele estiliza.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `list-global-classes` | `class_ids` (array opcional; omitir = todas) | `Global_Classes_Repository::make()->all()` → normaliza `Collection`/array, para cada item resolve `{id, label, css}` onde `css` é `flatten_variants()` — um mapa `breakpoint[:state] → {prop:valor}` com os `$$type`-wrapped props desembrulhados via `EMCP_Tools_Atomic_Props::unwrap()`. | `check_read_permission`: `edit_posts` | **readonly**, idempotente |
### Gotcha documentado (#57) — resolução defensiva por item
Cada item é resolvido dentro do seu próprio `try/catch`. **Antes desta guarda**, uma única
classe malformada fazia a enumeração inteira (`resolve-all`, sem `class_ids`) falhar por
completo, enquanto pedidos com `class_ids` explícitos (que saltam a entrada má) continuavam a
funcionar — inconsistência confusa de diagnosticar ("porque é que só falha quando não passo
IDs?"). A correcção devolve a classe problemática mesmo assim, com `css:[]` e um campo `error`
explicativo, em vez de a fazer desaparecer da lista ou abortar tudo. **Blueprint:** ao
enumerar uma colecção de N itens onde cada um pode ter forma inesperada, isolar cada resolução —
nunca deixar um item mau abortar os outros N-1.
---
## 7. `EMCP_Tools_Global_Classes_Write_Abilities` — `includes/abilities/class-global-classes-write-abilities.php`
**Condição de registo:** mesmo padrão auto-gated (`is_available()` delega para a classe de
leitura se existir, senão verifica a mesma constante `REPOSITORY` directamente). 4 tools —
**todas em `emcp_tools_disabled_tools` por omissão** (mutação de CSS partilhado entre todas as
páginas do site é tratada como categoria de alto risco, ver `skill://emcp-tools` §2.9/§5).
Permissão de escrita: `elementor_global_classes_update_class` (capability própria do Elementor,
tipicamente só admin) OU `manage_options`.
| Tool | Input (resumo) | O que faz | Destructive |
|---|---|---|---|
| `create-global-class` | `label*`, `styles` (mapa amigável), `props` (escape-hatch raw `$$type`), `breakpoint` (enum de 7 valores, default desktop), `state` (opcional: hover/focus/…) | Lê o estado actual (`items`, `order`) via `read_state()`, gera um novo ID (`mint_id()` — `g-` + 4 bytes hex aleatórios, sem colisão), constrói o objecto `{id, type:class, label, variants:[{meta, props}]}` combinando `styles` (traduzido via `EMCP_Tools_Atomic_Styles::build_common_props()`+`build_flex_props()`) com `props` raw por cima, escreve com `write_state()`. | não |
| `update-global-class` | `id*`, `label`, `styles`, `props`, `breakpoint`, `state`, `replace_variant` (bool) | Localiza a variante pelo par `(breakpoint, state)` (`find_variant_index()`); se não existir, acrescenta uma nova variante; se existir e `replace_variant=false` (default), faz merge dos props na variante existente; se `true`, substitui-a inteira. | não |
| `delete-global-class` | `id*`, `confirm*` (deve ser `true`) | Remove do mapa `items` e da lista `order`; **exige `confirm:true`** explicitamente — é a única das 4 a ter esse requisito extra, porque apaga a classe de TODOS os elementos que a usam. | **sim** |
| `reorder-global-classes` | `order*` (array de IDs `g-`) | A ordem do Class Manager É a ordem de saída CSS — decide qual classe ganha quando duas se aplicam ao mesmo elemento com a mesma especificidade. IDs omitidos em `order` são acrescentados no fim, na ordem actual — **nenhuma classe pode desaparecer** por um reorder parcial (a "baseline order" é a união de `current_order` + `array_keys(items)`, nunca só o que o chamador mandou). | não |
### Como as escritas persistem — o padrão `read_state()`/`write_state()`
Todas as 4 tools passam pelo repositório oficial do Elementor
(`Global_Classes_Repository::make()`), nunca por meta directa: lê o mapa completo `id => item`
+ `order[]`, muta em memória, chama `put($items, $order)` — **o Elementor calcula o diff
add/modify/delete internamente** e trata relações + limpeza de uso. `write_state()` faz
best-effort de espelhar também para o contexto de preview (`set_preview(true)->put(...)`) — se
esse segundo write falhar, é tolerado silenciosamente (só logado com `WP_DEBUG`) porque **o
write de frontend é a fonte de verdade**; o preview só afecta o que o editor mostra até
recarregar.
**Achado de design — reutilização directa dos tijolos atómicos v4:** `build_variant_props()`
chama `EMCP_Tools_Atomic_Styles::build_common_props()`/`build_flex_props()` — as MESMAS classes
de suporte que o sistema de widgets atómicos (doc 02) usa para construir estilos locais por
elemento. Isto significa que "escrever uma Global Class" e "aplicar um estilo local a um
elemento atómico" partilham o mesmo motor de tradução `styles amigável → props $$type-wrapped`
— não há dois formatos de estilo diferentes no plugin, só dois destinos de armazenamento
(classe global partilhada vs. classe local de um elemento).
---
## 8. `EMCP_Tools_Custom_Code_Abilities` — `includes/abilities/class-custom-code-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`. `add-custom-js` sempre regista
(funciona com Elementor free, via widget HTML); as outras 3 só se `defined('ELEMENTOR_PRO_VERSION')`.
4 tools no total (1 free + 3 Pro). Injecção de código executável — a classe com o perfil de
risco mais alto deste doc, com o maior número de comentários de segurança no código-fonte.
| Tool | Tier | Input (resumo) | O que faz | `permission_callback` | Destructive |
|---|---|---|---|---|---|
| `add-custom-css` | **Pro** | `post_id*`, `element_id` (omitir=nível de página), `css*`, `replace` (bool) | CSS por elemento usa o placeholder `selector` como wrapper (substituído pelo Elementor no seu gerador de CSS); grava em `settings.custom_css` do elemento ou em `page_settings.custom_css`. Por omissão faz *append*; `replace=true` sobrescreve. Sanitização: remove tags PHP e `<script>`, e **neutraliza `</style>` em loop até fixpoint** (ver F-004 abaixo). | `check_edit_permission` | não |
| `add-custom-js` | free | `post_id*`, `parent_id*`, `js*`, `position`, `wrap_dom_ready` (bool) | Insere um **widget HTML** contendo `<script>{js}</script>` na árvore da página (não é injecção site-wide, é conteúdo normal da página). Remove qualquer `<script>`/`</script>` que o chamador já tenha incluído (evita duplo-wrap); opcionalmente envolve em `DOMContentLoaded`. | `check_js_permission`: `edit_posts`+per-post **E** `unfiltered_html` — a única tool do grupo a exigir `unfiltered_html` além da capability de edição normal, porque injecta um `<script>` executável que o WordPress tiraria a um utilizador sem essa capability (ex. não-super-admin em multisite) | não |
| `add-code-snippet` | **Pro** | `title*`, `code*`, `location` (`head`\|`body_start`\|`body_end`, default head), `priority` (1-10, clamp), `status` (`publish`\|`draft`), `ensure_jquery` (bool) | Cria um post CPT `elementor_snippet` com meta `_elementor_location`/`_elementor_priority`/`_elementor_code`/`_elementor_template_type=code_snippet` — **injecção site-wide**, em TODAS as páginas, ao contrário de `add-custom-js` (só naquela página). | `check_snippet_permission`: `manage_options` **E** `unfiltered_html` | não |
| `list-code-snippets` | **Pro** | `location` (filtro), `status` (default `any`) | Lista posts `elementor_snippet` (até 100), devolve `{id, title, location, priority, status, code, edit_url}` por snippet. | `check_manage_permission`: `manage_options` | readonly |
### Gotchas de segurança documentados no código (Custom Code)
- **F-004 — bypass de `</style>` neutralizado em loop até fixpoint:** o CSS de `add-custom-css`
é emitido dentro de um bloco `<style>`, que o parser HTML trata como texto bruto — a ÚNICA
forma de escapar para HTML vivo (vector XSS, ex. `</style><img onerror=...>`) é a tag literal
`</style>`; `<`, `>` isolados ou até `<img>` sozinhos são inertes sem ela. O código remove
`</\s*style` **num `while` até `$previous === $css`**, especificamente para impedir que
remover UMA ocorrência reconstrua outra por concatenação adjacente. Importante: preserva TODA
a CSS válida — combinadores `>`/`~`/`+`, media queries com `<`/`>`, strings de conteúdo — só a
sequência exacta `</style` desaparece.
- **F-008 — regex de handlers `on*=` precisa da flag `/s` (DOTALL):** em `sanitize_svg_content()`
(classe SVG, não esta, mas o padrão de regex é idêntico e vale a pena registar aqui porque
`add-custom-css` faz sanitização de string semelhante) — sem `/s`, um handler cujo VALOR contém
uma quebra de linha (`onclick="alert(1)\n"`) escapa ao match porque `.` por omissão não cruza
linhas.
- **Distinção free vs Pro não é arbitrária:** `add-custom-js` (free) é sempre **por-página** (um
widget HTML normal, mesma superfície de risco que qualquer conteúdo de página); os 3 Pro
operam **site-wide** — `add-custom-css` a nível de página TAMBÉM é possível mas
`add-code-snippet` injecta sempre em todas as páginas. É essa amplitude, não a linguagem em
si, que justifica o gate `manage_options` (site-wide) vs `edit_posts` (por-página).
---
## 9. `EMCP_Tools_Svg_Icon_Abilities` — `includes/abilities/class-svg-icon-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre — mas na prática a tool
não depende de nada específico do Elementor além do formato de saída (o objecto ícone). 1 tool.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `upload-svg-icon` | `svg_url` OU `svg_content` (mutuamente exclusivos), `title` | Faz upload/sideload de um SVG para a Media Library e devolve o objecto de ícone Elementor pronto a usar: `{value:{id,url}, library:'svg'}` — directamente atribuível a `selected_icon` em qualquer widget icon/icon-box/button. | `check_upload_permission`: `upload_files` | não-readonly, não-destructive, não-idempotente |
### Pipeline de segurança (fail-closed em 3 camadas)
1. **Download (`svg_url`):** via `EMCP_Tools_Url_Guard::safe_download()` — guarda SSRF que
bloqueia hosts privados/reservados/loopback e **revalida cada hop de redirect** (não é lido
nesta tarefa, mas o nome da classe/comportamento fica documentado como dependência crítica).
2. **Bypass temporário do filtro de MIME do WordPress:** dois filtros temporários
(`upload_mimes`, `wp_check_filetype_and_ext`) permitem o `.svg` só durante a chamada e são
removidos logo a seguir — não altera a política global de uploads do site.
3. **Sanitização fail-closed, dupla camada:**
- Verificação superficial: rejeita se contiver `<script` (regra própria, antes de chamar o
sanitizador).
- Sanitizador real: `\Elementor\Core\Utils\Svg\Svg_Sanitizer` (classe do PRÓPRIO Elementor,
reaproveitada — não é uma dependência própria do EMCP Tools). **Se a classe do sanitizador
não existir, a tool recusa o upload por completo** (`no_svg_sanitizer`) em vez de aceitar
markup minimamente verificado — fail-closed genuíno, não um "melhor esforço".
- Para `svg_content`: além do sanitizador, regex adicionais removem handlers `on*=` (com
`/s` — ver F-008 acima) e URLs `javascript:`.
**Blueprint:** este é o padrão de referência para QUALQUER tool de upload de conteúdo
potencialmente executável (SVG pode conter script) — nunca confiar só na extensão de ficheiro
nem só num sanitizador; usar o MELHOR sanitizador disponível (aqui, reaproveitar o do Elementor
em vez de reescrever um) e recusar em vez de degradar quando ele não está presente.
---
## 10. `EMCP_Tools_Composite_Abilities` — `includes/abilities/class-composite-abilities.php`
**Condição de registo:** dentro de `if ( $elementor_active )`, sempre. 1 tool — mas é a mais
sofisticada do doc: constrói uma página inteira a partir de uma especificação declarativa
aninhada, numa única chamada MCP.
| Tool | Input (resumo) | O que faz | `permission_callback` | readonly / destructive |
|---|---|---|---|---|
| `build-page` | `title*`, `status` (default draft), `post_type` (default page), `page_settings`, `dry_run` (bool), `structure*` (árvore declarativa: `{type, widget_type?, settings?, children?}[]`) | Constrói recursivamente a árvore Elementor a partir de `structure` (em memória, sem write ainda), cria o post e grava só se `dry_run` não estiver activo. | `check_create_permission`: `publish_pages` \|\| `edit_pages` | não-readonly, não-destructive, não-idempotente |
### Passos de execução (`execute_build_page`)
1. **Guarda de suporte de container** (idêntica à de `add-container`, ver §2) — recusa
antecipadamente se a experiência Flexbox Container estiver desligada, porque `build-page`
emite `container`s legados que renderizariam vazios (#111).
2. **`build_elements()` recursivo** — percorre `structure`, normaliza cada nó
(`normalize_node()`, ver abaixo), constrói `container`s ou widgets, acumula
`$this->elements_created` e `$this->warnings`.
3. Se `elements_created > 150` (`SOFT_ELEMENT_LIMIT`), acrescenta um aviso sobre risco de
timeout num conector MCP remoto — sugere `dry_run` primeiro ou dividir em várias chamadas.
4. **`dry_run=true`:** devolve `{dry_run:true, would_create:N, warnings:[...]}` **sem tocar na
base de dados** — nenhum post é criado.
5. Caso contrário: `wp_insert_post()`, `save_page_data()`, `save_page_settings()` (se
fornecido), devolve `post_id`/`edit_url`/`preview_url`/`elements_created`/`warnings`.
### `normalize_node()` — coerção tolerante de shorthand de modelos fracos
Modelos de IA mais fracos escrevem rotineiramente `{"type":"heading", ...}` em vez da forma
canónica `{"type":"widget","widget_type":"heading"}`, ou dão a um container um `type` diferente
de `"container"` mas ainda com `children`. Em vez de descartar silenciosamente estes nós (o que
faria o pedido "ter sucesso" com colunas vazias no resultado — o pior tipo de falha, porque
parece funcionar), `normalize_node()`:
- Se o nó tem `children` não-vazio → tratado como container, independentemente do `type`
declarado; se o `type` original não era `"container"`, regista um warning.
- Se não tem `children` e tem `type` não-vazio → interpretado como **shorthand de widget**: o
próprio `type` torna-se `widget_type` (ex. `"heading"` → widget heading); warning explica a
forma preferida.
- Devolve sempre o nó com `type` canónico (`container`/`widget`), nunca lança erro — a
filosofia é "aceitar o que o modelo quis dizer, mas dizer exactamente o que foi assumido".
### Layout automático em containers `flex_direction=row`
Quando um container pai tem `flex_direction=row` (ou `row-reverse`) e mais de 1 filho:
- Containers filhos SEM largura explícita (`width`/`_flex_size`/`_flex_grow` já definidos)
recebem `content_width=full` + `width={size: 100/N, unit:'%'}` automaticamente — replica o
padrão nativo de colunas do Elementor sem o agente ter de calcular percentagens.
- **Widgets colocados directamente como filho de um row** (sem container intermédio) são
**automaticamente envolvidos** num container-coluna com a mesma largura calculada — porque o
modelo flex do Elementor exige um container como flex-item; um widget "nu" não tem
flex-basis e simplesmente esticaria para preencher a row em vez de formar uma coluna própria.
Isto acrescenta um elemento extra à árvore (contabilizado em `elements_created`) que o
chamador não pediu explicitamente, mas sem o qual o layout pedido (colunas lado-a-lado) nem
sequer seria possível.
- **Explicitamente PROIBIDO no schema** (bloco `description` da tool): nunca definir `flex_wrap`
ou `_flex_size` manualmente — a tool já trata disto e sobreposições manuais causam overflow de
layout.
### `build_widget()` — ponte para widgets atómicos v4 dentro de `build-page`
Se `EMCP_Tools_Atomic_Widget_Map::is_atomic($widget_type)` for verdadeiro, `build-page` **não**
usa `factory->create_widget()` legado — usa `factory->create_atomic_widget()` com os settings
mapeados por `EMCP_Tools_Atomic_Widget_Map::settings()` (o mesmo mapeamento que as tools
`add-atomic-*` individuais usam, doc 02), e aplica os parâmetros de estilo restantes como uma
classe local via `EMCP_Tools_Atomic_Styles::create_local_class()`. Isto significa que
**`build-page` é atomic-aware por composição**, não por duplicação — reaproveita inteiramente o
motor do doc 02 em vez de ter a sua própria lógica de tradução de props atómicas.
**Blueprint:** `build-page` é o exemplo mais claro no plugin de "tool de alto nível como
orquestrador fino sobre primitivas de baixo nível" — não introduz nenhuma capacidade de
persistência nova, só composição declarativa + tolerância a input ambíguo em cima de
`Element_Factory`+`Data`(+`Atomic_*` quando aplicável). Uma réplica deveria construir esta tool
POR ÚLTIMO, depois de todas as primitivas (container/widget/atomic) já funcionarem
individualmente.
---
## Serviços de suporte
### `EMCP_Tools_Element_Factory` — `includes/class-element-factory.php`
Fábrica pura (sem I/O, sem WordPress DB) que constrói arrays PHP no formato exacto que o
Elementor espera para cada tipo de elemento. Métodos: `create_container()`, `create_widget()`,
`create_section()`/`create_column()` (legado pré-Container, usado só residualmente — nenhuma
tool destas 10 classes os invoca directamente, mantidos por compatibilidade), e os 3 atómicos
`create_atomic_widget()`/`create_flexbox()`/`create_div_block()` (Elementor 4.0+, cobertos em
detalhe no doc 02 mas usados aqui indirectamente por `build-page`).
**Duas normalizações estáticas reutilizadas em toda a base de código** (chamadas não só pela
factory mas também por `EMCP_Tools_Data::update_element_settings()`):
- **`normalize_container_settings()`** — remapeia os atalhos sem prefixo `justify_content` /
`align_items` / `align_content` para as chaves prefixadas `flex_justify_content` /
`flex_align_items` / `flex_align_content` que o schema de container do Elementor **realmente
lê**. **Gotcha crítico (#32):** sem este remap, os valores eram persistidos sob nomes que o
gerador de CSS do Elementor nunca consulta — as custom properties CSS (`--justify-content`,
`--align-items`) nunca são emitidas e o container renderiza com alinhamento default no
frontend, apesar dos dados estarem "correctos" na base de dados. Chaves prefixadas fornecidas
pelo chamador sempre ganham sobre o atalho, se ambas aparecerem no mesmo payload.
- **`normalize_background_settings()`** — corrige 3 formas erradas-mas-intuitivas de background
que modelos fracos emitem: (1) um grupo aninhado `background: {background_image, size, ...}`
é achatado para chaves `background_*` de topo (o Elementor não tem control de grupo
`background`, um objecto aninhado é simplesmente ignorado); (2) `background_image` dado como
array de objectos `[{id,url}]` (o modelo espelha a forma de um media-repeater) é desembrulhado
para o objecto único `{id,url}` esperado; (3) quando existe imagem OU cor mas falta o
activador `background_background`, injecta `classic` automaticamente — sem o activador o
Elementor nunca renderiza background nenhum. Idempotente e não-destrutivo: chaves planas já
fornecidas pelo chamador sempre ganham sobre o que é elevado do grupo aninhado.
Container `create_container()` também aplica um default de UX: **auto-centra
`flex_align_items='center'`** em containers coluna não-grid quando o chamador não especificou
alinhamento — só linhas (`row`) ficam com o comportamento default do Elementor.
### `EMCP_Tools_Data` — `includes/class-elementor-data.php`
**A camada de leitura/escrita real do `_elementor_data`** — usada por praticamente todas as 10
classes deste doc (excepto Global Classes, que usa o repositório próprio do Elementor
directamente, e Global_Abilities, que usa o kit manager). ~700 linhas; o ficheiro mais denso em
comentários de bug-fix real de todo o doc. Métodos-chave:
- **`get_document()`** — obtém o `\Elementor\Core\Base\Document` para um post via
`Plugin::$instance->documents->get($post_id)`. Guardado por `elementor_documents_ready()`:
o gestor de documentos do Elementor só existe depois do seu próprio hook `init` correr; durante
a ACTIVAÇÃO do Elementor (que insere o kit por omissão via `save_post`, o que dispara o
indexador do EMCP Tools) essa dependência ainda não existe — sem a guarda seria um fatal
error por null-deref (#105).
- **`get_page_data()`** — tenta primeiro `$document->get_elements_data()`; se vazio, cai para
leitura directa de `_elementor_data` (post meta bruto, `json_decode`). O fallback existe
porque em contexto CLI/proxy (sem browser, sessão de editor) o API do documento por vezes
devolve vazio mesmo com dados presentes na base de dados.
- **`save_page_data()` — o método mais complexo de todo o ficheiro (~140 linhas).** Fluxo
completo:
1. **`EMCP_Tools_Atomic_Props::coerce_tree($data)`** — varre a árvore INTEIRA (não só o
elemento a alterar) antes de gravar. **Gotcha #102:** uma versão anterior só coagia o
elemento sendo escrito; como o Elementor 4.x valida a ÁRVORE COMPLETA no save, um único
widget com um valor de prop bruto (não `$$type`-wrapped) noutro sítio da página bloqueava
TODOS os saves futuros — incluindo o save destinado a reparar esse mesmo widget. Fazer a
coerção ser sempre sobre a árvore inteira é um no-op para páginas saudáveis e uma rede de
segurança universal para páginas com dados legados/corrompidos.
2. **Preserva `_elementor_data` corrupto** antes de sobrescrever — se a meta actual for uma
string não-vazia que não faz `json_decode` válido, é copiada para
`_elementor_data_emcp_corrupt` antes do save prosseguir. Sem isto, `get_page_data()` trata
"corrupto" como "vazio" e um save subsequente apagaria os dados originais para sempre.
3. **`try { $document->save(...) } catch (\Throwable $e)`** — o Elementor 4.x atómico
**lança excepção** (não devolve `false`) quando a validação de settings/estilos falha.
`is_atomic_validation_rejection()` distingue uma rejeição de validação legítima (mensagem
contém "validation failed" ou "invalid_value") de um erro fatal genuíno. **Gotcha #112:**
um prop `{$$type:'dynamic'}` gravado pelo editor ao vivo pode referenciar uma dynamic tag
que o registo atómico em contexto CLI/REST não consegue resolver — e como a validação é
sobre a árvore inteira, isso bloquearia QUALQUER save da página, incluindo edições a
elementos totalmente não relacionados. Uma rejeição de validação é tratada como "dados
legítimos que este contexto não sabe verificar" e roteada para o fallback de meta directa
em vez de reprovada.
4. **Verificação pós-save (#98):** mesmo quando `$document->save()` devolve verdadeiro sem
excepção, relê `_elementor_data` e confirma que os dados enviados realmente persistiram —
em certos contextos 4.x/atómicos/REST o save pode devolver "sucesso" e ainda assim
esvaziar `_elementor_data`. Se detectado, força o mesmo fallback de escrita directa em vez
de reportar um sucesso fantasma ao chamador.
5. **Fallback de meta directa:** `update_post_meta('_elementor_data', wp_slash(json_encode($data)))`
+ garante `_elementor_edit_mode=builder` + `_elementor_version` + invalida cache CSS
(`delete_post_meta('_elementor_css')` + apaga o ficheiro físico
`uploads/elementor/css/post-{id}.css` se existir) + invalida a cache de elemento renderizado
do Elementor 4.2 (`_elementor_element_cache`, ver hook `init()` abaixo).
6. **Regista no change-ledger** — `EMCP_Tools_Change_Recorder::record_elementor()` (ou
fallback directo a `EMCP_Tools_Change_Log::record()`), capturando o `_elementor_data`
ANTERIOR completo para permitir rollback via `rollback-change` (doc 05).
- **`init()` (hook estático global)** — regista em `added_post_meta`/`updated_post_meta`: sempre
que `_elementor_data` é escrito, por QUALQUER caminho (as nossas tools, o editor, um import),
apaga `_elementor_element_cache`. **Motivo (#111 revisitado):** o Elementor 4.2 introduziu uma
cache de HTML renderizado nessa meta key; o próprio Elementor limpa-a em `Document::save()`,
mas o fallback de meta directa do EMCP Tools bypassa isso. Numa instalação com object cache
persistente (ex. WP Engine), uma entrada vazia/obsoleta sobrevivia a QUALQUER escrita de
conteúdo subsequente — uma página criada via MCP (escrita enquanto os dados ainda eram `[]`,
renderizada [caching vazio], depois preenchida) servia o render vazio em cache para sempre.
Este hook restaura a invalidação universalmente para qualquer caminho de escrita.
- **`insert_element()` / `remove_element()` / `reassign_ids()` / `reassign_element_ids()` /
`count_elements()` / `find_element_by_id()`** — utilitários recursivos puros sobre a árvore em
memória (todos operam por referência `&$data` onde relevante para evitar cópias de arrays
grandes a cada nível de recursão). `reassign_element_ids()` também chama
`EMCP_Tools_Atomic_Styles::remap_local_classes()` — **gotcha #97:** classes de estilo locais
v4 (`e-<id>-<hash>`) pertencem a UM elemento; duplicar um elemento sem remapear as suas classes
locais fazia o duplicado partilhar as classes do original — vazamento de estilo entre
elementos e duplicação da "Origem de Estilo" no editor.
- **`update_element_settings()` — o segundo método mais complexo.** Além do merge óbvio de
`settings`, faz:
- **Hoist de chaves-irmãs da raiz** (`styles`, `editor_settings`) — em elementos atómicos v4,
o mapa `styles` local e `editor_settings` (rótulo Navigator) vivem na RAIZ do elemento, como
irmãos de `settings`, não dentro dele. Um agente naturalmente aninha-os sob `settings`;
`update_element_settings()` intercepta essas duas chaves ANTES do merge normal, remove-as do
payload de settings, e faz `deep_merge()` para a raiz do elemento (**gotcha #72/#73** — sem
isto, eram gravadas em `settings.styles`, uma chave morta que o Elementor nunca lê).
- **Normalização condicional por tipo:** containers passam por
`normalize_container_settings()`; qualquer outro elType passa só por
`normalize_background_settings()` (mesma correcção de background, sem o remap de flex que
só faz sentido em containers).
- **`EMCP_Tools_Atomic_Props::coerce_settings()` no settings MERGED** (não só no incoming) para
widgets — **gotcha #101:** um valor bruto (`'Hello'` em vez de
`{$$type:'html-v3',value:'Hello'}`) em prop atómico não é simplesmente ignorado — "envenena"
o elemento: o Elementor cai para o default do prop (renderiza texto placeholder) E todo o
save subsequente da página lança "Settings validation failed", trancando a página fora tanto
da API como do próprio editor visual. Correr a coerção sobre o resultado do merge aceita
valores simples que um agente naturalmente envia E repara qualquer coisa que uma versão
anterior já tenha gravado incorrectamente.
- **`sync_local_class_refs()` quando `styles` foi tocado — gotcha #92:** uma classe de estilo
local só renderiza se `settings.classes` (o prop `$$type:'classes'` que lista IDs aplicados)
referenciar o seu ID. Um agente que escreve um mapa `styles` mas esquece de acrescentar o ID
a `classes` obtém um no-op silencioso — o estilo persiste na base de dados mas nunca se
aplica visualmente. Este método varre `item['styles']` (só entradas `type==='class'`) e
garante que todos os IDs aparecem em `settings.classes.value`, idempotente.
- **`deep_merge()`** — merge recursivo próprio: mapas associativos fazem merge chave-a-chave;
listas (arrays numéricos sequenciais, ex. um array `variants`) e escalares são **substituídos
por inteiro** pelo valor incoming. Permite que um update parcial de `styles`/`editor_settings`
toque só uma classe/chave sem apagar as irmãs, mantendo ao mesmo tempo a substituição total
quando o chamador manda de facto uma lista nova completa.
### `EMCP_Tools_Element_Validator` — `includes/validators/class-element-validator.php`
Classe pequena e propositadamente simples (~55 linhas) — valida só a **forma estrutural** de um
elemento isolado antes de ser gravado: `id` presente, `elType` presente e num allowlist fixo
(`container`, `widget`, `section`, `column`, mais os tipos atómicos `e-div-block`, `e-flexbox`,
mais os tipos de estrutura de formulário atómico `e-tabs*`/`e-form*`), e `widgetType` presente
quando `elType==='widget'`. **Não é chamada por nenhuma das 10 classes deste doc directamente**
(não aparece em nenhuma das leituras de `add-container`/`add-*-widget`/`build-page`) — é
provavelmente invocada num caminho de import/validação mais genérico não coberto por esta
tarefa (possivelmente `import-sandbox-artifact` ou o dispatcher de validação de widgets custom
do doc 06, dado o allowlist incluir tipos `e-form-*` que não aparecem em mais nenhum ficheiro
lido nesta tarefa). Não confundir com `EMCP_Tools_Settings_Validator` (validador de VALORES de
settings contra o schema de controls de um widget — usado por `Widget_Abilities::execute_add_widget()`,
ver §3) nem com `EMCP_Tools_Atomic_Props` (coerção/validação de tipos de prop atómico v4 — usado
extensivamente por `EMCP_Tools_Data`, coberto em detalhe no doc 02).
---
## Blueprint para réplica
### Copiar quase 1:1 (baixo risco de reescrever pior)
- **`EMCP_Tools_Element_Factory`** — fábrica pura, sem I/O; as duas normalizações estáticas
(`normalize_container_settings`, `normalize_background_settings`) codificam conhecimento
tácito real sobre onde o Elementor lê cada chave (não documentado nem no Elementor nem em
lado nenhum público) — recriá-las do zero significa redescobrir os mesmos 3-4 bugs (#32 em
particular) por tentativa e erro num site de produção.
- **O padrão try/save/verify/fallback de `save_page_data()`** — é o resultado de pelo menos 5
issues reais numeradas (#98, #101, #102, #105, #111, #112) resolvidas ao longo de várias
versões. Uma réplica que escreva `_elementor_data` só com `update_post_meta()` direto (sem
passar primeiro pelo `Document::save()` nativo) perde a regeneração de CSS automática do
Elementor; uma que só use `Document::save()` sem fallback nem verificação pós-save vai falhar
silenciosamente em contexto CLI/REST exactamente como a v1.0.0 original deste plugin
presumivelmente fazia antes destas correcções serem adicionadas.
- **O hook `init()` de invalidação de `_elementor_element_cache`** — 4 linhas de código que
previnem uma classe inteira de bugs "página fica vazia depois de criada via MCP" em sites com
object cache persistente. Trivial de replicar, caro de não ter.
- **O padrão de `Global_Classes_Write_Abilities`: read-mutate-put via o repositório oficial do
Elementor** em vez de escrita directa de meta — delega o cálculo de diff e limpeza de relações
ao próprio Elementor. Reescrever isto por fora (calcular o diff manualmente) só faz sentido se
se estiver a substituir inteiramente o sistema de Class Manager, não a interagir com ele.
### Simplificar na reescrita
- **Os 3 tools Pro de `Custom_Code_Abilities`** (`add-custom-css`, `add-code-snippet`,
`list-code-snippets`) dependem de comportamento interno específico do Elementor Pro (CPT
`elementor_snippet`, meta keys `_elementor_location`/`_elementor_priority`/`_elementor_code`).
Numa réplica sem o objectivo de espelhar exactamente o Elementor Pro, um sistema de snippets
site-wide próprio (CPT nosso, sem tentar imitar o formato do Elementor Pro) é mais simples de
manter e não fica preso a mudanças não documentadas do formato interno de um plugin de
terceiros.
- **Os 6 tools Pro de `Template_Abilities`** (theme templates, dynamic tags, popups) só fazem
sentido se se estiver mesmo a espelhar o Elementor Pro Theme Builder; se a réplica não visa
paridade total com Elementor Pro, este bloco inteiro pode ficar de fora sem perda de valor
para o caso de uso "gerar/editar páginas com Elementor free".
- **`normalize_node()` (shorthand coercion em `build-page`)** — é uma correcção pragmática para
modelos de IA fracos, não uma necessidade estrutural. Numa réplica visada a modelos fortes
(ex. só Claude/GPT-4 classe), pode-se optar por rejeitar shorthand com erro claro em vez de
coagir silenciosamente — troca tolerância por previsibilidade; ambas são escolhas válidas,
mas a escolha deve ser deliberada, não copiada por omissão.
### Riscos/gotchas a não esquecer (lista consolidada, por nº de issue)
| # | Onde | Risco se ignorado numa réplica |
|---|---|---|
| #32 | `normalize_container_settings` | Atalhos `justify_content`/`align_items` gravados mas nunca lidos pelo gerador CSS — alinhamento nunca aplica no frontend |
| #38 | `save_elementor_conditions` | Bypass da API de condições do Theme Builder invalida location cache de TODOS os templates, não só o alterado |
| #72/#73/#92 | `update_element_settings` (styles/editor_settings hoist + sync_local_class_refs) | Estilos locais v4 gravados mas nunca aplicados; rótulo Navigator gravado em chave morta |
| #83 | `full_bleed_preset` | Faixas brancas em templates Canvas com secções full-width |
| #97 | `reassign_element_ids` | Duplicar um elemento atómico faz o duplicado herdar (e poluir) as classes locais do original |
| #98 | `save_page_data` verificação pós-save | `Document::save()` pode devolver sucesso e mesmo assim não persistir nada em contexto 4.x/REST |
| #101/#102 | `coerce_settings`/`coerce_tree` | Um valor bruto num prop atómico tranca TODA a página fora de futuros saves, não só o elemento afectado |
| #104/#72(layout) | `is_container_type` | Ferramentas de layout que só reconhecem `container` legado ignoram containers atómicos v4 (`e-flexbox`/`e-div-block`) |
| #105 | `elementor_documents_ready` | Fatal error por null-deref se o Elementor ainda não completou o próprio boot |
| #108 | `Global_Classes_Write_Abilities` (comentário de cabeçalho) | Tools de escrita de Global Classes são recentes (3.9.0) — API do Elementor para isto é jovem, sujeita a mudança |
| #111 | `is_container_supported` (2 sítios: add-container e build-page) | Container gravado com sucesso mas invisível no frontend se a experiência Flexbox Container estiver desligada — falha totalmente silenciosa |
| #112 | `is_atomic_validation_rejection` | Uma dynamic tag não resolvível em contexto CLI bloqueia save de página inteira, incluindo edições não relacionadas |
| F-004 | `add-custom-css` sanitização | Bypass de `</style>` como vector XSS se a remoção não for feita em loop até fixpoint |
| F-008 | Regex de handlers `on*=` (SVG + custom CSS) | Handler com valor multi-linha escapa à sanitização sem a flag `/s` |
---
## Fonte
Leitura directa (19-08-2026) de, sob
`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
- `includes/abilities/class-page-abilities.php`
- `includes/abilities/class-layout-abilities.php`
- `includes/abilities/class-widget-abilities.php`
- `includes/abilities/class-template-abilities.php`
- `includes/abilities/class-global-abilities.php`
- `includes/abilities/class-global-classes-abilities.php`
- `includes/abilities/class-global-classes-write-abilities.php`
- `includes/abilities/class-custom-code-abilities.php`
- `includes/abilities/class-svg-icon-abilities.php`
- `includes/abilities/class-composite-abilities.php`
- `includes/class-element-factory.php`
- `includes/class-elementor-data.php`
- `includes/validators/class-element-validator.php`
- `includes/abilities/class-ability-registrar.php` (linhas 430-530, só para confirmar a condição
de registo — `if ( $elementor_active )` — e a ausência de qualquer classe de CRUD de widget
custom neste bloco)
Cruzado com `docs/00-ARQUITECTURA.md` (mesma tarefa, doc irmão) para o contrato de
`emcp_tools_register_ability()` e a ordem de arranque, e com `skill://emcp-tools` (auditoria de
segurança, 16-08-2026) para a lista de tools deste grupo presentes no deny-list por omissão
(§2.6/§2.9/§2.10 desse documento).
+515
View File
@@ -0,0 +1,515 @@
# 02 — Elementor Atomic v4 (widgets/layout/global classes de leitura) e Gutenberg nativo
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Complementa `docs/00-ARQUITECTURA.md` (arquitectura geral, contrato de
`emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança/deny-list).
## 0. Panorama e gating
Este documento cobre três classes de abilities e seis serviços de suporte. Todas as três
classes de abilities são instanciadas em `EMCP_Tools_Ability_Registrar::register_groups()`
(`includes/abilities/class-ability-registrar.php`), mas com gating muito diferente:
| Classe | Onde é instanciada no registrar | Gate adicional dentro da própria classe |
|---|---|---|
| `EMCP_Tools_Atomic_Widget_Abilities` | Dentro do bloco `if ( $elementor_active )` | `register()` faz `return` cedo se `EMCP_Tools_Atomic_Props::is_atomic_supported()` for `false` — **nenhuma** das 10 tools regista |
| `EMCP_Tools_Atomic_Layout_Abilities` | Dentro do bloco `if ( $elementor_active )` | Mesmo guard `is_atomic_supported()` — e, ao contrário do que a doc-comment do código sugere, isto também bloqueia `detect-elementor-version` (ver §3, gotcha) |
| `EMCP_Tools_Gutenberg_Abilities` | Na secção "always-on" (topo de `register_groups()`), **sem** verificar `$elementor_active` | Nenhum — regista sempre, mesmo com Elementor completamente ausente/inactivo |
**Consequência prática:** num site com Elementor activo mas sem o Elementor 4.0+/atomic
ligado (a maioria dos sites em 2026, dado que `is_atomic_supported()` não é uma simples
verificação de versão — ver §4), as 19 tools atomic (10 + 3, menos 1 sobreposta, ver tabelas)
não existem de todo no `wp_get_abilities()`; as 10 tools Gutenberg existem sempre, com ou sem
Elementor.
---
## 1. `EMCP_Tools_Atomic_Widget_Abilities` — `includes/abilities/class-atomic-widget-abilities.php`
**Condição de registo:** classe instanciada só quando `$elementor_active` é `true`; dentro
dela, `register()` só prossegue se `EMCP_Tools_Atomic_Props::is_atomic_supported()` devolver
`true` (ver §4 para o mecanismo de detecção).
Duas tools "universais" (aceitam qualquer `widget_type` atómico com settings em bruto no
formato `$$type`) mais oito tools de conveniência (uma por widget atómico, com parâmetros
planos que a própria classe converte para `$$type` via `EMCP_Tools_Atomic_Widget_Map`).
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|---|---|---|---|---|
| `add-atomic-widget` | `post_id`(int,req), `parent_id`(string,req), `position`(int, -1=append), `widget_type`(string,req, ex. `e-heading`), `settings`(object, valores já em `$$type`) | Tool genérica: cria o elemento via `EMCP_Tools_Element_Factory::create_atomic_widget()` com settings passados tal-e-qual (sem conveniência), insere no `parent_id` na `position` dada, grava a página. | `check_edit_permission` (`edit_posts` + `edit_post` do `post_id` se dado) | false / false / false |
| `update-atomic-widget` | `post_id`(int,req), `element_id`(string,req), `settings`(object,req, `$$type`-wrapped) | Merge PARCIAL de settings num widget atómico já existente (só as chaves fornecidas mudam) via `EMCP_Tools_Data::update_element_settings()`. | `check_edit_permission` | false / false / **true** |
| `add-atomic-heading` | `post_id`,`parent_id`(req), `position`, `title`, `tag`(enum h1-h6, default h2), `link`, `css_id` | Widget `e-heading`. Mapeia `title`→prop `title` (html-v3), `tag`→prop `tag` (string). | `check_edit_permission` | false / false / false |
| `add-atomic-paragraph` | `post_id`,`parent_id`(req), `position`, `content`, `link`, `css_id` | Widget `e-paragraph`. **Gotcha:** a prop real chama-se `paragraph`, não `text` — ver §5. | `check_edit_permission` | false / false / false |
| `add-atomic-button` | `post_id`,`parent_id`(req), `position`, `text`, `link`, `target_blank`(bool), `css_id` | Widget `e-button`. `link` aceita `target_blank`. | `check_edit_permission` | false / false / false |
| `add-atomic-image` | `post_id`,`parent_id`(req), `position`, `image_id`(int) OU `image_url`(string), `alt`, `link`, `css_id` | Widget `e-image`. `image_id` XOR `image_url`. Para `image_id`, o `alt` é escrito em `_wp_attachment_image_alt` (não na prop) — ver §5. | `check_edit_permission` | false / false / false |
| `add-atomic-svg` | `post_id`,`parent_id`(req), `position`, `svg_id`(int) OU `svg_url`(string), `css_id` | Widget `e-svg`. Usa o tipo `svg-src`, distinto de `image-src`. | `check_edit_permission` | false / false / false |
| `add-atomic-youtube` | `post_id`,`parent_id`,`video_url`(**todos req**), `position`, `css_id` | Widget `e-youtube`. `source` é uma prop STRING simples (não um shape). | `check_edit_permission` | false / false / false |
| `add-atomic-video` | `post_id`,`parent_id`(req), `position`, `video_url`(string) OU `video_id`(int), `css_id` | Widget `e-self-hosted-video`. `source` é o shape `video-src` (XOR id/url) — diferente de `add-atomic-youtube`. | `check_edit_permission` | false / false / false |
| `add-atomic-divider` | `post_id`,`parent_id`(req), `position`, `css_id` | Widget `e-divider`. Sem conteúdo próprio; só a cauda partilhada (link/css_id/classes, mas divider não usa link na prática). | `check_edit_permission` | false / false / false |
**Mecanismo partilhado das 8 convenience tools:** `register_atomic_convenience()` monta um
schema comum (`post_id`,`parent_id`,`position` + os `extra_props` de cada widget) e um
`execute_callback` genérico que: (1) chama `$settings_fn($input)` — um closure que invoca
`EMCP_Tools_Atomic_Widget_Map::settings($widget_type, $input)`; (2) constrói o elemento via
`$this->factory->create_atomic_widget()`; (3) se o input tiver parâmetros de estilo comuns
(`padding`, `background_color`, `min_height`, etc.), constrói-os via
`EMCP_Tools_Atomic_Styles::build_common_props()` e aplica-os como uma classe de estilo local
via `create_local_class()` + `apply_to_element()`; (4) insere e grava. Isto significa que
**qualquer** convenience tool aceita implicitamente os parâmetros de estilo comuns
(`padding`, `background_color`, `min_height`, `width`, `border_radius`, `color`, etc.) mesmo
que não apareçam no `extra_props` explícito de cada tool individual, porque
`build_common_props()` corre sobre o `$input` inteiro.
`check_edit_permission($input)`: requer `current_user_can('edit_posts')`; se `post_id` for
fornecido e não-zero, requer adicionalmente `current_user_can('edit_post', $post_id)`.
---
## 2. `EMCP_Tools_Atomic_Layout_Abilities` — `includes/abilities/class-atomic-layout-abilities.php`
**Condição de registo:** idêntica à classe anterior — instanciada só com `$elementor_active`,
e `register()` faz `return` cedo se `is_atomic_supported()` for `false`. Ver §3 para o gotcha
sobre `detect-elementor-version`.
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|---|---|---|---|---|
| `add-flexbox` | `post_id`(req), `parent_id`(vazio=top-level), `position`, `tag`(enum div/header/section/article/aside/footer), `direction`(row/column/…), `justify`, `align`, `gap`+`gap_unit`, `wrap`, `css_id`, `padding`, `background_color`, `min_height` | Cria um container `e-flexbox` (Elementor 4.0+). As propriedades de layout (direction/justify/align/gap/wrap) e as comuns (padding/background/min-height) são extraídas de uma lista fixa de `style_keys` no `execute_add_flexbox()`, convertidas via `EMCP_Tools_Atomic_Styles`, e aplicadas como classe de estilo local — **não** via `register_atomic_convenience()` (esta tool tem o seu próprio `execute_callback`, não reutiliza o mecanismo da classe Widget). Se `parent_id` vazio, insere top-level (`array_splice`/append directo em vez de `insert_element()`). | `check_edit_permission` | false / false / false |
| `add-div-block` | `post_id`(req), `parent_id`, `position`, `tag`(mesmo enum), `css_id`, `padding`, `background_color` | Cria um container `e-div-block` (layout de fluxo/bloco, NÃO flex) — para quando não se quer um flexbox. Mesmo padrão de inserção top-level vs `parent_id`. | `check_edit_permission` | false / false / false |
| `detect-elementor-version` | Sem input (`properties: {}`) | Devolve `elementor_version` (`ELEMENTOR_VERSION`), `elementor_pro_version`, `supports_atomic` (via `is_atomic_supported()`), `supports_container` (via `is_container_supported()`), `recommended_mode` (`atomic`\|`legacy`\|`unsupported`) e, se `unsupported`, um `warning` a avisar que os experiments "Flexbox Container" / "Atomic Elements" estão ambos desligados e que páginas criadas via MCP vão gravar dados mas renderizar vazias. **Ver gotcha em baixo — na prática, esta tool só está disponível quando `recommended_mode` já seria `atomic`.** | closure inline: `current_user_can('edit_posts')` | **true** / false / **true** |
**GOTCHA de código encontrado (não documentado como tal no próprio ficheiro):** a doc-comment
acima de `register_detect_elementor_version()` diz literalmente `// Detect version (always
registers, even on < 4.0)`. Mas o método `register()` da classe é:
```php
public function register(): void {
if ( ! EMCP_Tools_Atomic_Props::is_atomic_supported() ) {
return; // <-- sai ANTES de chamar register_detect_elementor_version()
}
$this->register_add_flexbox();
$this->register_add_div_block();
$this->register_detect_elementor_version();
}
```
O `return` cedo bloqueia as TRÊS chamadas, incluindo a de `detect-elementor-version` — pelo
que esta tool só existe quando o site JÁ suporta atomic, exactamente o cenário oposto ao mais
útil (um agente que precisa de descobrir se deve usar tools legacy ou atomic não consegue
chamar esta tool quando mais precisa dela — nos sites em `legacy`/`unsupported` a tool
simplesmente não aparece em `wp_get_abilities()`). Numa réplica, isto seria trivial de
corrigir: mover `register_detect_elementor_version()` para fora do guard (registá-la sempre,
independentemente de `is_atomic_supported()`).
---
## 3. `EMCP_Tools_Gutenberg_Abilities` — `includes/abilities/class-gutenberg-abilities.php`
**Condição de registo:** **sempre** — está na secção "always-on" do registrar
(`$gutenberg = new EMCP_Tools_Gutenberg_Abilities(); $gutenberg->register();`), sem nenhum
`if ($elementor_active)` nem verificação de módulo. É pura WordPress core: opera sobre
`post_content` de qualquer post via `parse_blocks()`/`serialize_blocks()` (funções nativas do
WP) e `EMCP_Tools_Block_Tree` (§7). Dez tools no total, desenhadas como um fluxo
discover→schema→edit incremental por PATH.
| Tool | Input schema (resumo) | O que faz | `permission_callback` | readonly / destructive / idempotent |
|---|---|---|---|---|
| `list-blocks` | `category`, `search` (ambos opcionais) | Lista block types registados via `WP_Block_Type_Registry::get_instance()->get_all_registered()`, filtrável por categoria/substring em nome+título. Devolve `{name,title,category}` por linha. Passo 1 do fluxo "construir página de blocos". | `check_read_permission` | true / false / true |
| `get-block-schema` | `name`(string) OU `names`(string[], lote) | Devolve `{name,title,category,attributes,supports,example}` por block type — `example` é um snippet mínimo de markup gerado (`<!-- wp:{short} -->…<!-- /wp:{short} -->`). Passo 2, antes de `add-block`. Nomes não registados devolvem `{name,error}` em vez de falhar o lote inteiro. | `check_read_permission` | true / false / true |
| `get-post-blocks` | `post_id`(req), `depth`(opcional, limita profundidade) | Devolve a árvore de blocos do post com um PATH de índices por bloco (ex. `[2,1]`), via `EMCP_Tools_Block_Tree::from_markup()`+`summarize()`. **Chamada obrigatória antes de qualquer `update-block`/`remove-block`/`move-block`/`duplicate-block`** para obter os paths actuais. | `check_read_permission` | true / false / true |
| `list-patterns` | `search`, `category` (opcionais) | Lista block patterns registados via `WP_Block_Patterns_Registry`, filtrável. Devolve `{name,title,categories,description}`. | `check_read_permission` | true / false / true |
| `add-block` | `post_id`(req), `markup`(string,req, pode conter vários blocos), `position`({mode,path}) | Insere markup Gutenberg bruto numa posição. `position.mode`: `append`\|`prepend`\|`before`\|`after`\|`inside` (os últimos três exigem `position.path`, resolvido via `get-post-blocks`). Valida que o `path` resolve para um bloco antes de inserir. | `check_write_permission` | false / false / false |
| `update-block` | `post_id`(req), `path`(int[],req), `markup`(string,req) | Substitui o bloco no `path` por novo markup (pode expandir para vários blocos). | `check_write_permission` | false / false / false |
| `remove-block` | `post_id`(req), `path`(int[],req) | Apaga o bloco no `path` (e os seus `innerBlocks`). **Única tool Gutenberg marcada `destructive:true`.** | `check_write_permission` | false / **true** / false |
| `move-block` | `post_id`(req), `path`(int[],req), `position`({mode,path},req) | Move o bloco de `path` para uma nova posição. Delegado a `EMCP_Tools_Block_Tree::move()`, que tem guards de segurança próprios (ver §7). | `check_write_permission` | false / false / false |
| `duplicate-block` | `post_id`(req), `path`(int[],req) | Clona o bloco no `path`, insere a cópia imediatamente a seguir. Devolve o `path` da cópia (calculado como `path` com o último índice +1 — assume que `duplicate()` sempre insere logo a seguir ao original no mesmo nível). | `check_write_permission` | false / false / false |
| `insert-pattern` | `post_id`(req), `pattern_name`(string,req, de `list-patterns`), `position`({mode,path}) | Insere um pattern registado (resolvido via `WP_Block_Patterns_Registry`) numa posição, expandindo o `content` do pattern para blocos via `parse_blocks()`. | `check_write_permission` | false / false / false |
**Permissões:** `check_read_permission($input)` requer `edit_posts`, mais `edit_post($post_id)`
se `post_id` for dado (mas não é required em todas as tools de leitura — só `get-post-blocks`
o exige no schema). `check_write_permission($input)` é mais estrito: requer `post_id`
**presente e não-zero** e `edit_post($post_id)` — nunca aceita uma escrita sem `post_id`
concreto.
**Persistência (`save_tree()`):** todas as seis tools de escrita convergem em `save_tree()`,
que faz `wp_update_post(['ID'=>…, 'post_content'=>wp_slash(Block_Tree::to_markup($tree))])`.
**Gotcha citado no código:** `wp_update_post()` corre `wp_unslash()` sobre os dados, e a
serialização de blocos emite escapes de barra invertida (`&`, `\"`, `\\`, …) nos atributos —
por isso o markup TEM de ser "slashed" antes de chegar a `wp_update_post()`, senão esses
escapes são removidos e o bloco corrompe-se. `save_tree()` também regista a alteração no
change-ledger via `EMCP_Tools_Change_Recorder::record_post_fields()` (domínio `gutenberg`,
action `block-write`), guardando o `post_content` ANTERIOR — é o que permite `rollback-change`
(doc 05) desfazer uma edição de blocos.
---
## 4. Serviços de suporte
### 4.1 `EMCP_Tools_Atomic_Props` — `includes/class-atomic-props.php` (973 linhas)
O coração do sistema `$$type`. Todo valor de prop atómico Elementor 4.0+ é um envelope
`{ '$$type': '<tipo>', 'value': <dados> }` — o objectivo desta classe é (a) construir esses
envelopes a partir de valores simples que um agente de IA escreveria naturalmente, e (b)
fazer o caminho inverso para leitura (`unwrap()`), mais (c) uma camada de auto-correcção que
salvou o plugin de uma classe inteira de bugs de produção (ver os números de issue citados
no próprio código).
**Builders de envelope (métodos estáticos, um por tipo primitivo/composto):**
| Método | Tipo `$$type` produzido | Nota de design |
|---|---|---|
| `string($v)` | `string` | Trivial. |
| `number($v)` | `number` | Trivial. |
| `boolean($v)` | `boolean` | Trivial. |
| `size($size,$unit='px')` | `size` → `{size,unit}` | Usado para qualquer dimensão CSS. |
| `color($color)` | `color` | **Não** é `string` — a prop `color` é um `Color_Prop_Type` e exige o envelope `color`; um `string` é rejeitado. |
| `background_color($color)` | `background` → `{color: <color-prop>}` | Não existe prop `background-color` — o Elementor guarda fundo como `Background_Prop_Type` cujo campo `color` é ele próprio um `color`-prop aninhado. Escrever `background-color` directamente é silenciosamente descartado. |
| `dimensions($sides)` | `dimensions` → `{block-start,block-end,inline-start,inline-end}` | Shape partilhado por `padding`/`margin`. Não existe prop `padding-block-start` individual — construir por lado sem usar este wrapper é descartado no save. |
| `html($text)` | `html-v3` → `{content:<string-prop>, children:[]}` | Usado para qualquer conteúdo de texto rico (heading, paragraph, button…). O nome do tipo já evoluiu `html`→`html-v2`→`html-v3`; a classe segue a Elementor como fonte de verdade em vez de fixar o nome (ver `coerce_against_prop`). |
| `url($url)` | `url` | Trivial. |
| `link($url,$target_blank=false)` | `link` → `{destination:<url-prop>, tag:<string-prop>('a'), isTargetBlank?:<boolean-prop>}` | `isTargetBlank` só é incluído quando `true` (omitido, não `false`, quando não pedido). |
| `classes($ids=[])` | `classes` | Array de IDs de classe (locais `e-*` ou globais `g-*`). |
| `image($id,$url='',$alt='')` | `image` → `{src:{$$type:'image-src', value:{id,url}}}` | `id` XOR `url` — `Image_Src_Prop_Type` exige exactamente um dos dois, o outro TEM de ser `null` (não omitido). Passar ambos, ou um `id` como `number` em vez de `image-attachment-id`, produz `image: invalid_value` (issue #74). `alt` só entra no envelope quando é uma imagem por `url`; para attachment é ignorado pelo Elementor (renderiza sempre o alt da media library) — ver `EMCP_Tools_Atomic_Widget_Map::image()`. |
| `video_src($id,$url='')` | `video-src` → `{id:{$$type:'video-attachment-id',...}}` OU `{url:<url-prop>}` | Shape distinto de `image-src`; um envelope `url` simples faz o Elementor **rejeitar o elemento inteiro** (`source: invalid_value`) em vez de só ignorar o valor — foi o que impedia `add-atomic-video` de funcionar de todo no Elementor 4.2 antes desta correcção. |
| `svg($id,$url='')` | `svg-src` | **Tipo distinto** de `image-src` — usar `image()` para um `e-svg` falha (issue #74). |
**Introspecção de schema (`props_schema()`):** em vez de fixar hard-coded que prop cada
widget espera, a classe pergunta directamente ao próprio Elementor:
`\Elementor\Plugin::$instance->widgets_manager->get_widget_types($widget_type)::get_props_schema()`,
com cache estática por `$widget_type` (uma passagem de coerção sobre uma página inteira
pergunta pelo mesmo punhado de schemas centenas de vezes).
**A camada de auto-correcção (`coerce_settings`/`coerce_with_schema`/`coerce_tree`):** um
agente de IA vai escrever naturalmente `'title' => 'Hello'` em vez do envelope
`{'$$type':'html-v3', value:{...}}`. Sem correcção, o Elementor cai para o valor por omissão
da prop (elemento renderiza texto placeholder) e **todo save subsequente dessa página passa a
falhar** com `Settings validation failed` — a página fica impossível de editar tanto via API
como via editor (issue #101). A correcção:
1. `apply_prop_aliases()` — renomeia chaves alias (`text`/`content`/`heading` → o nome
canónico `title`) usando a MESMA metadata que o Elementor expõe (`$prop->get_meta_item('aliases')`),
**antes** de qualquer validação. Crítico: o `Props_Parser` do Elementor **descarta
silenciosamente** chaves que não reconhece (não rejeita, apaga) — por isso uma chave alias
não corrigida a tempo perde o conteúdo em vez de ser rejeitada com erro visível (issue #102).
Um valor já presente sob o nome canónico nunca é substituído por um alias.
2. `coerce_against_prop()`/`candidates_for()`/`coerce_shape()` — para cada prop, se o valor
já for aceite por `$prop->validate()`, fica como está; senão constrói candidatos a partir
dos próprios `get_prop_types()`/`get_key()`/`get_shape()` do prop Elementor (nunca hardcoded)
e testa cada um contra `validate()`, usando o primeiro aceite. Cobre tanto valores planos
(string→envelope certo) como shapes compostos (ex. `link` legado `{url,is_external}` →
`{destination,isTargetBlank}`).
3. `coerce_tree()` — corre sobre a ÁRVORE INTEIRA no save, não só o elemento tocado, porque o
Elementor valida a página inteira de uma vez: um único widget por corrigir, em qualquer
parte da página, bloqueava até a própria edição destinada a reparar a página (issue #102).
**`unwrap()`/`unwrap_array()`:** direcção inversa — usado por `get-element-settings` (doc 01)
para devolver valores planos e legíveis a um agente em vez do envelope `$$type` bruto.
**`is_atomic_supported()` / `is_container_supported()`:** a peça mais subtil de todo o
ficheiro. **Não** é baseada em `version_compare(ELEMENTOR_VERSION, '4.0.0', '>=')` — o
Elementor lança o atomic/v4 como experiment opt-in enquanto `ELEMENTOR_VERSION` continua a
reportar um valor 3.x. O sinal AUTORITATIVO é se os TIPOS de elemento `e-flexbox`/`e-div-block`
estão realmente REGISTADOS (`$elementor->elements_manager->get_element_types()`), porque é
isso que garante que `Document::save()` preserva os dados em vez de os sanitizar
silenciosamente. Deliberadamente NÃO usa o experiment `e_opt_in_v4_page` (que liga o EDITOR
v4 sem garantir que os tipos de elemento estão registados — um site pode ter esse experiment
ligado e `e_atomic_elements` desligado, escrever "com sucesso" e `_elementor_data` fica vazio
após o save). Cai depois para os experiments `e_atomic_elements`/`atomic_widgets`, e só por
último para o `version_compare` genérico (fallback forward-compatible). Comentário explícito
no código: "NB: do NOT use `class_exists('\Elementor\Modules\AtomicWidgets\Module')` as a
signal — that class is autoloaded even when the atomic experiment is OFF". `is_container_supported()`
segue o mesmo padrão para o experiment legado (3.x) `container`.
### 4.2 `EMCP_Tools_Atomic_Widget_Map` — `includes/class-atomic-widget-map.php`
Mapa único de "parâmetros amigáveis → settings `$$type`", partilhado por §1 (convenience
tools) E pela tool composta `build-page` (doc 01) — razão de existir: `build-page` passava
settings de widgets atómicos em bruto, e como props complexas (`e-image`.`image`,
`e-self-hosted-video`.`source`) não têm chave equivalente em bruto, o widget ficava vazio. Ao
centralizar aqui, ambos os caminhos produzem settings byte-idênticas para o mesmo input.
`atomic_types()`: os 8 tipos conhecidos — `e-heading`, `e-paragraph`, `e-button`, `e-image`,
`e-svg`, `e-youtube`, `e-self-hosted-video`, `e-divider`. `settings($widget_type,$params)`
despacha para um builder privado por tipo; `is_atomic($widget_type)` verifica pertença.
| Builder | Gotcha documentado no código |
|---|---|
| `heading()` | Directo — `title`→html, `tag`→string. |
| `paragraph()` | **A prop chama-se `paragraph`, não `text`** (Html_V3) — escrever `text` apagava o conteúdo silenciosamente (issue #56). |
| `button()` | Directo, mas passa `$link_target_blank=true` ao `finish()` partilhado (só o botão honra `target_blank`). |
| `image()` | `image_id` XOR `image_url`. Para `image_id`, escreve o `alt` em `update_post_meta($image_id, '_wp_attachment_image_alt', $alt)` — a única forma que faz efeito, porque `e-image` não tem prop `alt` de topo e para uma attachment o Elementor renderiza sempre o alt da media library. |
| `svg()` | Usa `EMCP_Tools_Atomic_Props::svg()` (tipo `svg-src`), nunca `image()`. |
| `youtube()` | `source` é `EMCP_Tools_Atomic_Props::string()` — um **union de string simples**, não um shape. |
| `video()` | `source` é `EMCP_Tools_Atomic_Props::video_src()` — um **shape XOR id/url**, distinto de `youtube()` apesar do nome de prop idêntico (`source`). Um envelope `url` simples faz o Elementor recusar o elemento inteiro. |
| `divider()` | Vazio — só a cauda partilhada. |
`finish($settings,$params,$link_target_blank=false)`: cauda partilhada por todos os
builders — adiciona `link` (se presente, com `esc_url_raw()`), `_cssid` (se presente, com
`sanitize_text_field()`), e sempre `classes` (vazio, ponto de ancoragem para
`EMCP_Tools_Atomic_Styles::apply_to_element()` adicionar depois uma classe local).
### 4.3 `EMCP_Tools_Atomic_Styles` — `includes/class-atomic-styles.php`
Constrói e aplica o mecanismo v4 de "classe de estilo local": em vez de propriedades CSS
inline no elemento, o v4 guarda estilo num mapa `styles` no próprio elemento, referenciado por
ID de classe em `settings.classes.value[]`.
- `create_local_class($element_id,$props,$breakpoint='desktop',$state=null)` — constrói UM
variant (par breakpoint+state) de uma definição de classe: `{id,label:'local',type:'class',
variants:[{meta:{breakpoint,state}, props, custom_css:null}]}`.
- `mint_class_id($element_id)` — gera `e-<element_id>-<7hex>`; o ID incorpora deliberadamente
o ID do elemento dono, porque as classes locais v4 pertencem a um único elemento.
- `remap_local_classes(&$element)` — **corrige um bug real de duplicação (issue #97):**
quando um elemento é duplicado com um `id` novo, as suas classes locais v4
(`e-<id-antigo>-<hash>`) continuam a embutir o `id` de ORIGEM e ficam partilhadas com a
fonte — uma escrita posterior no mapa `styles` sangra entre os dois, e o popover "Style
Origin" do editor mostra entradas duplicadas. Este método re-minta as chaves do mapa
`styles` (e o `id` de cada `style_def`) contra o `id` ACTUAL do elemento, e repõe
`settings.classes.value` das IDs antigas para as novas — só toca em classes LOCAIS deste
elemento; classes globais (`g-…`) referenciadas ficam intocadas.
- `build_flex_props($params)` — mapeia parâmetros planos (`direction`/`flex_direction`,
`justify`/`justify_content`, `align`/`align_items`, `wrap`/`flex_wrap`, `gap`+`gap_unit`,
`row_gap`, `column_gap`) para props CSS `$$type` em kebab-case (`flex-direction`,
`justify-content`, …).
- `build_common_props($params)` — `width`/`min_height`/`border_radius` (size simples);
`padding`/`margin` via `build_dimensions()` (shorthand de 4 lados — um valor único aplica
aos 4 lados, `*_top/_right/_bottom/_left` definem por lado individualmente, o shorthand
ganha se ambos presentes; **não existe prop `padding-block-start` individual, construir por
lado sem este wrapper é descartado no save**); `background_color` (via
`Atomic_Props::background_color()`, nunca uma prop `background-color`); `color` (via
`Atomic_Props::color()`, nunca `string`).
- `apply_to_element(&$element,$class_id,$style_def)` — push de `$class_id` em
`element.settings.classes.value[]` e de `$style_def` em `element.styles[$class_id]`.
### 4.4 `EMCP_Tools_Widget_Loader` — `includes/class-widget-loader.php`
Fora do âmbito directo atomic/Gutenberg — pertence ao mecanismo Sandbox de widgets Elementor
gerados (doc 06), mas foi incluído nesta batch de leitura. Padrão de design digno de nota:
- **Carregamento manifest-only** — nunca faz scan-and-include de um directório; lê um
manifesto de widgets activos, verifica cada ficheiro contra o seu sha256 registado (guarda
contra adulteração), e inclui dentro de isolamento de erro fatal.
- **Shutdown handler de atribuição** — se um `include_once` disparar um fatal de
compilação/parse (que um `try/catch` não apanha, porque um parse error num ficheiro incluído
aborta o request), um `register_shutdown_function()` regista o `$this->loading` (post ID do
widget a meio de inclusão) e, no shutdown, atribui o fatal a esse widget e desactiva-o —
garantindo que um widget mau nunca consegue white-screenar o site repetidamente.
- **Gate Pro total** — `has_access()` exige `emcp_tools_fs()->can_use_premium_code()`; num
build Free/sem licença (como este), tanto `register_widgets()` como `register_assets()`
saem imediatamente — é um NO-OP total neste site.
- Regista handles de CSS/JS (`wp_register_style`/`wp_register_script`) só como metadata em
`wp_enqueue_scripts` — o Elementor só enfileira efectivamente quando o widget está
presente na página, mantendo o custo baixo mesmo com muitos widgets activos.
### 4.5 `EMCP_Tools_Widget_Catalog` + `includes/widgets/catalog-free.php` — `includes/widgets/class-widget-catalog.php`
Fonte única de metadata para widgets Elementor CLÁSSICOS (pré-4.0/não-atomic) — usada por
`list-widgets`, `get-widget-schema`, `add-free-widget`, `add-pro-widget` (documentadas no
doc 01, Elementor clássico). **Não** é usada pelas tools atomic desta doc (essas usam
`EMCP_Tools_Atomic_Widget_Map` + introspecção ao vivo do `props_schema()` do Elementor).
`EMCP_Tools_Widget_Catalog::get()` funde três ficheiros de dados estáticos
(`catalog-free.php`+`catalog-pro.php`+`catalog-woo.php`) num único array chaveado por
`widget_type`, com cache em memória estática (`self::$catalog`). API de leitura:
`get_widget($type)`, `all_types()`, `by_tier($tier)`, `tier_of($type)`, `is_pro($type)`,
`search($query)` (substring case-insensitive sobre `type`+`title`+`use_case`+`keywords`, usado
por `list-widgets` para pesquisa por intenção), `flush_cache()` (seam de teste).
**`catalog-free.php` — 680 linhas, 26 widgets clássicos gratuitos.** Cada entrada é um array
com a forma:
```php
'<widget_type>' => [
'tier' => 'free',
'title' => 'Nome legível',
'category' => 'basic',
'requires' => null, // ou o slug do plugin exigido (null nos gratuitos)
'use_case' => 'Frase para pesquisa por intenção.',
'keywords' => ['palavra1', 'palavra2', ...],
'params' => [ 'nome_prop' => ['type'=>..., 'enum'=>[...], 'description'=>...], ... ],
'required' => ['prop_obrigatoria'],
'defaults' => ['prop' => valor],
],
```
Os 26 widgets: `heading`, `text-editor`, `image`, `button`, `video`, `icon`, `spacer`,
`divider`, `icon-box`, `accordion`, `alert`, `counter`, `icon-list`, `image-box`,
`image-carousel`, `progress`, `social-icons`, `star-rating`, `tabs`, `testimonial`, `toggle`,
`html`, `menu-anchor`, `shortcode`, `rating`, `text-path`. Cada `params` é um schema
simplificado mas fiel aos formatos NATIVOS de controlo Elementor (não `$$type` — isto é o
formato clássico `_elementor_data`, ex.: `{size,unit}` para dimensões, `{url,is_external,
nofollow}` para links, `{value,library}` para ícones, `yes`/`''` para toggles clássicos em
vez de booleanos reais). Exemplo representativo (`button`): 24 params cobrindo texto, link,
tamanho, tipo, alinhamento, ícone+posição, cores (normal/hover, fundo/texto/borda), animação
de hover, borda (estilo/largura/cor/raio), box-shadow, tipografia completa (família,
tamanho, peso, transform, letter-spacing), text-shadow, padding — o nível de detalhe é
tipicamente 15-25 params por widget, reflectindo directamente os controlos Elementor reais.
**`catalog-pro.php` — 1049 linhas, 30 widgets Elementor Pro** (existência confirmada, conteúdo
NÃO lido em detalhe por instrução de âmbito). Estrutura de dados idêntica a `catalog-free.php`
(mesma forma de array, `tier'=>'pro'`, `requires'=>'elementor-pro'`). Pelos nomes das chaves
top-level visíveis no ficheiro (sem ler os `params` internos): `form`, `posts`, `countdown`,
`price-table`, `flip-box`, `animated-headline`, `call-to-action`, `slides`,
`testimonial-carousel`, `price-list`, `gallery`, `share-buttons`, `table-of-contents`,
`blockquote`, `lottie`, `hotspot`, `nav-menu`, `loop-grid`, `loop-carousel`, `media-carousel`,
`nested-tabs`, `nested-accordion`, `portfolio`, `author-box`, `login`, `code-highlight`,
`reviews`, `off-canvas`, `progress-tracker`, `search` — parecem cobrir formulários, grids de
posts dinâmicos (loop), navegação, carrosséis multimédia, e widgets de UI avançada
(nested-tabs/accordion, off-canvas, progress-tracker), tudo dependente de `elementor-pro`.
**`catalog-woo.php` — 92 linhas, 5 widgets WooCommerce** (existência confirmada, conteúdo NÃO
lido em detalhe por instrução de âmbito). Mesma forma de array, `tier'=>'woo'`,
`requires'=>'woocommerce'`. Chaves: `woocommerce-products`, `wc-add-to-cart`,
`woocommerce-cart`, `woocommerce-checkout-page`, `woocommerce-menu-cart` — cobrem grid de
produtos, botão de compra, e as páginas completas de carrinho/checkout como widgets
embebíveis, mais um mini-carrinho para menu/header.
### 4.6 `EMCP_Tools_Block_Tree` — `includes/class-block-tree.php`
Transformações puras e sem estado sobre `parse_blocks()`/`serialize_blocks()` (funções core
do WordPress). Blocos são endereçados por um PATH de índices (array de ints):
`[0]` = primeiro bloco top-level (após remover blocos separadores em branco), `[2,1]` =
`innerBlocks[1]` do bloco top-level de índice 2. **Todos os métodos de mutação devolvem uma
ÁRVORE NOVA; nenhum muta in-place.**
- `from_markup()`/`to_markup()` — wrappers de parse/serialize (blocos unidos por linha em
branco); `strip_separators()` remove blocos top-level de separador (blockName `null` +
HTML em branco).
- `at($blocks,$path)` — resolve um path para um nó ou `null`.
- `insert/replace/remove/duplicate/move` — as 5 mutações principais, todas construídas sobre
dois primitivos de baixo nível: `edit_siblings()` (navega até ao array de irmãos que CONTÉM
o nó do path, aplica um callback que recebe `(siblings, index)` e devolve o novo array de
irmãos) e `edit_node()` (wrapper fino para editar o próprio nó).
- **`move()` tem três guards de segurança explícitos** que valem a pena replicar tal-e-qual:
1. Mover relativo a si próprio (`mode` before/after com `$from === $to`) é no-op.
2. Rejeita um movimento cujo alvo está DENTRO da própria subárvore do nó movido — senão o
nó seria removido e depois a inserção falharia (o path já não resolve), perdendo o bloco
silenciosamente.
3. **Correcção de deslocamento de índice:** `remove()` desloca cada irmão posterior sob o
pai de `$from` uma posição para a esquerda. Quando o path-alvo passa pelo MESMO pai numa
posição posterior à de `$from`, esse índice fica desactualizado — é decrementado antes de
`insert()`, aplicando-se a QUALQUER modo (before/after entre irmãos OU inside um
container posterior) a qualquer profundidade.
- `summarize($blocks,$depth,$prefix)` — vista compacta com path por get-post-blocks:
`{path, blockName, attributes, innerBlocksCount}`, com `innerBlocks` aninhado só até
`$depth` (se dado).
- **`inner_content_for()` — o internals mais delicado do ficheiro.** Reconstrói o array
`innerContent` de um bloco container quando o número de `innerBlocks` muda, PRESERVANDO o
HTML de wrapper do container. `innerContent` intercala chunks de string literal (o HTML do
wrapper) com placeholders `null` (um por bloco filho, consumidos por ordem por
`serialize_block()`). Dois caminhos: (a) o container já tinha filhos (havia `null`s) →
mantém o chunk antes do primeiro `null` e depois do último, reemite um `null` por filho
novo; (b) container estava vazio (sem `null`s) → "descasca" a sequência final de tags de
fecho via regex (`/((?:\s*<\/[a-zA-Z][a-zA-Z0-9]*>)+\s*)$/`) para que os filhos inseridos
fiquem DENTRO do wrapper em vez de depois dele.
---
## 5. Blueprint para réplica
**Copiar quase 1:1 (alto valor, baixo risco de reescrever mal):**
- **`EMCP_Tools_Atomic_Props` inteiro.** É a peça mais valiosa deste documento. Reescrever do
zero equivaleria a reproduzir ~2 anos de bugs de produção já corrigidos e documentados nas
próprias issues citadas no código (#36, #56, #74, #97, #101, #102, #111). Atenção especial
a três decisões de design: (1) `coerce_tree()` corre sobre a ÁRVORE INTEIRA no save, não só
o elemento tocado — porque o Elementor valida a página inteira de uma vez; (2)
`apply_prop_aliases()` corre ANTES da validação, nunca depois — porque o parser de props do
Elementor descarta silenciosamente chaves não reconhecidas em vez de as rejeitar; (3)
`is_atomic_supported()`/`is_container_supported()` NÃO se baseiam em `version_compare()`
mas em introspecção de tipos de elemento realmente registados — o Elementor já enviou
atomic como experiment opt-in em versões que ainda reportam `ELEMENTOR_VERSION` 3.x.
- **`EMCP_Tools_Atomic_Widget_Map`** — pequeno (≈200 linhas) mas denso em armadilhas
específicas do Elementor 4.0+ (chave `paragraph` vs `text`; `source` string vs shape;
alt só funciona por `url`, para attachment vai para post meta). Copiar incluindo os
comentários com o número de issue — são a única documentação que existe destas armadilhas.
- **`EMCP_Tools_Block_Tree`** — ~330 linhas, código puro sem qualquer dependência Elementor.
A lógica de `inner_content_for()` e os três guards de segurança de `move()`
(auto-referência, alvo-dentro-da-subárvore, correcção de índice) representam bugs subtis já
resolvidos; portar tal-e-qual evita reintroduzir os mesmos erros ao reescrever de raiz.
**Vale a pena simplificar:**
- **`EMCP_Tools_Atomic_Styles`** — a estrutura de "classe de estilo local" (variants por
breakpoint+state, ID mintado com o `element_id` embutido) é ditada directamente pelo
formato nativo `styles` do Elementor 4.0+, por isso tem de ser replicada fielmente SE a
réplica quiser gerar CSS local por elemento — mas se só forem necessários estilos simples
sem responsividade/estados, pode simplificar-se para um único variant fixo
(`desktop`/`null`) e cortar a generalização de breakpoint/state.
- **As Gutenberg abilities (a classe de abilities em si, não `Block_Tree`)** — muito mais
simples de reescrever de raiz do que as atomic, porque não há `$$type` nem dependência
Elementor nenhuma; a única peça deste grupo que vale a pena copiar exactamente é
`EMCP_Tools_Block_Tree`, pelas razões acima.
**Vale a pena deixar de fora:**
- **`EMCP_Tools_Widget_Loader`** — 100% Pro-gated e específico ao mecanismo Sandbox de
widgets gerados por IA (doc 06); sem relação directa com atomic/Gutenberg. Só relevante se
a réplica também for construir um "widget builder" próprio a partir de código PHP gerado.
- **Os tiers Pro/Woo do widget catalog** (`catalog-pro.php` 1049 linhas, `catalog-woo.php` 92
linhas) — dados estáticos de descrição de widgets de terceiros que só fazem sentido se a
réplica também for suportar Elementor Pro/WooCommerce como dependência opcional; a
ESTRUTURA de dados (idêntica à de `catalog-free.php`) é trivial de reproduzir, o valor real
está no CONTEÚDO (descrições/enums correctos por widget), que teria de ser levantado
widget a widget contra a documentação oficial Elementor Pro/WooCommerce — não vale a pena
tentar adivinhar a partir dos nomes de chave.
**Riscos/gotchas não óbvios encontrados no código (citações directas, valem ouro):**
1. *"`insert_element()` mutates `$page_data` by reference and returns a bool; save the
modified `$page_data`, never the bool (issue #36)."* — padrão repetido em quase todos os
`execute_*` callbacks das duas classes atomic; um erro comum e fácil seria gravar o valor
de retorno booleano em vez da estrutura mutada.
2. *"Elementor's Props_Parser SILENTLY DISCARDS keys it does not recognise and still reports
the result as valid"* (issue #102) — motivo estrutural pelo qual `apply_prop_aliases()`
tem de correr ANTES da validação, nunca depois.
3. *"The `e-paragraph` content prop is named `paragraph` (Html_V3), not `text`. Writing `text`
silently dropped the content (issue #56)."*
4. *"There is no `background-color` style prop, so writing one is silently discarded"* /
*"Elementor has no per-side `padding-block-start` style prop, so writing those
individually is silently discarded on save"* — classe inteira de bugs "escreveu mas não
aconteceu nada, sem erro" nas style props do v4; qualquer réplica precisa de mapear
explicitamente cada shorthand em vez de assumir que props CSS planas funcionam.
5. *"The `e-svg` widget's `svg` prop is a distinct `svg-src` type — NOT the `image`/`image-src`
type used by `e-image`"* e *"`e-youtube`'s video prop is `source`, a plain string (union),
NOT the video-src shape the self-hosted video widget uses"* — dois pares de widgets com
nomes de prop idênticos (`source`, formatos tipo "src") mas shapes incompatíveis;
confundir os dois quebra o widget silenciosamente ou faz o Elementor rejeitar o elemento
inteiro.
6. Detecção de suporte atomic/container **não é por número de versão** — ver §4.1, último
parágrafo. Uma implicação prática: nem sequer basta verificar a feature flag do editor
(`e_opt_in_v4_page`), porque é uma experiment SEPARADA de `e_atomic_elements` — um site
pode ter a primeira ligada e a segunda desligada, escrever "com sucesso", e o
`Document::save()` sanitizar/remover os elementos atómicos silenciosamente.
7. **Gotcha nosso, não assinalado como tal no código:** a doc-comment `// Detect version
(always registers, even on < 4.0)` em `class-atomic-layout-abilities.php` está
desactualizada/incorrecta face ao guard clause real de `register()` — ver §2 para o
detalhe. Numa réplica, registar `detect-elementor-version` INCONDICIONALMENTE (fora de
qualquer guard de suporte atomic) resolve a inconsistência e torna a tool útil
precisamente no cenário em que mais falta faz.
8. *"`wp_update_post()` runs `wp_unslash()` on the data, and block serialization emits
backslash escapes (&, \", \\ …) in attributes — so the markup MUST be slashed here or those
escapes get stripped and the block corrupts."* — em `save_tree()` das Gutenberg abilities;
um erro fácil de introduzir ao reescrever a persistência de blocos sem este detalhe.
9. Issue #97 (`remap_local_classes`): duplicar um elemento atomic sem re-mintar as suas
classes de estilo locais faz com que o duplicado e o original PARTILHEM a mesma classe,
causando "style bleed" entre os dois e entradas duplicadas no popover "Style Origin" do
editor Elementor — um bug de UX subtil que só aparece depois de duplicar e depois estilizar
um dos dois separadamente.
---
## Fonte
Leitura directa (19-08-2026) de: `includes/abilities/class-atomic-widget-abilities.php`,
`includes/abilities/class-atomic-layout-abilities.php`,
`includes/abilities/class-gutenberg-abilities.php`, `includes/class-atomic-props.php` (973
linhas, completo), `includes/class-atomic-widget-map.php` (completo),
`includes/class-atomic-styles.php` (completo), `includes/class-widget-loader.php` (completo),
`includes/widgets/class-widget-catalog.php` (completo),
`includes/widgets/catalog-free.php` (680 linhas, completo), `includes/class-block-tree.php`
(completo); existência + estrutura de chaves top-level (sem leitura de `params` internos) de
`includes/widgets/catalog-pro.php` (1049 linhas, 30 widgets) e
`includes/widgets/catalog-woo.php` (92 linhas, 5 widgets). Cruzado com
`includes/abilities/class-ability-registrar.php` (já lido em sessão anterior, ver
`docs/00-ARQUITECTURA.md`) para as condições exactas de gating de cada classe.
+494
View File
@@ -0,0 +1,494 @@
# 03 — WordPress core (conteúdo/media/settings/plugins/temas/users/menus) e integrações de temas/frameworks de blocos
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Ver `docs/00-ARQUITECTURA.md` para o contrato `emcp_tools_register_ability()` e o
padrão de arranque; este documento assume esse contexto e não o repete.
Este grupo cobre **duas famílias distintas** de abilities:
1. **WordPress core puro** (content, media, settings, plugins, themes, users, nav menus) — sempre
activas, sem dependência de Elementor nem de nenhum framework de terceiros.
2. **Integrações de temas/frameworks de blocos** (Astra, Spectra, Kadence, Kadence Blocks) — o
padrão "Themes tab" de dois dispatchers (`<id>-read`/`<id>-write`) por integração, cada uma
condicional à presença do tema/plugin correspondente. Free tem 5 integrações concretas
(Active Theme + Astra + Spectra + Kadence + Kadence Blocks); GeneratePress/GenerateBlocks/
Blocksy são referenciadas no registrar mas **não existem como ficheiros no build Free**
(ver §16).
## 1. `EMCP_Tools_Content_Abilities` — `includes/abilities/class-content-abilities.php`
Condição de registo: **sempre-on** (`register_groups()` linha ~158, sem guarda). Deliberadamente
agnóstico ao Elementor — opera sobre `post_content` (HTML clássico ou markup de blocos Gutenberg)
e nunca toca em `_elementor_data`.
8 tools. `check_read_permission`=`edit_posts`; `check_create_permission`=`edit_posts` (mesmo cap
que ler — mimetiza o core WP, onde publicar é gated à parte); `check_edit_permission`=`edit_posts`
+ `edit_post` por-post quando há `post_id`; `check_delete_permission`=`delete_posts` +
`delete_post` por-post.
| Tool | Input (resumo) | O que faz | Permission | RO/Destr. |
|---|---|---|---|---|
| `list-post-types` | `public_only?:bool` (default true) | Lista post types registados (nome, label, hierárquico, `supports`, taxonomias); filtra internos (`revision`, `nav_menu_item`, `wp_template`, etc.) quando `public_only`. | `check_read_permission` | RO |
| `list-taxonomies` | `post_type?`, `include_terms?:bool`, `terms_limit?:int` (≤500, default 100) | Lista taxonomias (opcionalmente filtradas por post type), com termos embutidos opcionais. | `check_read_permission` | RO |
| `create-post` | `post_type?`(default post), `title`, `content`, `excerpt`, `status?`(enum draft/publish/pending/private/future), `slug?`, `author?`, `date?`, `parent?`, `menu_order?`, `comment_status?`, `terms?:{tax:ids[]}`, `meta?:{}`, `featured_image?:{id\|url}\|null` | `wp_insert_post()`. Recusa post types internos/inexistentes; `publish` exige `publish_posts`; `author` diferente do actual exige `edit_others_posts`; meta protegida (`_`-prefix ou `is_protected_meta`) é sempre recusada salvo allowlist via filtro `emcp_tools_content_allowed_protected_meta`. `featured_image.url` faz sideload via `EMCP_Tools_Url_Guard::safe_download()` (SSRF-guarded, bloqueia hosts privados/loopback/metadata cloud e revalida cada redirect). Regista change (`EMCP_Tools_Change_Recorder::record_post_create`). | `check_create_permission` | write, não-destr. |
| `get-post` | `post_id` | Serialização completa: title/slug/status/content/excerpt/datas/parent/menu_order/comment_status/permalink/edit_link/author/terms/meta(filtrada)/featured_image/`is_elementor` (flag `_elementor_edit_mode==='builder'`). | `check_read_permission` | RO |
| `update-post` | `post_id`, campos parciais iguais ao create + `terms_mode?`(replace/append) | Update parcial via `wp_update_post()`. Captura before-image (campos + meta + terms) para rollback via `Change_Recorder::record_post_fields`. Se o slug muda num post publicado, captura o URL antigo e sugere um redirect (`with_redirect_suggestion` → `EMCP_Tools_Redirect_Abilities::push_suggestion`, só quando `EMCP_Tools_Redirect_Module::is_enabled()`). | `check_edit_permission` | write, não-destr. |
| `delete-post` | `post_id`, `force?:bool` | `force=false` (default): `wp_trash_post()`. `force=true`: snapshot completo (`Change_Recorder::snapshot_post`) antes de `wp_delete_post($id,true)` (reversível). Ambos os caminhos sugerem redirect para o URL morto. | `check_delete_permission` | write, **destrutivo** |
| `list-posts` | `post_type?`(str\|array), `status?`(str\|array), `search?`, `taxonomy?:{tax:terms[]}`(AND), `author?`, `parent?`, `per_page?`(≤100), `page?`, `orderby?`, `order?` | `WP_Query` paginado, linhas compactas (sem body de conteúdo — usar `get-post` para isso). | `check_read_permission` | RO |
| `set-post-terms` | `post_id`, `taxonomy`, `terms:(int\|string)[]`, `mode?`(replace/append/remove), `create_missing?:bool`(default true) | `wp_set_object_terms`/`wp_remove_object_terms`. Quando `create_missing=false`, resolve nomes para IDs existentes via `get_term_by`(name→slug) e descarta os que não existem (não cria). | `check_edit_permission` | write, não-destr. |
**Detalhe de implementação notável:** `apply_write_extras()` é o método partilhado usado por
`create-post`/`update-post` para aplicar `terms`/`meta`/`featured_image` — inclui o comentário
explícito no código sobre porquê carrega `wp-admin/includes/{file,media,image}.php` on-demand:
essas funções (`media_handle_sideload`) não estão carregadas em pedidos REST/WP-CLI (que é onde o
servidor MCP corre).
## 2. `EMCP_Tools_Media_Library_Abilities` — `includes/abilities/class-media-library-abilities.php`
Condição: **sempre-on** (linha ~143). Nasceu para preencher a lacuna que as tools de stock-image
não cobrem: encontrar as fotos **próprias** do cliente já na Media Library (issue #25 do repo
upstream). Recebe `EMCP_Tools_Data` no construtor (data-access layer partilhada — mesma classe
usada por várias famílias de abilities de conteúdo Elementor, não específica de media).
5 tools.
| Tool | Input (resumo) | O que faz | Permission | RO/Destr. |
|---|---|---|---|---|
| `list-media` | `search?`, `mime_type?`(default `image`; aceita "any"), `page?`, `per_page?`(≤100), `orderby?`(date/title), `order?` | `WP_Query` sobre `attachment`. O `search` do WP_Query cobre title/caption/description mas **não** alt text (vive em postmeta) — por isso resolve IDs por texto e por alt em duas queries `ids`-only e faz a união via `post__in`, sem filtros globais de query. | `check_read_permission` (`edit_posts`) | RO |
| `get-media` | `id` | Detalhe completo de 1 attachment: todos os tamanhos de imagem registados (url+dimensões via `wp_get_attachment_image_src` por tamanho), mime, filesize, alt, caption, descrição, autor, `post_parent`, metadata bruta. | `check_read_permission` | RO |
| `upload-media` | `filename`, `data`(base64, aceita prefixo `data:...;base64,`), `alt?`, `title?`, `caption?`, `description?`, `post_id?`, `convert_webp?:bool` | **Companheira de sideload-image**: sideload-image busca um URL que o SERVIDOR alcança; `upload-media` recebe bytes RAW do CLIENTE (útil quando o utilizador quer enviar um ficheiro do seu próprio computador). Decodifica base64 (limite `UPLOAD_MAX_BYTES=32MB`, filtrável via `emcp_tools_upload_media_max_bytes`), escreve para `wp_tempnam()`, valida tipo com `wp_check_filetype_and_ext()` contra `get_allowed_mime_types()` (bloqueia executáveis mesmo que um filtro afrouxe depois), entrega a `media_handle_sideload()`. Suporta `convert_webp:false` via filtro `emcp_tools_optimize_attachment` (idêntico a sideload-image). | `check_upload_permission` (`upload_files`) | write, não-destr. |
| `update-media` | `id`, `title?`, `alt?`, `caption?`, `description?` | Update parcial. `description` mapeia para `post_content` e **não** é `sanitize_text_field` (permitiria HTML legítimo — `wp_update_post` já aplica `wp_filter_post_kses` para quem não tem `unfiltered_html`). Captura before-image para rollback. | `check_edit_permission` (por-attachment) | write, não-destr. |
| `delete-media` | `id`, `confirm:true`(obrigatório), `force?:bool` | **Destrutivo e efectivamente permanente**: WordPress ignora a Trash para media a menos que `MEDIA_TRASH` esteja definido — o próprio schema avisa disto na descrição. Snapshot completo antes de apagar (post + meta + cópia trashed de todos os ficheiros) via `Change_Recorder::snapshot_attachment`, só quando não vai para trash. | `check_delete_permission` (por-attachment) | write, **destrutivo** |
## 3. `EMCP_Tools_Settings_Abilities` — `includes/abilities/class-settings-abilities.php`
Condição: **sempre-on** (linha ~224). Nome deliberadamente distinto de `EMCP_Tools_Settings_Validator`
(que valida settings de widgets Elementor — classe não relacionada).
**Decisão de design central:** NÃO expõe `get_option`/`update_option` arbitrário. Só a
**allowlist tipada e curada** em `allowlist()` — 32 chaves cobrindo os 6 ecrãs de Settings do core
(General/Reading/Writing/Discussion/Media/Permalinks). Cada entrada tem `{group, label, type
(string|int|bool|enum), writable, options?, min?, max?, pattern?}`. Notavelmente **ausentes** da
allowlist: `siteurl`/`home` (lock-out do site), `users_can_register`/`default_role` (escalada de
registo). `admin_email` está presente mas `writable=false` (só leitura).
2 tools, ambas `check_manage_permission` = `manage_options`.
| Tool | Input | O que faz | RO/Destr. |
|---|---|---|---|
| `get-settings` | `group?`(enum dos 6 ecrãs), `keys?:string[]` | Sem args devolve as 32; filtra por grupo/chaves. Cada linha inclui `value` (coagido ao tipo declarado), `writable`, e `options[]` quando é enum — **duplica como discovery** para `update-settings`. | RO |
| `update-settings` | `settings:{key:value}` | Escreve só chaves allowlisted+writable; tudo o resto (chave desconhecida, read-only, valor inválido) cai em `skipped[]` com motivo — **uma chave má nunca aborta o batch**. Coerção por tipo com clamp min/max (int), regex `pattern` (string, ex. `permalink_structure`), validação de `enum`. Se qualquer `permalink_structure`/`category_base`/`tag_base` mudar, chama `flush_rewrite_rules(false)` automaticamente e reporta `rewrite_flushed`. Regista before-map para rollback. | write, não-destr. (idempotente) |
## 4. `EMCP_Tools_Plugin_Abilities` — `includes/abilities/class-plugin-abilities.php`
Condição: **sempre-on** (linha ~229). 7 tools: 2 leitura (sempre activas) + 5 mutação
(instalação/activação/desactivação/update/delete), todas construídas sobre APIs core (`Plugin_Upgrader`,
`plugins_api`, `activate_plugin`/`deactivate_plugins`/`delete_plugins`) e guardadas por
`EMCP_Tools_Package_Guard` (classe partilhada com theme-abilities — ver comentário do ficheiro:
"as 5 tools de mutação vêm desligadas por omissão, o admin liga-as na tab Tools").
| Tool | Input | O que faz | Permission | RO/Destr. |
|---|---|---|---|---|
| `list-plugins` | `status?`(all/active/inactive) | `get_plugins()` + `get_site_transient('update_plugins')` para flag de update disponível; inclui `is_protected` (via `Package_Guard::is_protected_plugin`). | `activate_plugins` | RO |
| `search-plugins` | `search`, `per_page?`(≤50) | `plugins_api('query_plugins', …)` — pesquisa no directório wordpress.org. | `install_plugins` | RO |
| `install-plugin` | `slug`, `activate?:bool` | `plugins_api('plugin_information')` → `Plugin_Upgrader::install()`. **Fonte sempre wordpress.org — URLs arbitrários nunca são aceites** (explicitamente afirmado na descrição da tool). | `install_plugins` | write, não-destr. |
| `activate-plugin` | `plugin`(file ou slug de pasta) | `activate_plugin()`. Resolve referência via `resolve_plugin_file()` (tenta como file exacto, depois como slug de pasta). | `activate_plugins` | write, idempotente |
| `deactivate-plugin` | `plugin` | `deactivate_plugins()`. Recusa plugins protegidos (**EMCP Tools e Elementor nunca podem ser desactivados via MCP** — protege contra o agente cortar o próprio ramo em que está sentado). | `activate_plugins` | write, idempotente |
| `update-plugin` | `plugin` | `wp_update_plugins()` refresca transient; se não há update pendente devolve `up_to_date:true` sem chamar o upgrader. Recusa plugins protegidos (mesma lista de deactivate). | `update_plugins` | write, não-destr. |
| `delete-plugin` | `plugin` | `delete_plugins()`. Recusa protegidos E recusa qualquer plugin **activo** (obriga a desactivar primeiro). | `delete_plugins` | write, **destrutivo** |
## 5. `EMCP_Tools_Theme_Abilities` — `includes/abilities/class-theme-abilities.php`
Condição: **sempre-on** (linha ~233). Espelho exacto do padrão de plugin-abilities mas para temas
(`Theme_Upgrader`, `themes_api`, `switch_theme`/`delete_theme`), mesmas 5+1 = 6 tools (sem
`search`+`install`+`switch`+`update`+`delete`+`list` = 6, plugins tem 7 porque tem
activate+deactivate separados; temas só têm `switch-theme`).
| Tool | Input | O que faz | Permission | RO/Destr. |
|---|---|---|---|---|
| `list-themes` | (nenhum) | `wp_get_themes()` + `get_site_transient('update_themes')`; inclui `parent` (para child themes) e `is_active`. | `switch_themes` | RO |
| `search-themes` | `search`, `per_page?`(≤50) | `themes_api('query_themes', …)` sobre wordpress.org. | `install_themes` | RO |
| `install-theme` | `slug`, `activate?:bool` | `themes_api('theme_information')` → `Theme_Upgrader::install()`. Fonte sempre wordpress.org. | `install_themes` | write, não-destr. |
| `switch-theme` | `stylesheet` | `switch_theme()`. Recusa temas com `$theme->errors()` (load errors). | `switch_themes` | write, idempotente |
| `update-theme` | `stylesheet` | `wp_update_themes()` refresca transient; `up_to_date:true` sem upgrade se nada pendente. | `update_themes` | write, não-destr. |
| `delete-theme` | `stylesheet` | `delete_theme()`. Recusa o tema activo **e o parent do tema activo** (`Package_Guard::active_theme_stylesheets()`). | `delete_themes` | write, **destrutivo** |
## 6. `EMCP_Tools_User_Abilities` — `includes/abilities/class-user-abilities.php`
Condição: **sempre-on** (linha ~238). O comentário de cabeçalho do ficheiro resume a filosofia:
"the security boundary is the design" — **sem tool de delete**, **sem tool de mudança de role**,
password sempre auto-gerada e nunca devolvida.
4 tools. Guarda de privilégio central: `protected_caps()` = `{manage_options, promote_users,
delete_users, edit_users, manage_network}`. `user_has_admin_caps($id)` verifica se um utilizador
tem QUALQUER uma destas caps (via `user_can`) — se sim, é **intocável** por `update-user`.
`role_has_admin_caps($role)` faz o mesmo a nível de role, para bloquear `create-user` de atribuir
uma role admin-grade.
| Tool | Input | O que faz | Permission | RO/Destr. |
|---|---|---|---|---|
| `list-users` | `role?`, `search?`, `per_page?`(≤100), `page?`, `orderby?`(registered/display_name/ID), `order?` | `WP_User_Query`. Linhas compactas (id, username, display_name, email, roles, registered, post_count) — nunca password/auth. | `list_users` | RO |
| `get-user` | `id` | Detalhe: username/email/display_name/first_name/last_name/nickname/url/description/roles/registered/post_count + flag `is_admin` (= `user_has_admin_caps`, sinaliza que `update-user` vai recusar). | `list_users` | RO |
| `create-user` | `username`, `email`, `role?`(default `subscriber`), `first_name?`, `last_name?`, `display_name?`, `url?`, `description?` | Password gerada com `wp_generate_password(24, true, true)` (nunca devolvida). Envia email de "definir password" via `wp_send_new_user_notifications($id, 'user')`. Recusa role inexistente ou admin-grade. **Anti-enumeração:** erros `existing_user_login`/`existing_user_email` são normalizados para uma mensagem genérica ("that username or email is not available") para que a tool não sirva para enumerar contas existentes. | `create_users` | write, não-destr. |
| `update-user` | `id`, `email?`, `first_name?`, `last_name?`, `display_name?`, `nickname?`, `url?`, `description?` | Recusa se o alvo tem admin-caps. Constrói o update **só** a partir dos campos permitidos — `role`/`password` **nunca são sequer lidos** do input, logo nunca podem mudar aqui (não é uma verificação em runtime, é ausência estrutural do campo). Captura before-image para rollback. | `edit_users` | write, não-destr. |
## 7. `EMCP_Tools_Nav_Menu_Abilities` — `includes/abilities/class-nav-menu-abilities.php`
Condição: **sempre-on** (linha ~243). Padrão de **dois dispatchers** (`menu-read`/`menu-write`),
o mesmo padrão que as integrações de tema (§9-13) e as abilities ACF/SEO/forms (doc 08) usam:
cada tool aceita `{operation, arguments}`; chamar sem `operation` devolve o catálogo de operações
disponíveis (nome + descrição) — auto-descoberta sem precisar de schemas JSON per-operation
separados. Ambos os dispatchers usam a mesma permissão: `edit_theme_options`.
**13 operações** no total:
| Operação | Modo | Argumentos | O que faz |
|---|---|---|---|
| `list-menus` | read | `{}` | Todos os menus: id, name, slug, item count, locations atribuídas. |
| `get-menu` | read | `{menu: id\|slug\|name}` | Um menu + a sua árvore de items aninhada (`build_item_tree`, construída a partir da lista flat via `menu_item_parent`). |
| `list-locations` | read | `{}` | Locations de menu registadas pelo tema + qual menu (se algum) está atribuído a cada uma. |
| `render` | read | `{menu\|location, depth?, container?, container_class?, menu_class?, menu_id?}` | `wp_nav_menu(['echo'=>false])` → devolve HTML. Útil para embutir num header custom. |
| `create-menu` | write | `{name}` | `wp_create_nav_menu()`. |
| `rename-menu` | write | `{menu, name}` | `wp_update_nav_menu_object()`. |
| `delete-menu` | write | `{menu}` | `wp_delete_nav_menu()`. |
| `assign-location` | write | `{menu, location}` | `set_theme_mod('nav_menu_locations', …)`. Valida que a location está registada pelo tema. |
| `unassign-location` | write | `{location}` | Remove a atribuição de uma location. |
| `add-item` | write | `{menu, type, object_id?, object?, url?, title?, parent?, position?, target?, classes?, description?, xfn?}` | `wp_update_nav_menu_item()`. `resolve_item_type()` valida `type` (custom/page/post/CPT/category/taxonomy) contra objectos reais existentes — nunca cria um item apontando para um post/termo inexistente. Items custom exigem `url`+`title`. |
| `update-item` | write | `{item, title?, url?, parent?, position?, target?, classes?, description?, xfn?}` | Update parcial que **preserva** todos os campos não especificados (`merge_existing_item()` lê o item actual via `wp_setup_nav_menu_item()` antes de mesclar as mudanças — evita a armadilha comum de `wp_update_nav_menu_item` de apagar campos omitidos). |
| `delete-item` | write | `{item}` | `wp_delete_post($id, true)` (items de menu são posts `nav_menu_item`). |
| `reorder-items` | write | `{menu, items: [{id, parent?, position}]}` | Reordena/reparenta múltiplos items numa chamada, preservando os restantes campos de cada um (reutiliza `merge_existing_item`). Ignora silenciosamente items inválidos/de outro menu/com parent inválido, e conta só os efectivamente actualizados. |
**Validações estruturais notáveis:** `validate_parent()` garante que um parent proposto é um item
de menu real e pertence **ao mesmo menu** (não permite cruzar menus). `resolve_item_type()` faz a
mesma validação de existência para `add-item` que `set-post-terms` faz para termos — nunca cria
referências soltas.
## 8. `EMCP_Tools_Image_Resize_Abilities` — `includes/abilities/class-image-resize-abilities.php`
Condição: **condicional dupla** (registrar linha ~149) — só regista quando `class_exists(
'EMCP_Tools_Image_Resize_Abilities' )` **E** `class_exists( 'EMCP_Tools_Image_Optimization_Module'
)` **E** `EMCP_Tools_Image_Optimization_Module::module_is_active()`. Ou seja: depende do módulo
"Image Optimization" (um dos 9 módulos opcionais documentados no doc 10) estar activo — reutiliza
a maquinaria de backup+compressão+WebP desse módulo (`EMCP_Tools_Image_Resizer::resize()`, classe
do módulo, não desta ability).
1 tool: `resize-media`. `attachment_id`, `width?`, `height?` (pelo menos um dos dois; escala
mantendo aspect ratio), `crop?:bool` (hard-crop exacto width×height, requer ambos). Permissão:
`edit_post($attachment_id)` se especificado, senão `upload_files`. O **ID do attachment e as suas
URLs mantêm-se os mesmos** (resize in-place), o original é feito backup (reversível), e todos os
sub-tamanhos + WebP são regenerados. Se o cap de dimensão máxima do módulo for menor que o alvo
pedido, o cap aplica-se silenciosamente.
## 9. `EMCP_Tools_Theme_Integration` (abstract) — `includes/abilities/class-theme-integration.php`
**Serviço de suporte**, não regista abilities por si — é a **classe base abstracta** para todas as
integrações de tema/framework (§10-13 + os Pro-only do §16). Implementa o padrão "dois
dispatchers" de forma genérica e reutilizável.
Contrato abstracto que cada subclasse implementa: `id()` (usado para construir os nomes das tools
`emcp-tools/<id>-read`/`-write`), `label()`, `is_available()` (gate de disponibilidade — tema
activo ou plugin activo), `operations()` (o mapa `name => {mode, run, perm, desc}`).
A base fornece:
- **`register()`** — regista os dois dispatchers via `emcp_tools_register_ability()`. Ambos usam
o mesmo `dispatch_schema()` genérico (`{operation?, arguments?}`).
- **`can_read()`/`can_write()`** — gate grosseiro **da tool** (`edit_theme_options` por omissão);
cada subclasse pode sobrepor (Spectra e Kadence Blocks sobrepõem para `edit_posts`, porque
constroem conteúdo, não opções de tema — ver §12/§13).
- **`dispatch()`** — resolve a operação pelo nome; se `operation` vazio devolve o **catálogo de
descoberta**; valida que o modo (read/write) bate; corre a **permissão específica da operação**
(`$op['perm']`) — nunca confia só no gate grosseiro do tool, cada operação individual
re-verifica. Um erro 404 `unknown_operation` ou 403 `forbidden` tem `status` no `WP_Error` data.
- **`capabilities()`** — descritor normalizado (`supports_patterns`, `supports_preview`,
`styles_model`: none/uniqueid/styles-object/attributes) que um agente lê para saber, sem
hard-coding por-framework, se este pack tem uma rota de inserção "editor-válida" (patterns) ou
preview, e como o pack se auto-estiliza. Default `{false, false, 'none'}`; cada subclasse
sobrepõe.
**Blueprint chave:** este é o padrão a copiar quase 1:1 numa réplica própria — uma classe base
abstracta de ~250 linhas que qualquer integração de tema/plugin de terceiros herda, ganhando
discovery + dispatch + permission-per-operation de graça. Adicionar um framework novo (ex.
GeneratePress) é só escrever a subclasse concreta com `operations()`.
## 10. `EMCP_Tools_Active_Theme_Integration` — `includes/abilities/class-active-theme-integration.php`
`id()`='theme'. `is_available()` = **sempre true** (funciona com qualquer tema activo — é o pack
agnóstico-de-framework). Descrição do ficheiro: "Building pages reuses the Gutenberg/Elementor
tools; this integration supplies the context the agent reasons over."
4 operações:
| Operação | Modo | Argumentos | O que faz |
|---|---|---|---|
| `get-theme-context` | read | `{}` | Identidade + capacidades do tema activo: stylesheet/parent, `is_child`, framework detectado (contra `KNOWN_FRAMEWORKS = {astra, kadence, generatepress, oceanwp, blocksy, neve, hello-elementor}`), `is_block_theme` (`wp_is_block_theme()`), nome/versão, `template_dir`, `theme supports` probed (`PROBED_SUPPORTS`: post-thumbnails, custom-logo, editor-styles, wp-block-styles, align-wide, responsive-embeds, custom-background, html5), menu locations registadas, `has_child` (`Child_Theme_Builder::child_exists()`). **"Call this first"** — é o ponto de entrada de contexto antes de qualquer outra operação de tema. |
| `get-mods` | read | `{}` | Todos os `theme_mods` do tema activo (estado do customizer). |
| `set-mods` | write | `{values: {key: value}}` | `set_theme_mod()` por chave, salvo `REFUSED_MODS = {nav_menu_locations, sidebars_widgets, custom_css_post_id}` — mods estruturais recusados (esses domínios têm as suas próprias tools: nav-menu, widgets não têm tool própria, custom CSS é módulo separado). |
| `create-child-theme` | write, `perm`=`can_manage_theme` (`switch_themes`+`edit_theme_options`) | `{confirm:true}`(obrigatório) | Delega para `EMCP_Tools_Child_Theme_Builder::create()` (§14). Exige `confirm:true` porque muda o tema activo E liga escrita de ficheiros. |
## 11. `EMCP_Tools_Astra_Integration` — `includes/abilities/class-astra-integration.php`
`id()`='astra'. `is_available()` = `'astra' === get_template()`. Lê/escreve a única option
`astra-settings` sobre uma **allowlist curada de 17 chaves** (`ALLOWLIST` const), agrupadas em
`colors`/`typography`/`layout`/`header-footer` — a mesma forma genérica get/update que
`EMCP_Tools_Settings_Abilities` usa para o core WordPress (§3), aplicada ao option próprio do
Astra. Allowlist explicitamente marcada como "verified against Astra 4.13.4 on the dev site".
2 operações: `get-settings` (`{group?, keys?}`) e `update-settings` (`{values:{key:value}}`,
chaves não-allowlisted em `skipped[]`). `read_value()` prefere o resolver nativo `astra_get_option()`
quando disponível (respeita o default do tema quando a option está ausente); senão cai no valor
bruto da option. **Detalhe de invalidação de cache notável:** Astra só refresca o seu CSS dinâmico
em cache no hook `customize_save_after`, **não** numa escrita simples de option — por isso
`execute_update_settings()` chama explicitamente `astra_clear_all_assets_cache()` após qualquer
update, senão as mudanças ficariam invisíveis até o próximo save do Customizer.
## 12. `EMCP_Tools_Spectra_Integration` — `includes/abilities/class-spectra-integration.php`
`id()`='spectra'. `is_available()` = `EMCP_Tools_Spectra_Catalog::is_active()`. Sobrepõe
`can_read()`/`can_write()` para `edit_posts` (constrói **conteúdo**, não opções de tema).
`capabilities()`: `{supports_patterns:false, supports_preview:false, styles_model:'attributes'}`.
3 operações — o padrão discover→inspect→act espelhando o catálogo de widgets Elementor:
| Operação | Modo | Argumentos | O que faz |
|---|---|---|---|
| `list-blocks` | read | `{category?, search?}` | Catálogo compacto (via `Spectra_Catalog::blocks_index()`). |
| `get-block-schema` | read | `{name?\|names?[], full?}` | Atributos reais + defaults (`Spectra_Catalog::real_attributes()`) + markup de exemplo gerado. Reporta também `shared_attributes` (ver §12.1) e um `note` quando trunca à `DEFAULT_CAP=30`. |
| `add-block` | write | `{post_id, block, attributes?, position?}` | Constrói o array de bloco parseado (`build_block()`), insere-o via `EMCP_Tools_Block_Tree::insert()` (serviço partilhado com Gutenberg — doc 02) na árvore existente do post, serializa de volta com `serialize_block()`, grava com `wp_update_post`. Gera `block_id` via `EMCP_Tools_Id_Generator::generate()`. |
### 12.1 `EMCP_Tools_Spectra_Catalog` — `includes/blocks-catalog/class-spectra-catalog.php`
Serviço de suporte (não regista abilities). Nada é adivinhado — as duas metades vêm do próprio
Spectra:
- **Lista de blocos** — `UAGB_Block_Module::get_blocks_info()` (a própria API pública do Spectra).
- **Atributos por bloco** — lidos directamente do ficheiro fonte do Spectra
`includes/blocks/<slug>/attributes.php` (via `require`, não introspecção de registry) — porque
Spectra não regista todos os atributos no `WP_Block_Type_Registry` core.
`STRUCTURE` const documenta hints estruturais que **não** podem vir de `attributes.php`: quais
blocos precisam de innerBlocks template (`uagb/buttons`→`uagb/buttons-child`, etc., 8 pares
documentados) e quais são dinâmicos/server-rendered (`post-grid`, `post-carousel`, `google-map`,
`forms`, etc. — `add-block` emite markup self-closing para estes).
**`SHARED_ATTRS`** é o achado mais interessante desta classe: documenta atributos nativos e reais
do `uagb/container` que são registados via um helper partilhado (`UAGB_Block_Helper`) em vez de
chaves literais em `attributes.php` — por isso **não aparecem** em `real_attributes()`, mas
existem e são editáveis (background image/video com overlay, border-radius por canto, box-shadow).
O comentário no código é explícito: "so they DO NOT appear in real_attributes()/get-block-schema,
yet they are real, editable attributes an agent should use instead of a core/html workaround" —
inclui até a gotcha exacta de que o overlay em gradiente vem de `gradientValue`, **não** de
`gradientOverlayColor1/2` como seria intuitivo.
## 13. `EMCP_Tools_Kadence_Integration` (theme settings) — `includes/abilities/class-kadence-integration.php`
`id()`='kadence'. `is_available()` = `'kadence' === get_template()`. Lê/escreve `theme_mods` do
Kadence (não uma option única como Astra) sobre uma **allowlist de 16 chaves** agrupadas em
`palette`/`colors`/`typography`/`layout`/`buttons`/`header-footer`. Cada entrada da allowlist
carrega um `shape` (string descritiva da forma do objecto esperado) — porque, ao contrário de
Astra, os valores de Kadence são **objectos estruturados**, não escalares (ex.
`link_color = { highlight: "palette1", "highlight-alt": "palette2", style: "standard" }`), e
referenciam uma **paleta global de 9 slots** (`palette1`..`palette9`) definida em `global_palette`.
2 operações: `get-settings`/`update-settings`, mesma forma que Astra. `read_value()` prefere
`\Kadence\kadence()->option($key)` (resolver nativo com theme_mod+default embutido). **Sem
invalidação de cache explícita** — comentário do ficheiro nota que "Kadence renders dynamic CSS
inline per request, so there is no cache to invalidate" (contraste directo com o gotcha do Astra
em §11).
## 14. `EMCP_Tools_Kadence_Blocks_Integration` — `includes/abilities/class-kadence-blocks-integration.php`
`id()`='kadence-blocks'. `is_available()` = `EMCP_Tools_Kadence_Blocks_Catalog::is_active()`.
Independente do tema activo (tal como Spectra — Kadence Blocks é um plugin, não amarrado ao tema
Kadence). `can_read()`/`can_write()` = `edit_posts`. `capabilities()`:
`{supports_patterns:true, supports_preview:true, styles_model:'uniqueid'}`.
**5 operações** — a mais rica das integrações de tema, porque acrescenta acesso à Kadence
Prebuilt Library (patterns prontos) por cima do padrão discover→inspect→act:
| Operação | Modo | Argumentos | O que faz |
|---|---|---|---|
| `list-blocks` | read | `{category?, search?}` | Catálogo curado dos 32 blocos top-level (via `Kadence_Blocks_Catalog`). |
| `get-block-schema` | read | `{name?\|names?[], full?}` | Atributos reais do `WP_Block_Type_Registry` (Kadence, ao contrário do Spectra, regista schemas completos com defaults no core registry — não precisa de ler ficheiro fonte). Inclui `inner_blocks` hint para containers, `content_attributes` para campos RichText/HTML-source, e um `editor_note` fixo (ver abaixo). |
| `add-block` | write | `{post_id, block, attributes?, position?}` | Gera `uniqueID` (chave CSS por-bloco do Kadence), escafolda inner blocks de container conforme `STRUCTURE`, **renderiza campos RichText directamente no HTML guardado** (não no JSON de atributos — ver `render_source_fields()` abaixo). |
| `list-patterns` | read | `{category?, search?, include_pro?, categories?:bool}` | Lista patterns da Kadence Design Library (cache local em `uploads/kadence_blocks_library/`). `categories:true` devolve só os nomes de categoria. |
| `insert-pattern` | write | `{post_id, pattern, position?, localize_images?:bool}` | Insere markup **canónico** de um pattern (busca via `Kadence_Blocks_Prebuilt_Library_REST_Controller::get_pattern_content` — o próprio controller REST do Kadence, chamado internamente sem HTTP). `localize_images:true` transfere as imagens do pattern para a Media Library via o próprio `process_pattern` do Kadence (requer `upload_files`). |
**Nota de robustez notável em `add-block`:** o output devolve sempre um `note` explícito — "Kadence
blocks use a static JS save(), so a headlessly-inserted block renders correctly on the front end
but the block editor may show 'Attempt recovery' on this block — one click regenerates valid
markup and preserves the content." Isto é honestidade de design: blocos Kadence construídos à mão
(não via pattern) não batem byte-a-byte com o `save()` estático do JS do bloco, então o editor
Gutenberg vai reclamar (recuperável, sem perda de dados) — só o caminho `insert-pattern` (markup
canónico do próprio Kadence) evita esse aviso.
**`render_source_fields()`** é o mecanismo mais complexo desta classe: para cada atributo que o
registry marca como `source: html|rich-text` (ex. o `content` do Advanced Heading), renderiza o
valor directamente em HTML dentro do bloco serializado usando o **selector real** registado por
esse atributo (tag+classe), em vez de o guardar no JSON de `attrs` — porque é assim que WordPress
lê esses valores de volta (parseados do HTML, não do JSON). Trata o caso especial "Advanced
Heading" (`selector_element()`): a sua tag vem de `htmlTag` (h1-h6/p/div/span), e a classe CSS
correcta é `kt-adv-heading{uniqueID}` — não o que o `selector` bruto do registry sugeriria.
### 14.1 `EMCP_Tools_Kadence_Blocks_Catalog` — `includes/blocks-catalog/class-kadence-blocks-catalog.php`
Serviço de suporte. Diferente do Spectra: ambas as metades (lista + atributos) vêm do
`WP_Block_Type_Registry` **core** — Kadence regista schemas de atributos completos com defaults,
não precisa de ler ficheiro fonte. `TOP_LEVEL` const lista os 32 slugs "placeable" (exclui blocos
filho/estruturais como `column-child`, `singlebtn`, `listitem`, `table-row`, que são inseridos via
`STRUCTURE`). `TITLES`/`CATEGORY`/`HIGHLIGHT`/`STRUCTURE` são curadoria manual (explicitamente
"live-verified on Kadence Blocks 3.7.9" contra uma spec própria referenciada no comentário).
`STRUCTURE['kadence/rowlayout']` tem uma gotcha documentada no comentário: `colLayout` **tem** de
ser não-vazio ("equal") ou o editor mostra o picker "Select Your Layout" do Kadence em vez das
colunas — "the frontend renders regardless" mas a experiência de edição fica quebrada sem isto.
### 14.2 `EMCP_Tools_Kadence_Pattern_Library` — `includes/blocks-catalog/class-kadence-pattern-library.php`
Serviço de suporte. Lê o **cache local de metadados** (`uploads/kadence_blocks_library/*.json`,
distingue o ficheiro de metadata do de preview-HTML pela presença de `slug`+`name` nas entradas) e
delega a obtenção do markup real ao **controller REST do próprio Kadence**
(`Kadence_Blocks_Prebuilt_Library_REST_Controller`), invocado **internamente** (constrói um
`WP_REST_Request` e chama o método directamente — sem round-trip HTTP). Nota explícita no cabeçalho
do ficheiro: se a cache local estiver vazia, o utilizador precisa de abrir a Kadence Design Library
uma vez no editor de blocos para a popular — a tool `list-patterns` devolve esse aviso quando
detecta catálogo vazio.
## 15. `EMCP_Tools_Child_Theme_Builder` — `includes/class-child-theme-builder.php`
Serviço de suporte, usado por `Active_Theme_Integration::execute_create_child_theme` (§10).
**Deliberadamente conservador**, três garantias explícitas no comentário de cabeçalho:
1. Só cria um child do parent **actualmente activo** — nunca toca noutro tema.
2. Recusa quando o tema activo **já é um child** (`is_active_a_child()`) — nunca cria um
"neto" (grandchild).
3. **Idempotente** — um child existente é apenas activado (`switch_theme`), nunca sobrescrito.
`create()`: cria a pasta (`wp_mkdir_p`), gera `style.css` mínimo (cabeçalho `Theme Name`/`Template`/
`Version`/`Description`) e `functions.php` mínimo (enfileira o `style.css` do parent via
`wp_enqueue_scripts`), depois `switch_theme($slug)`. Escreve via `$wp_filesystem->put_contents()`
quando inicializado, senão `file_put_contents()` directo — o mesmo padrão dual usado pelas
Filesystem tools (doc 07). Devolve `{child, parent, directory, activated, created}`.
**É o enabler estrutural para edição de ficheiros de tema pelo agente**: depois de criar o child,
o agente edita `style.css`/`functions.php`/templates via as tools de Filesystem (ABSPATH-confined,
gated por `edit_files`, com backup e audit-log — doc 07), nunca directamente aqui.
## 16. Pro-only confirmados ausentes: GeneratePress, GenerateBlocks, Blocksy
`class-ability-registrar.php` linhas 342-355 referencia 4 classes adicionais de integração de
tema, seguindo o comentário explícito no código: *"GeneratePress + GenerateBlocks (Pro; classes
only present when Pro loaded)"* e *"Blocksy (Pro): blocks + Companion extensions."*
```php
if ( class_exists( 'EMCP_Tools_GeneratePress_Integration' ) ) { … }
if ( class_exists( 'EMCP_Tools_GenerateBlocks_Integration' ) ) { … }
if ( class_exists( 'EMCP_Tools_Blocksy_Blocks_Integration' ) ) { … }
if ( class_exists( 'EMCP_Tools_Blocksy_Extensions_Integration' ) ) { … }
```
**Confirmado por listagem directa do directório** `includes/abilities/` neste build Free (19-08-2026,
48 ficheiros `class-*.php` listados): **nenhum ficheiro** `class-generatepress-integration.php`,
`class-generateblocks-integration.php`, `class-blocksy-blocks-integration.php` ou
`class-blocksy-extensions-integration.php` existe. As 4 classes são referenciadas apenas pelo
`class_exists()` gate — em produção Free, este gate é sempre `false`; as 4 integrações só passam a
existir (ficheiro + classe) quando o build **Pro** (`emcp-pro/`) está instalado e activo.
Cada uma seguiria, por analogia estrutural às 5 integrações Free já lidas (§10-14), o mesmo padrão
herdado de `EMCP_Tools_Theme_Integration` (§9):
| Classe (Pro-only, código não acessível) | Gating no registrar | Padrão inferido por analogia (não confirmado) |
|---|---|---|
| `EMCP_Tools_GeneratePress_Integration` | `class_exists()` | `id()`='generatepress' provavelmente; `is_available()` provavelmente `'generatepress' === get_template()` — tema settings via GP Premium/GenerateBlocks options, seguindo o molde Astra/Kadence (§11/§13). |
| `EMCP_Tools_GenerateBlocks_Integration` | `class_exists()` | Pack de blocos GenerateBlocks (catálogo + add-block), seguindo o molde Spectra/Kadence Blocks (§12/§14) — seria o "blocks/builder" companion do GeneratePress. |
| `EMCP_Tools_Blocksy_Blocks_Integration` | `class_exists()` | Pack de blocos Blocksy (a Blocksy tem os seus próprios blocos Gutenberg nativos). |
| `EMCP_Tools_Blocksy_Extensions_Integration` | `class_exists()` | Gestão de "Extensions" do Blocksy (o painel de extensões modulares do tema — ex. Content Blocks, Custom Fonts, Ecommerce, etc.), provavelmente um dispatcher settings-like análogo ao Astra. |
**Não inventar schemas nem comportamento além desta inferência estrutural** — sem acesso ao código
fonte destas 4 classes, este é o limite do que se pode documentar com honestidade.
## Blueprint para réplica
**Copiar quase 1:1:**
- **`EMCP_Tools_Theme_Integration` (abstract, §9)** — o padrão de 2 dispatchers + discovery +
permission-per-operation é genérico, pequeno (~250 linhas), e escala bem para qualquer novo
framework/plugin de terceiros. É o único ponto de extensão que vale a pena manter exactamente
como está numa réplica.
- **`EMCP_Tools_Settings_Abilities` (§3) e o padrão allowlist tipada** — a decisão de nunca expor
`get_option`/`update_option` cru é a protecção certa; a allowlist com `{type, writable, min,
max, pattern, options}` é suficientemente expressiva sem ser um mini-JSON-Schema paralelo.
Reutilizar esta forma para settings de qualquer plugin de terceiros (Astra/Kadence já o fazem).
- **Guarda de privilégio de `User_Abilities` (§6)** — `protected_caps()` + `user_has_admin_caps`/
`role_has_admin_caps` é uma barreira de segurança simples e correcta (nenhuma tool de delete,
role/password nunca lidos do input de update). Copiar tal e qual.
- **`Package_Guard` (referenciado por Plugin/Theme abilities, §4/§5)** — lista de protecção
(nunca desactivar/apagar/actualizar o próprio plugin nem o Elementor) é um padrão de
auto-preservação que qualquer plugin MCP-serving precisa de replicar (senão o agente pode
cortar o próprio ramo em que está sentado).
- **`Child_Theme_Builder` (§15)** — as 3 garantias conservadoras (só o parent activo, nunca um
grandchild, idempotente) são exactamente as invariantes certas para este tipo de operação.
**Simplificar:**
- **Kadence Blocks `render_source_fields()`/`selector_element()` (§14)** — é código correcto mas
bastante intrincado (resolve selectors CSS de volta para tag+classe HTML por heurística). Numa
réplica que não precise de suportar Kadence Blocks especificamente, este é o tipo de
complexidade a NÃO reinventar — só vale a pena se formos mesmo integrar Kadence Blocks.
- **`Spectra_Catalog::real_attributes()` a fazer `require` de um ficheiro fonte de outro plugin**
(§12.1) é frágil a mudanças de versão do Spectra (o caminho do ficheiro é hard-coded); preferir,
quando possível, ler do `WP_Block_Type_Registry` (como Kadence faz) em vez de abrir ficheiros
fonte de terceiros directamente — só recorrer a isto quando o plugin de terceiros não regista
todos os atributos no registry core (razão pela qual o Spectra precisou desta técnica).
**Deixar de fora (a menos que haja procura real de clientes):**
- As 4 integrações Pro-only (§16) — só valem a pena reimplementar quando houver um cliente
concreto a usar GeneratePress/GenerateBlocks/Blocksy. Sem acesso ao código nem à base de
utilizadores, seria trabalho especulativo.
- `Plugin_Abilities`/`Theme_Abilities` install/update/delete completos (§4/§5) — install/delete
arbitrário de plugins/temas via MCP é uma superfície de risco elevada (mesmo restrita a
wordpress.org); para um clone interno de uso próprio da Descomplicar, ponderar reduzir a
read-only (`list`/`search`) + `activate`/`deactivate`, deixando install/update/delete fora do
MCP por preferir esse fluxo pelo wp-admin/WP-CLI directamente.
**Riscos/gotchas não óbvios encontrados no código (citações):**
- Astra: cache de CSS dinâmico só invalida em `customize_save_after`, **não** numa escrita de
option simples — sem a chamada explícita a `astra_clear_all_assets_cache()`, updates via MCP
ficariam invisíveis até o próximo save do Customizer (§11).
- Spectra: overlay de imagem de fundo em gradiente vem de `gradientValue`, **não** de
`gradientOverlayColor1/2` como seria intuitivo — documentado explicitamente no
`SHARED_ATTRS` do próprio código (§12.1).
- Kadence Blocks `rowlayout`: `colLayout` vazio faz o editor mostrar um picker de layout em vez
das colunas — "the frontend renders regardless" mas a experiência de edição fica quebrada; fix
é forçar `colLayout: 'equal'` como default quando o caller não o especifica (§14.1).
- Kadence Blocks construídos à mão (não via pattern) disparam "Attempt recovery" no editor porque
não batem byte-a-byte com o `save()` estático JS — o front-end renderiza correctamente na mesma;
só `insert-pattern` (markup canónico do próprio Kadence) evita o aviso (§14).
- `upload-media`/`create-post` (featured_image sideload): as funções de sideload
(`media_handle_sideload` e dependências) vivem em `wp-admin/includes/*` que **não** está
carregado em pedidos REST/WP-CLI — têm de ser `require_once`'d on-demand a cada chamada (§1, §2).
- `create-user`: erros de conta existente são normalizados para uma mensagem genérica
precisamente para impedir que a tool sirva de oráculo de enumeração de contas (§6).
## Fonte
Leitura directa (19-08-2026) de: `includes/abilities/class-content-abilities.php`,
`includes/abilities/class-media-library-abilities.php`,
`includes/abilities/class-settings-abilities.php`,
`includes/abilities/class-plugin-abilities.php`,
`includes/abilities/class-theme-abilities.php`,
`includes/abilities/class-user-abilities.php`,
`includes/abilities/class-nav-menu-abilities.php`,
`includes/abilities/class-image-resize-abilities.php`,
`includes/abilities/class-theme-integration.php`,
`includes/abilities/class-active-theme-integration.php`,
`includes/abilities/class-astra-integration.php`,
`includes/abilities/class-spectra-integration.php`,
`includes/abilities/class-kadence-integration.php`,
`includes/abilities/class-kadence-blocks-integration.php`,
`includes/blocks-catalog/class-kadence-blocks-catalog.php`,
`includes/blocks-catalog/class-kadence-pattern-library.php`,
`includes/blocks-catalog/class-spectra-catalog.php`,
`includes/class-child-theme-builder.php`; grep de
`includes/abilities/class-ability-registrar.php` (linhas 135-165, 220-250, 320-380) para as
condições de registo exactas e a confirmação da lista de 4 classes Pro-only referenciadas mas não
implementadas; listagem directa de `includes/abilities/` (48 ficheiros) para confirmar a ausência
física dos ficheiros GeneratePress/GenerateBlocks/Blocksy neste build Free. Cruzado com
`docs/00-ARQUITECTURA.md` (mesma sessão) para o contrato `emcp_tools_register_ability()`.
+687
View File
@@ -0,0 +1,687 @@
# 04 — Themer (header/footer/single/archive/search/404, condições, render, blocos/widgets dinâmicos, Themer PHP)
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Módulo confirmado como o mais substancial do bundle Free (`skill://emcp-tools` §6-§7):
CPT + condições + render controller + blocos + widgets + templates PHP — só a categoria "Themer
(free)" já soma 8 tools sempre activas, mais 5 tools do sub-toggle "Themer PHP" quando ligado.
## 0. Visão geral e ciclo de vida do módulo
`EMCP_Tools_Themer_Module` (`includes/modules/class-themer-module.php`) estende
`EMCP_Tools_Module`: `id()='themer'`, `tier()='free'`, `default_active()=true` — activo por
omissão em qualquer instalação. `register()` é chamado pelo `EMCP_Tools_Modules_Registry` em
`init:5`, só quando o módulo está activo, e é o único ponto de entrada que fia tudo:
```php
public function register(): void {
( new EMCP_Tools_Themer_CPT() )->register();
if ( class_exists( 'EMCP_Tools_Themer_HFE_Conflict' ) ) { EMCP_Tools_Themer_HFE_Conflict::init(); }
EMCP_Tools_Themer_Index::register_hooks();
// one-time heal do índice (ver §6 — bug histórico de ordem de save)
if ( ! is_admin() ) { ( new EMCP_Tools_Themer_Render_Controller() )->init(); }
if ( is_admin() && class_exists( 'EMCP_Tools_Themer_Metabox' ) ) { ( new EMCP_Tools_Themer_Metabox() )->init(); }
if ( class_exists( 'EMCP_Tools_Themer_Blocks' ) ) { ( new EMCP_Tools_Themer_Blocks() )->init(); }
if ( class_exists( 'EMCP_Tools_Themer_Widgets' ) ) { ( new EMCP_Tools_Themer_Widgets() )->init(); }
if ( class_exists( 'EMCP_Tools_Themer_PHP' ) ) { ( new EMCP_Tools_Themer_PHP() )->init(); }
}
```
**Gate de kill-switch verdadeiro:** desligar o módulo (`emcp_tools_active_modules` sem `themer`)
pára o CPT, o take-over de front-end, a tab de admin — **e** o registrador de abilities
(`class-ability-registrar.php`, linhas 202-211) omite as 8 tools do grupo Themer, porque o
registo de abilities corre em `wp_abilities_api_init` (ANTES de `init:5`), pelo que a condição
`EMCP_Tools_Themer_Module::is_enabled()` tem de ler a option `emcp_tools_active_modules`
directamente em vez de depender de qualquer estado que só existiria depois do boot do módulo:
```php
public static function is_enabled(): bool {
$active = (array) get_option( EMCP_Tools_Module::OPTION_ACTIVE, array() );
return in_array( 'themer', $active, true );
}
```
**Sub-toggle independente — Themer PHP:** as 5 tools `*-theme-php-template` só se registam
quando, ADICIONALMENTE ao módulo Themer estar activo, a option própria
`emcp_tools_themer_php_enabled` estiver a `'1'` (`EMCP_Tools_Themer_PHP::enabled()`,
`includes/abilities/class-ability-registrar.php` linhas 213-221). É desligado por omissão em
qualquer instalação nova.
**Estruturas de dados (todas nativas WordPress, zero tabelas SQL próprias):**
| Estrutura | Tipo | Papel |
|---|---|---|
| CPT `emcp_theme_template` | post type, `show_ui=true`, menu próprio | O template em si (título + conteúdo Elementor/Gutenberg/clássico) |
| meta `_emcp_themer_type` | string | `header\|footer\|single\|archive\|search\|404` |
| meta `_emcp_themer_conditions` | array `{include:Rule[], exclude:Rule[], priority:int}` | Condições de exibição |
| meta `_emcp_themer_php_template` | int (post id) | Template PHP anexado a este slot (0/ausente = usa o conteúdo do builder) |
| option `emcp_tools_themer_index` (autoloaded) | `{type => rows[]}` | Índice pré-computado, ver §6 — o "fast path" de zero queries no front-end |
| option `emcp_tools_themer_index_healed` | `'1'` | Marcador do heal único (bug histórico, ver §6) |
| option `emcp_tools_module_themer_force_render` | `'0'\|'1'` | Full-page takeover para temas não suportados |
| option `emcp_tools_themer_php_enabled` | `'0'\|'1'` | Sub-toggle Themer PHP |
| option `emcp_tools_hfe_conflict_dismissed` | `'1'` | Dispensa da notice de conflito com Ultimate Addons for Elementor |
| CPT `emcp_theme_php` (privado, `show_ui=false`) | post type | Templates PHP em bruto (§11) |
| meta `_emcp_theme_php_code`/`_type`/`_validation`/`_hash`/`_error` | — | Estado do template PHP |
| ficheiro `{sandbox}/theme-php/{id}.php` + `theme-php-manifest.json` | filesystem | Função PHP compilada + manifesto hash-verificado (§11.2-11.3) |
**Filtros de extensão (o seam onde a versão Pro se encaixa sem qualquer código Pro na árvore
free):**
| Filtro | Free devolve | O que o Pro acrescentaria |
|---|---|---|
| `emcp_themer_selectors` | 7 chaves largas (`entire-site`, `all-singular`, `all-archives`, `front-page`, `post-type`, `post-type-archive`, `tax-archive`) | Selectores granulares (`post`, `term`, `author`, `date`) — a mera PRESENÇA de `'post'` neste array é usada em vários sítios (`pro_conditions_available()`, `is_pro()`) como o teste "é Pro?" |
| `emcp_themer_matchers` | 7 matchers correspondentes | Matchers para os selectores granulares acima |
| `emcp_themer_condition_schema` | Só a relação Include + folhas largas | Relação Exclude + pesquisa de objecto específico + nós Author/Date/In-term |
| `emcp_themer_rank` | `fn($row) => 0` (sem prioridade real) | Um ranker que lê `$row['priority']` de facto |
| `emcp_themer_quota` | `1` (por tipo) | `PHP_INT_MAX` |
| `emcp_themer_theme_adapters` | 7 temas mapeados (Astra/GeneratePress/Kadence/OceanWP/Blocksy/Neve/Hello Elementor) | Mais temas, ou pode ser estendido por qualquer terceiro |
---
## 1. Abilities MCP — `EMCP_Tools_Themer_Abilities` (`includes/abilities/class-themer-abilities.php`)
Regista sempre as 8 tools quando o módulo Themer está activo (gate em §0). Duas permissões
partilhadas: `check_read_permission` (`edit_posts`) e `check_write_permission`
(`edit_post($template_id)` se o id já existir; senão `publish_pages || edit_pages` para criação).
| Tool | Input schema (resumo) | O que faz | Permissão | readonly / destructive |
|---|---|---|---|---|
| `list-theme-templates` | `{ type?: string }` | `WP_Query` sobre `emcp_theme_template` (publish+draft, até 200), filtrável por tipo via `meta_key`; devolve `{templates: summary[]}` com `template_id, title, type, status, conditions, edit_url`. | `check_read_permission` | readonly, idempotent |
| `get-theme-template` | `{ template_id: int }` **obrigatório** | Devolve `template_id, title, type, conditions, builder (elementor/gutenberg/classic via `Content_Renderer::detect_builder`), content` (post_content bruto). | `check_read_permission` | readonly, idempotent |
| `list-condition-targets` | `{}` | Discovery para `set-template-conditions`: `selectors` (o set válido actual via `valid_selectors()`), `post_types` (todos os públicos), `taxonomies` (todas as públicas + `object_types`). | `check_read_permission` | readonly, idempotent |
| `create-theme-template` | `{ type: enum(6 tipos)*, title?, content?, scope? }` | Cria o post CPT; aplica a quota 1-por-tipo (`Themer_CPT::can_create`) — devolve `{error}` se excedida; semeia um `scope` largo automático por omissão (header/footer→`entire-site`, single→`all-singular`, archive→`all-archives`); valida o `scope` contra `valid_selectors()` (selector inválido = template criado mas SEM condição, não falha); chama `Themer_Index::rebuild()` no fim. | `check_write_permission` | write, não destructive, não idempotent |
| `update-theme-template` | `{ template_id: int*, title?, content? }` | `wp_update_post` parcial (só os campos passados). | `check_write_permission` | write, não idempotent |
| `set-template-conditions` | `{ template_id: int*, include: object[]*, exclude?: object[], priority?: int }` | Valida cada regra (`include`+`exclude`) contra `valid_selectors()`; rejeita com erro se `exclude` não vazio e sem camada Pro (`pro_conditions_available()`); ignora silenciosamente `priority` não-zero em free (é um Pro tie-break); grava a meta `_emcp_themer_conditions`; `Themer_Index::rebuild()`. | `check_write_permission` | write, idempotent |
| `delete-theme-template` | `{ template_id: int*, force?: bool }` | `wp_delete_post($id, $force)` — trash por omissão, `force=true` apaga definitivo; `Themer_Index::rebuild()`. | `check_write_permission` | **destructive** |
| `resolve-template` | `{ post_id?: int, context?: enum(front-page,search,404) }` | Constrói um contexto (`Themer_Context::from_parts`) a partir do `post_id`/`context` dado, corre `Themer_Resolver::resolve()` com um registry fresco e o ranker `emcp_themer_rank` (free = 0), devolve `{slots:{header,body,footer}, context}` — verificação directa de "que template ganha aqui". | `check_read_permission` | readonly, idempotent |
**Nota de design em `execute_set_conditions`:** o comentário no código é explícito — regras
`exclude` **não são silenciosamente ignoradas** em free, são **rejeitadas com erro**
("Exclude rules require EMCP Pro"), porque um `exclude` que nunca é avaliado (porque não há
matcher registado para o selector) equivaleria a um no-op silencioso — falhar alto evita que o
agente pense que configurou uma exclusão que na verdade nunca aplica.
---
## 2. Abilities MCP — `EMCP_Tools_Themer_PHP_Abilities` (`includes/abilities/class-themer-php-abilities.php`)
5 tools, só registadas quando `EMCP_Tools_Themer_PHP::enabled()` (módulo Themer activo **E**
`emcp_tools_themer_php_enabled='1'`). Duas permissões: escrita exige
`EMCP_Tools_Themer_PHP_Store::can_edit()` (`manage_options` **E** `unfiltered_html` — a dupla
capability é deliberada, ver §11.2); leitura exige `can_read()` (`manage_options`).
| Tool | Input schema (resumo) | O que faz | Permissão | readonly / destructive |
|---|---|---|---|---|
| `create-theme-php-template` | `{ code: string*, type: enum(header,footer,single,archive,any)* , title? }` | Cria um `emcp_theme_php` em DRAFT via `Themer_PHP_Store::create_draft()`; valida com `EMCP_Tools_PHP_Snippet_Validator` (parse PHP + heurísticas de segurança do sandbox partilhado, ver doc 06); rejeita se inválido/inseguro. **Não existe tool `attach`** — deliberado (ver §11 e blueprint). | `check_write_permission` | write, não destructive |
| `list-theme-php-templates` | `{ type?: string }` | Lista drafts (`id, title, type, compiled, last_error`), filtro opcional por tipo. | `check_read_permission` | readonly, idempotent |
| `get-theme-php-template` | `{ template_id: int* }` | Registo completo: código, tipo, estado compilado, relatório de validação. | `check_read_permission` | readonly, idempotent |
| `update-theme-php-template` | `{ template_id: int*, title?, code?, type? }` | Actualização parcial; re-valida sempre; se já estava compilado (referenciado por um post Themer), recompila a partir do novo código. | `check_write_permission` | write, não idempotent |
| `delete-theme-php-template` | `{ template_id: int* }` | Apaga o registo CPT **e** o ficheiro compilado no sandbox (`decompile()` + `wp_delete_post(force=true)`). | `check_write_permission` | **destructive**, idempotent |
**Blueprint-relevante:** o comentário de topo do ficheiro é a especificação do modelo de
segurança inteiro numa frase — "*AI authors + validates DRAFT PHP templates; there is
intentionally no attach tool — a human selects a template in the Themer metabox (the execution
gate)*". Isto é o padrão mais reutilizável de todo este documento — ver blueprint final.
---
## 3. CPT + quota — `EMCP_Tools_Themer_CPT` (`includes/themer/class-themer-cpt.php`)
Regista `emcp_theme_template`: `public=false` mas `publicly_queryable=true` (deliberado — o
comentário explica: permite ao iframe de preview do editor Elementor renderizar a própria vista
singular do template; fica fora de menus/pesquisa/arquivos via `exclude_from_search=true`,
`has_archive=false`, `rewrite=false`). `menu_position=21` (logo a seguir a Páginas). Suporta
`title, editor, author, custom-fields`.
**Gotcha WordPress genérico, útil para qualquer CPT editável com Elementor:**
`add_post_type_support( self::POST_TYPE, 'elementor' )` é OBRIGATÓRIO — o Elementor faz gate do
seu editor em `post_type_supports($type, 'elementor')`; sem isto, "Edit with Elementor" não faz
absolutamente nada (falha silenciosa, sem erro visível). Também precisa dos dois filtros
`elementor/cpt_support/get_public_post_types` e `elementor/utils/get_public_post_types` para o
Elementor listar o CPT nos sítios certos da UI.
**Quota (o mecanismo de "free = 1 por tipo"):**
```php
public static function quota( string $type ): int {
return (int) apply_filters( 'emcp_themer_quota', 1, $type ); // Pro sobe para PHP_INT_MAX
}
public static function can_create( string $type, int $existing_count ): bool {
return $existing_count < self::quota( $type );
}
```
`count_of_type()` conta ao vivo via `WP_Query` (`found_posts`, sem cache) — chamado tanto pela
ability `create-theme-template` (§1) como pela UI (`render_free_limits_notice()`, que desenha
"chips" por tipo `used/cap` na lista do CPT).
**Duas heurísticas de UX no ecrã de listagem, dignas de nota como padrão de qualidade:**
1. **`render_adapter_notice()`** — mostra se o tema activo é directamente suportado pelo mapa de
adapters (§5.3); se não for, explica as duas alternativas (tag `emcp_themer_location()` ou o
toggle de full-page-takeover).
2. **`render_type_mismatch_notice()`** — heurística de detecção de erro humano: percorre todos os
templates e sinaliza (a) templates sem `type` definido (nunca renderizam), (b) um template
`header`/`footer` cujo conteúdo contém elementos body-only (detecta por substring
`emcp/post-title`, `emcp/archive-loop`, etc no `post_content`/`_elementor_data` — sinal de que
o utilizador construiu conteúdo de página dentro de um template de header por engano), (c) um
template cujo TÍTULO sugere um tipo diferente do `type` gravado (`type_hint_from_title()` —
conservador, só palavras-chave inequívocas como "header"/"404"/"single"). Isto é puro código
de qualidade-de-vida sem qualquer dependência de licença — vale a pena copiar tal-e-qual.
---
## 4. Sistema de condições — o núcleo mais reutilizável do módulo
Arquitectura em 5 peças puras + 1 fio de ligação WordPress, desenhada para nunca tocar a BD no
caminho crítico do front-end (ver §6 para o índice que torna isto possível).
### 4.1 Schema da UI — `EMCP_Tools_Themer_Condition_Schema` (`class-themer-condition-schema.php`)
`for_type(string $type): {relations, groups}` — constrói a árvore de opções em cascata que o
metabox (JS `themer-conditions.js`) consome: Relação (`include`, +`exclude` via filtro Pro) →
Grupo (`Entire site`/`Archives`/`Singular`, condicionados por tipo — header/footer vêem os 3,
single só vê Singular, archive só vê Archives) → Sub-tipo (folha concreta, ex.
`post-type-archive:{slug}` para cada post type com arquivo, `tax-archive:{slug}` para cada
taxonomia pública). Free = só folhas largas; o filtro `emcp_themer_condition_schema` é o único
ponto onde o Pro injecta pesquisa de objecto específico e os nós granulares.
### 4.2 Matcher registry — `EMCP_Tools_Themer_Matcher_Registry` (`class-themer-matcher-registry.php`)
Mapa `selector-key => {specificity: int, callback: fn(rule, ctx): bool}`. `fresh()` monta o
registry free e aplica `apply_filters('emcp_themer_matchers', ...)`. `key()` extrai a chave antes
do primeiro `:` do `object` da regra (`post-type:page` → chave `post-type`, parâmetro `page` via
`param()`). `matches()`/`specificity()` são os dois métodos públicos que o resto do sistema usa —
uma regra desconhecida NUNCA faz match (fail-closed).
Especificidades free: `entire-site=0` < `all-singular`/`all-archives=10` <
`front-page`/`post-type`/`post-type-archive`/`tax-archive=20`. A escala é o que garante que "toda
a categoria" nunca ganha sobre "categoria X" quando ambos aplicam (ver §4.5).
### 4.3 Avaliação pura — `EMCP_Tools_Themer_Conditions` (`class-themer-conditions.php`)
`evaluate({include, exclude}, ctx, registry): ?int` — função pura, sem I/O. Percorre `include`,
guarda a MAIOR especificidade entre as regras que fazem match (`$best`); se nenhuma fizer match
devolve `null` (não aplica). Senão, percorre `exclude`: qualquer match aí devolve `null`
imediatamente (exclude ganha sempre a include). Caso contrário devolve `$best`. É este inteiro
(ou `null`) que o resolver usa para desempatar entre templates concorrentes do mesmo tipo.
### 4.4 Contexto de pedido — `EMCP_Tools_Themer_Context` (`class-themer-context.php`)
`from_parts(array $parts): array` — normalizador puro, aplica defaults a TODAS as chaves
(`is_singular, is_archive, is_search, is_404, is_front_page, is_home, is_post_type_archive,
is_author, is_date, post_id, post_type, author_id, queried_post_type, queried_taxonomy,
queried_term_id, term_ids`) para que matchers/testes nunca tenham de tratar chaves em falta.
`from_query()` é o único ponto de contacto com WordPress: lê os condicionais da main query
(`is_singular()`, etc) + `get_queried_object()`, incluindo `collect_terms()` (todos os term ids
do post, por taxonomia) para suporte a `in-term` no Pro.
### 4.5 Resolução de slots — `EMCP_Tools_Themer_Resolver` (`class-themer-resolver.php`)
Função pura central: `resolve(index, ctx, registry, ranker): {header:?int, body:?int, footer:?int}`.
```php
public static function body_type( array $ctx ): ?string {
if ( $ctx['is_404'] ) return '404';
if ( $ctx['is_search'] ) return 'search';
if ( $ctx['is_singular'] ) return 'single';
if ( $ctx['is_archive'] || is_post_type_archive || is_author || is_date || is_home )
return 'archive';
return null;
}
```
Para cada slot (`header`, `body` — com o tipo dinâmico de `body_type()`, `footer`), `winner()`
percorre as linhas candidatas do índice desse tipo, chama `Conditions::evaluate()` por linha, e
escolhe segundo um critério de desempate em 3 níveis, por esta ordem: **(1) maior especificidade**
(`$spec`), **(2) maior prioridade** (`$prio`, via `$ranker($row)` — free devolve sempre 0, logo
este nível nunca decide nada em free), **(3) maior id** (o template mais recente ganha em caso de
empate total). Este algoritmo — puro, testável isoladamente, zero acoplamento a WordPress — é o
activo de engenharia mais valioso de todo o módulo.
### 4.6 `resolve-template` — como a ability expõe isto
`execute_resolve()` (§1) reconstrói um contexto a partir do input (`post_id` → singular; ou
`context: front-page/search/404`) e chama exactamente o mesmo `Resolver::resolve()` que o
front-end usa (via `Themer_Matcher_Registry::fresh()` + `Themer_Index::get()`), garantindo que a
resposta da tool é sempre um espelho fiel do que realmente vai renderizar — não uma simulação
paralela que possa divergir.
---
## 5. Pipeline de render — o "motor híbrido"
### 5.1 `EMCP_Tools_Themer_Render_Controller` (`class-themer-render-controller.php`)
`init()` liga dois hooks: `template_include` (prioridade **99**, deliberadamente tardia — "*so we
can defer to Elementor Pro's own theme builder when it wins*", ver `elementor_theme_builder_owns_body()`)
e `template_redirect` (para injectar header/footer standalone).
`slots()` é **memoizado por pedido** (`private static $slots`) — resolve uma única vez por
request, reutilizado por `render_mode()`, `maybe_take_over()`, `maybe_inject_parts()`, e pela
função global `emcp_themer_location()`.
**`render_mode()` é a decisão de design mais importante do módulo — 3 modos:**
| Modo | Condição | Comportamento |
|---|---|---|
| `none` | Nenhum template `body` ganhou | Não mexe em nada — deixa o tema tratar tudo (um header/footer standalone ainda pode injectar via adapter) |
| `body` | Há `body` mas NÃO (header E footer) | **Preserva o chrome do tema**: troca só a área de conteúdo (`template-body.php`) — chama `get_header()`/`get_footer()` do tema activo |
| `full` | Há `body` **E** header **E** footer (ou a option `force_render='1'`) | **Takeover total**: documento standalone completo (`template-canvas.php`), zero chrome do tema |
Isto evita o erro clássico de plugins "theme builder": um utilizador que só quer substituir o
`single.php` do tema NÃO perde acidentalmente o header/footer do tema só porque criou UM
template body — o full takeover só acontece quando o admin conscientemente criou os 3 slots (ou
forçou via option).
`maybe_take_over()` tem uma excepção crítica antes de qualquer resolução: ao editar/pré-visualizar
o próprio CPT `emcp_theme_template`, serve sempre um canvas em branco
(`template-edit-canvas.php`) — **nunca aplica a resolução Themer à própria vista singular do CPT**
(evitaria um paradoxo: um template a tentar resolver-se a si próprio).
`maybe_inject_parts()` (em `template_redirect`) só corre quando o modo NÃO é `full` (evita
duplicar header/footer). Chama `Themer_Theme_Adapters::current()`; se o tema for suportado,
`wire_adapter()` faz `remove_all_actions($hook)` seguido de `add_action($hook, ...)` — **remove
TODAS as callbacks existentes no hook do tema antes de adicionar a própria**, para o header do
tema não renderizar ao lado/atrás do header Themer. Se o tema não for suportado e
`force_render='1'`, cai para full-page takeover mesmo sem um template body (outro filtro em
`template_include`, prioridade 100). Se nada disto aplicar, só a tag manual
`emcp_themer_location('header'|'footer')` (que o próprio tema teria de chamar) funciona.
### 5.2 `EMCP_Tools_Themer_Content_Renderer` (`class-themer-content-renderer.php`)
`detect_builder(post_id): 'elementor'|'gutenberg'|'classic'` — inspecciona
`_elementor_edit_mode='builder'` primeiro, senão `has_blocks($content)`. `render(post_id)`:
1. **Delegação PHP primeiro** — se o sub-módulo Themer PHP está activo e há um
`_emcp_themer_php_template` anexado, chama `Themer_PHP_Renderer::render()`; se devolver algo
não-vazio, usa isso e **pára aí** (o PHP template substitui o conteúdo do builder para essa
região). Saída vazia cai de volta para o builder — nunca deixa a região em branco por um
template PHP falhado.
2. **Elementor** — `\Elementor\Core\Files\CSS\Post::create($id)->enqueue()` (garante o CSS gerado
do template, que normalmente só é enfileirado no contexto da própria página, é injectado fora
de contexto) + `Plugin::$instance->frontend->get_builder_content_for_display($id)`.
3. **Gutenberg/clássico** — ambos passam por `apply_filters('the_content', $post->post_content)`
(resolve blocos + shortcodes num único caminho).
Garantia de "nunca fatal num builder desconhecido": o caminho por omissão é sempre `the_content`.
### 5.3 `EMCP_Tools_Themer_Theme_Adapters` (`class-themer-theme-adapters.php`)
Mapa estático `template-slug => {header: hook, footer: hook}` para 7 temas populares (Astra,
GeneratePress, Kadence, OceanWP, Blocksy, Neve, Hello Elementor), extensível via
`emcp_themer_theme_adapters`. `current()` usa `get_template()` (slug do tema PAI, não do filho) —
correcto para temas filhos.
### 5.4 `EMCP_Tools_Themer_HFE_Conflict` (`class-themer-hfe-conflict.php`)
Trata a colisão com "Ultimate Addons for Elementor" (UAE, antigo "Header Footer Elementor" — os
hooks/nomes de classe internos ainda usam `HFE`). Ambos os sistemas constroem header/footer e
injectam nos mesmos slots; sem mediação, dá dois headers ou uma vitória aleatória "quem se
registou por último".
**Resolução determinística (não é só um aviso):** `filter_header()`/`filter_footer()`
ligam-se a `enable_hfe_render_header`/`enable_hfe_render_footer`/`enable_hfe_render_before_footer`
com prioridade 20 e **desligam o gate de render do HFE** (`return false`) para o slot que o Themer
já resolveu para este pedido (`Render_Controller::slots()`) — Themer ganha sempre que tem
template para o slot; o HFE continua a renderizar qualquer slot que o Themer não reclame. A
verificação é deliberadamente conservadora: qualquer falha ao resolver devolve `false`
(o Themer "não reclama"), nunca arrisca perder o header/footer do site por um bug de integração.
Adicionalmente mostra uma admin notice explicando o conflito, com um link para gerir módulos e um
"Dismiss" persistido em option — UX de reconhecer um conflito real de ecossistema em vez de
fingir que não existe.
---
## 6. Índice de condições (cache) — `EMCP_Tools_Themer_Index` (`class-themer-index.php`)
Uma ÚNICA option autoloaded (`emcp_tools_themer_index`) guarda `{type => rows[{id, include,
exclude, priority}]}` — o resolver (§4.5) lê isto directamente, **zero queries à BD por pedido**
no caminho de render (a option autoloaded já está em memória desde o boot do WordPress).
`build(records): index` é puro (registos planos → agrupados por tipo). `rebuild()` é o fio WP:
`WP_Query` sobre `emcp_theme_template` **só `post_status='publish'`** (draft nunca aplica ao
front-end — comentário explícito: "*a draft is work-in-progress and must not render for
visitors*"), lê a meta de cada post, chama `build()`, grava a option.
**Bug histórico documentado + o mecanismo de "heal" que ficou no código como cicatriz
permanente**, ordem dos hooks em `register_hooks()`:
```php
// Priority 99: the metabox and the MCP abilities write the type/conditions
// meta on save_post_{type} at priority 10, so the rebuild must run AFTER
// them or it reads stale/absent meta and produces an empty index (which
// makes the front end fall back to the theme's own templates).
add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'rebuild' ), 99 );
```
Se o rebuild corresse à prioridade 10 (ou sem prioridade explícita, ligando-se antes da metabox),
lia a meta ANTES dela ser escrita — índice ficava vazio, templates paravam de aplicar
silenciosamente. A correcção não foi só mudar a prioridade: o módulo carrega um marcador
`OPTION_INDEX_HEALED` e, uma única vez por instalação afectada, força um `rebuild()` no boot para
sites que já tinham este bug gravado no seu índice (`class-themer-module.php`, comentário
"*One-time heal: a prior build could leave the condition index empty*").
**Lição de engenharia para a réplica:** qualquer sistema com um índice/cache derivado de meta
escrita por MÚLTIPLAS fontes (aqui: metabox humana + 2 abilities MCP diferentes) precisa de uma
ordem de prioridade EXPLÍCITA e testada, não implícita. Um bug deste tipo é invisível em testes
manuais normais (a metabox humana normalmente já grava e o `save_post` global corre depois de
qualquer forma) mas manifesta-se de forma imprevisível consoante QUEM escreveu por último.
`on_deleted_post()` liga-se a `deleted_post`/`trashed_post`/`untrashed_post`, filtra por tipo de
post, e chama `rebuild()` — garante que apagar/mover-para-trash/restaurar um template também
actualiza o índice.
---
## 7. Metabox de admin — `EMCP_Tools_Themer_Metabox` (`class-themer-metabox.php`)
UI server-driven: o PHP monta o `<select>` de tipo, o `<select>` opcional de template PHP
anexado, e um `<div id="emcp-themer-conditions-app">` + `<input type="hidden">` com o JSON
serializado das condições. Todo o construtor em cascata (Relação→Grupo→Sub-tipo) é montado
client-side por `assets/js/themer-conditions.js` a partir do schema localizado
(`emcpThemerCond.schemasByType`), com `emcpThemerCond.isPro` a controlar visibilidade de UI Pro.
**Decisão deliberada anti-erro-silencioso:** um template NOVO nunca herda um `type` por omissão —
fica vazio até o utilizador escolher conscientemente (comentário: "*Do NOT default to a real type
('header') — that silently mistyped templates*"). Só é pré-preenchido a partir de
`?emcp_themer_type=` na URL (ex. um botão "Add New Header" que já passa o tipo pretendido).
**Detecção de conflito no próprio ecrã de edição:** `find_conflicts()` procura outros templates DO
MESMO TIPO cujas condições `include` partilhem pelo menos um `object` (`entire-site`,
`post-type:post`, etc — comparação por string exacta, não por especificidade) com o template
actual, e mostra uma notice inline com links directos — porque só um template pode ganhar um dado
slot, dois templates a apontar ao mesmo selector é quase sempre um erro do utilizador e o
resolver (§4.5) resolve isto de forma silenciosa e não óbvia (especificidade→prioridade→id mais
recente) sem esta notice.
`save()`: valida nonce, ignora autosave, verifica `edit_post`; grava `_emcp_themer_type` (só se
válido); se Themer PHP activo, valida e aplica o attach do template PHP
(`Themer_PHP_Admin::validate_attachment` + `apply_attachment` — este É o único ponto do sistema
inteiro onde um template PHP passa de "draft nunca executa" a "compilado e a renderizar", e requer
uma submissão de formulário humana com nonce, não uma chamada MCP); por fim
`sanitize_conditions()` — descodifica o JSON, valida cada `object` contra `valid_selectors()`
(regras com selector desconhecido são silenciosamente DESCARTADAS aqui, ao contrário da ability
que REJEITA — inconsistência aceitável: a UI já só oferece selectores válidos, então uma regra
"desconhecida" só chegaria por manipulação directa do campo hidden), e em free força sempre
`exclude=[]`/`priority=0` mesmo que o payload tente enviar algo (dupla proteção, já para lá da
rejeição da ability).
---
## 8. Conteúdo dinâmico partilhado — `EMCP_Tools_Themer_Dynamic` (`class-themer-dynamic.php`)
**A peça de design mais elegante do módulo**: um único catálogo estático de 10 elementos
(`post-title, archive-title, breadcrumbs, post-meta, site-logo, site-title, nav-menu, description,
post-content, archive-loop`), cada um com um método `public static function` que devolve HTML já
escapado. Gutenberg (§9) e Elementor (§10) chamam exactamente os MESMOS métodos — a lógica de
"o que é o título do post/arquivo agora" existe UMA VEZ, nunca duplicada por builder.
`args_from(key, rawAttrs): array` traduz os atributos de QUALQUER builder (Gutenberg `camelCase`
booleanos JS `true/false`, ou Elementor `snake_case`/`'yes'/''`) para o formato de argumentos
interno partilhado — normaliza truthy de ambos os mundos automaticamente. `render(key, args)` é o
dispatcher final, usado directamente pelos dois builders.
Todos os elementos resolvem contra a **main query actual**, não o template — é assim que um
template Themer "single" mostra o post realmente visitado. `queried_id()` cobre o caso `is_home()`
(blog page separada). Detalhes de qualidade notáveis:
- **`breadcrumbs()`** prefere a função de breadcrumb de um plugin SEO já activo (Yoast/Rank
Math/SEOPress, por esta ordem) antes de cair no trail simples próprio — evita reinventar
algo que o site já pode ter bem configurado (schema, hierarquia custom, etc).
- **`custom_field()`** é ACF-aware (`function_exists('get_field')`) com fallback a
`get_post_meta()` — preenche o gap de "campo dinâmico" que nem Gutenberg nem Elementor free têm
nativamente.
- **`archive_loop()`** distingue explicitamente contexto real de preview
(`is_preview_context()` — testa `REST_REQUEST`, `is_admin()`, modo editor/preview do Elementor)
para usar a `$wp_query` real num arquivo a sério, ou uma `WP_Query` de amostra (respeitando
`query_post_type`/`query_orderby`/`query_tax`/`query_term` opcionais) quando está só a ser
desenhado — evita que o widget pareça "sem posts" enquanto o utilizador o configura.
- **`is_preview_context()`** é reutilizado por vários pontos do módulo como o teste canónico
"estou num contexto de edição, não num pedido real de visitante".
---
## 9. Blocos Gutenberg dinâmicos — `EMCP_Tools_Themer_Blocks` (`blocks/class-themer-blocks.php`)
Regista uma categoria própria (`emcp-themer`) e os 10 blocos `emcp/{key}` via
`register_block_type()` API v2, com `render_callback` no servidor (nunca `save` client-side —
todo o output é dinâmico). `blocks()` é a fonte única de verdade: título/ícone/`attributes`
(tipos+defaults)/`supports` (align/color/spacing/typography/border nativos do editor)/`controls`
(descritores partilhados de UI, ex. `{key:'tag', type:'select', options:[...]}`) — o MESMO array
serve para registar o bloco em PHP **e** é `wp_localize_script()`'d para
`assets/js/themer-blocks.js` construir os controlos `InspectorControls` no editor sem qualquer
passo de build (JS vanilla, sem JSX/Webpack).
`render_block()` chama `Dynamic::args_from()` + `Dynamic::render()` (§8); se a saída for vazia
E estivermos num pedido REST (preview do editor), mostra um placeholder com o título do bloco em
vez de nada — o editor nunca parece "partido" mesmo quando não há dados de amostra.
---
## 10. Widgets Elementor dinâmicos — `EMCP_Tools_Themer_Widgets` + `Widget_Base` (`widgets/`)
`class-themer-widgets.php` é só o loader: liga `elementor/elements/categories_registered`
(categoria "EMCP Themer"), `elementor/widgets/register` (`require_once` tardio de
`class-themer-widget-classes.php` — só dentro deste hook, quando `\Elementor\Widget_Base` está
garantidamente carregado), e reutiliza a MESMA folha de estilos dos blocos Gutenberg
(`themer-blocks.css`) para o layout partilhado.
`class-themer-widget-classes.php` define `EMCP_Tools_Themer_Widget_Base` (abstract, estende
`\Elementor\Widget_Base`) + **10 subclasses triviais** (uma linha cada: `emcp_key(): string`). A
base faz TODO o trabalho:
- `register_controls()` — constrói os controlos de conteúdo a partir dos MESMOS descritores
partilhados de `Themer_Blocks::blocks()[$key]['controls']` (mapeados para tipos de controlo
Elementor via `control_args()`: select/toggle/text/number/menu) — de novo, zero duplicação entre
a definição do bloco Gutenberg e do widget Elementor.
- Tab de Estilo partilhada (alinhamento, cor de texto, cor de link, grupo de tipografia via
`Group_Control_Typography`) igual em todos os 10 widgets; o widget `archive-loop`
adicionalmente ganha uma secção "Cards" completa (gap, largura de imagem, fundo, borda, raio,
padding, sombra, cores de título/meta/excerpt/read-more).
- `render()` delega directamente a `Dynamic::render()` (§8) — o output já vem escapado do
provider partilhado, por isso o `echo` aqui não escapa de novo (comentário explícito no código).
---
## 11. Subsistema Themer PHP — templates PHP em bruto autorados por IA
Feature mais sensível de todo o módulo (execução de código), desenhada com um modelo de segurança
em camadas — a peça mais valiosa para copiar tal-e-qual numa réplica.
### 11.1 Coordenador — `EMCP_Tools_Themer_PHP` (`php/class-themer-php.php`)
Classe fina. `enabled()` é o único ponto de decisão: módulo Themer activo **E**
`emcp_tools_themer_php_enabled='1'`. `init()` regista o CPT **incondicionalmente** (mesmo que o
toggle esteja desligado — drafts existentes continuam consultáveis/apagáveis se a feature for
depois desligada), mas só arranca o admin quando `enabled()`.
### 11.2 Store — `EMCP_Tools_Themer_PHP_Store` (`php/class-themer-php-store.php`)
CPT privado `emcp_theme_php` (`show_ui=false`, `show_in_rest=false`, `public=false` — invisível
fora deste subsistema). Meta: `_emcp_theme_php_code` (raw PHP), `_type`, `_validation` (JSON do
relatório do validador partilhado), `_hash` (sha256 — **a presença desta meta É a definição de
"compilado"**), `_error` (última mensagem de fatal capturada).
**Permissões deliberadamente duplas:** `can_edit()` exige `manage_options` **E**
`unfiltered_html` — a segunda capability é a que o WordPress core usa para gate de código PHP
arbitrário (ex. editor de temas/plugins); herdar exactamente essa capability em vez de inventar
uma nova é a escolha correcta (multisite normalmente REMOVE `unfiltered_html` de admins de site
por omissão, fechando esta feature automaticamente nesses contextos).
**`create_draft()`/`update()`** correm sempre a validação partilhada
(`EMCP_Tools_PHP_Snippet_Validator::validate()`, ver doc 06 — parse PHP + heurísticas de
segurança) e recusam com `WP_Error` se `!valid` (erro de parse) ou `!safe` (finding crítico:
execução de código, shell, carregamento de ficheiros, rede, escrita em ficheiros — listado na
description da ability, §2).
**Compila-se apenas quando referenciado — o coração do modelo de segurança:**
```php
public static function sync_reference( int $id ) {
if ( self::reference_count( $id ) > 0 ) { return self::ensure_compiled( $id ); }
self::decompile( $id );
return true;
}
```
`reference_count()` conta posts `emcp_theme_template` cuja meta `_emcp_themer_php_template`
aponta para este id. Um draft criado/editado pela IA **não tem ficheiro `.php` em disco até um
humano o anexar via metabox** (`Metabox::apply_attachment()`, §7, chama `sync_reference()` depois
da meta gravada). Desanexar (ou apagar o post Themer que o referenciava) desfaz a compilação
automaticamente. Isto reduz drasticamente a superfície de "código PHP a correr no site" ao
subconjunto que um humano explicitamente ligou — a IA nunca consegue tornar um template
executável por si só.
**`ensure_compiled()`** — re-valida, envolve o corpo (já com as tags PHP removidas por
`Validator::strip_tags()`) numa função nomeada `emcp_theme_php_{id}` guardada por
`function_exists()`, faz `token_get_all($php, TOKEN_PARSE)` como verificação extra de sintaxe
antes de escrever, grava o ficheiro em `{sandbox}/theme-php/{id}.php`, e grava o sha256 do
conteúdo final como `_hash`. `opcache_invalidate()` é chamado explicitamente após cada
escrita/remoção de ficheiro (evita servir bytecode obsoleto em produção com OPcache).
**Manifesto** (`theme-php-manifest.json`): `rebuild_manifest()` percorre TODOS os drafts, inclui
só os que têm `_hash` presente (i.e., compilados), grava `{post_id, func, php_path, hash, type}`
por entrada — este ficheiro é a ÚNICA fonte que o renderer (§11.3) consulta, nunca faz scan de
directório.
### 11.3 Renderer — `EMCP_Tools_Themer_PHP_Renderer` (`php/class-themer-php-renderer.php`)
`render(id): string` — devolve `''` em QUALQUER falha (o content-renderer, §5.2, cai de volta ao
conteúdo do builder nesse caso). Três camadas de defesa antes de sequer chamar a função:
1. **Manifest-only lookup** — `manifest_entry()` procura no `read_manifest()`; nenhum ficheiro
nunca é localizado por varrimento de directório.
2. **Path containment guard** — `0 !== strpos(normalize($path), normalize($sandbox))` — o caminho
resolvido tem de viver dentro do directório sandbox; defende um manifesto envenenado por
qualquer via.
3. **Tamper guard** — `hash('sha256', file_get_contents($path)) !== $entry['hash']` → recusa. O
ficheiro em disco tem de bater exactamente com o hash gravado no manifesto no momento da
compilação — qualquer edição directa do `.php` no disco (fora do fluxo Store) invalida-o
silenciosamente.
Só depois de passar as 3 camadas é que `include_once` carrega o ficheiro (isto define a função —
"*running no user code*" ainda, o corpo só corre quando chamada). A chamada em si acontece dentro
de `ob_start()` + `try/catch(\Throwable)` — uma excepção marca o erro (`mark_error()`, que
**também decompila** o template) e devolve `''`.
**Fatal-recovery via shutdown handler — a rede de segurança final:**
```php
public static function on_shutdown(): void {
if ( null === self::$active ) { return; }
$err = error_get_last();
$fatal = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR );
if ( is_array($err) && in_array($err['type'], $fatal, true) ) {
EMCP_Tools_Themer_PHP_Store::mark_error( self::$active, $err['message'] ?? '...' );
}
}
```
`self::$active` marca qual template está a incluir/executar; se o PROCESSO INTEIRO morrer com
fatal error (algo que nem `try/catch` apanha — um erro de PHP verdadeiramente fatal, não uma
`\Throwable`), o shutdown handler regista-o e chama `mark_error()`, que **decompila o template**
(remove `_hash`, apaga o ficheiro). **Consequência prática:** um template com um bug que provoque
fatal auto-recupera para "desligado" logo após o PRIMEIRO pedido que o parte — não continua a
derrubar cada pedido subsequente do site. É o mecanismo mais importante para tornar "deixar uma IA
escrever PHP que corre no site" aceitável em produção.
### 11.4 Admin UI — `EMCP_Tools_Themer_PHP_Admin` (`php/class-themer-php-admin.php`)
Submenu sob o CPT Themer (`edit.php?post_type=emcp_theme_template&page=emcp-themer-php`). Reusa
`wp_enqueue_code_editor(['type'=>'text/x-php'])` — o MESMO CodeMirror que o editor nativo de
temas/plugins do WordPress usa, com linting — em vez de reinventar um editor de código. Lista
templates, e ao ver um (`?view={id}`) mostra o editor completo com título/tipo/código, guardar
(re-valida, recompila se já anexado) e apagar. É código de admin puro, sem qualquer registo MCP —
existe só para o humano que precisa de intervir manualmente num template gerado pela IA.
---
## Blueprint para réplica
**Copiar quase 1:1 (o valor está no design, não na implementação de detalhe):**
1. **O sistema de condições completo (§4)** — Schema/Matcher-Registry/Conditions/Context/Resolver
é uma máquina de resolução de "qual template para este pedido" desenhada com disciplina pura +
glue. É genérico o suficiente para servir QUALQUER sistema de "theme builder"/"template
assignment" (não só Themer) — vale a pena extrair como um pacote isolado logo de início.
2. **O padrão "catálogo partilhado + dispatcher" de `Themer_Dynamic` (§8)** — um único ponto de
verdade para "o que é X neste contexto", consumido por N builders/superfícies diferentes (aqui
Gutenberg e Elementor; podia ser qualquer par). Evita a divergência clássica "o título mostra
uma coisa no bloco e outra no widget".
3. **O modelo de segurança do Themer PHP inteiro (§11)** — compila-só-quando-referenciado +
manifesto hash-verificado + `path containment guard` + shutdown fatal-recovery é a resposta
correcta a "deixar um agente de IA escrever PHP executável" e generaliza-se a qualquer feature
futura do género (snippets, sandbox de widgets/blocos custom — a doc 06 provavelmente reutiliza
o mesmo `PHP_Snippet_Validator`/`PHP_Snippet_Store` subjacentes).
4. **O `render_mode()` de 3 estados (none/body/full, §5.1)** — a decisão de nunca fazer takeover
total do documento a menos que o admin tenha deliberadamente os 3 slots (ou tenha forçado) é a
diferença entre "plugin de theme builder que não assusta ninguém" e "plugin que às vezes come o
header do tema sem aviso".
5. **A ausência deliberada de uma tool `attach` no grupo Themer PHP** — replicar o princípio
directamente: **qualquer feature que gere código executável via IA deve ter o "ligar à
execução" como um passo humano fora do protocolo MCP**, nunca uma ability chamável.
**Simplificar numa reescrita própria:**
- **Os 3 níveis do índice de condições (§6)** são bom design mas exigem disciplina de ordenação de
hooks nada óbvia (o bug histórico de §6 prova isto). Numa reescrita, considerar calcular o
resolve directamente a partir da CPT em cada pedido com `WP_Object_Cache`/transient de curto TTL
em vez de uma option autoloaded mantida manualmente — mais simples de raciocinar, ao custo de
uma query extra em cache-miss (aceitável face ao ganho de robustez).
- **Os 7 theme adapters fixos (§5.3)** são um mapa estático de hooks específicos por tema —
correcto para os temas mais populares mas frágil a longo prazo (nomes de hooks mudam entre
versões major de tema). Considerar documentar isto como convenção pública (`emcp_themer_location()`
já existe para esse fim) em vez de tentar manter uma lista de adapters actualizada
indefinidamente.
- **A UI de admin nativa completa (metabox + condition-builder JS + PHP-editor CodeMirror)** é
~1500+ linhas de PHP mais JS não lido nesta tarefa (`assets/js/themer-conditions.js`,
`assets/js/themer-blocks.js`) — para uma réplica focada em "agente MCP + template engine",
considerar reduzir a UI humana ao mínimo (edição via qualquer builder já suportado + um ecrã de
condições simples) e investir o esforço poupado na cobertura de testes do resolver puro (§4.5),
que é o componente que realmente importa estar correcto.
**Riscos/gotchas não óbvios a não repetir sem pensar:**
- A verificação `check_write_permission` de `Themer_Abilities` aceita `publish_pages ||
edit_pages` para CRIAÇÃO (sem `template_id` ainda) mas exige `edit_post($id)` específico para
edição — replicar esta assimetria correctamente é fácil de errar (a tentação óbvia é usar a
mesma capability para os dois casos, o que ou é permissivo demais na criação ou impossível na
edição de um post ainda inexistente).
- `is_pro()`/`pro_conditions_available()` testam a presença de `'post'` no array de selectores
filtrado como proxy de "há licença Pro" — um padrão frágil (qualquer terceiro que registe um
selector chamado `post` por acidente activaria funcionalidade Pro sem querer) mas simples;
numa reescrita própria, preferir uma função de capability explícita (`is_premium()`, já usada em
`Themer_CPT`) em vez de inferir por presença de string.
- `render_type_mismatch_notice()` e `find_conflicts()` (heurísticas de UX) fazem `WP_Query` de até
100-200 posts em CADA carregamento do ecrã de admin relevante — aceitável à escala de "poucos
templates de tema por site" mas não escalaria a um cenário de centenas de templates; não é um
problema real neste domínio (o próprio quota de 1-por-tipo em free e o uso normal em Pro nunca
chega a esses números), mas vale registar se a réplica reutilizar este padrão noutro contexto de
volume maior.
---
## Fonte
Leitura directa (19-08-2026) de todos os 22 ficheiros do módulo Themer em
`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
`includes/abilities/class-themer-abilities.php`, `includes/abilities/class-themer-php-abilities.php`,
`includes/themer/class-themer-cpt.php`, `includes/themer/class-themer-resolver.php`,
`includes/themer/class-themer-conditions.php`, `includes/themer/class-themer-condition-schema.php`,
`includes/themer/class-themer-dynamic.php`, `includes/themer/class-themer-metabox.php`,
`includes/themer/class-themer-matcher-registry.php`, `includes/themer/class-themer-context.php`,
`includes/themer/class-themer-render-controller.php`, `includes/themer/class-themer-content-renderer.php`,
`includes/themer/class-themer-hfe-conflict.php`, `includes/themer/class-themer-theme-adapters.php`,
`includes/themer/class-themer-index.php`, `includes/themer/blocks/class-themer-blocks.php`,
`includes/themer/widgets/class-themer-widgets.php`, `includes/themer/widgets/class-themer-widget-classes.php`,
`includes/themer/php/class-themer-php.php`, `includes/themer/php/class-themer-php-store.php`,
`includes/themer/php/class-themer-php-renderer.php`, `includes/themer/php/class-themer-php-admin.php`.
Mais, para contexto do ciclo de vida (não listado no pedido original mas necessário para
compreender o gate de activação em §0): `includes/modules/class-themer-module.php`. Verificação
cruzada do gating exacto (linhas 202-221) contra `includes/abilities/class-ability-registrar.php`.
Cruzado com `skill://emcp-tools` (auditoria de postura de segurança, 16-08-2026) e
`docs/00-ARQUITECTURA.md` (arquitectura geral do plugin, já escrito nesta série).
+727
View File
@@ -0,0 +1,727 @@
# 05 — Redirects, Search Index, Page Snapshot e Change-Ledger/Content-Mirror
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Contexto herdado de `00-ARQUITECTURA.md` (cadeia de arranque, contrato
`emcp_tools_register_ability()`, dispatcher compacto) e `skill://emcp-tools` (postura de
segurança activa/desligada por site) — não repetidos aqui salvo onde relevante para os
quatro subsistemas abaixo.
Este documento cobre **quatro subsistemas independentes** que partilham uma característica:
são todos **"always-on"** (registados incondicionalmente em `class-ability-registrar.php`,
sem gate de plugin de terceiros nem de licença Pro) — **excepto o Redirect Manager**, que é o
único dos quatro atrás de um module gate (`redirects`, free, activo por omissão). Isto é um
sinal de design deliberado: o autor considera pesquisa de conteúdo, snapshot de página,
ledger de alterações e mirror de conteúdo **infra-estrutura nuclear** do plugin, não
funcionalidades opcionais — mesmo o Redirect Manager, apesar de "module-gated", vem activo
por omissão em todos os sites verificados.
## 0. Nota sobre um ficheiro fora do agrupamento temático: `class-url-guard.php`
`includes/class-url-guard.php` (`EMCP_Tools_Url_Guard`) foi incluído na lista de ficheiros
desta tarefa mas **não pertence funcionalmente** a nenhum dos quatro subsistemas — é um
**serviço SSRF partilhado**, usado pelas tools de sideload de imagem/SVG (doc 09) e, desde a
v3.2.0, por uma validação mais estrita usada por uma tool `web_fetch` de AI Chat (Pro, fora
deste build). Documentado em separado na §6 por completude do ficheiro pedido, mas não faz
parte da arquitectura de Redirects/Search/Snapshot/Ledger em si.
---
## 1. Redirect Manager
**Classe de abilities:** `EMCP_Tools_Redirect_Abilities`
(`includes/abilities/class-redirect-abilities.php`)
**Condição de registo** (copiada de `class-ability-registrar.php`, linhas 162-169):
```php
// Redirect Manager abilities (301/302 redirects + broken-link scan; no
// Elementor). Gated on the Redirects module (on by default) — abilities
// register before the module boots on init:5, so gate on is_enabled().
if ( class_exists( 'EMCP_Tools_Redirect_Module' ) && EMCP_Tools_Redirect_Module::is_enabled() ) {
$redirects = new EMCP_Tools_Redirect_Abilities();
$redirects->register();
$this->ability_names = array_merge( $this->ability_names, $redirects->get_ability_names() );
}
```
**Ponto de design a reter:** as abilities registam-se em `wp_abilities_api_init`, que corre
**antes** do módulo arrancar (`init:5`) — por isso o gate não pode depender de estado de
instância do módulo; `EMCP_Tools_Redirect_Module::is_enabled()` tem de ser uma leitura
**estática e sem efeitos secundários** (lê `emcp_tools_active_modules` directamente do
option). Este é o padrão correcto para qualquer grupo de abilities gated a um módulo numa
réplica: nunca depender da ordem de hooks do próprio módulo.
Não é Elementor-dependente (funciona em qualquer site, mesmo sem Elementor activo).
### 1.1 Tools
Todas as 5 tools partilham `permission_callback` = `check_manage_permission()` →
`current_user_can('manage_options')` — **inclusive as de leitura** (`list-redirects`,
`find-broken-links`), ao contrário dos outros três subsistemas deste documento que usam
`edit_posts` para leitura. Reflecte que redirects tocam routing de produção com impacto
directo em SEO — o autor optou por um limiar de permissão mais alto mesmo para inspecção.
| Tool | Input schema (resumo) | O que faz | `meta.annotations` |
|---|---|---|---|
| `list-redirects` | `enabled?:bool`, `search?:string`, `per_page?:int` (1-500, def 100), `page?:int` (def 1) | Lista redirects da tabela `{prefix}emcp_redirects` com filtro/paginação; devolve `{redirects[], total}`. | readonly, não-destructive, idempotent |
| `create-redirect` | `source*:string`, `target?:string` **OU** `target_post_id?:int` (mutuamente exclusivos), `status_code?:enum[301,302]` (def 301), `ignore_query?:bool` (def true). `required:[source]` | Cria um redirect 301/302. **Avisa mas não bloqueia** quando o `source` já resolve para uma página publicada e viva (`shadow_warning()` — `url_to_postid()` + `get_post_status()==publish` → devolve `warning` no output em vez de recusar). Toda escrita passa por `EMCP_Tools_Change_Recorder::record_redirect()`. | não-readonly, não-destructive, não-idempotent |
| `update-redirect` | `id*:int` + qualquer subconjunto de `source/target/target_post_id/status_code/ignore_query/enabled`. `required:[id]` | Actualiza só os campos fornecidos (patch parcial). Grava o antes (`$prior`) antes de aplicar, para o ledger. | não-readonly, não-destructive, **idempotent=true** |
| `delete-redirect` | `id*:int`. `required:[id]` | Elimina por id. Reversível a partir da tab History. | não-readonly, **destructive=true**, não-idempotent |
| `find-broken-links` | `max_posts?:int` (1-2000, def 200), `max_seconds?:int` (1-60, def 10) | Varre `post_content` de todos os post types públicos e publicados, extrai `href=` via regex, classifica cada link interno como `external/ok/dead/redirected` contra as fontes de redirect activas. **Só leitura — propõe, não corrige nada.** Limitado por posts E por tempo (`microtime()`), devolve `partial:true` se algum limite disparar a meio. | readonly, não-destructive, idempotent |
### 1.2 `EMCP_Tools_Redirect_Handler` — o hook de front-end
`includes/redirects/class-redirect-handler.php`. Regista-se em `template_redirect` prioridade
**1** (o mais cedo possível, antes de qualquer templating de 404). `should_skip()` recusa
disparar em `is_admin()`, `wp_doing_cron()`, `wp_is_json_request()`, e por regex em
`wp-admin|wp-json|wp-login.php` no `REQUEST_URI` cru (defesa redundante ao `is_admin()`/REST
check para o caso de esses helpers ainda não estarem disponíveis nesta fase tão cedo do ciclo
de vida). Hot path: `normalize_path($uri)` → `find_by_source()` (lookup indexado único) →
`resolve_target()` → `would_loop()` guard → forward do query string original SE o target não
tiver já um `?` → `record_hit()` (incrementa contador+timestamp) → `wp_redirect($target,$code)`
+ `exit`.
**Gotcha observado (não documentado no código, inferido por leitura cruzada):** o campo
`ignore_query` é capturado e persistido na tabela, mas **`maybe_redirect()` nunca o lê**. O
matching é sempre por `source_path` normalizado (que já descarta a query string em
`normalize_path()`, através de `wp_parse_url($s)['path']`), logo a query é **sempre**
ignorada para efeitos de correspondência, independentemente do valor de `ignore_query`. O
que o handler efectivamente usa a query original para é só reencaminhá-la para o alvo quando
este não já tiver a sua própria (`?query`). Ou este campo é vestigial (pensado para um modo
de correspondência exacta com query que nunca chegou a ser implementado), ou é
intencionalmente sempre-true na prática e o toggle serve outro propósito ainda não coberto
por estes ficheiros (ex.: UI apenas). **Numa réplica, decidir explicitamente** um dos dois:
implementar correspondência exacta por query quando `ignore_query=false`, ou remover o campo.
### 1.3 `EMCP_Tools_Redirect_Store` — tabela própria + CRUD + normalização
`includes/redirects/class-redirect-store.php`. Segue o mesmo padrão de storage do
Search Index (§2): tabela custom `{prefix}emcp_redirects`, `DB_VERSION` const (`1`) + option
`emcp_tools_redirects_db_version`, instalação via `dbDelta()` gated em `init:20`
(`maybe_install()`, corre só quando `get_option(DB_VERSION_OPTION,0) < DB_VERSION`).
Schema:
```sql
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_path VARCHAR(191) NOT NULL, -- UNIQUE KEY source_unique
target TEXT NOT NULL,
target_post_id BIGINT UNSIGNED NULL,
status_code SMALLINT NOT NULL DEFAULT 301,
match_type VARCHAR(20) NOT NULL DEFAULT 'exact', -- ver nota abaixo
ignore_query TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
hits BIGINT UNSIGNED NOT NULL DEFAULT 0,
last_hit DATETIME NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
KEY enabled_idx (enabled)
```
**`match_type` é escrito sempre como `'exact'`** por `create()`/`row_for_write()` — nenhum
código nestes ficheiros lê ou ramifica sobre este valor. É claramente uma coluna preparada
para um futuro modo de correspondência (prefixo/regex/wildcard) que ainda não existe — outro
campo "de intenção futura" como o `ignore_query`.
**Helpers puros (sem BD, testáveis sem WordPress a correr, comentário explícito no header do
ficheiro):**
- `normalize_path()` — normaliza URL/path para path comparável home-relative: extrai só
`path` via `wp_parse_url`, remove prefixo de subdirectório (`home_url('/')`), descodifica
(`rawurldecode`), lowercase, colapsa `//` repetidos, garante 1 slash inicial, remove slash
final; raiz mantém-se `/`.
- `would_loop($source,$target)` — `normalize_path(source) === normalize_path(target)`.
- `resolve_target($row)` — quando `target_post_id>0`, resolve o permalink **ao vivo**
(`get_permalink()`) em vez de guardar a URL estática — sobrevive a mudanças de slug do
próprio post de destino; devolve `''` (redirect tratado como inactivo) se o post já não
existir.
**Validações em `create()`** (ordem exacta): source vazio/raiz rejeitado → source>191 chars
rejeitado → `target` E `target_post_id` em simultâneo rejeitado (`ambiguous_target`) → nenhum
dos dois rejeitado (`missing_target`) → self-loop rejeitado (`redirect_loop`) → source
duplicado rejeitado (`duplicate_source`, via `find_by_source()` antes do INSERT — não confia
só na UNIQUE KEY da BD).
**`rollback($rb)` — o applier do tipo `redirect-row` do ledger** (chamado a partir de
`EMCP_Tools_Change_Log::apply_rollback()`, §4.4): 3 formas conforme a acção original —
`create` → `before:{id}` → apaga a linha; `update`/`delete` → `before:{row:{...linha
completa}}` → restaura/reinsere a linha completa preservando o `id` original
(`row_for_write()` mapeia todas as colunas incluindo `id`, com formatos `%d/%s/%s/%d/…`
próprios para insert vs update, via `write_formats()`/`write_formats_no_id()`). **Nota
arquitectural importante:** este applier **não vive no dispatcher central**
(`class-change-log.php`) — vive na própria classe de domínio (`Redirect_Store`), e o
dispatcher central limita-se a `EMCP_Tools_Redirect_Store::rollback($rb)` dentro do seu
`switch`. Ver §4 para o significado disto no design geral do ledger.
---
## 2. Índice de pesquisa de conteúdo (Content Search Index)
**Classe de abilities:** `EMCP_Tools_Search_Abilities` (`includes/abilities/class-search-abilities.php`)
— sempre registada (linhas 186-189 do registrar: `// Content search — lexical index over
pages/templates/widgets/globals (always-on).`), sem qualquer `class_exists()`/module gate.
### 2.1 Tools
Ambas usam `check_read_permission()` → `current_user_can('edit_posts')`. **Diferença notável
face ao Redirect Manager:** nenhuma das duas chamadas `emcp_tools_register_ability()` inclui
um bloco `meta` explícito (nem `output_schema`) — ao contrário de todas as tools do Redirect
Manager. Como `emcp_tools_register_ability()` não injecta um `meta.annotations` por omissão
visível nestes ficheiros, o comportamento efectivo (readonly/destructive) para clientes MCP
que inspeccionam anotações fica indefinido/omisso para estas duas tools — inconsistência de
estilo entre grupos de abilities do mesmo plugin, a evitar numa réplica (declarar sempre
`meta.annotations`, mesmo quando óbvio).
| Tool | Input schema (resumo) | O que faz |
|---|---|---|
| `search-content` | `query*:string`, `types?:array<enum page,template,widget,global_color,global_font,global_class>`, `limit?:int` (def 20). `required:[query]` | Pesquisa o índice léxico materializado, devolve `{query, results[], count}`. Cada resultado: `{object_type, object_id, title, score, snippet, meta}`. |
| `reindex-search` | `types?:array<mesmo enum>` (vazio = todos) | Reconstrói o índice (total ou parcial), devolve `{indexed:{tipo:contagem}, total}`. |
### 2.2 `EMCP_Tools_Search_Index` — a tabela + os document builders
`includes/class-search-index.php`. Tabela custom `{prefix}emcp_search_index`, `DB_VERSION=1`,
option `emcp_tools_search_index_db_version`, instalação `dbDelta()` gated em `init:20`.
```sql
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
object_type VARCHAR(20) NOT NULL,
object_id VARCHAR(64) NOT NULL,
title TEXT NOT NULL,
content LONGTEXT NOT NULL,
tokens LONGTEXT NOT NULL, -- ver nota "coluna morta" abaixo
meta LONGTEXT NULL, -- JSON
updated_at INT NOT NULL,
UNIQUE KEY object_unique (object_type, object_id),
KEY object_type_idx (object_type)
```
**Achado — coluna `tokens` é escrita mas nunca lida:** `upsert()` calcula
`EMCP_Tools_Search_Ranker::tokenize(title.' '.content)` e guarda-o em `tokens` a cada
inserção/substituição, mas `search()` faz `SELECT object_type,object_id,title,content,meta`
— **`tokens` não entra na query**. O `EMCP_Tools_Search_Ranker::rank()` retokeniza
`title`+`content` **ao vivo**, a cada pesquisa, para todos os documentos do tipo filtrado.
Isto é armazenamento morto: escreve-se trabalho computacional (tokenização) numa coluna que
nunca é consultada, e o custo de tokenização repete-se em runtime a cada pesquisa em vez de
ser amortizado. **Numa réplica:** ou remover a coluna, ou (melhor) usar FULLTEXT MySQL sobre
`tokens` e evitar o full-table-scan + retokenização em PHP a cada pesquisa — a v1 "lexical"
descrita no header do ranker é claramente um MVP consciente disto (comentário: "the
embedding-backed rerank is a future upgrade layered on top of this").
**Hooks de manutenção** (`init()`): `init:20` instala; `save_post:30` reindexa
incrementalmente; `deleted_post:10` remove do índice.
`on_save_post()` — cadeia de guardas antes de reindexar: ignora autosave → ignora revisão →
ignora se a tabela ainda não tem a versão instalada → **ignora se
`EMCP_Tools_Data::elementor_documents_ready()` for falso** (guarda contra um fatal
documentado como **issue #105**: o Elementor insere o seu kit por omissão durante a sua
**própria** activação, um `save_post` que aterra aqui antes do document manager do Elementor
existir — indexar nesse momento desreferenciaria um manager nulo e provocaria fatal na
activação do Elementor) → se o post gravado for o **kit activo**
(`elementor_active_kit` option), reindexa só os globais (`index_globals()`) em vez de o
tratar como um "template" comum → senão, indexa como `template` (post_type
`elementor_library`) ou `page` (`page`/`post` com `_elementor_edit_mode=builder`).
`rebuild($types)` — 4 grupos independentes, cada um com `clear_type()` (DELETE por tipo)
antes de reindexar:
- **widgets** — `widget_documents()`: percorre `EMCP_Tools_Widget_Catalog::get()` (catálogo
PHP estático, doc 02 — não vem de BD nem de posts), constrói `content` a partir de
title+use_case+keywords+category+widget_type(normalizado)+nomes dos parâmetros.
- **pages** — `WP_Query` sobre `page`/`post`, **todos os status**
(`publish/draft/pending/private/future`), filtro `_elementor_edit_mode=builder`.
- **templates** — `WP_Query` sobre `elementor_library`, mesmos status.
- **globals** — lê `_elementor_page_settings` do kit activo (`elementor_active_kit`),
indexa `system_colors`+`custom_colors` como `global_color` e
`system_typography`+`custom_typography` como `global_font`; conteúdo inclui o valor
(cor hex / nome da fonte) para que a pesquisa por valor também funcione.
**`page_document()`** — reutiliza directamente os helpers puros do Page Snapshot (§3):
`EMCP_Tools_Page_Snapshot::normalize_tree()` (para os tipos de widget usados, normalizados
`-`/`_`→espaço), `content_stats()` (para o texto dos headings), `extract_tokens()` (ids de
cores/classes globais em uso) — mais uma recolha recursiva de `label`s de elementos
(`collect_labels()`). **Ponto de arquitectura a reter para a réplica:** o índice de pesquisa
não tem a sua própria lógica de leitura de árvore Elementor — delega inteiramente à camada
Page Snapshot, evitando duplicar o parsing de settings Elementor em dois sítios.
### 2.3 `EMCP_Tools_Search_Ranker` — TF-IDF léxico puro
`includes/class-search-ranker.php`. **Zero dependência de WordPress** excepto um
`apply_filters()` opcional guardado por `function_exists()` — pode correr em testes
unitários puros sem framework nenhum, exactamente como o header documenta.
- `tokenize()` — lowercase, split por `[^a-z0-9]+`, descarta tokens <2 chars, descarta uma
stopword-list curada pequena (inglês; nada de PT-PT — relevante para uma réplica com
conteúdo em português, onde esta lista teria de ser adaptada ou substituída).
- `rank($docs,$query,$limit)` — TF-IDF campo-ponderado: `TITLE_BOOST=3.0` (ocorrências no
título contam 3x face ao corpo), IDF calculado por `log(1 + (n-df+0.5)/(df+0.5))` (fórmula
tipo BM25 mas sem o factor de saturação de frequência de termo completo — é uma
aproximação simplificada, não BM25 puro), score final `sum((tf/(tf+1)) * idf)` por termo
da query presente no documento.
- **Seam explícito para reranking futuro:** `apply_filters('emcp_tools_search_rerank',
$scored, $query)` corre **depois** da ordenação léxica e **antes** do `array_slice` ao
limite — comentário no código di-lo directamente: "This is the seam for an
embedding-backed reranker (a future Pro upgrade); the lexical order is the default."
- `snippet()` — extrai ~120 chars à volta da primeira ocorrência do primeiro termo da query
encontrado (não do termo com melhor score — é posicional, não semântico).
---
## 3. Page Snapshot
**Classe de abilities:** `EMCP_Tools_Snapshot_Abilities` (`includes/abilities/class-snapshot-abilities.php`)
— sempre registada (linhas 176-179 do registrar: `// Page Snapshot — always-on normalized
page digest (read foundation).`), recebe `EMCP_Tools_Data` injectado no construtor (dependência
partilhada com quase todo o resto do plugin, doc 01).
### 3.1 Tool
| Tool | Input schema (resumo) | O que faz |
|---|---|---|
| `get-page-snapshot` | `post_id*:int`, `include?:array<enum performance,a11y,seo>`, `sections?:array<enum post,structure,tokens,responsive,content,seo_lite,warnings>`, `fresh?:bool`. `required:[post_id]` | Devolve **um** digest normalizado da página em vez de forçar o agente a encadear `get-page-structure`+`get-global-settings`+`list-global-classes`+etc. |
`permission_callback` = `check_read_permission()` → `edit_posts`. Também sem bloco `meta`
explícito (mesmo padrão de omissão que o Search, §2.1).
**`execute()`** detecta o builder da página com `detect_builder()` (estático, público):
`is_elementor` (via `_elementor_edit_mode=builder` postmeta) → `'elementor'`; senão
`has_blocks($content)` → `'gutenberg'`; senão `'classic'`. Este valor entra no objecto
`post` devolvido e é passado ao builder (§3.2).
### 3.2 `EMCP_Tools_Page_Snapshot` — o builder
`includes/class-page-snapshot.php`. Duas listas de secções:
- **`CORE_SECTIONS`** (`post, structure, tokens, responsive, content, seo_lite, warnings`) —
sempre computadas em processo, baratas, puras (nenhuma chamada de rede/loopback).
- **`HEAVY_SECTIONS`** (`performance, a11y, seo`) — **opt-in** via `include`, cacheadas em
transient 15 min (`emcp_snap_{post_id}_{section}`), bypass com `fresh:true`.
**Achado — a árvore só é lida quando `builder==='elementor'`:** `build()` só chama
`$this->data->get_page_data($post_id)` quando `$args['builder']==='elementor'`; para
`gutenberg`/`classic` `$elements` fica `array()` vazio, e por isso `structure`, `tokens`,
`responsive` e a maior parte de `content` saem **essencialmente vazios** para páginas não-
Elementor, apesar de `detect_builder()` os identificar correctamente. `get-page-snapshot` é,
na prática, uma ferramenta **Elementor-first**; uma implementação equivalente para Gutenberg
exigiria o seu próprio tree-walker sobre `parse_blocks()` — não existe neste build.
**Secção `performance`** (única heavy section livre no Free): exige `manage_options`
(verificação extra dentro do próprio builder, independente do `permission_callback` da
ability — defesa em profundidade), delega a `EMCP_Tools_Performance_Analyzer` (doc 07),
resultado achatado a `{available, score, grade, recommendations[≤5]}`.
**Secções `a11y`/`seo`** (Pro): resolvidas via o filtro `emcp_tools_page_snapshot_sections`
— quando nada responde ao filtro (build Free), degradam para
`{available:false, pro_gated:true}`. **Padrão de "seam" reutilizável numa réplica:** o
core Free nunca sabe o que o Pro faz, só declara a forma do buraco (`available`/`pro_gated`)
e deixa o filtro preenchê-lo se existir um overlay activo.
**`seo_lite()`** — leitura gratuita e superficial de SEO: conta H1 a partir de `content`,
lê chaves de postmeta conhecidas de Yoast/Rank Math/SEOPress em cascata (primeira não-vazia
ganha) para `meta_title`/`meta_description`/`canonical`/`og_image`. Filtro
`emcp_tools_page_snapshot_seo_lite` permite a um plugin que **não** guarda SEO em postmeta
(ex.: All in One SEO, tabela própria) injectar os valores correctos — mesmo padrão de seam
usado noutros pontos do plugin.
**Helpers puros de árvore (reutilizados por §2 e potencialmente por qualquer outra tool que
precise de "entender" uma árvore Elementor sem reescrever o parsing):**
- `normalize_tree()` — recursivo, produz `{tree, counts}` com `containers`, `widgets`,
`by_widget_type`, `max_depth`, `total_elements`; cada nó da árvore ganha `label` derivado
de `element_label()`.
- `element_label()` — primeiro campo não-vazio de
`_title|title|text|editor|heading_title` nas settings, tags HTML removidas, cortado a 60
chars (`snippet()`).
- `extract_tokens()` — cores/tipografia globais referenciadas via `__globals__` (regex
`globals/colors?id=…` / `globals/typography?id=…`), classes `g-` (de `_css_classes`/
`classes` string OU `classes.value` array — dois formatos coexistentes, clássico vs
atómico), fontes/cores hex "em uso" (heurística: chave de settings contém `font_family` ou
`color` + valor casa `#hex`).
- `detect_responsive()` — regex `_(tablet|mobile|laptop|widescreen|mobile_extra|
tablet_extra)$` sobre as chaves de settings de cada nó.
- `content_stats()` — outline de headings, contagem de palavras, imagens/links/botões,
imagens sem alt. **Cobre tanto widgets clássicos como atómicos (Elementor 4.0+)** — os
ramos atómicos (`e-heading`/`e-paragraph`/`e-button`/`e-image`) foram adicionados
explicitamente com o comentário: *"Atomic (Elementor 4.0) widgets store their content as
$$type-wrapped props under different keys than classic widgets, so the classic branches
above miss them entirely (**issue #91**). Handle them here."* — um bug real, corrigido,
citado no código; qualquer réplica que suporte Elementor 4.0 tem de replicar este
desdobramento clássico+atómico em paralelo, não assumir que um cobre o outro.
- `warnings()` — 4 cheiros estruturais: `no_h1`, `multiple_h1`, `deep_nesting` (depth≥6),
`empty_container` (recursivo `has_empty_container()`).
---
## 4. Change-Ledger / Rollback (Transacções "AI-safe")
Este é, tal como assinalado no pedido, **o subsistema mais valioso a replicar bem** — é a
rede de segurança de **todas** as escritas do plugin (Elementor, filesystem, BD directa,
posts/CPT, settings, redirects, utilizadores, ACF, media). Quatro classes cooperam:
```
EMCP_Tools_Transaction_Abilities → as 3 tools MCP (list-changes/get-change/rollback-change)
EMCP_Tools_Change_Log → o ledger em si (option capado) + o DISPATCHER de rollback
EMCP_Tools_Change_Recorder → a FACHADA que cada write-site chama para gravar "antes"
EMCP_Tools_Change_Blobs → tabela SQL para before-images grandes fora do option
```
### 4.1 `EMCP_Tools_Transaction_Abilities` — as 3 tools
`includes/abilities/class-transaction-abilities.php`. Sempre registada (linhas 181-184 do
registrar: `// AI-safe transactions — change ledger + rollback (always-on, write
foundation).`). `permission_callback` para as 3 = `check_manage()` →
`current_user_can('manage_options')`, com o comentário explícito no código: *"the ledger
spans admin-grade fs/db targets"* — é o único dos 4 subsistemas deste documento (à parte o
Redirect Manager) que exige `manage_options` mesmo para leitura.
| Tool | Input schema (resumo) | O que faz |
|---|---|---|
| `list-changes` | `domain?:enum[elementor,filesystem,database]`, `rolled_back?:bool`, `reversible?:bool`, `limit?:int` (def 50) | Lista entradas mais recentes primeiro (`array_reverse`), cada uma com `id, ts, user_login, domain, action, target, summary, rolled_back, reversible, rollback` (o `rollback` devolvido é uma versão "leve" — ver `light_rollback()` abaixo). `reversible` é **derivado**: `!empty(rollback) && empty(rolled_back)`. |
| `get-change` | `id*:string`. `required:[id]` | Devolve a entrada **completa** (incluindo o `rollback` ref inteiro, sem strip). |
| `rollback-change` | `id*:string`, `force?:bool` (def false) | Desfaz a entrada. `force` salta o "conflict guard" (§4.3). |
**`light_rollback()`** — usada só por `list-changes` (não por `get-change`): remove
`before_rows`/`before`/`inserted_key` do ref de rollback antes de o incluir na resposta —
evita que uma listagem de 50 entradas arraste payloads pesados (mesmo já offloadados para
blob, o `blob_id` sozinho é leve, mas antes de existirem blobs os `before`/`before_rows`
inline podiam ser grandes). **Nota:** o enum declarado no `input_schema` de `domain`
(`elementor|filesystem|database`) é **mais estreito** do que os domínios realmente gravados
pelo Recorder (`content, settings, redirect, users, acf, media` também existem — ver §4.2) —
`execute_list()` faz uma comparação de string simples (`$e['domain']!==$domain`), não valida
contra o enum, logo um cliente que ignore o schema declarado e passe `domain:"redirect"`
**funciona na mesma**. Inconsistência schema-vs-implementação a corrigir numa réplica (alargar
o enum para cobrir todos os domínios reais, ou documentar que o filtro aceita qualquer
string).
### 4.2 `EMCP_Tools_Change_Log` — o ledger + o dispatcher de rollback
`includes/class-change-log.php`. **O ledger em si não tem tabela SQL própria** — é
`get_option('emcp_tools_changelog', [])`, um array PHP serializado, sem versionamento de
schema (não precisa: é só um array de linhas leves). `MAX_COUNT=500`, `MAX_BYTES=2097152`
(~2 MB) — `cap()` primeiro corta por contagem (`array_slice` às 500 mais recentes), depois
itera a apagar a mais antiga (`array_shift`) enquanto o JSON codificado continuar acima do
limite de bytes. **Toda linha descartada por `cap()`/`delete()`/`clear()` passa por
`forget_blobs()`**, que apaga o `blob_id` referenciado em `EMCP_Tools_Change_Blobs` — sem
isto, o offload de before-images pesados (§4.4) acumularia blobs órfãos indefinidamente.
**A flag de supressão — o mecanismo central que evita recursão:**
```php
public static $suppress = false; // estática, pública
```
Quando `true`, `record()` é um no-op imediato (`return ''`). `rollback()` liga-a
(`self::$suppress = true`) **antes** de chamar `apply_rollback($rb)` e desliga-a num
`finally` **antes** de (a) marcar a entrada como `rolled_back` e (b) gravar a entrada
compensatória. **Consequência de design:** qualquer escrita que o próprio `apply_rollback()`
provoque (ex.: `rollback_elementor()` chama `EMCP_Tools_Data::save_page_data()`, que
internamente também grava no ledger via o Recorder) fica **suprimida** — não gera uma
entrada duplicada. Mas a **entrada compensatória do próprio rollback** é gravada
DEPOIS do `finally` reactivar `$suppress=false`, logo essa **é** gravada normalmente. O
resultado líquido: um `rollback-change` produz exactamente **uma** nova entrada no ledger
(`action:'rollback'`), nunca duas nem zero. **Este é o padrão exacto a copiar numa réplica**
— uma flag estática global de supressão, ligada só durante a aplicação do efeito colateral
do próprio rollback, desligada antes do housekeeping final do próprio rollback.
**`get()`** é O(n) linear sobre `all()` — sem índice, aceitável até 500 entradas mas não
escalaria numa réplica com um teto de retenção maior sem passar a tabela SQL indexada.
### 4.3 O "conflict guard" — `detect_conflict()`
Antes de aplicar um rollback (salvo `force:true`), compara `rb['after_hash']` (gravado no
momento da escrita original, §4.4) contra `current_hash($rb)` (recalculado **agora**, no
momento do rollback). Se diferentes → `WP_Error('conflict', …)`, obrigando o chamador a
decidir explicitamente sobre-escrever com `force:true`.
**`current_hash()` só sabe recalcular 5 dos 15 tipos de rollback:**
```
elementor-data → hash_elementor(post_id)
file-backup/file-create → hash_file(target_path)
option → hash_options(option_keys) OU hash_option(option) [legacy single-key]
post-fields → hash_post(post_id)
meta-before-image → hash_meta(object, id, meta_keys)
default (tudo o resto) → '' → tratado como "não é possível recalcular, não bloquear"
```
**Consequência directa e não-óbvia:** rollbacks dos tipos `db-before-image`,
`post-create`, `post-restore`, `attachment-delete`, `user-create`, `user-fields`,
`acf-fields`, `redirect-row` **nunca disparam o conflict guard** — avançam sempre como se
`force:true` estivesse implícito, porque `record_db()`/`record_post_create()`/etc. nunca
gravam um `after_hash` correspondente (confirmado por leitura de `class-change-recorder.php`
— nenhuma dessas chamadas `record_*()` inclui `rb['after_hash']=…`). O único freio para
esses tipos são as verificações **internas** de cada `rollback_*()` (ex.: `user-create`
recusa apagar um utilizador que entretanto ganhou `manage_options`; `post-create` devolve
`true` silenciosamente se o post já não existir). **Numa réplica que queira o guard
uniforme**, seria preciso estender `hash_*()`+`current_hash()` para cobrir também DB rows
(hash das colunas-chave), criação/eliminação de posts/utilizadores/anexos, etc. — hoje é uma
protecção parcial, não total, apesar do nome "AI-safe transactions" sugerir cobertura
completa.
### 4.4 `EMCP_Tools_Change_Recorder` — a fachada de gravação ("o que gravar, quando")
`includes/class-change-recorder.php`. **Contrato universal:** cada write-site (uma ability
de escrita, fora do âmbito destes ficheiros) é responsável por **capturar o estado "antes"
ele próprio, antes de efectuar a mutação**, e passar esse "antes" a um dos métodos
`record_*()` do Recorder. O Recorder **não lê o estado anterior por iniciativa própria** —
excepto os dois helpers de snapshot explícitos (`snapshot_attachment()`/`snapshot_post()`),
documentados com o aviso literal *"MUST be called BEFORE wp_delete_attachment"* /
implícito para `wp_delete_post` — ou seja, o padrão é sempre: **1) o chamador lê/captura o
antes (directamente ou via `snapshot_*()`), 2) o chamador efectua a mutação, 3) o chamador
chama `record_*()` com o antes capturado.**
**Os 13 métodos `record_*()` e o que cada um espera como "antes":**
| Método | Domain/action gravado | "Antes" esperado do chamador | Estampa `after_hash`? |
|---|---|---|---|
| `record_elementor(post_id, before_tree, summary, target)` | `elementor` / `page-edit` | árvore `_elementor_data` anterior completa | sim, `hash_elementor()` |
| `record_db(entry)` | livre (o chamador constrói a entrada inteira; só `rollback.before_rows` é tratado especialmente) | `entry['rollback']['before_rows']` (linhas SQL antes) | **não** |
| `record_file(entry, written_abs)` | livre (chamador constrói) | ref `file-backup`/`file-create` já montado pelo chamador | sim, `hash_file($written_abs)` |
| `record_post_fields(post_id, before, summary, target, domain='content', action='update-post')` | configurável | `{fields, meta, terms}` parcial (só o que a escrita pode mudar) | sim, `hash_post()` |
| `record_post_create(post_id, summary, target)` | `content` / `create-post` | nada (undo = apagar o post criado) | não |
| `record_post_delete(post_id, snapshot, forced, summary, target)` | `content` / `delete-post` | `snapshot_post()` **só se `$forced`** (trash usa `mode:'untrash'`, sem snapshot) | não |
| `record_options(before_map, summary, target, domain='settings', action='update-settings')` | configurável | `{option => valor anterior \| '__ABSENT__'}` | sim, `hash_options()` |
| `record_redirect(action, before, summary, target)` | `redirect` / create\|update\|delete | `{id}` (create) ou `{row:{...}}` (update/delete) — delega ao applier do Store (§1.3) | não |
| `record_meta(object, id, before_map, summary, target, domain='content', action='update')` | configurável | `{meta_key => valor anterior}` (post OU term) | sim, `hash_meta()` |
| `record_user_create(user_id, summary, target)` | `users` / `create-user` | nada (undo = apagar utilizador) | não |
| `record_user_fields(user_id, before, summary, target)` | `users` / `update-user` | `{campo wp_update_user => valor anterior}` | não |
| `record_acf_fields(acf_target, before, summary, target)` | `acf` / `update-fields` | `{field_key => valor bruto anterior}` | não |
| `record_attachment_delete(snapshot, att_id, summary, target)` | `media` / `delete-media` | `snapshot_attachment($att_id)` (chamado **antes** de `wp_delete_attachment`) | não |
**`attach_before($rb, $heavy)`** — decide inline-vs-blob por **tamanho do JSON codificado**:
se `strlen(wp_json_encode($heavy)) > BLOB_THRESHOLD` (4096 bytes) **e** a classe de blobs
existe, offload para `EMCP_Tools_Change_Blobs::put($heavy)` e o `rb` guarda só `blob_id`;
senão, faz `array_merge($rb, $heavy)` inline. Chamado por `record_elementor`, `record_db`,
`record_post_fields`, `record_post_delete` (modo `forced`), `record_options`,
`record_meta`, `record_user_fields`, `record_acf_fields`, `record_attachment_delete` — ou
seja, **quase todos** os tipos que carregam um "antes" estruturado; os que não têm "antes"
(criações) não precisam deste passo.
**A flag `partial`** (mencionada nos outputs de `rollback-change`) **não é computada pelo
Recorder nem pelo Change_Log** — é um campo que o **chamador de `record_db()`** deve
definir ele próprio dentro do `rollback` que constrói, quando limita quantas `before_rows`
capturou (ex.: um `update-rows`/`delete-rows` que tope a captura a N linhas por segurança de
memória). O Recorder e o Log limitam-se a propagá-lo verbatim até ao output de
`rollback-change`. **Contrato implícito para qualquer nova write-tool numa réplica:** se
limitares as linhas "antes" capturadas, marca `rollback.partial=true` tu próprio.
### 4.5 O dispatcher de rollback — `apply_rollback()`, 15 tipos
`EMCP_Tools_Change_Log::apply_rollback($rb)`. **Primeiro**, resolve um `blob_id` se
existir (`EMCP_Tools_Change_Blobs::get()`, funde no `$rb` — devolve `WP_Error('blob_missing')`
se o blob já não existir, ex. por ter sido varrido por `prune_before()`), **depois** despacha
por `$rb['type']`:
| `type` | Applier | Lógica de reversão | Onde vive |
|---|---|---|---|
| `elementor-data` | `rollback_elementor()` | Regrava a árvore anterior via `EMCP_Tools_Data::save_page_data()` | Change_Log |
| `file-backup` | `rollback_file_restore()` | `copy(backup, target)`, confinado a ABSPATH via `EMCP_Tools_Filesystem_Guard::resolve_path()` (doc 07) | Change_Log |
| `file-create` | `rollback_file_delete()` | `unlink()` do ficheiro criado, confinado a ABSPATH; já-ausente devolve `true` silenciosamente | Change_Log |
| `db-before-image` | `rollback_db()` | `update`: **recusa se `key_cols` vazio** (evita `$wpdb->update()` sem WHERE, que tocaria todas as linhas — regra de segurança dura, não contornável mesmo com `force`); `delete`: reinsere cada `before_rows`; `insert`: apaga por `inserted_key` | Change_Log |
| `meta-before-image` | `rollback_meta()` | post OU term meta; valor vazio (`''`/`[]`/`null`) → apaga a chave, senão actualiza | Change_Log |
| `post-fields` | `rollback_post_fields()` | Restaura `fields` (wp_update_post), `meta` (sentinela `'__DELETE__'` apaga a chave), `terms` (`wp_set_object_terms`, `append=false`) | Change_Log |
| `post-create` | `rollback_post_create()` | `wp_delete_post($id, true)` — force delete; já-ausente devolve `true` | Change_Log |
| `post-restore` | `rollback_post_restore()` | `mode:'untrash'` → `wp_untrash_post()`; `mode:'reinsert'` → reinsere do snapshot completo (post+meta+terms), **reaproveita o `id` original se estiver livre** (`import_id`) | Change_Log |
| `option` | `rollback_option()` | Por nome: `'__ABSENT__'` → `delete_option()`, senão `update_option()` | Change_Log |
| `attachment-delete` | `rollback_attachment_delete()` | Reinsere o post do anexo, restaura toda a meta (`add_post_meta` por valor, não substitui), copia os ficheiros da "lixeira" (`emcp-originals/trash/{att_id}/`) de volta aos caminhos originais | Change_Log |
| `user-create` | `rollback_user_create()` | `wp_delete_user()` — **recusa se o utilizador entretanto ganhou `manage_options`** (`rollback_refused`, não `rollback_failed` — código de erro distinto para "recusado por segurança" vs "falhou tecnicamente") | Change_Log |
| `user-fields` | `rollback_user_fields()` | `wp_update_user(['ID'=>id, ...before])` | Change_Log |
| `acf-fields` | `rollback_acf_fields()` | `update_field($field_key, $value, $target)` por campo — reversão correcta de campos simples E complexos porque passa pela API do ACF, não escreve postmeta bruto | Change_Log |
| `redirect-row` | delega a `EMCP_Tools_Redirect_Store::rollback($rb)` | Ver §1.3 | **Redirect_Store** (não Change_Log!) |
| *(default)* | — | `WP_Error('unknown_rollback')` | Change_Log |
**Ponto de arquitectura chave para a réplica:** 14 dos 15 appliers vivem centralizados em
`Change_Log`, mas o `redirect-row` delega para a classe de domínio (`Redirect_Store`). É a
**única** excepção — mostra que o dispatcher central é desenhado para permitir extensão por
delegação: um novo domínio (numa réplica, ex. um domínio "SEO" ou "menu") pode manter o seu
próprio applier de rollback junto do resto da sua lógica de domínio, e o `apply_rollback()`
central só precisa de um `case` de uma linha a delegar, sem ter de concentrar toda a lógica
num único ficheiro gigante. **Recomenda-se replicar este padrão de delegação por omissão**,
não a centralização usada nos outros 14 casos (que provavelmente só não foram refactorizados
por serem código mais antigo, escritos antes do padrão de delegação ter emergido).
---
## 5. Content Mirror (export/restore git-friendly)
**Classe de abilities:** `EMCP_Tools_Content_Mirror_Abilities`
(`includes/abilities/class-content-mirror-abilities.php`) — sempre registada (linhas 191-194
do registrar: `// Content mirror — export/restore page content as git-trackable files
(always-on).`). `permission_callback` = `check_permission()` → `edit_posts` (mais permissivo
que o ledger, alinhado com Search/Snapshot).
**Relação com o ledger** (do header do ficheiro `class-content-mirror.php`): *"Complements
AI-safe transactions: transactions are an in-DB recent-change ledger + rollback; the mirror
is durable, diffable, file-based history."* — são **mecanismos paralelos e independentes**,
não um substituto do outro: o ledger cobre "a última hora de escritas, reversível ao nível
da linha"; o mirror cobre "snapshot completo e legível em qualquer altura, feito para diff em
git". **O plugin nunca corre `git` a si próprio** — só escreve ficheiros; cabe ao utilizador
(ou a um CI) fazer `git add`/`commit`.
### 5.1 Tools
| Tool | Input schema (resumo) | O que faz |
|---|---|---|
| `export-content` | `post_id?:int` (omitido = exporta todos) | Exporta um post/template Elementor, ou todos, para JSON em disco. |
| `restore-content` | `post_id*:int`. `required:[post_id]` | Regrava o `_elementor_data` do post a partir do ficheiro mirror existente (undo baseado em ficheiro). |
| `list-content-exports` | `{}` (sem propriedades) | Lista os ficheiros de mirror em disco: `{file, id, type, title, exported_at}`. |
### 5.2 `EMCP_Tools_Content_Mirror` — storage em disco
`includes/class-content-mirror.php`. **Sem tabela SQL nem custom post type** — armazenamento
puro em ficheiro, sob `wp-content/uploads/emcp-content-mirror/` (`MIRROR_DIR` +
`wp_upload_dir()['basedir']`). Nome de ficheiro determinístico e legível:
`{type}-{id}-{slug-sanitizado}.json` (`type` = `template` se `post_type===elementor_library`,
senão `page`; slug passado por `preg_replace('/[^A-Za-z0-9]+/','-', …)` + trim de hífens).
`build_export()` (função pura): `{id, type, slug, title, elementor_data, exported_at}`.
`export_post()`: obtém `elementor_data` via `EMCP_Tools_Data::get_page_data()` (try/catch —
falha silenciosamente para `array()` se a leitura Elementor rebentar), grava com
`JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` — **formatação
deliberadamente amigável a diff de git**, não a formatação compacta que outras partes do
plugin usam para `wp_json_encode()` de opções/BD. No primeiro export de sempre, cria também
um `README.txt` explicativo no directório, e o comentário no código é explícito: *"Do NOT
ignore json — this dir is meant to be committed."*
`restore_post()`: lê o JSON, valida `elementor_data` presente e é array, chama
`EMCP_Tools_Data::save_page_data()` — a mesma chamada usada por `rollback_elementor()` no
ledger (§4.5). **Consequência prática:** um `restore-content` gera, ele próprio, uma nova
entrada no ledger (via o Recorder chamado dentro de `save_page_data()`, fora do âmbito destes
ficheiros mas implícito pela partilha do mesmo método de escrita) — os dois subsistemas
interoperam: pode fazer-se `restore-content` e depois, se o resultado não agradar,
`rollback-change` a esse mesmo restore através do ledger.
`export_all()`: varre **só posts `publish`** (`page`/`post` com `_elementor_edit_mode=builder`)
+ **só templates `publish`** — mais restritivo que `export_post()` (que não impõe status
quando o `post_id` é explícito) e mais restritivo que o `rebuild()` do Search Index (§2.2,
que indexa `draft/pending/private/future` também). Três políticas de "que status contam"
diferentes dentro do mesmo plugin, cada uma justificável pelo seu propósito (indexar
rascunhos ajuda a pesquisa; espelhar só o publicado evita ruído no histórico git de
conteúdo ainda não decidido) — mas vale a pena decidir isto **conscientemente** numa réplica,
não por acidente de cópia de código.
**Auto-export opt-in:** `init()` liga `save_post:40` + `before_delete_post:10`, mas
`on_save_post()`/`on_delete_post()` só actuam quando `enabled()` →
`get_option('emcp_tools_content_mirror_enabled')==='1'` — **desligado por omissão** (a UI de
admin tem o toggle em "EMCP Tools → Tools", fora do âmbito destes ficheiros). O `on_delete_post`
corre em `before_delete_post` (não `deleted_post`) porque só precisa do `post_type`/`post_name`
para calcular o nome do ficheiro a apagar — não precisa que o post já tenha desaparecido da
BD.
---
## 6. `EMCP_Tools_Url_Guard` — serviço SSRF partilhado (fora do agrupamento temático)
`includes/class-url-guard.php`. Não pertence a nenhum dos 4 subsistemas acima — documentado
aqui só porque estava na lista de ficheiros desta tarefa. Duas camadas de validação
distintas, adicionadas em versões diferentes:
**Camada 1 — `is_safe_remote_url()` + `safe_download()`** (desde 1.9.1, usada pelo sideload de
imagem/SVG, doc 09): valida esquema http(s), `wp_http_validate_url()` (bloqueia a maioria dos
ranges RFC1918/loopback), depois **complementa** com `filter_var($ip,
FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE)` sobre o resultado de
`gethostbyname()` — o comentário no código é explícito sobre a lacuna que isto tapa:
*"`wp_http_validate_url()` rejects most private RFC1918 ranges, loopback, and non-80/443/8080
ports — but NOT the link-local 169.254.0.0/16 range (which includes the cloud-metadata
endpoint 169.254.169.254), and it does not cover IPv6 internal addresses."* `safe_download()`
adiciona `reject_unsafe_urls:true` + capa `redirection` a no máximo 2 saltos via um filtro
`http_request_args` temporário (adicionado e removido à volta da chamada), para que
`download_url()` (que por si só **não** valida saltos de redirect) revalide cada hop.
**Camada 2 — `validate()` + `ip_is_blocked()`** (desde 3.2.0, para uma tool `web_fetch` de AI
Chat, Pro, fora deste build): gate mais estrito, com resolver **injectável**
(`?callable $resolver`) — desenhado explicitamente para ser 100% testável sem rede real. Além
de bloquear ranges privados/loopback/link-local (IPv4 **e** IPv6, incluindo o caso
IPv4-mapped-em-IPv6 `::ffff:127.0.0.1`, desembrulhado via `inet_pton`), bloqueia também
**credenciais na URL** (`user:pass@host`) e restringe a **só as portas 80/443**
(`ALLOWED_PORTS`). Resolve **A e AAAA** (não só o primeiro A record, ao contrário da Camada
1) e exige que **todos** os IPs resolvidos sejam públicos — comentário explícito: *"a host
publishing one public and one internal record must not slip through."* **Falha fechado**:
qualquer IP não parseável em `in_any_cidr()` é tratado como bloqueado.
**Limitação documentada e não resolvida (TOCTOU):** o comentário do método é directo sobre
isto: *"WordPress's HTTP API connects by hostname, so a TOCTOU window remains between this
check and the TCP connect (DNS rebinding). Re-validating every redirect hop and using a short
timeout narrow it; closing it entirely needs `CURLOPT_RESOLVE` pinning."* — ou seja, o autor
sabe que este guard não é 100% hermético contra DNS rebinding sofisticado, e diz exactamente
qual seria a correcção completa (`CURLOPT_RESOLVE`), sem a ter implementado.
---
## Blueprint para réplica
**Copiar quase 1:1 (o valor está no design, não no código específico):**
- **O mecanismo `$suppress` do ledger** (§4.2) — a peça mais elegante de todo este
subconjunto. Uma flag estática global, ligada só durante o efeito colateral do próprio
rollback, desligada antes de gravar a entrada compensatória. Sem isto, qualquer rollback
que reutilize a mesma via de escrita normal (ex. `save_page_data()`) criaria ruído
recursivo no ledger.
- **O padrão de offload para blob por tamanho** (`attach_before()`, §4.4) — decidir
inline-vs-out-of-band por `strlen(json_encode())` comparado a um threshold simples (4 KB)
é suficiente e evita over-engineering; não vale a pena um sistema de chunking mais
complexo para este caso de uso.
- **O padrão de delegação do `redirect-row` applier** (§4.5) — cada domínio novo deve poder
manter o seu próprio applier de rollback junto da sua lógica de domínio, com o dispatcher
central a delegar por um `case` de uma linha, em vez de forçar tudo para um ficheiro
central gigante (como aconteceu com os outros 14 tipos, provavelmente por acumulação
histórica mais do que por escolha deliberada).
- **A reutilização de `EMCP_Tools_Page_Snapshot`'s helpers puros pelo Search Index** (§2.2) —
nunca duplicar o parsing de árvore Elementor entre dois subsistemas que ambos precisam
dela; um só "tree walker" alimenta snapshot E indexação.
- **O padrão "seam" via `apply_filters()`** repetido em `emcp_tools_page_snapshot_sections`,
`emcp_tools_page_snapshot_seo_lite`, `emcp_tools_search_rerank` — free core declara a forma
do output e um valor por omissão sensato (`{available:false, pro_gated:true}` ou o
resultado léxico simples); um overlay Pro/plugin externo pode substituir sem o core
precisar de saber que ele existe.
- **A dupla cobertura clássico+atómico em `content_stats()`** (issue #91, §3.2) — qualquer
função que percorra árvores Elementor numa réplica com suporte a 4.0 tem de tratar
explicitamente os dois formatos de settings (`$$type`-wrapped vs directo), nunca assumir
que um cobre o outro.
**Simplificar:**
- **O ranking léxico (`Search_Ranker`)** pode começar mais simples do que este TF-IDF
aproximado — mesmo uma pontuação por contagem de termos com boost de título já cobriria
90% do valor para um MVP; a fórmula IDF tipo-BM25 aqui só compensa em corpora maiores do
que uma réplica inicial provavelmente terá. Manter o seam de rerank desde o dia 1, mesmo
que a v1 seja trivial.
- **A coluna `tokens` morta no Search Index** (§2.2) — não replicar; se se quiser um índice
mais eficiente do que retokenizar tudo a cada pesquisa, ir directo para `FULLTEXT` MySQL
sobre `content`/`title`, ou uma tabela invertida `(termo, object_type, object_id, tf)`
própria — não guardar tokens concatenados numa coluna que ninguém consulta.
- **O `conflict guard` parcial** (§4.3, só 5 de 15 tipos suportados) — decidir
deliberadamente se vale a pena estender a todos os tipos (mais seguro, mais trabalho) ou
manter parcial e documentar claramente ao utilizador que "conflito" só é detectado para
certos tipos de escrita.
**Deixar de fora / decidir explicitamente antes de copiar:**
- **`match_type` e `ignore_query` no Redirect Manager** (§1.1/§1.3) — campos "de intenção
futura" nunca lidos pelo matcher real. Ou implementar o comportamento prometido, ou não os
incluir no schema até o fazer.
- **As três políticas diferentes de "que status conta"** entre `Search_Index::rebuild()`
(todos os status), `Content_Mirror::export_all()` (só publish) e o `export_post()`
individual (qualquer status) — cada uma faz sentido isolada mas o conjunto não foi
desenhado como um todo coerente; numa réplica, escolher conscientemente por que motivo
cada subsistema difere.
- **A inconsistência de `meta.annotations` declarado** — Redirect Manager declara sempre
`meta` com anotações explícitas; Search/Snapshot/Transactions não declaram nada. Uma
réplica deve escolher **um** padrão e aplicá-lo a todas as abilities sem excepção (a
camada `emcp_tools_register_ability()` já documentada em `00-ARQUITECTURA.md` §5 seria o
sítio certo para impor isto por omissão, em vez de confiar em cada classe de abilities
lembrar-se de o declarar).
- **O enum de `domain` desalinhado com os domínios reais gravados** em `list-changes` (§4.1)
— corrigir antes de copiar, é um bug de schema trivial de evitar desde o início.
## Fonte
Leitura directa (19-08-2026) de:
`includes/abilities/class-redirect-abilities.php`,
`includes/redirects/class-redirect-handler.php`,
`includes/redirects/class-redirect-store.php`,
`includes/class-url-guard.php`,
`includes/abilities/class-search-abilities.php`,
`includes/class-search-index.php`,
`includes/class-search-ranker.php`,
`includes/abilities/class-snapshot-abilities.php`,
`includes/class-page-snapshot.php`,
`includes/abilities/class-transaction-abilities.php`,
`includes/class-change-log.php`,
`includes/class-change-recorder.php`,
`includes/class-change-blobs.php`,
`includes/abilities/class-content-mirror-abilities.php`,
`includes/class-content-mirror.php`.
Gating condition do Redirect Manager confirmada por grep directo a
`includes/abilities/class-ability-registrar.php` (linhas 161-195, incluindo os comentários
"always-on" para os outros 3 subsistemas). Contexto de arranque/contrato de registo herdado
de `00-ARQUITECTURA.md` e de `skill://emcp-tools` (não relidos linha a linha nesta tarefa,
usados só como pano de fundo já validado em sessões anteriores).
+336
View File
@@ -0,0 +1,336 @@
# 06 — Sandbox de PHP Snippets e Infraestrutura de Widgets/Blocos Custom
Este documento mapeia o subsistema "Sandbox" do EMCP Tools: a funcionalidade Free de PHP Snippets criados por agentes de IA com aprovação humana obrigatória, o contrato de export/import cross-artefacto (`EMCP_Tools_Sandbox_Artifact`), a infraestrutura de caminhos partilhada, e o Widget Builder (Pro) — cuja parte MCP está **ausente** desta build Free, deixando apenas a camada de armazenamento/admin acessível a partir do wp-admin. Documenta-se também `class-mcpb-builder.php`, cujo nome sugere parentesco com o Widget/Block Builder mas que é, na realidade, uma feature totalmente distinta (gerador de bundle Claude Desktop `.mcpb`).
---
## 1. Ability: `EMCP_Tools_PHP_Snippet_Abilities`
**Ficheiro:** `includes/abilities/class-php-snippet-abilities.php`
**Condição de registo** (`class-ability-registrar.php`, linhas ~416-421):
```php
// PHP Snippet abilities (Sandbox) — free, capability-gated, no Elementor.
if ( class_exists( 'EMCP_Tools_PHP_Snippet_Abilities' ) ) {
$php_snippets = new EMCP_Tools_PHP_Snippet_Abilities();
$php_snippets->register();
...
}
```
A classe existe **incondicionalmente** na build Free — não há gate de Pro nem de Elementor. O gate real é feito **por-tool** via `permission_callback`, apoiado em duas capabilities WordPress nativas: `manage_options` (leitura) e `manage_options` + `unfiltered_html` (escrita — as mesmas capabilities que já permitem a um utilizador editar código de plugin).
| Ability | Input (resumo) | O que faz | Permission callback | readonly / destructive |
|---|---|---|---|---|
| `validate-php-snippet` | `code` (string, obrigatório) | Verifica estaticamente o código SEM o guardar nem correr: confirma que faz *parse* e depois corre o scanner de segurança (execução de código, shell, escritas de ficheiro, rede, ofuscação, SQL destrutivo). Devolve relatório `{valid, safe, parse_error, findings[]}`. Pensado para iterar antes de `create-php-snippet`. | `check_read_permission` → `manage_options` | readonly=true, destructive=false, idempotent=true |
| `create-php-snippet` | `title?`, `code` (obrigatório), `context?` (enum `shortcode`\|`hook`\|`both`), `hook?`, `priority?` | Cria o snippet como **DRAFT INACTIVO**. Nunca corre. Valida primeiro; rejeita com `invalid_php` (erro de parse) ou `unsafe_php` (finding crítico) devolvendo o relatório de validação para o agente corrigir. | `check_edit_permission` → `manage_options` AND `unfiltered_html` | readonly=false, destructive=false, idempotent=false |
| `update-php-snippet` | `snippet_id` (obrigatório) + campos de `create-php-snippet` (parciais) | Actualiza código/config. Re-valida (mesmas rejeições). Se o snippet já estiver activo, **recompila** o executável em disco; se a recompilação falhar, **degrada automaticamente para draft**. A activação continua a exigir um passo humano separado. | `check_edit_permission` | readonly=false, destructive=false, idempotent=false |
| `get-php-snippet` | `snippet_id` (obrigatório) | Devolve o registo completo: código, status (draft/active), contexto de execução, shortcode gerado, e o último relatório de validação. | `check_read_permission` | readonly=true, idempotent=true |
| `list-php-snippets` | `status?` (enum `active`\|`draft`\|`any`) | Lista snippets com status, contexto e shortcode. Devolve `{count, snippets[]}`. | `check_read_permission` | readonly=true, idempotent=true |
| `delete-php-snippet` | `snippet_id` (obrigatório) | Apaga permanentemente o snippet (post CPT + ficheiro sandbox, se existir). | `check_edit_permission` | readonly=false, **destructive=true**, idempotent=false |
**Decisão de design central (citação directa do topo do ficheiro):** *"Lets an AI agent author, validate, read, and manage PHP snippets — but NEVER run them. There is intentionally no 'activate' tool: a snippet created via MCP is an inactive draft until a human administrator reviews it and activates it in the Sandbox admin screen."*
**Helper `normalize_write_result()`:** transforma um `WP_Error` de rejeição de validação (`invalid_php`/`unsafe_php`) numa resposta estruturada `{success:false, reason, validation}` — em vez de um erro opaco, o agente recebe o relatório de findings completo para poder corrigir o código e tentar de novo. Em sucesso, acrescenta sempre uma `note` a lembrar que o draft precisa de aprovação de um administrador.
---
## 2. Ability: `EMCP_Tools_Sandbox_Cloud_Abilities`
**Ficheiro:** `includes/abilities/class-sandbox-cloud-abilities.php`
**Condição de registo** (`class-ability-registrar.php`, linhas ~429-434):
```php
// Sandbox cloud export/import (free; operates over the bundle contract).
if ( class_exists( 'EMCP_Tools_Sandbox_Cloud_Abilities' ) ) {
$cloud = new EMCP_Tools_Sandbox_Cloud_Abilities();
$cloud->register();
...
}
```
Sempre registada na build Free (ao contrário do Widget Builder, esta classe **não** se autoguarda por Pro). O gate por-`kind` acontece dentro de `resolve_artifact()`, não na classe.
| Ability | Input (resumo) | O que faz | Permission callback | readonly / destructive |
|---|---|---|---|---|
| `export-sandbox-artifact` | `kind` (enum `block`\|`widget`\|`snippet`, obrigatório), `id` (int, obrigatório) | Exporta um artefacto de sandbox como bundle portátil, verificado por checksum, pronto para partilha/sincronização cloud. Devolve `{bundle: object}`. | `current_user_can('manage_options')` | readonly=true, destructive=false, idempotent=true |
| `import-sandbox-artifact` | `bundle` (object, obrigatório — produzido por `export-sandbox-artifact`) | Importa um bundle como um **novo draft local**. O bundle é validado (versão de schema, checksum) antes de qualquer escrita. Devolve `{id: int}`. | `current_user_can('manage_options')` | readonly=false, destructive=false, idempotent=false |
**Resolução de artefacto (`resolve_artifact($kind)`):**
```php
switch ( $kind ) {
case 'block': return class_exists('EMCP_Tools_Block_Store') ? EMCP_Tools_Block_Store::instance() : null; // Pro-only, ausente nesta build
case 'widget': return new EMCP_Tools_Widget_Bundle_Adapter(); // sempre disponível
case 'snippet': return new EMCP_Tools_Snippet_Bundle_Adapter(); // sempre disponível
default: return null;
}
```
Quando `resolve_artifact()` devolve `null` para um `kind` válido (`block`), a ability devolve um `WP_Error` `pro_required` em vez de fatal — **falha limpa**. Nota importante: mesmo com `kind=widget` resolúvel (o adapter existe sempre), a operação real de import ainda pode falhar internamente com `WP_Error('forbidden', ...)` dentro de `EMCP_Tools_Widget_Store::create()` se a licença Pro não estiver activa — o gate real vive na *store*, não no resolver.
---
## 3. Serviços de suporte
### 3.1 `EMCP_Tools_PHP_Snippet_Store` — `includes/class-php-snippet-store.php`
**Storage:** CPT privado `emcp_php_snippet` (`public=false`, `show_in_rest=false`, `capability_type=page`, `map_meta_cap=true`).
**Meta keys:** `_emcp_snippet_code` (código raw, `wp_slash()`-eado), `_emcp_snippet_context`, `_emcp_snippet_hook`, `_emcp_snippet_priority`, `_emcp_snippet_validation` (JSON do relatório), `_emcp_snippet_hash` (sha256 do ficheiro compilado), `_emcp_snippet_error`.
**Post status = flag de activação:** `publish` = activo, `draft` = inactivo. Este é o único mecanismo de "ligar/desligar" um snippet.
**Ficheiro em disco:** `wp-content/emcp-sandbox/snippets/{id}.php` — só existe enquanto o snippet está **activo**. Um draft não tem artefacto executável em disco. O ficheiro é gerado por `write_executable()`: o código é despido de tags PHP (`strip_tags()`), embrulhado numa função única (`emcp_php_snippet_{id}()`), e o resultado final passa por um `token_get_all(..., TOKEN_PARSE)` de segurança (guarda final antes de escrever).
**Manifest:** `wp-content/emcp-sandbox/snippets-manifest.json` — array de `{post_id, func, php_path, hash, context, hook, priority}` apenas dos snippets `publish`. Reconstruído (`rebuild_manifest()`) após qualquer create/update/set_status/delete/mark_error.
**Permissões:**
- `can_edit()`: `manage_options` AND `unfiltered_html`
- `can_read()`: `manage_options`
**Fluxo CRUD:**
- `create_draft()` — sempre cria em `draft`; valida primeiro e rejeita com `invalid_php`/`unsafe_php` (WP_Error com o relatório em `error_data['validation']`).
- `update()` — re-valida; se o snippet já estava activo, chama `write_executable()` de novo; se a escrita falhar, **degrada automaticamente para draft** e regista o erro.
- **`set_status('active'|'draft')`** — **é o portão de aprovação humana**. Nunca é chamado pelas MCP abilities (só pelo handler AJAX do admin). Ao activar: re-valida (bloqueia com `activation_blocked` se inválido/inseguro), escreve o executável, muda para `publish`. Ao desactivar: apaga o ficheiro, apaga o hash, muda para `draft`.
- `mark_error()` — chamado pelo *loader* quando um snippet crasha em runtime: desactiva automaticamente, regista o erro, reconstrói o manifest.
- `uninstall_cleanup()` — apaga todos os posts + o `manifest.json` no desinstalar do plugin.
### 3.2 `EMCP_Tools_PHP_Snippet_Loader` — `includes/class-php-snippet-loader.php`
Corre em `plugins_loaded`, regista o shortcode `[emcp_snippet id="N"]` e carrega os snippets activos.
**`load()` é manifest-only** (nunca faz scan de directório). Para cada entrada do manifest:
1. **Path containment**: `0 !== strpos(normalize(path), normalize(sandbox))` → salta (defende contra manifest envenenado).
2. **Tamper guard**: recalcula `hash('sha256', file_get_contents($path))` e compara com o hash registado → salta se não bater certo.
3. `include_once` do ficheiro — isto só *define* a função, **não executa** código do utilizador.
4. Se `context` for `hook`/`both`, faz `add_action($hook, closure, $priority)` que despoleta `run_on_hook()`.
**Execução:**
- `render_shortcode()` — só corre se `context` for `shortcode`/`both`; captura output via `ob_start()`; se a função devolver string/numérico, é concatenado ao output do buffer.
- `run_on_hook()` — corre a função directamente no hook (output vai inline, ex.: `wp_footer`).
**Isolamento de falhas** — camada dupla:
1. `try { ... } catch (\Throwable $e)` em cada execução.
2. `register_shutdown_function` como rede de segurança para os fatais que um `try/catch` não apanha (`E_ERROR`, `E_PARSE`, `E_CORE_ERROR`, `E_COMPILE_ERROR`, `E_USER_ERROR`) — se um snippet crasha (mesmo em erro de parse do próprio ficheiro incluído), `mark_error()` desactiva-o automaticamente para a request seguinte recuperar. Um snippet mau não consegue white-screenar o site de forma persistente.
### 3.3 `EMCP_Tools_PHP_Snippet_Validator` — `includes/class-php-snippet-validator.php` — **SECÇÃO CRÍTICA**
Duas camadas de validação:
**Camada 1 — PARSE.** O código é embrulhado exactamente como vai correr: `<?php function __emcp_snippet_validate() { CODE\n}` — e passa por `token_get_all($wrapped, TOKEN_PARSE)`. Um `\ParseError`/`\Throwable` aqui devolve `valid=false` com a mensagem de erro, sem sequer chegar ao scan de segurança.
**Camada 2 — SECURITY SCAN.** Percorre os tokens significativos (whitespace/comments removidos) e aplica regras por token e por vizinhança (prev/next).
#### Bloqueio CRÍTICO por nome de função (mapa `severity → reason`, bloqueia `create`/`activate`):
| Categoria | Funções |
|---|---|
| Execução de código arbitrário | `eval`, `assert`, `create_function` |
| Shell / processo | `exec`, `system`, `shell_exec`, `passthru`, `proc_open`, `popen`, `pcntl_exec`, `expect_popen` |
| Invocação dinâmica (bypassa este próprio check) | `call_user_func`, `call_user_func_array`, `forward_static_call`, `forward_static_call_array`, `func_get_args` |
| Escritas/apagamentos de ficheiro | `file_put_contents`, `fwrite`, `fputs`, `fputcsv`, `ftruncate`, `unlink`, `rmdir`, `rename`, `copy`, `mkdir`, `chmod`, `chown`, `chgrp`, `symlink`, `link`, `move_uploaded_file` |
| Rede | `curl_init`, `curl_exec`, `curl_setopt`, `fsockopen`, `pfsockopen`, `stream_socket_client`, `socket_create`, `socket_connect` |
| Decoders de ofuscação (sinal nº1 de malware) | `base64_decode`, `gzinflate`, `gzuncompress`, `gzdecode`, `str_rot13`, `convert_uudecode`, `hex2bin` |
| Runtime/ambiente | `dl`, `putenv`, `ini_set`, `ini_alter`, `apache_setenv`, `virtual`, `set_error_handler`, `register_shutdown_function`, `register_tick_function`, `extract` |
#### Bloqueio CRÍTICO por construto de linguagem (análise de tokens, não lista de nomes):
- **Backtick shell execution** — `` `...` ``
- **`include`/`include_once`/`require`/`require_once`** — bloqueia **sempre**, mesmo estático ("loads and runs another PHP file")
- **`T_EVAL`** — construto de linguagem `eval`, além da função
- **Chamada de função por variável** — `$var(...)` — "Calls a function named by a variable (bypasses static checks)"
- **Instanciação dinâmica** — `new $var` — classe escolhida em runtime
- **Reflection/Closure factories** — `new ReflectionFunction/ReflectionMethod/ReflectionClass/ReflectionObject/Closure`
- **Tag de fecho PHP embutida** — `?>` — bloqueia porque permitiria "escapar" do wrapper para output HTML cru
- **SQL destrutivo dentro de string literal** — regex `/\b(DROP|TRUNCATE|ALTER)\s+(TABLE|DATABASE)\b/i` ou `/\bDELETE\s+FROM\b/i`
#### Apenas AVISO (`warning`, não bloqueia; fica visível ao revisor humano na UI):
- Leitura de ficheiros: `fopen`, `file_get_contents`, `readfile`, `fread`, `fgets`, `scandir`, `glob`, `opendir`
- `define`, `header`, `setcookie`, `error_reporting`
- WordPress sensível: `update_option`, `delete_option`, `add_option`, `wp_mail`, `wp_delete_post`, `wp_delete_user`, `wp_insert_user`, `wp_update_user`, `switch_theme`, `activate_plugin`, `deactivate_plugins`, `do_action`
- **Callbacks dinâmicos** — o vector clássico de bypass da lista crítica acima (ex.: `array_map('system', $_GET['cmds'])` evitaria a checagem directa de `system`): `array_map`, `array_filter`, `array_walk`, `array_walk_recursive`, `array_reduce`, `usort`, `uasort`, `uksort`, `ob_start`, `preg_replace_callback`, `preg_replace_callback_array`, `set_exception_handler`, `iterator_apply`
- `die`/`exit` (`T_EXIT`)
- Variável variável (`$$x`)
- Supressão de erro (`@`)
- Superglobais: `$_GET`, `$_POST`, `$_REQUEST`, `$_FILES`, `$_COOKIE`, `$_SERVER`, `$_ENV`, `$GLOBALS`
- Definição de `function`/`class`/`trait`/`interface` dentro do snippet (risco de redeclaração fatal em re-execuções)
#### `strip_tags()`
Remove um único `<?php` (ou `<?=`/`<?`) inicial via regex, para aceitar código com ou sem tags de abertura — usado tanto na validação como na compilação final.
#### Postura de segurança declarada (citação directa do topo do ficheiro):
> *"IMPORTANT — this is a GUARDRAIL, not a guarantee. PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe. The real safety boundary is the capability gate (manage_options + unfiltered_html) plus the human approval step: an AI can create a DRAFT and run the validator, but only an admin can activate a snippet so it actually executes."*
Esta mensagem é reforçada literalmente na UI de administração (`admin/views/sandbox/snippets.php`): *"The validator blocks obviously dangerous code (...), but static analysis is a guardrail, not a guarantee, only activate code you have read and trust. Activation is the approval step; AI can only create inactive drafts."*
### 3.4 `EMCP_Tools_Sandbox_Paths` — `includes/sandbox/class-sandbox-paths.php`
Centraliza **todos** os caminhos de sandbox (usado por snippets, widgets, blocks Pro, theme-php).
- **Base dir:** `wp-content/emcp-sandbox` (nome filtrável via `emcp_tools_sandbox_folder`, caminho absoluto filtrável via `emcp_tools_sandbox_dir`, URL via `emcp_tools_sandbox_url`).
- **Localização legada:** `wp-content/uploads/emcp-widgets` — antes de a v3.7 introduzir a pasta única sob `wp-content/`, os artefactos viviam dispersos sob uploads.
- **`maybe_migrate()`** — migração automática one-time, corre no bootstrap (`plugins_loaded`), antes dos loaders (`init`). Tenta `rename()`; se falhar (device diferente), faz `copy_tree()` + `rmdir_tree()` como fallback. Guarda o flag em `option('emcp_tools_sandbox_location')` para nunca repetir. **Insight de design:** os manifests guardam caminhos *relativos* + hashes de conteúdo — por isso a migração nunca precisa de reconstruir um único manifest, todos os hashes continuam válidos contra a nova base.
- **`harden()`** — escreve um `index.php` de silêncio + um `.htaccess` que bloqueia execução directa de `.php` (`<FilesMatch "\.php$"> Require all denied`) mas continua a permitir servir `.css`/`.js` estáticos.
- **`guard_subdir()`** — garante `index.php` de silêncio em cada subpasta nova.
- **`relative_base()`** — caminho relativo a `ABSPATH`, usado pelo scanner de malware (ver `skill://emcp-tools`, já auditada) para **excluir** o próprio PHP sandboxado do plugin da verificação de malware.
### 3.5 `EMCP_Tools_Sandbox_Bundle` — `includes/sandbox/class-sandbox-bundle.php`
Envelope de portabilidade partilhado por todos os `kind`s (`block`, `widget`, `snippet`).
- `SCHEMA_VERSION = 1`, `KINDS = ['block', 'widget', 'snippet']`.
- **`build()`** — monta `{schema_version, kind, uuid, meta, spec, assets, version, updated_at, checksum}`.
- **`checksum()`** — `ksort($assets)` seguido de `sha256(wp_json_encode($assets))` — determinístico, prefixado `sha256:`.
- **`validate()`** — valida `schema_version` (1..`SCHEMA_VERSION`), `kind` conhecido, presença de todas as chaves obrigatórias, `assets` é array, e **recalcula o checksum e compara** — devolve `WP_Error('bundle_checksum', ...)` ("tampered or corrupt") se não bater certo.
### 3.6 `EMCP_Tools_Sandbox_Store` (abstract) — `includes/sandbox/class-sandbox-store.php`
Classe-base comum para stores tipo-artefacto, implementa `EMCP_Tools_Sandbox_Artifact` parcialmente.
- Meta keys partilhadas: `_emcp_uuid`, `_emcp_origin`, `_emcp_remote_id`, `_emcp_sync_state`, `_emcp_version`, `_emcp_updated_at`.
- `ensure_uuid()`/`uuid()` — gera UUID4 uma vez, persiste.
- `bump_version()` — incrementa versão, marca `sync_state='dirty'`, regista `updated_at`.
- `sync_meta()` — pacote de metadados de sincronização cloud.
- `artifact_dir()`/`artifact_url()` — caminho e URL por artefacto (útil para enfileirar assets próprios de um artefacto, ex. script de editor de um bloco, cujo URL o resolver `file:` de `block.json` do WordPress não consegue calcular para uma sandbox fora de um plugin/tema).
- Helpers I/O partilhados: `write_file`/`read_file`/`delete_file`/`rmdir_recursive` (com invalidação de opcache em escritas `.php`).
**Nota de arquitectura:** apesar de existir, esta classe abstract **não é usada pelo `EMCP_Tools_Widget_Store`** — que reimplementa manualmente os mesmos padrões (write_file/read_file/rmdir_recursive/sync-like meta). É consumida pelo Block Store (Pro, ausente desta build). A explicação plausível: o Widget Store é `@since 1.9.0` (anterior), esta classe `sandbox/` é claramente da geração `@since 3.7.0` dos adapters cloud — dívida técnica de evolução do produto, não um erro.
### 3.7 Adapters — `class-snippet-bundle-adapter.php` e `class-widget-bundle-adapter.php`
Ambos implementam `EMCP_Tools_Sandbox_Artifact` **sem tocar** no store subjacente — thin adapters puros.
| | `EMCP_Tools_Snippet_Bundle_Adapter` | `EMCP_Tools_Widget_Bundle_Adapter` |
|---|---|---|
| `assets()` | `{code.php: código raw NÃO compilado}` — deliberadamente **não** o executável envolvido em função (esse é maquinaria local do store, re-derivada em cada import) | `{widget.php, style.css?, script.js?}` — os ficheiros **gerados** (compilados) |
| `to_bundle()` | Monta via `EMCP_Tools_Sandbox_Bundle::build('snippet', ...)` | Monta via `EMCP_Tools_Sandbox_Bundle::build('widget', ...)`, usando `EMCP_Tools_Widget_Store::get_spec()` como `spec` |
| `apply_bundle()` | Chama `EMCP_Tools_PHP_Snippet_Store::create_draft()` — sempre novo draft, **nunca activa** | Chama `EMCP_Tools_Widget_Store::create($spec, false)` — `$active=false` explícito, mesmo princípio |
Ambos gravam `_emcp_uuid = bundle.uuid` e `_emcp_origin = 'imported'` no post recém-criado. **O portão de aprovação humana é preservado mesmo no fluxo de import** — importar um bundle nunca substitui a necessidade de um administrador activar o resultado.
### 3.8 `interface EMCP_Tools_Sandbox_Artifact` — `includes/sandbox/interface-sandbox-artifact.php`
Contrato mínimo, consumido por `EMCP_Tools_Sandbox_Cloud_Abilities::resolve_artifact()`:
```php
interface EMCP_Tools_Sandbox_Artifact {
public function kind(): string;
public function uuid( int $id ): string;
public function to_bundle( int $id ); // array|WP_Error
public function apply_bundle( array $bundle ); // int|WP_Error (novo id local)
public function checksum( int $id ): string;
public function sync_meta( int $id ): array;
}
```
### 3.9 `EMCP_Tools_Widget_Store` — `includes/class-widget-store.php` (839 linhas na tarefa)
**CONFIRMAÇÃO DIRECTA:** este ficheiro **existe** na build Free (lido na íntegra via SSH). O que **não** existe é `includes/abilities/class-widget-builder-abilities.php` — ausente da listagem de `includes/abilities/` neste servidor. O registrar só instancia condicionalmente:
```php
// Widget Builder (Pro; self-guards on license).
if ( class_exists( 'EMCP_Tools_Widget_Builder_Abilities' ) ) {
$widget_builder = new EMCP_Tools_Widget_Builder_Abilities();
$widget_builder->register();
...
}
```
Como o ficheiro dessa classe não existe, `class_exists()` é sempre `false` nesta build → **`create-custom-widget`, `create-custom-block` e afins NÃO estão registadas** como MCP abilities. "Self-guards on license" refere-se ao comportamento *se* o overlay Pro privado estivesse presente: a própria classe verificaria a licença dentro de si. Aqui nem chega a ser definida.
Também confirmado ausente: `includes/class-widget-generator.php` (o compilador spec→PHP) e `includes/class-block-store.php` — nenhum dos dois consta da listagem de `includes/` desta build. Isto confirma explicitamente a nota da própria `class-sandbox-cloud-abilities.php`: *"the 'block' kind is Pro-only via the resolver: `EMCP_Tools_Block_Store` is absent on free sites"*.
**Duas camadas de gate independentes para a funcionalidade real** (mesmo hipotetizando que a classe de abilities existisse):
1. **Gate de licença Freemius** (soft — a classe corre, devolve `false`): `user_has_access()` → `emcp_tools_fs()->can_use_premium_code() && current_user_can('manage_options')`.
2. **Gate de código ausente** (hard): `write_widget()` verifica `class_exists('EMCP_Tools_Widget_Generator')` — o compilador spec→PHP, ficheiro Pro-only ausente desta árvore. Se ausente, devolve `WP_Error('emcp_pro_required', 'Widget Builder requires EMCP Pro.')` mesmo que `user_has_access()` fosse `true`.
**O que a classe faz** (arquitectura, documentada mesmo sem poder correr nesta build):
- CPT privado `emcp_widget`. Meta: `_emcp_spec` (JSON regenerável, **fonte-de-verdade**), `_emcp_widget_name`, `_emcp_class_name`, `_emcp_php_hash`, `_emcp_css_hash`, `_emcp_js_hash`, `_emcp_last_error`.
- Post status = activação: `publish` = activo (carregado no Elementor), `draft` = inactivo.
- Storage: `wp-content/emcp-sandbox/widgets/{id}/widget.php` (+ `style.css`/`script.js` opcionais).
- **Nunca escreve no tema, no core ou noutros plugins** — sandbox isolada, tal como os snippets (declarado no cabeçalho do ficheiro).
- **`create(spec, active=true)`**: insere o post primeiro (para o ID poder semear nomes únicos de classe `EMCP_Widget_{id}`/widget `emcp_custom_{id}`), depois `write_widget()`, e se `$active` chama `safeguard_active()` **antes** de aparecer no manifest.
- **`write_widget()`**: chama `EMCP_Tools_Widget_Generator::generate($spec, $class_name, $widget_name, $handles)` — compila a spec estruturada em PHP. **O agente de IA nunca escreve PHP cru, só a spec JSON** — confirmado pelo texto da própria UI admin: *"these widgets are PHP compiled by this plugin from an AI-supplied spec (the AI never writes raw PHP). Output is escaped by control type."*
- **Defesa de injecção nos assets estáticos**: CSS/JS aceitam ficheiros completos opcionais, mas com `preg_replace('/<\?(?:php|=)?/i', '', $out)` — garante que um `.css`/`.js` gerado nunca pode ser executado como PHP mesmo com `short_open_tag` activo num servidor mal configurado.
- **`runtime_validate()` — safeguard notável**: instancia a classe gerada e chama `$instance->get_stack()` (força `init_controls()` → `register_controls()`, exactamente o que o editor Elementor faz), **antes** de deixar o widget ir para produção. Se rebentar, é apanhado aqui em vez de dar white-screen no painel do editor Elementor. Chamado tanto em `create()` como em `set_status('active')`.
- **`safeguard_active()`**: se a validação runtime falhar, **degrada automaticamente para draft** e regista o erro — nunca deixa algo quebrado ficar "activo".
- **`mark_error()`**: chamado pelo *loader* em runtime (paralelo ao snippet loader) quando um widget crasha após já ter sido carregado.
- Mesmo padrão manifest-only (`rebuild_manifest()`/`read_manifest()`) dos snippets.
- **`uninstall_cleanup()`**: apaga todos os posts e faz `rmdir_recursive(self::sandbox_dir())` — **a árvore sandbox inteira**, não só `widgets/`. Note-se que isto é diferente do `PHP_Snippet_Store::uninstall_cleanup()`, que só apaga os seus próprios ficheiros individuais — risco arquitectural a evitar numa réplica se vários subsistemas partilharem a mesma raiz de sandbox (aqui não causa dano porque o desinstalar do plugin apaga tudo de uma vez, mas é frágil).
### 3.10 `EMCP_Tools_Widget_Loader` — `includes/class-widget-loader.php` (bónus — runtime companion do Widget Store)
Não foi pedido explicitamente na lista de ficheiros, mas foi lido para fechar a compreensão do ciclo de vida completo do widget (paralelo directo ao `PHP_Snippet_Loader`).
- `has_access()`: mesmo gate Freemius do Store — *"The whole loader is Pro-gated: on a free/unlicensed site nothing is loaded"* (comentário do próprio autor).
- Regista categoria Elementor **"Custom (EMCP)"** (slug `emcp-custom`) só se `has_access()`.
- `register_widgets()`: manifest-only, tamper guard sha256, path-containment guard — padrão idêntico ao snippet loader.
- `register_assets()`: `wp_register_style`/`wp_register_script` por widget com handle `emcp-widget-{id}-style`/`-script`, **versionados pelo hash do ficheiro** (cache-busting automático em cada regeneração).
- Mesma rede de segurança de shutdown handler + isolamento de fatais que o snippet loader.
### 3.11 `EMCP_Tools_Mcpb_Builder` — `includes/admin/class-mcpb-builder.php` — **NÃO é "Widget/Block Builder"**
**Desambiguação explícita, confirmada por leitura directa do ficheiro:** apesar de o nome "MCP Builder" sugerir parentesco com o Widget/Block Builder, esta é uma feature **completamente distinta e não relacionada** com o sandbox de código custom. Constrói um bundle `.mcpb` (formato Claude Desktop Extension) que instala um **servidor MCP standalone** que faz proxy para o REST API do WordPress — é o mecanismo para ligar o Claude Desktop directamente a este site sem passar por um MCP host remoto.
- **`build_manifest()`**: gera `manifest.json` versão MCPB `0.3`. O `name` é **único por site**, derivado do host (`host_slug()`), porque *"Claude Desktop identifies extensions by the manifest `name` (not the filename or `display_name`), so the name must be unique per site or a second install replaces the first"* — comentário que referencia um bug real (**#86**) já reportado.
- `mcp_config.args` usa `${__dirname}/server/index.js` (variável de substituição MCPB) em vez de caminho relativo — comentário do autor explica: *"Claude Desktop does not cd into the extracted bundle dir before running `node`, so a relative path resolves against the wrong CWD and Node throws 'Cannot find module' → instant 'Server disconnected'"*.
- **`build_zip()`**: lê `bin/mcp-proxy.mjs` (fonte ESM, testável com `node --test`), converte para CJS self-contained via regex simples (troca `import ... from 'node:X'` por `require('X')`, remove `export`), e **embute as credenciais** (`WP_URL`, `WP_USERNAME`, `WP_APP_PASSWORD`) como overrides de `process.env` no topo do ficheiro — para funcionar mesmo que o host Claude Desktop não injecte `mcp_config.env`.
- **`validate_server_js()`**: sanity check pós-build — confirma presença dos 4 `require()` esperados e ausência de qualquer `import`/`export` ESM residual; falha cedo em vez de distribuir um bundle partido.
- Disparado pelo admin-post `emcp_tools_download_mcpb` (`NONCE_DOWNLOAD_MCPB`, visto em `includes/admin/class-admin.php`), a partir do separador **"Connection"** das definições do plugin — **nada a ver com a página Sandbox**.
---
## 4. Blueprint para réplica
### Copiar quase 1:1
1. **O validador em 3 camadas** (parse → scan de tokens → classificação critical/warning) — é o coração da segurança deste subsistema e está bem pensado: cobre tanto nomes de função óbvios como vectores de bypass (chamada dinâmica, callbacks). A lista de funções críticas está bem pesquisada; copiar quase literalmente.
2. **O padrão "manifest-only loading + tamper guard sha256 + path containment check"** — usado tanto no snippet loader como no widget loader. É elegante e evita I/O desnecessário em cada request (nunca faz scan de directório).
3. **O padrão "shutdown handler + auto-deactivate on fatal"** — garante que um snippet/widget mau nunca consegue white-screenar o site de forma persistente (recupera na request seguinte). Detalhe fácil de esquecer numa reescrita ingénua.
4. **O gate de duas camadas para escrita de PHP** (`create` só pode produzir draft; `activate` é acção humana separada, nunca exposta via MCP) — é a decisão de design mais importante deste subsistema todo e deve ser preservada tal e qual: *"There is intentionally no 'activate' tool"* no MCP.
5. **O envelope de bundle** (`EMCP_Tools_Sandbox_Bundle`) com checksum determinístico (`ksort` + `sha256`) — simples e eficaz para portabilidade/import-export entre sites.
6. **A defesa de injecção de tag PHP** em ficheiros CSS/JS gerados (`preg_replace('/<\?(?:php|=)?/i', '', $out)`) — detalhe fácil de esquecer que evita um vector de RCE se `short_open_tag` estiver activo no servidor de destino.
7. **`runtime_validate()` do Widget Store** — instanciar e forçar `get_stack()` antes de activar, para nunca deixar código gerado partido chegar ao editor Elementor.
### Simplificar
- A dualidade `EMCP_Tools_Sandbox_Store` (abstract, `@since 3.7.0`) vs a implementação manual duplicada em `EMCP_Tools_Widget_Store` (`@since 1.9.0`) é dívida técnica visível — ambas fazem o mesmo (`write_file`/`read_file`/`rmdir_recursive`/sync-meta) com código quase idêntico. Numa reescrita, fazer o Widget Store herdar de `Sandbox_Store` desde o início.
- A migração legada `uploads/emcp-widgets` → `wp-content/emcp-sandbox` (`EMCP_Tools_Sandbox_Paths::maybe_migrate`) só é necessária porque o produto original mudou de local a meio da vida. Numa réplica de raiz, ir directo para `wp-content/{slug}-sandbox` sem essa bagagem.
### Deixar de fora ou adiar
- **O MCPB Builder** (Claude Desktop `.mcpb`) é uma feature auxiliar completamente ortogonal ao sandbox — só implementar se o objectivo for também suportar o cliente Claude Desktop nativo além de MCP hosts remotos. Não é "custom code sandbox", é "distribuição de credenciais de ligação".
- **O sistema cloud/marketplace** (Save to Cloud/Publish/View on Marketplace, visto nas views admin como `EMCP_Tools_Admin::render_sandbox_cloud_actions()`/`render_cloud_library()`) claramente pertence a outro documento (provavelmente o de integrações cloud/OAuth) — não aprofundado aqui, apenas mencionado como consumidor do contrato `EMCP_Tools_Sandbox_Artifact`.
### Gotchas não óbvios (citações directas)
- *"Claude Desktop identifies extensions by the manifest `name` (not the filename or `display_name`), so the name must be unique per site or a second install replaces the first"* — referenciando o bug **#86** relatado.
- *"Claude Desktop does not cd into the extracted bundle dir before running `node`, so a relative path resolves against the wrong CWD"* — motivo de usar `${__dirname}`.
- *"PHP is expressive enough to hide intent (variable functions, decoded strings, reflection), so static analysis cannot prove arbitrary code is safe"* — o autor é honesto sobre os limites do validador, não vende segurança que não tem.
- `EMCP_Tools_Widget_Store::uninstall_cleanup()` apaga `sandbox_dir()` **inteiro** (não só `widgets/`) — cuidado ao portar esta lógica se outros artefactos (snippets, blocks) partilharem a mesma raiz de sandbox; podia apagar dados de outros subsistemas por engano num desinstalar parcial (aqui não acontece porque o plugin desinstala tudo de uma vez, mas é um risco arquitectural a ter em conta numa réplica com desinstalação modular).
- A lista de funções `warning` inclui explicitamente callbacks dinâmicos (`array_map`, `usort`, `preg_replace_callback`, etc.) precisamente porque são o vector clássico para contornar a lista `critical` de chamada directa (ex.: `array_map('system', $input)`) — um detalhe de segurança sofisticado que vale a pena preservar em qualquer réplica do validador.
---
## 5. Fonte
Todos os ficheiros lidos via `ssh://server/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
- `includes/abilities/class-php-snippet-abilities.php`
- `includes/class-php-snippet-store.php`
- `includes/class-php-snippet-loader.php`
- `includes/class-php-snippet-validator.php`
- `includes/abilities/class-sandbox-cloud-abilities.php`
- `includes/sandbox/class-sandbox-paths.php`
- `includes/sandbox/class-snippet-bundle-adapter.php`
- `includes/sandbox/class-sandbox-bundle.php`
- `includes/sandbox/class-sandbox-store.php`
- `includes/sandbox/class-widget-bundle-adapter.php`
- `includes/sandbox/interface-sandbox-artifact.php`
- `includes/class-widget-store.php`
- `includes/admin/class-mcpb-builder.php`
- `includes/class-widget-loader.php` (bónus, consultado para confirmar o runtime companion do Widget Store)
- `includes/admin/views/sandbox/widgets.php` (confirmação da UI Pro-gated do Widget Builder)
- `includes/admin/views/sandbox/blocks.php` (confirmação de que `EMCP_Tools_Block_Store` também é Pro-only/ausente)
- `includes/admin/views/sandbox/snippets.php` (confirmação da UI de aprovação humana dos snippets)
- `includes/abilities/class-ability-registrar.php` (grep pontual, linhas ~416-434 e ~549-554, gating exacto dos grupos)
- `includes/admin/class-admin.php` (grep pontual: admin-post `handle_download_mcpb` + ajax handlers de toggle/delete de widget/block)
- `includes/abilities/class-custom-code-abilities.php` (verificação rápida — confirmar que é feature distinta: Elementor Custom CSS/JS/Code Snippets, não faz parte deste subsistema)
- Listagens de directório (via `read` em modo directório): `includes/abilities/`, `includes/`, `includes/admin/`, `includes/admin/views/sandbox/` — usadas para confirmar a **ausência** de `class-widget-builder-abilities.php`, `class-widget-generator.php` e `class-block-store.php` nesta build Free.
+629
View File
@@ -0,0 +1,629 @@
# 07 — System Ops: Filesystem, Base de Dados, WP-CLI, Security Scanner, Performance Analyzer
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral, cadeia de arranque,
`emcp_tools_register_ability()`) e `skill://emcp-tools` (postura de segurança ao vivo nos 3
sites do ecossistema, mecanismo do deny-list incremental).
**Estes são os grupos de MAIOR RISCO do plugin.** Todas as 6 tools de filesystem que mutam
estado, todas as 6 de base de dados que mutam estado, e as 4 de WP-CLI (incluindo as duas de
só leitura, `get-wp-cli-job`/`list-wp-cli-jobs`) fazem parte dos 131 slugs desligados por
omissão em `emanuelalmeida.pt`/`starter.descomplicar.pt` (ver `skill://emcp-tools` §2.1/§2.2).
Security Scanner e Performance Analyzer são as únicas duas ferramentas deste documento que ficam
**activas por omissão** — são estritamente de leitura, nunca escrevem nada.
Todos os grupos deste documento registam-se **sempre** (não dependem de Elementor activo, nem de
nenhum módulo opcional) — são chamados directamente em `EMCP_Tools_Ability_Registrar::register_groups()`,
fora de qualquer bloco condicional `if ( class_exists(...) )` ou `if ( $elementor_active )`. A
única coisa que os torna "inúteis" num site com config por omissão é estarem no deny-list
aplicado por `EMCP_Tools_Plugin::filter_disabled_tools()` (ver doc 00 §3).
---
## 1. Filesystem
**Classe de abilities:** `EMCP_Tools_Filesystem_Abilities`
(`includes/abilities/class-filesystem-abilities.php`)
**Condição de registo:** sempre activo (chamado sem guarda em `register_groups()`).
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )` — mesmo as
de leitura, porque `read-file`/`search-files` podem expor segredos de config de outros
ficheiros do site.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `read-file` | `path` (string, **required**), `offset` (int, 1-based), `limit` (int) | Lê um ficheiro dentro de `ABSPATH`. Limite de 5 MB (`MAX_READ_BYTES`); recusa binários (devolve `{binary:true}` em vez do conteúdo); recusa `wp-config.php` (`is_read_protected`); suporta slice por linhas via `offset`/`limit`. | readonly / não destrutivo |
| `list-directory` | `path` (opt, default raiz), `recursive` (bool, profundidade máx. 5, cap 2000 entradas) | Lista entradas (nome/path/tipo/size/mtime) de um directório dentro de `ABSPATH`. | readonly |
| `search-files` | `query` (string, **required**), `path` (opt), `extensions` (array de string), `max_results` (int, default 200, tecto 500) | Grep de substring (case-sensitive) recursivo por uma árvore; ignora ficheiros >5 MB e `wp-config.php`; devolve `{file, line, text}` capado a 300 chars por match. | readonly |
| `write-file` | `path`, `content` (ambos **required**) | Cria ou sobrescreve um ficheiro. Faz backup do existente primeiro; recusa `wp-config.php`/`.htaccess`; limite 5 MB (`MAX_WRITE_BYTES`); requer `writes_allowed()` (capability `edit_files` + `!DISALLOW_FILE_EDIT`); invalida OPcache se `.php`; grava no change ledger unificado. | **não readonly, destrutivo** — **desligado por omissão** |
| `edit-file` | `path`, `old_string`, `new_string` (**required**), `replace_all` (bool) | Substituição exacta de string (`old_string` deve corresponder exactamente uma vez, a menos que `replace_all`). Backup, mesmo gate de escrita, mesma protecção de ficheiros, mesmo registo no ledger. | **destrutivo** — **desligado por omissão** |
| `delete-file` | `path` (**required**), `confirm` (bool) | Apaga um ficheiro. Exige `confirm:true`; backup antes de apagar; mesmo gate de escrita e protecção. | **destrutivo** — **desligado por omissão** |
### Guard: `EMCP_Tools_Filesystem_Guard`
Ficheiro: `includes/class-filesystem-guard.php`. Comentário do próprio ficheiro: *"This is the
security boundary for the filesystem tools. resolve_path() is the one chokepoint that makes
'inside the WordPress install only' true."*
Constantes: `MAX_READ_BYTES = 5242880` (5 MB), `MAX_WRITE_BYTES = 5242880` (5 MB),
`BACKUP_DIR = 'emcp-fs-backups'`.
- **`resolve_path( string $path, ?string $root = null )`** — o chokepoint único. `$root`
por omissão `ABSPATH` (parâmetro só existe para testes). Rejeita path vazio ou com byte NUL.
Detecta se é absoluto (começa por `/`, `\`, ou `C:\`-like via regex). Constrói o candidato
(absoluto tal-e-qual, ou `rtrim($root) . '/' . ltrim($path)`). Faz `realpath()`; se o próprio
alvo não existir ainda (caso de escrita nova), resolve o **directório pai** com `realpath()`
e reconstrói `parent/basename`. Compara o prefixo do caminho resolvido contra
`realpath($root)`: tem de ser **exactamente igual** ou começar por
`root . DIRECTORY_SEPARATOR` — nunca um simples `strpos`, para evitar que
`/var/www/site-evil` passe por prefixo de `/var/www/site`. Devolve `WP_Error('outside_root')`
se escapar.
- **`is_protected( string $abs )`** — lista de basenames **escrita/eliminação**-protegidos:
`wp-config.php`, `.htaccess` (case-insensitive), filtrável via `emcp_tools_fs_protected_paths`.
- **`is_read_protected( string $abs )`** — lista de basenames **leitura**-protegidos: **só**
`wp-config.php` — `.htaccess` fica de fora porque não é um segredo, comentário explícito no
código: *"wp-config.php carries the DB credentials and auth salts; .htaccess is not a secret
so it stays readable."* Filtrável via `emcp_tools_fs_read_protected_paths` — um admin pode
adicionar `.env`, ficheiros de chave, etc. **Nota importante:** as duas listas são
deliberadamente diferentes (escrita ⊃ leitura) — não é o mesmo array reutilizado.
- **`backup_name( rel, timestamp )`** — pure: nome de ficheiro sanitizado
`<timestamp>-<path-com-/-substituído-por-\->`, caracteres fora de `A-Za-z0-9._-` viram `-`.
- **`is_utf8( content )`** — pure: byte NUL ou falha em `preg_match('//u', $content)` → binário.
- **`check_writes( can_edit_files, disallow_file_edit )`** — pure: combina a capability
`edit_files` com a constante `DISALLOW_FILE_EDIT`.
- **`writes_allowed()`** — wrapper live: `current_user_can('edit_files') && !(defined(DISALLOW_FILE_EDIT) && DISALLOW_FILE_EDIT)`.
- **`to_relative( abs )`** — inverso de `resolve_path` para display/log (relativo a `ABSPATH`,
slashes normalizados).
- **`backup( abs )`** — copia o ficheiro-alvo para
`wp-content/uploads/emcp-fs-backups/<timestamp>-<path-flat>` **antes** de qualquer
write/edit/delete. Cria `.htaccess` (`Require all denied`) + `index.html` vazio no directório
de backups na primeira utilização (bloqueia acesso web directo aos backups). Devolve `''`
quando o ficheiro-fonte ainda não existe (é uma criação, não um overwrite) — nesse caso o
rollback ficheiro-a-ficheiro é do tipo `'file-create'` em vez de `'file-backup'`.
- **`log()`** — `@deprecated 3.10.0`, no-op. Auditoria foi migrada para o change ledger unificado
(`EMCP_Tools_Change_Log`/`EMCP_Tools_Change_Recorder::record_file()`, documentado no doc 05) —
`class-filesystem-abilities.php::record_fs_change()` monta a entrada do ledger com `rollback`
do tipo `file-backup`/`file-create` e chama o recorder directamente.
Efeito colateral extra em cada escrita/edição/eliminação de `.php`: `invalidate_php_opcache()`
chama `opcache_invalidate($abs, true)` — sem isto, o pedido seguinte podia executar bytecode
cached obsoleto em vez do ficheiro recém-alterado.
**Storage:** backups em `wp-content/uploads/emcp-fs-backups/` (protegido de acesso web via
`.htaccess`). Registo de mudanças no change ledger unificado (ver doc 05) — nenhum log próprio
separado.
---
## 2. Base de dados directa
**Classe de abilities:** `EMCP_Tools_Database_Abilities`
(`includes/abilities/class-database-abilities.php`)
**Condição de registo:** sempre activo.
**`permission_callback` (todas as 6 tools):** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `list-tables` | (sem input) | Lista tabelas via `information_schema.TABLES` — nome, `table_rows` estimado, tamanho em bytes (`data_length + index_length`). | readonly |
| `describe-table` | `table` (string, **required**) | Valida o nome contra `EMCP_Tools_Database_Guard::valid_table()` e corre `DESCRIBE`; devolve colunas/tipos/keys. | readonly |
| `query` | `sql` (string, **required**), `limit` (int, default/tecto `MAX_ROWS=1000`) | Corre SQL de leitura validado por `is_read_only_sql()`. Recusa também leitura das tabelas `users`/`usermeta` mesmo em modo `SELECT` (`query_touches_protected`), apontando para `list-users`/`get-user` em vez disso. | readonly |
| `insert-row` | `table`, `data` (objecto) — ambos **required** | `$wpdb->insert()` parametrizado. Recusa tabelas protegidas. Regista no ledger (`rollback.type = db-before-image`, `op = insert`). | **destrutivo** — **desligado por omissão** |
| `update-rows` | `table`, `data`, `where` (todos objecto) — **required** | Exige `where` não-vazio (nunca um UPDATE sem condição). Captura `before_image()` (snapshot das linhas afectadas, cap 500) antes de `$wpdb->update()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
| `delete-rows` | `table`, `where` (**required**), `confirm` (bool) | Exige `confirm:true` + `where` não-vazio. `before_image()` antes de `$wpdb->delete()`. Recusa tabelas protegidas. | **destrutivo** — **desligado por omissão** |
### Guard: `EMCP_Tools_Database_Guard`
Ficheiro: `includes/class-database-guard.php`. Comentário: *"is_read_only_sql() is the safety
boundary for the flexible read path."* Constantes: `MAX_ROWS = 1000`, `BEFORE_IMAGE_CAP = 500`.
- **`normalize_sql( string $sql )`** — pure, scanner char-a-char (não regex — evita
problemas de backtracking/ReDoS em SQL longo). Substitui todo o comentário (`--`, `#`,
`/* */`) por um espaço, todo o literal de string (`'...'`/`"..."`, com escapes de backslash e
duplicação de quote reconhecidos) por `''`, e todo o identificador entre backticks por
`` `` ``. Não trata `/*! ... */` (comentários executáveis do MySQL) — esses são rejeitados
**antes** mesmo de chamar `normalize_sql`.
- **`is_read_only_sql( string $sql )`** — a gate real, em cadeia:
1. Se `$sql` contém `/*!` → rejeita de imediato. Comentário: *"MySQL executes the body of
/*! ... *​/ executable comments, so we cannot safely strip-and-trust."*
2. `normalize_sql()` + `trim()`.
3. Multi-statement: qualquer `;` que não seja o único carácter final (depois de `rtrim`)
→ rejeita.
4. Vectores de acesso a ficheiros — regex
`/\b(into\s+outfile|into\s+dumpfile|load_file\s*\(|load\s+data\b)/i`. Nota deliberada no
código: **sem `\b` a fechar** — porque `load_file(` termina em `(`, e `(` seguido de outro
não-word-char não tem word boundary; um `\b` final deixaria passar `LOAD_FILE` por engano.
5. Primeira palavra tem de ser uma de `SELECT/SHOW/DESCRIBE/DESC/EXPLAIN/WITH`.
6. Denylist da frase inteira: `INSERT|UPDATE|DELETE|REPLACE|MERGE|DROP|TRUNCATE|ALTER|CREATE|RENAME|GRANT|REVOKE|HANDLER|CALL|LOCK|UNLOCK|PREPARE|EXECUTE|INTO` — como comentários/literais já
foram removidos por `normalize_sql`, isto só apanha keywords reais (não texto dentro de
uma string).
- **`valid_table( string $table )`** — nomes de tabela **não podem ser parametrizados** em SQL
(`?`/`%s` só serve para valores), por isso resolve contra `SHOW TABLES` ao vivo e devolve o
nome real exacto (preserva case) ou `WP_Error('unknown_table')`.
- **`table_is_protected`/`is_protected( $table )`** — tabelas protegidas por omissão:
`$wpdb->users`, `$wpdb->usermeta`, filtrável via `emcp_tools_db_protected_tables`.
- **`query_touches_tables`/`query_touches_protected( $sql )`** — o análogo do lado da leitura:
remove backticks, normaliza (comentários/strings fora), e testa se algum nome de tabela
protegida aparece como identificador real com word boundaries (`wp_users_backup` **não**
corresponde a `wp_users`). Aplicado ao tool `query` para bloquear leitura directa de
password hashes/tokens de sessão via `SELECT * FROM wp_users`.
- **`before_image( $table, $where )`** — `SELECT *` com condições de igualdade AND
(parametrizado via `$wpdb->prepare`), `LIMIT 500`, chamado antes de update/delete.
- **`log()`** — `@deprecated 3.10.0`, no-op; ledger via `EMCP_Tools_Change_Recorder::record_db()`.
**Storage:** nenhum armazenamento próprio — escreve directamente nas tabelas alvo via `$wpdb`;
before-images e rollback vão para o change ledger unificado (blob store para snapshots grandes,
ver doc 05).
---
## 3. WP-CLI (execução + jobs assíncronos)
**Classe de abilities:** `EMCP_Tools_WPCLI_Abilities` (`includes/abilities/class-wpcli-abilities.php`)
**Condição de registo:** sempre activo. **Todas as 4 tools deste grupo fazem parte dos 131
slugs desligados por omissão** (`skill://emcp-tools` §2.1) — incluindo as duas de puro leitura
(`get-wp-cli-job`/`list-wp-cli-jobs`), ao contrário de outros grupos onde read/write têm gates
separadas. Provavelmente porque estas duas só fazem sentido em conjunto com `run`/`dispatch`.
**`permission_callback` (todas as 4):** `current_user_can( 'manage_options' )`.
Comentário do cabeçalho do ficheiro, citado por ser exactamente o resumo de risco correcto:
*"Risk notice. WP-CLI is powerful and, via the shell path, is effectively command execution.
The tool is confined by a command blocklist (no eval, eval-file, shell, raw db query, config
writes, package install, or arbitrary PHP flags), ships disabled-by-default, is admin-gated, and
audit-logs runs."*
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `run-wp-cli` | `command` (string, **required**, sem `wp` inicial), `timeout` (int, default 60, tecto 300) | Corre um comando WP-CLI síncrono. Devolve stdout/stderr/exit_code. Executa **in-process** (`WP_CLI::runcommand`) se este pedido já corre dentro de um processo WP-CLI (transporte stdio), ou via **shell** (`proc_open` com um binário `wp` configurado) se ligado por HTTP. | **destrutivo** (por defeito de anotação — pode invocar qualquer subcomando não bloqueado) — **desligado por omissão** |
| `dispatch-wp-cli` | `command` (**required**), `timeout` (int, default 900, conselho até 86400) | Corre como job **detached** em background (para migrações/bulk tasks longas). Requer o caminho shell disponível — não é possível fazer detach de um comando in-process. Devolve `job_id`. | **destrutivo** — **desligado por omissão** |
| `get-wp-cli-job` | `job_id` (string, **required**) | Devolve estado do job (running/completed/failed), exit_code, stdout/stderr (tail capado). | readonly (anotação) — **mesmo assim desligado por omissão** |
| `list-wp-cli-jobs` | (sem input) | Lista jobs recentes (metadata apenas, sem stdout/stderr completos). | readonly (anotação) — **mesmo assim desligado por omissão** |
Cada execução (`run` e `dispatch`) é registada no change ledger, mas **não é reversível** —
comentário explícito no código: *"not reversible — commands have no before-image"*.
### Validator: `EMCP_Tools_WPCLI_Validator`
Ficheiro: `includes/wpcli/class-wpcli-validator.php`. **A gate de segurança do grupo inteiro.**
Comentário do cabeçalho: *"Args are always passed to the runner as an array (never interpolated
into a shell string), so metacharacters in values are inert; this validator blocks the command
surface that would let an operator run arbitrary PHP, raw SQL, or arbitrary shell."*
- **`BLOCKED_COMMANDS`** = `eval`, `eval-file`, `shell`, `server` — comandos WP-CLI que dão
execução de PHP arbitrário, shell interactivo, ou arrancam um servidor web embutido.
- **`BLOCKED_SUBCOMMANDS`** (pares `comando subcomando`) = `db query`, `db cli`, `db import`,
`db export`, `db reset`, `db drop`, `db clean`, `config set`, `config delete`, `config edit`,
`package install`, `package update`, `package uninstall`, `cli update`, `cli cmd-dump`,
`cli info`. Note-se: `db query`/`db cli`/etc. é redundante em parte com a gate própria da
ability `database` (§2), mas é defesa em profundidade — um agente não pode contornar o guard
de SQL via WP-CLI.
- **`BLOCKED_FLAG_PREFIXES`** = `--exec`, `--require` (carregam PHP arbitrário), `--path`,
`--ssh`, `--http` (retargeting do WP-CLI para outro install/servidor — poderia escapar do
site actual), `--prompt` (ficaria pendurado à espera de input interactivo), `--user=0`.
- **`validate( $command )`**: `trim()`; rejeita `\r`/`\n` (anti-injecção de linha); remove
prefixo `"wp "` tolerado; tokeniza; varre TODOS os tokens contra os prefixos de flag
bloqueados (`stripos`, case-insensitive, por prefixo — não exact-match); identifica a
"command word" = primeiro token não-flag (não começa por `-`) e o subcommand = segundo token
não-flag; rejeita se o comando isolado ou o par comando+subcomando estiverem nas listas.
Todas as três listas são filtráveis (`emcp_tools_wpcli_blocked_commands`,
`_blocked_subcommands`, `_blocked_flags`).
- **`tokenize( $command )`**: tokenizador consciente de aspas (single/double quotes). Fora de
aspas, um backslash é **literal** (friendly para paths Windows tipo `C:\wp\wp-cli.phar`).
Dentro de aspas duplas, só `\"` e `\\` são escapes reconhecidos; dentro de aspas simples,
nada é escapado. Devolve `WP_Error('wpcli_unterminated_quote')` se uma aspa não fechar.
### Runner: `EMCP_Tools_WPCLI_Runner`
Ficheiro: `includes/wpcli/class-wpcli-runner.php`. `OUTPUT_CAP = 262144` (256 KB) por stream,
truncado com `"…[output truncated]"`.
- **`is_cli_context()`** — `defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI')` — true se
este pedido já corre dentro de um processo WP-CLI (o caso normal deste ecossistema, que liga
via SSH+STDIO a `wp mcp-adapter serve`, ver doc 00 e `skill://emcp-tools`).
- **`base_command()`** — o binário `wp` configurado, ordem de prioridade: constante
`EMCP_TOOLS_WPCLI_COMMAND` > option `emcp_tools_wpcli_command` > filtro
`emcp_tools_wpcli_command`.
- **`shell_available()`** — `proc_open` existe, `base_command()` não vazio, e `proc_open` não
está em `disable_functions` do php.ini.
- **`run( $command, $timeout=60 )`** — valida via `Validator::validate()`; se `is_cli_context()`
→ `run_in_process()` usa `WP_CLI::runcommand($cmd, ['return'=>'all','exit_error'=>false,'launch'=>false,'parse'=>false])`
— corre **dentro do mesmo processo PHP**, sem `fork`/`exec`. Senão, se `shell_available()` →
`run_shell()`.
- **`run_shell()`** — `proc_open($argv_array, ...)`. Comentário do código, crucial:
*"PHP 7.4+: an array command is executed WITHOUT a shell — arguments are passed verbatim, so
no metacharacter can be interpreted."* `$argv = base_argv() + tokens + ['--path=' . ABSPATH, '--no-color']`.
Poll não-bloqueante (`stream_set_blocking(false)`), timeout com `proc_terminate($proc, 9)`
(SIGKILL) se ultrapassar deadline (`timed_out=true`, `exit_code=124`), drena pipes no fim.
### Jobs assíncronos: `EMCP_Tools_WPCLI_Jobs`
Ficheiro: `includes/wpcli/class-wpcli-jobs.php`. `KEEP = 50` job dirs mantidos (mais antigos são
apagados por `prune()`, por `filemtime`).
- **`dir()`** — `wp-content/uploads/emcp-wpcli-jobs/`, criado + protegido na primeira utilização
com `.htaccess` (`Require all denied\nDeny from all`) + `index.php` silencioso.
- **Estrutura por job** (`<id>/`, `$id = gmdate('Ymd-His') . '-' . substr(md5(uniqid()),0,6)`):
- `meta.json` — `{id, command, timeout, status, created, started, finished, exit_code, user}`;
`status` transita `queued → running → (completed|failed)`.
- `stdout.log` / `stderr.log` — streams capturados.
- `run.sh` (POSIX) ou `run.bat` (Windows) — **launcher gerado**, com o comando completo já
montado via `escapeshellarg()` por token (`implode(' ', array_map('escapeshellarg', $argv))`)
— a escaping fica de fora do `proc_open`/`popen` de spawn.
- `exit_code` — ficheiro escrito **pelo launcher** quando o comando termina — é a fonte de
verdade para o estado terminal (não um polling do processo pai).
- **`dispatch( $command, $timeout=900 )`** — requer `shell_available()` (impossível fazer
detach de algo já in-process); valida; `prune()`; grava `meta.json` inicial; `spawn()` lança
o launcher **detached** — POSIX: `proc_open(['sh','-c', 'nohup sh run.sh > /dev/null 2>&1 &'], ...)`;
Windows: `popen('cmd /c start /B "" cmd /c run.bat', 'r')`. O processo pai **não espera** —
retorna `job_id` de imediato com `status='running'`.
- **`get( $id )`** — sanitiza `$id` (regex `[^a-z0-9-]` removido); lê `meta.json`; deriva o
estado terminal do ficheiro `exit_code` se existir (`0 = completed`, `!=0 = failed`); devolve
`tail()` do stdout/stderr (capado a `OUTPUT_CAP`, prefixo `"…[output truncated]"` — mostra o
**fim** do log, não o início).
- **`all()`** — todos os jobs (`glob(GLOB_ONLYDIR)`), ordenados por `created` desc, sem
stdout/stderr completos.
- **`spawn()`** — vale a pena ler: gera o `run.sh`/`run.bat` completo primeiro (incluindo
redirecção de stdout/stderr/exit_code), depois só lança um shell trivial que executa esse
ficheiro — separa completamente a lógica de "o que corre" da lógica de "como fica detached".
**Storage:** `wp-content/uploads/emcp-wpcli-jobs/<job-id>/` (protegido de acesso web).
---
## 4. Security & Malware Scanner
**Classe de abilities:** `EMCP_Tools_Security_Abilities` (`includes/abilities/class-security-abilities.php`)
**Condição de registo:** sempre activo. **`scan-security` está ACTIVA por omissão** (não faz
parte do deny-list — a única categoria deste documento com essa distinção, junto com
`analyze-performance`).
**`permission_callback`:** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `scan-security` | `checks` (array de enum `malware`/`integrity`/`hardening`/`software`, opt — omitir corre as 4), `deep` (bool, default false), `max_files` (int, default 2000, tecto 20000), `max_seconds` (int, default 20, tecto 120) | Corre até 4 audits e devolve `{summary:{score 0-100, grade A-F, counts}, sections:{malware,integrity,hardening,software}, scan_meta, top_recommendations}`. `deep=false` cobre só `uploads/` + plugins activos + tema activo; `deep=true` cobre toda a `wp-content/` (mais lento). | **readonly, destructive=false, idempotent=true — activa por omissão** |
### Orchestrator: `EMCP_Tools_Security_Scanner`
Ficheiro: `includes/security/class-security-scanner.php`.
`CRITICAL_WEIGHT=20`, `WARNING_WEIGHT=5`, `CATEGORY_CRIT_CAP=60` (o penalty de criticals
**satura a 60 por categoria** — impede que uma categoria sozinha com muitos criticals leve o
score a zero), `TOP_RECS=8`.
- **Construção LAZY dos 4 audits** — só instanciados na primeira `scan()` que de facto precisa
deles. Decisão de design explícita (comentário completo citado em §4.1 abaixo, ligado ao
**issue #100**): registar a tool **não pode** instanciar o motor de audit, porque o registo de
abilities corre em **cada** carregamento de página de admin e **cada** pedido REST.
- **`resolve_checks( $requested )`** — pure: normaliza para o subset válido em ordem canónica;
vazio/tudo-inválido → todos os 4.
- **`scan( $input )`** — corre os checks pedidos, agrega findings, `summarize()`.
- **`summarize( $findings )`** — pure: conta por `status` (critical/warning/pass/info); penalty
**por categoria** — `cat_crit_pen[cat] = min(60, soma_de_20_por_cada_critical_nessa_categoria)`;
`score = 100 - soma(penalties_por_categoria) - (nº_warnings * 5)`, clamp `[0,100]`;
`grade` A(≥90)/B(≥80)/C(≥70)/D(≥60)/F(resto).
- **`group_by_category`** — agrupa em 4 secções fixas.
- **`rank_recommendations`** — críticos primeiro, depois warnings, corta a 8, formato
`"[label] recomendação"`.
### Value object: `EMCP_Tools_Security_Finding`
Ficheiro: `includes/security/class-security-finding.php`. Uma única factory pure:
`make( id, category, label, status, value, message, recommendation='' )` → array uniforme.
`status` ∈ `pass|warning|critical|info`; `recommendation` deve ser não-vazio quando `status !=
'pass'`.
### 4.1 Audit — Malware (`EMCP_Tools_Security_Malware_Audit`)
Ficheiro: `includes/security/class-security-malware-audit.php`.
`MAX_FILE_BYTES=2MB` (ficheiros maiores são saltados), `MAX_LINE_BYTES=64KB` (cap por linha
alimentada às regex — guarda anti-ReDoS/backtrack-limit; o comentário explica: linhas longas
fazem `preg_match` devolver `false` silenciosamente ao atingir `pcre.backtrack_limit`, mascarando
um hit real), `MAX_FILES=2000`/`CEILING=20000`, `TIME_BUDGET=20s`/`CEILING=120s`,
`SNIPPET_LEN=120`, `MAX_FINDINGS_PER_FILE=5`.
- **`scan_code( code, relpath, in_uploads )`** — pure: corre 5 regras de assinatura linha a
linha (ver abaixo); nunca devolve mais de 5 achados por ficheiro; o `value` de cada finding é
`{location: "path:line", snippet}` — **nunca o conteúdo completo do ficheiro**.
- **`is_misplaced_php( relpath )`** — PHP executável dentro de `uploads/` (extensões
`php/phtml/php3-7/phps/pht`).
- **`is_trivial_php( code )`** — pure, usa `token_get_all()` para distinguir um
`index.php` "Silence is golden" (só `T_OPEN_TAG`/`CLOSE_TAG`/`WHITESPACE`/`COMMENT`/`DOC_COMMENT`)
de PHP com código real — evita falsos positivos em guardas de directório vazias.
- **`is_excluded( relpath, prefixes )`** — exclui a própria pasta de instalação do plugin (via
`EMCP_TOOLS_DIR`) + o directório sandbox gerido (`EMCP_Tools_Sandbox_Paths::relative_base()`),
para o scanner não se auto-detectar como malware.
- **`run( deep, max_files, max_seconds )`** — `scan_roots(deep)`: `false` →
`uploads/` + plugins **activos** + tema activo/pai; `true` → toda a `wp-content/`. Percorre com
`RecursiveIteratorIterator` + `FOLLOW_SYMLINKS`, mas **valida que o caminho resolvido
continua dentro de `ABSPATH`** (bloqueia escape de symlink). Ficheiro PHP executável sob
`uploads/` com código real (não trivial) gera um achado crítico dedicado
`malware_uploads_php` **antes** de correr as 5 regras normais.
**🏆 O achado mais importante deste ficheiro (citação literal, comentário do autor):**
As assinaturas de malware (`eval`, `assert`, `create_function`, `base64_decode`, `gzinflate`,
`system`, `exec`, `shell_exec`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO`, etc.) **NÃO
estão escritas de forma literal no ficheiro-fonte** — estão fragmentadas em
`signature_tokens()` como concatenações (`'ev' . 'al'`), reunidas em runtime via
`expand()`/`strtr()`. Comentário do autor no código:
> *"This class is a malware scanner, so its rules have to name the exact functions and
> webshell handles that host-level scanners (Imunify360, maldet, Wordfence, ModSecurity) hunt
> for. Spelled out intact, this file reads as a c99-style webshell and gets quarantined or
> zeroed in place. `require_once` then still succeeds (the path exists) but the class is never
> declared, which used to fatal every wp-admin page and REST request (issue #100)."*
> *"Splitting the tokens means no intact signature ever sits on disk. The patterns compiled
> below are byte-identical to the originals, so detection behaviour is unchanged. Keep any new
> signature split the same way."*
Este é o motivo directo por trás **tanto** da construção lazy no orchestrator (§4, issue #100)
**como** desta fragmentação de tokens: um scanner de malware do **próprio host** identificava
literalmente este ficheiro como um webshell e colocava-o em quarentena/zerava-o, partindo o site
inteiro (a classe deixava de existir mas o `require_once` continuava a "ter sucesso"
silenciosamente — o registo de abilities engolia a excepção mas o servidor MCP ficava sem esta
tool).
**As 5 regras de assinatura:**
1. `malware_eval_obfuscation` (critical) — `eval`/`assert`/`create_function` envolvendo um
decoder (`base64_decode`/`gzinflate`/`gzuncompress`/`str_rot13`/`strrev`/`convert_uudecode`).
2. `malware_request_eval` (critical) — `eval`/`assert`/`system`/`exec`/`passthru`/
`shell_exec`/`popen`/`proc_open` recebendo directamente `$_GET`/`POST`/`REQUEST`/`COOKIE`/`SERVER`
— o backdoor RCE clássico.
3. `malware_command_exec` (warning; **critical se `in_uploads`**) — qualquer chamada de
`shell_exec`/`passthru`/`proc_open`/`popen`/`system`/`exec` isolada.
4. `malware_webshell_marker` (critical) — strings de webshells conhecidos
(`FilesMan`, `c99shell`, `r57shell`, `b374k`, `phpspy`, `WSO<versão>shell`).
5. `malware_long_base64` (warning) — blob de 260+ chars base64-like (payload escondido).
### 4.2 Audit — Integrity (`EMCP_Tools_Security_Integrity_Audit`)
Ficheiro: `includes/security/class-security-integrity-audit.php`.
- **`diff( checksums, hasher )`** — pure: compara o manifesto de checksums oficial do
wordpress.org (md5 por ficheiro core-relative) contra o hash real via `hash_equals()`
(timing-safe); ficheiro em falta → `warning('integrity_missing')`; hash não bate →
`critical('integrity_modified')`.
- **`run()`** — usa a função core `get_core_checksums($wp_version, $locale)`
(`wp-admin/includes/update.php`); **exclui tudo em `wp-content/`** (não faz parte dos
checksums oficiais de core). Se a API estiver inacessível (offline) devolve **um único**
finding `info` com `api.ok=false` — degrada graciosamente em vez de falhar todo o scan.
### 4.3 Audit — Hardening (`EMCP_Tools_Security_Hardening_Audit`)
Ficheiro: `includes/security/class-security-hardening-audit.php`. `FETCH_TIMEOUT=8s`.
7 checks, cada `evaluate_*()` pure, `run()` gathers live + **UM** loopback GET reutilizado para
dois checks (headers + generator meta):
| Check | Pass | Warning/Critical |
|---|---|---|
| `harden_file_edit` | `DISALLOW_FILE_EDIT` definido true | editor de ficheiros do admin ligado |
| `harden_debug_display` | `WP_DEBUG_DISPLAY` off | `warning` se on em produção; `info` noutro ambiente |
| `harden_admin_user` | sem user `admin` | `username_exists('admin')` |
| `harden_xmlrpc` | XML-RPC desligado | `xmlrpc.php` existe + filtro `xmlrpc_enabled` true |
| `harden_https` | scheme de `home_url()` é `https` | qualquer outro |
| `harden_security_headers` | X-Frame-Options + X-Content-Type-Options + Strict-Transport-Security + Content-Security-Policy todos presentes | falta pelo menos um (via 1 loopback GET a `home_url('/')`) |
| `harden_version_disclosure` | sem `readme.html` nem meta `generator` no HTML | qualquer um presente |
### 4.4 Audit — Software (`EMCP_Tools_Security_Software_Audit`)
Ficheiro: `includes/security/class-security-software-audit.php`.
`MAX_ABANDONED_LOOKUPS=30` (orçamento de chamadas `plugins_api` **ao vivo** por scan),
`ABANDONED_CACHE_TTL=43200` (12h, transient por-slug).
Checks: core desactualizado, plugins/temas desactualizados (um finding por item), plugins
inactivos (contagem — `info`, não `warning`), **plugins abandonados/removidos do directório
wordpress.org** — via `plugins_api('plugin_information', ['slug'=>$slug])` e o campo
`$info->closed`. O resultado (`closed`/`open`) fica em cache num transient
`emcp_sec_abandoned_<md5(slug)>` durante 12h; um `WP_Error` (plugin premium não no wp.org, ou API
em baixo) **não é marcado nem colocado em cache** — retenta no próximo scan. Slugs já em cache
não contam para o orçamento de 30 chamadas ao vivo — só chamadas realmente feitas são limitadas.
---
## 5. Performance Analyzer
**Classe de abilities:** `EMCP_Tools_Performance_Abilities` (`includes/abilities/class-performance-abilities.php`)
**Condição de registo:** sempre activo. **`analyze-performance` está ACTIVA por omissão**
(igual a `scan-security`).
**`permission_callback`:** `current_user_can( 'manage_options' )`.
| Tool | `input_schema` (resumo) | O que faz | Readonly / Destructive |
|---|---|---|---|
| `analyze-performance` | `url` (uri, opt — página deste site, hosts externos rejeitados), `post_id` (int, opt — ignorado se `url` definido), `include_page_fetch` (bool, default true — `false` corre só server/DB), `deep_assets` (bool, **reservado, ainda não implementado**) | Sem `url`/`post_id` analisa a frontpage. Devolve `{target, summary{score,grade,counts}, sections, page_fetch, top_recommendations}`. | **readonly, destructive=false, idempotent=true — activa por omissão** |
### Orchestrator: `EMCP_Tools_Performance_Analyzer`
Ficheiro: `includes/performance/class-performance-analyzer.php`.
`CRITICAL_WEIGHT=15`, `WARNING_WEIGHT=4`, `TOP_RECS=8` (pesos diferentes do Security Scanner —
mais leve, **sem** cap por categoria).
- **`analyze( $input )`** — `resolve_target()` primeiro (`url`|`post_id`|frontpage); corre
**sempre** o server audit; corre o page audit condicionalmente
(`include_page_fetch`); combina findings; `summarize()` + `group_by_category()`.
- **`resolve_target( $input )`** — valida que `url` está no **mesmo host** que `home_url()` via
`validate_same_host()` (pure) — protecção anti-SSRF/anti-scan-de-outro-site logo à entrada.
- **`summarize( $findings )`** — pure: `score = 100 - (critical*15) - (warning*4)`, **sem** cap
por categoria (diferente do Security Scanner); grade A-F igual.
- **`group_by_category`** — 5 secções fixas: `server`, `database`, `config`, `page`, `assets`.
### Value object: `EMCP_Tools_Performance_Finding`
Ficheiro: `includes/performance/class-performance-finding.php`. Idêntico em forma ao
`Security_Finding` — factory pure `make(id, category, label, status, value, message, recommendation='')`.
### 5.1 Audit — Server (`EMCP_Tools_Performance_Server_Audit`)
Ficheiro: `includes/performance/class-performance-server-audit.php`. **Todo in-process, sem
HTTP.** `MIN_MEMORY_BYTES=128MB`, `AUTOLOAD_WARN=1MB`/`CRIT=3MB`, `PLUGIN_WARN_COUNT=40`,
`REVISIONS_WARN_COUNT=1000`, `TOP_TABLES=5`, `TOP_AUTOLOAD_OPTIONS=5`.
11 checks pure+live: **versão PHP** (≥8.2 pass, ≥8.0 warning, senão critical), **memory_limit**
(≥128MB pass, `-1`=ilimitado=pass, senão warning), **OPcache** activo, **object cache
persistente** (`wp_using_ext_object_cache()`), **biblioteca de imagem** (Imagick ou GD),
**WP_DEBUG** em produção (warning) vs outros ambientes (info), **contagem de plugins activos**
(>40 → warning), **revisões de posts** (>1000 → warning, `COUNT(*) WHERE post_type='revision'`),
**backlog de cron** overdue >5min (via `_get_cron_array()`), **tamanho de opções autoload**
(`SUM(LENGTH(option_value)) WHERE autoload IN ('yes','on','auto')` — nota: cobre as **3
variantes** de valor da coluna `autoload`, não só `'yes'`; devolve top 5 maiores), **tamanho da
base de dados** (`SUM(data_length+index_length)` de `information_schema.TABLES`, top 5 tabelas
maiores).
### 5.2 Audit — Page (`EMCP_Tools_Performance_Page_Audit`)
Ficheiro: `includes/performance/class-performance-page-audit.php`. **1 loopback HTTP fetch +
parsing DOM. Não executa JS** — análise de HTML/headers, não Core Web Vitals reais.
`FETCH_TIMEOUT=10s`, `MAX_HTML_BYTES=2MB` (cap de parsing), `MAX_REDIRECTS=3`,
`RESPONSE_WARN_MS=800`, `HTML_WARN_BYTES=500KB`, `RENDER_BLOCK_WARN=5`.
- **`fetch( $url, $timeout=10 )`** — segue redirects **manualmente** (`redirection=0` em cada
`wp_remote_get`, loop até `MAX_REDIRECTS`) **em vez de** delegar no `redirection` nativo do
`wp_remote_get`. Motivo: cada hop precisa de ser revalidado contra o host de origem
(`safe_redirect_target()`) — um redirect para outro host é **recusado**, não seguido.
- **`safe_redirect_target( location, current_url, origin_host )`** — resolve `Location`
relativo contra o URL actual (scheme+host), depois testa se o host de destino ==
`origin_host` (case-insensitive); devolve `''` se sair do host. **Guarda anti-SSRF
explícita**: um atacante não pode fazer o site "chutar" outro host arbitrário através de um
redirect 301/302 configurado maliciosamente numa página que este tool analisa.
- **`analyze( $fetched, $deep_assets )`** — pure. Se o fetch falhou → 1 finding `warning` e
degrada graciosamente (server/DB continuam reportados normalmente). Se OK: `http_status`
(200=pass), `response_time` (>800ms=warning), `html_size` (>500KB=warning), `compression`
(gzip/br em `content-encoding`), `cache_headers` (Cache-Control/Expires/X-Cache presentes),
depois `parse_dom()` com `DOMDocument` (`libxml_use_internal_errors` para engolir HTML
malformado) + `asset_findings()`: **render-blocking** (`<link rel=stylesheet>` no `<head>` +
`<script>` síncrono no `<head>` sem `async`/`defer`, warning se >5), **asset_counts** (info),
**image_lazy_loading** (contagem de `<img>` sem `loading="lazy"`, info), **third_party**
(domínios de `<link>`/`<script src>` diferentes do host analisado, info).
**Storage:** nenhum armazenamento próprio para nenhum dos dois audits — tudo calculado ao vivo e
devolvido na resposta; nada persistido em disco/BD (excepto os transients de cache do audit de
software do Security Scanner, que é um documento diferente — §4.4).
---
## Blueprint para réplica
### Copiar quase 1:1 (código já correcto, difícil de reproduzir sem reintroduzir bugs)
1. **`EMCP_Tools_Filesystem_Guard::resolve_path()`** — o chokepoint de confinamento a `ABSPATH`.
A lógica de comparação de prefixo (`===` OU `strpos($real, $root . DIRECTORY_SEPARATOR) === 0`,
nunca um `strpos` simples) evita a classe de bugs "`/var/www/site-evil` passa como prefixo de
`/var/www/site`". O tratamento de caminhos que ainda não existem (resolver o **pai** com
`realpath` para permitir criar um ficheiro novo) é subtil e vale a pena copiar tal-e-qual.
2. **A distinção `is_protected` (write) vs `is_read_protected` (read)** — não são a mesma lista.
`.htaccess` pode ser lido mas não escrito/apagado; `wp-config.php` não pode nem sequer ser
lido. Uma réplica que use uma única lista "protected" para tudo está a proteger a menos (deixa
ler segredos) ou a mais (bloqueia leitura de `.htaccess`, que é inofensiva e útil de auditar).
3. **Padrão "backup antes de escrever, directório de backups bloqueado a acesso web"** —
`.htaccess Require all denied` + `index.html` vazio no directório de backups, gerado on-demand
na primeira escrita. Simples e eficaz; copiar tal-e-qual.
4. **`EMCP_Tools_Database_Guard::normalize_sql()` + `is_read_only_sql()`** — o scanner
char-a-char que neutraliza comentários/strings/identificadores antes de testar keywords é
sofisticado e já tem dois bugs de "quase-passou" documentados directamente nos comentários do
código (o truque `/*!` do MySQL, e o cuidado de não pôr `\b` a fechar no regex de
`LOAD_FILE(`). Reimplementar do zero arrisca reintroduzir exactamente estes dois bugs já
corrigidos aqui.
5. **`valid_table()` a resolver contra `SHOW TABLES` ao vivo** em vez de confiar no input —
nomes de tabela não podem ser parametrizados em SQL preparado, por isso a única forma segura
de os validar é confirmá-los contra uma lista real.
6. **`EMCP_Tools_WPCLI_Validator`** — a combinação de blocklist de comandos/subcomandos/flags +
tokenizador consciente de aspas (com as regras específicas de escaping por tipo de aspa, e o
comportamento "backslash literal fora de aspas" para paths Windows) é bem pensada e testada.
Copiar a estrutura (3 listas filtráveis + tokenizer) quase tal-e-qual.
7. **O princípio "argv array, nunca string interpolada"** em `WPCLI_Runner::run_shell()` e
`WPCLI_Jobs::spawn()` — mesmo quando o `spawn()` monta uma string via
`implode(' ', array_map('escapeshellarg', $argv))` para o ficheiro launcher, cada TOKEN
individual passa primeiro por `escapeshellarg()`; nunca há concatenação directa de input do
utilizador numa string de shell. Esta é a regra arquitectural mais importante de todo este
documento — qualquer tool nova que precise de invocar um processo externo deve seguir o mesmo
padrão.
8. **A técnica de fragmentação de assinaturas do malware scanner** (`signature_tokens()` +
`expand()`) — o achado mais interessante de todo o ficheiro: se uma réplica alguma vez
escrever o seu próprio scanner de malware, tem de aplicar a mesma técnica ou arrisca ser
confundida com o próprio malware que procura, por scanners de host (Imunify360, maldet,
Wordfence). Ver citação completa em §4.1.
9. **`Performance_Page_Audit::fetch()` / `safe_redirect_target()`** — seguir redirects
manualmente em vez de delegar no parâmetro `redirection` do `wp_remote_get`, revalidando o
host em cada hop. Este padrão é reutilizável para qualquer futura tool "analisar um URL deste
site" (ex. um crawler de sitemap) — nunca confiar que o WordPress core segue redirects de
forma segura para este caso de uso.
### Simplificar
1. **O caminho "shell" completo de WP-CLI (binário `wp` configurável via option/constante,
`proc_open` HTTP, jobs em background detached)** é infraestrutura pesada que só é necessária
quando o servidor MCP é alcançado por HTTP puro (sem contexto WP-CLI). **Neste ecossistema, os
3 servidores MCP já ligam via SSH+STDIO a `wp mcp-adapter serve` (ver doc 00 e
`skill://emcp-tools`)** — ou seja, `is_cli_context()` é **sempre verdadeiro** e o caminho
`run_in_process()` cobre 100% da utilização real. Uma réplica focada neste padrão de
deployment pode **eliminar inteiramente** o caminho shell + jobs detached (`WPCLI_Runner::run_shell`,
toda a classe `WPCLI_Jobs`) e ficar só com `WP_CLI::runcommand(..., launch=false)` — muito
menos superfície de ataque e código para manter, sem perder funcionalidade no cenário real de
uso.
2. **`Security_Finding`/`Performance_Finding`** são factories triviais de um único método —
copiar tal-e-qual sem redesenho, não há aqui nada a simplificar mais.
3. O cap `CATEGORY_CRIT_CAP=60` por categoria no Security Scanner é razoável mas arbitrário —
uma réplica pode adoptar o mesmo valor sem re-derivar a heurística, ou simplesmente usar o
scoring mais simples do Performance Analyzer (sem cap) se a diferenciação por categoria não
for um requisito.
### Deixar de fora (ou adiar)
1. **`deep_assets` no Performance Analyzer** — está no schema como "reserved", nunca chegou a ser
implementado neste build. Não vale a pena reservar espaço para uma feature que o próprio
vendor não implementou em 12 versões (`since 3.0.0`).
### Gotchas não óbvios a não esquecer
- **Invalidação de OPcache após escrever/apagar `.php`** (`opcache_invalidate($abs, true)`) — é
fácil esquecer este passo numa réplica e ter bugs "a alteração não teve efeito" reportados como
falsos negativos de teste.
- **O deny-list incremental (doc 00 §8) é a camada EXTERNA de defesa; os guards deste documento
são a camada INTERNA.** Mesmo que uma réplica não implemente um deny-list configurável, TODAS
as gates internas aqui documentadas (confinamento de path, validação de SQL, blocklist de
WP-CLI, `confirm:true` obrigatório em deletes) devem ser mantidas — são a última linha de
defesa se o deny-list for mal configurado (ver o achado crítico de `descomplicar.pt` no
`skill://emcp-tools` §8.2, onde o deny-list externo falhou completamente e só as gates internas
— que continuam a existir mas não bastam sozinhas quando a tool está "activa" — teriam impedido
o pior).
- **`get-wp-cli-job`/`list-wp-cli-jobs` são readonly e mesmo assim ficam desligados por omissão
em bloco com `run`/`dispatch`** — é uma escolha deliberada de UX/segurança (não faz sentido dar
acesso a resultados de jobs sem dar acesso a criá-los), mas quebra o padrão "read sempre
activo, write desligado" que domina o resto do plugin. Documentar esta excepção explicitamente
numa réplica para não ser "corrigida" por engano.
- **A construção lazy dos audits de segurança não é só uma optimização de performance — é uma
medida de resiliência anti-fatal-error** (issue #100). Qualquer réplica que registe abilities em
cada `wp_abilities_api_init` (que corre em TODO pedido admin/REST) deve tratar a construção de
qualquer dependência pesada/frágil da mesma forma: lazy, e sempre com um `try/catch` a envolver
o registo global (ver `EMCP_Tools_Ability_Registrar::register_all()` no doc 00, que já faz
exactamente isto ao nível do registrador inteiro).
---
## Fonte
Leitura directa (19-08-2026) de:
`includes/abilities/class-filesystem-abilities.php`,
`includes/class-filesystem-guard.php`,
`includes/abilities/class-database-abilities.php`,
`includes/class-database-guard.php`,
`includes/abilities/class-wpcli-abilities.php`,
`includes/wpcli/class-wpcli-runner.php`,
`includes/wpcli/class-wpcli-validator.php`,
`includes/wpcli/class-wpcli-jobs.php`,
`includes/abilities/class-security-abilities.php`,
`includes/security/class-security-scanner.php`,
`includes/security/class-security-malware-audit.php`,
`includes/security/class-security-hardening-audit.php`,
`includes/security/class-security-software-audit.php`,
`includes/security/class-security-integrity-audit.php`,
`includes/security/class-security-finding.php`,
`includes/abilities/class-performance-abilities.php`,
`includes/performance/class-performance-analyzer.php`,
`includes/performance/class-performance-server-audit.php`,
`includes/performance/class-performance-page-audit.php`,
`includes/performance/class-performance-finding.php`.
Cruzado com `includes/abilities/class-ability-registrar.php` (condições de registo — todos os 5
grupos deste documento registam-se sem guarda condicional, fora do bloco `if ($elementor_active)`)
e com `docs/00-ARQUITECTURA.md` + `skill://emcp-tools` (contexto de arquitectura geral e postura
de deny-list ao vivo nos 3 sites do ecossistema).
+457
View File
@@ -0,0 +1,457 @@
# 08 — Integrações com plugins de terceiros (ACF, Meta Box, Forms, SEO, WooCommerce)
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cobre as classes que ligam o EMCP Tools a dados/estruturas geridas por outros
plugins: ACF, Meta Box, plugins de formulários (Contact Form 7 no Free) e plugins de SEO
(Slim SEO no Free), mais o inventário de integrações Pro-only que a árvore Free referencia
mas não contém (WooCommerce, 8 plugins de formulários adicionais, 6 plugins de SEO
adicionais, 2 pacotes de widgets Elementor, e a Ultimate Addons for Elementor).
Contexto arquitectural (ver `docs/00-ARQUITECTURA.md`): toda ability regista-se via
`emcp_tools_register_ability()` — nunca `wp_register_ability()` directamente —, que normaliza o
schema, envolve o `execute_callback` num veto de escrita (`emcp_tools_before_write`) e num
`normalize_result()` (garante retorno sempre objecto JSON), antes de delegar ao core. O gating
de cada grupo vem de `includes/abilities/class-ability-registrar.php` (lido na íntegra nesta
tarefa) — os excertos exactos são reproduzidos abaixo, não parafraseados.
## 0. Padrão comum às cinco integrações deste documento: o "dispatcher de dois braços"
Todas as cinco famílias (ACF, Meta Box, Forms, SEO — WooCommerce presumivelmente também,
mas Pro) seguem a MESMA forma: em vez de registar N abilities MCP individuais (uma por
operação), registam **exactamente duas** — um tool `<domínio>-read` e um tool `<domínio>-write` —
cada uma com o schema `{ operation?: string, arguments?: object }`. Chamar sem `operation`
devolve um catálogo de descoberta (`{ mode, operations: [{operation, description, ...}] }`);
chamar com `operation` despacha para um executor interno. Isto é o MESMO padrão de "compact tool
mode" que o dispatcher global de 3 tools usa ao nível do servidor inteiro (`docs/00`, §4) —
aqui aplicado ao nível de CADA integração de terceiros, mantendo o catálogo total de abilities
pequeno mesmo com dezenas de operações internas por plugin.
Duas implementações distintas deste padrão coexistem no código:
1. **ACF e Meta Box** — cada classe implementa o dispatcher "à mão" (método `dispatch()`
privado + `operations()` que devolve o mapa nome→{mode,run,perm,desc,...}). Não há classe
base partilhada entre ACF e Meta Box.
2. **Forms e SEO** — têm uma classe base abstracta partilhada por TODAS as integrações da
categoria (`EMCP_Tools_Form_Integration`, `EMCP_Tools_SEO_Integration`), da qual CF7 e Slim
SEO (Free) e os 8+6 plugins Pro (não presentes nesta árvore) herdam. A base implementa
`register()`, `dispatch()`, o catálogo de descoberta, o schema `{operation,arguments}` e uma
gate de confirmação genérica (`confirm: true` obrigatório em `arguments` para operações
marcadas `confirm=>true` no mapa de operações — mecanismo de "são irreversíveis" ainda por
usar em CF7/Slim SEO Free, mas presumivelmente usado pelos plugins Pro com delete de
entradas/redirects).
**Nuance de segurança repetida nas 4 classes**: o `permission_callback` registado no MCP (a
"porta grossa" que decide se o tool sequer aparece/corre) é sempre uma capacidade **larga**
(`edit_posts` para leitura; para escrita varia — ver tabelas abaixo). A gate **fina e real** por
operação corre DENTRO do `dispatch()`, comparando com o `perm` específico de cada operação do
mapa `operations()`. Um utilizador pode assim ver o tool `acf-write` listado mas ser recusado
(`forbidden`) ao tentar `create-field-group` se só tiver `edit_posts` e não `manage_options`.
## 1. ACF (Advanced Custom Fields / ACF PRO)
**Ficheiro:** `includes/abilities/class-acf-abilities.php` (1627 linhas — a maior classe de
abilities do plugin). **Classe:** `EMCP_Tools_ACF_Abilities`. **Desde:** 3.2.1.
**Condição de registo** (`class-ability-registrar.php`):
```php
// ACF abilities — only when Advanced Custom Fields (free or Pro) is active.
if ( class_exists( 'EMCP_Tools_ACF_Abilities' ) && EMCP_Tools_ACF_Abilities::acf_active() ) {
$acf = new EMCP_Tools_ACF_Abilities();
$acf->register();
...
}
```
`acf_active()` = `function_exists('acf_get_field_groups')` (ACF free OU Pro, qualquer versão
recente). Sem gate de licença Pro do EMCP em si — funciona com ACF free.
### 1.1 Abilities MCP registadas (2)
| Ability | Input schema | Descrição | `permission_callback` | readonly/destructive |
|---|---|---|---|---|
| `emcp-tools/acf-read` | `{ operation?: string, arguments?: object }` | Lê dados ACF: field groups, valores de campos, options pages, e (ACF 6.1+) CPTs/taxonomias geridos por ACF. Sem `operation` → catálogo. | `check_read_permission()` = `current_user_can('edit_posts')` | readonly=true, destructive=false, idempotent=true |
| `emcp-tools/acf-write` | `{ operation?: string, arguments?: object }` | Escreve dados ACF (valores, field groups, CPTs/taxonomias). **Escritas desligadas por omissão** (activar em Tools → Plugins → ACF). Sem `operation` → catálogo. | `check_read_permission()` — **nota**: a gate registada no MCP é a mesma de leitura; a gate real de escrita (`manage_options`/`edit_post`) corre por operação dentro de `dispatch()` | readonly=false, destructive=false, idempotent=false |
### 1.2 As 15 operações internas (via `operation` + `arguments`)
| operação | modo | `perm` (capability) | requer ACF 6.1+? | O que faz |
|---|---|---|---|---|
| `list-field-groups` | read | `check_read_permission` (`edit_posts`) | não | Lista field groups: `key, id, title, active, local, field_count`. Args opcionais: `post_id` (filtra por contexto), `search`, `active_only` (default true). |
| `get-field-group` | read | `check_read_permission` | não | Devolve um field group completo: `location` rules + árvore recursiva de campos (`sub_fields`/`layouts` até profundidade 10). Args: `{ key }` (key ou ID numérico). |
| `list-options-pages` | read | `check_read_permission` | não | Lista options pages ACF (feature PRO; vazio em ACF free). Sem args. |
| `get-fields` | read | `check_fields_permission` (`edit_post`/`manage_options` conforme alvo) | não | Lê valores de campos de um post ou options page. Args: `{ post_id }` OU `{ options_page }`; opcionais `{ fields: string[] }` (filtra), `{ include_field_objects: bool }` (envolve valor com type/label). |
| `list-post-types` | read | `check_manage_permission` (`manage_options`) | **sim** | Lista CPTs geridos por ACF (`acf-post-type` CPT interno). |
| `get-post-type` | read | `check_manage_permission` | **sim** | Devolve um CPT ACF completo por `{ key }`. |
| `list-taxonomies` | read | `check_manage_permission` | **sim** | Lista taxonomias geridas por ACF. |
| `get-taxonomy` | read | `check_manage_permission` | **sim** | Devolve uma taxonomia ACF completa por `{ key }`. |
| `update-fields` | write | `check_fields_permission` | não | Escreve valores de campos (incl. linhas repeater/flexible/gallery) num post ou options page. Args: `{ post_id\|options_page, fields: {name: value} }`. Campos de tipo PRO (repeater/flexible_content/gallery/clone) recusados se ACF não for Pro. Linhas de `flexible_content` validadas contra `acf_fc_layout` conhecido. Regista before-image no Change Ledger (`EMCP_Tools_Change_Recorder::record_acf_fields`) se essa classe existir. |
| `create-field-group` | write | `check_manage_permission` | não | Cria um field group com campos + location rules via `acf_import_field_group()` (persiste no CPT `acf-field-group`; NÃO usa `acf_add_local_field_group()`, que seria só memória). Args: `{ title, fields: [...], location?: [[...]] }`. |
| `update-field-group` | write | `check_manage_permission` | não | Edita um field group **guardado em BD**: título, location, active, position, adiciona novos campos (`add_fields`), altera settings mutáveis de campos existentes (`update_fields`). **Recusa** grupos `local` (registados por acf-json/PHP) e qualquer alteração a `name`/`type` de um campo existente (imutáveis — evitaria orfanar postmeta). Sem delete. |
| `create-acf-post-type` | write | `check_manage_permission` | **sim** | Regista um CPT via ACF (dados, sem código) usando `acf_import_post_type()`. Args: `{ post_type, title, singular?, public?, hierarchical?, show_in_rest?, supports?, has_archive?, taxonomies? }`. Slug validado contra 21 slugs reservados do WordPress (`post`, `page`, `attachment`, etc.). |
| `update-acf-post-type` | write | `check_manage_permission` | **sim** | Edita um CPT ACF existente por `{ key }`. **Slug imutável** (recusa se `post_type` no input difere do actual — orfanaria conteúdo). |
| `create-acf-taxonomy` | write | `check_manage_permission` | **sim** | Regista uma taxonomia via ACF. Args: `{ taxonomy, title, object_type: string[] (post types), singular?, hierarchical?, public?, show_in_rest? }`. |
| `update-acf-taxonomy` | write | `check_manage_permission` | **sim** | Edita uma taxonomia ACF existente por `{ key }`. **Slug imutável** (orfanaria termos). |
`cpt_tax_supported()` = `function_exists('acf_get_acf_post_types') && acf_get_acf_taxonomies && acf_import_post_type && acf_import_taxonomy` (ACF 6.1+). Operações CPT/tax são omitidas do catálogo de descoberta e recusam com `acf_cpt_tax_unsupported` se a versão for anterior.
### 1.3 Decisões de design não óbvias (ACF)
- **Escrita sempre por `field_key`, nunca por `name`**: comentário no código — `update_field()` por nome falha silenciosamente em alvos que ainda não têm valor guardado para o campo. O resolvedor de campo (`resolve_field()`) tenta: (1) prefixo `field_` directo via `acf_get_field()`, (2) índice por nome/key construído a partir dos field groups aplicáveis ao alvo, (3) fallback `get_field_object()` para alvos options sem valor guardado ainda.
- **`update_field()` não confia no valor de retorno**: devolve `false` também num no-op (valor idêntico), por isso o sucesso é confirmado por uma releitura (`get_field()`) após cada escrita, não pelo booleano.
- **Índice de campos por request, invalidado após escrita**: `field_index_cache` (array associativo por `target`) evita relistar field groups a cada operação, mas é explicitamente apagado (`unset`) após `update-fields`/`create-field-group`/`update-field-group` para uma leitura subsequente no mesmo request ver o estado fresco.
- **Options pages sem lookup reverso**: ACF não tem forma de mapear um alvo `options` de volta aos seus field groups; o índice cai para (a) todo o field group com QUALQUER regra `location` de tipo `options_page`, mais (b) `get_field_objects()` para capturar campos já guardados mesmo sem regra de localização detectável.
- **Normalização de valores**: `WP_Post`/`WP_User`/`WP_Term` viram resumos compactos (`{id, title/name, ...}`); arrays de imagem/ficheiro ACF (`return_format=array`) reduzidos a `{id, url, alt, mime}` em vez do array completo (que inclui `sizes` com dezenas de entradas).
- **Sem delete, em lado nenhum**: nem field groups, nem campos, nem valores, nem CPTs/taxonomias — write-only-forward. Confirma o padrão documentado no cabeçalho do ficheiro: "there is deliberately NO delete tool".
## 2. Meta Box (metabox.io)
**Ficheiro:** `includes/abilities/class-metabox-abilities.php`. **Classe:**
`EMCP_Tools_Meta_Box_Abilities`. **Desde:** 3.4.2.
**Condição de registo:**
```php
// Meta Box abilities — only when Meta Box (free or extensions) is active.
if ( class_exists( 'EMCP_Tools_Meta_Box_Abilities' ) && EMCP_Tools_Meta_Box_Abilities::metabox_active() ) {
$metabox = new EMCP_Tools_Meta_Box_Abilities();
$metabox->register();
...
}
```
`metabox_active()` = `defined('RWMB_VER') && function_exists('rwmb_get_registry')`.
### 2.1 Abilities MCP registadas (2)
| Ability | Input schema | Descrição | `permission_callback` | readonly/destructive |
|---|---|---|---|---|
| `emcp-tools/metabox-read` | `{ operation?, arguments? }` | Lê field groups Meta Box registados, as suas definições de campo, e valores de campo num post/objecto. | `check_read_permission()` = `edit_posts` | readonly=true, idempotent=true |
| `emcp-tools/metabox-write` | `{ operation?, arguments? }` | Escreve valores de campo Meta Box. **Desligado por omissão** (Tools → Plugins → Meta Box). | `check_read_permission()` — mesma nota que ACF: gate real é por operação | readonly=false, destructive=false, idempotent=false |
### 2.2 As 4 operações internas
| operação | modo | Descrição |
|---|---|---|
| `list-field-groups` | read | Lista meta boxes registados: `id, title, object_type, post_types[], field_count`. Args opcionais: `search`, `object_type`. |
| `get-field-group` | read | Um meta box completo por `{ id }`: título, object_type, post_types, árvore de campos recursiva (campos `clone`/group aninhados até profundidade 10). |
| `get-fields` | read | Lê valores via `rwmb_meta()`. Args: `{ post_id }` OU `{ object_type, object_id }`; opcionais `{ fields: string[], include_field_settings: bool }`. |
| `update-fields` | write | Escreve valores via `rwmb_set_meta()` (não devolve nada — confirmação por releitura). Args: `{ post_id\|object_type+object_id, fields: {id: value} }`. |
Ambas as operações de leitura/escrita têm o mesmo `perm`: `check_read_permission`/`check_fields_permission` — sem distinção read/write op-a-op como no ACF (só há uma operação de escrita).
### 2.3 Diferenças-chave face ao ACF
- **Meta Box free core não tem UI de construção de campos** — campos são declarados em PHP via o filtro `rwmb_meta_boxes`. Por isso NÃO HÁ authoring de field groups/CPT/taxonomia aqui (ao contrário do ACF) — só leitura de definições e leitura/escrita de VALORES.
- **Nomenclatura invertida face ao ACF**: no Meta Box, o `id` de um campo É a meta key (equivalente ao `name` do ACF), e `name` é o label humano (equivalente ao `label` do ACF) — nota explícita no cabeçalho do ficheiro por ser uma fonte comum de confusão ao portar lógica entre as duas integrações.
- **`applicable_fields()`**: para alvos `post`, só considera meta boxes cujo `post_types` inclui o tipo do post concreto (Meta Box permite meta boxes restritos a post types específicos); para outros `object_type`, aceita todas as meta boxes desse tipo sem mais filtragem.
- **Normalização de valor**: reconhece o formato específico de imagem/ficheiro do Meta Box — array associativo com `ID`+`url` E (`full_url` OU `path` OU `mime_type`) — distinto da assinatura ACF (`ID`+`url`+`mime_type`).
- **`resolve_target()` tem um caso de normalização subtil**: `{ object_type:'post', object_id:N }` sem `post_id` é reescrito internamente para o caminho `post_id` (garante o mesmo 404-check e gate `edit_post` que `{post_id:N}` receberia diretamente).
## 3. Forms — infra-estrutura genérica (`EMCP_Tools_Form_Integration`)
**Ficheiro:** `includes/abilities/forms/class-form-integration.php`. **Classe:** classe base
abstracta `EMCP_Tools_Form_Integration`. **Desde:** 3.5.0. Directório `forms/` no Free contém
apenas ESTE ficheiro + `class-cf7-integration.php` — nenhum outro ficheiro de plugin de
formulário existe nesta árvore (confirmado por listagem directa, secção 6).
### 3.1 Contrato abstracto
Cada integração concreta implementa:
- `id(): string` — id curto, usado para construir os nomes dos tools (`<id>-read`/`<id>-write`).
- `label(): string` — label humano.
- `is_active(): bool` — se o plugin de formulários alvo está activo.
- `operations(): array<string,array>` — mapa `nome => { mode, run, perm, desc, confirm? }`.
`is_available()` (usado pelo registrar para decidir se regista) = `is_active()` por omissão.
### 3.2 O que a base fornece a TODAS as integrações de formulários
- **`register()`** — regista os dois tools `<id>-read`/`<id>-write` com o schema partilhado
`{ operation?, arguments? }`. Meta annotations do tool write: `readonly=false, destructive=true,
idempotent=false` — nota: **destructive=true por omissão na base**, diferente de ACF/Meta
Box/SEO (que marcam `destructive=false`) — reflecte que integrações Pro de formulários
provavelmente apagam entradas/submissões, uma operação genuinamente destrutiva que CF7 (sem
armazenamento de submissões) nunca exerce.
- **`can_read()`** = `edit_posts`; **`can_write()`** = `manage_options` — ambas as gates coarse
registadas no MCP; a gate real corre por operação via `$op['perm']`.
- **`dispatch()`** — resolve `operation`, verifica `is_active()` (senão `plugin_inactive`, HTTP 409),
valida a operação existe no modo certo (`unknown_operation`, HTTP 404), corre `$op['perm']`
(`forbidden`, HTTP 403), e — **gate de confirmação genérica**: se `$op['confirm']===true`,
exige `arguments.confirm===true` (senão `confirmation_required`, HTTP 400); o campo `confirm`
é removido de `$args` antes de chamar o executor real (não polui o input do handler).
- **Sem mecanismo de change-ledger automático** — ao contrário da base SEO (secção 4), a base
Forms não regista snapshots before-image; qualquer undo teria de ser implementado por
integração concreta.
## 4. Contact Form 7 (integração Free com ficheiro dedicado)
**Ficheiro:** `includes/abilities/forms/class-cf7-integration.php`. **Classe:**
`EMCP_Tools_CF7_Integration extends EMCP_Tools_Form_Integration`. **Desde:** 3.5.0.
**Condição de registo:**
```php
if ( class_exists( 'EMCP_Tools_CF7_Integration' ) ) {
$form_integrations[] = new EMCP_Tools_CF7_Integration();
}
... // depois, para todos os $form_integrations:
foreach ( $form_integrations as $form_integration ) {
if ( $form_integration->is_available() ) {
$form_integration->register();
...
}
}
```
`is_active()` = `class_exists('WPCF7_ContactForm') || defined('WPCF7_VERSION')`. Nenhum gate de
licença Pro do EMCP — CF7 é a ÚNICA integração de formulários incluída no build Free.
Verificado contra Contact Form 7 6.1.6 (comentário no cabeçalho do ficheiro lista a superfície
API usada: `WPCF7_ContactForm::find()`, `::get_instance()`, `->id()/->name()/->title()`,
`->scan_form_tags()`, `->prop()`, `->set_properties()+->save()`).
### 4.1 Abilities MCP registadas (2, via a base)
| Ability | Descrição gerada (`label().' Read/Write'`) | `permission_callback` |
|---|---|---|
| `emcp-tools/cf7-read` | "Contact Form 7, read operations. Call with no operation to list them." | `can_read()` = `edit_posts` |
| `emcp-tools/cf7-write` | "Contact Form 7, write operations..." | `can_write()` = `manage_options` |
### 4.2 As 7 operações internas
| operação | modo | `perm` (capability CF7 real) | Descrição |
|---|---|---|---|
| `list-forms` | read | `wpcf7_read_contact_forms` (≈`edit_posts`) | Lista todos os formulários CF7: `id, title, slug, field_count`. |
| `get-form` | read | idem | Um formulário completo por `{ form_id }`: `fields[], mail, mail_2, messages, additional_settings`. |
| `list-notifications` | read | idem | Os dois templates de email (`mail`, `mail_2`) por `{ form_id }`. |
| `get-settings` | read | idem | `messages` + `additional_settings` por `{ form_id }`. |
| `update-notification` | write | `wpcf7_edit_contact_forms` (≈`publish_pages`) | Actualiza um template de email por merge (não substitui): `{ form_id, notification: "mail"\|"mail_2", mail: {subject?, sender?, recipient?, body?, additional_headers?, attachments?, use_html?, active?} }`. |
| `update-messages` | write | idem | Actualiza mensagens de validação/resposta por merge: `{ form_id, messages: {key: value} }`. |
| `update-form-settings` | write | idem | Substitui por completo o bloco Additional Settings: `{ form_id, additional_settings: string }`. |
**Nota**: os `perm` reais aqui são MAIS finos que a gate coarse da base (`can_write()=manage_options`)
— usam as capacidades nativas do CF7 (`wpcf7_edit_contact_forms`), que por omissão mapeiam para
`publish_pages`, não `manage_options`. Um editor sem `manage_options` mas com `publish_pages` é
assim recusado pela gate coarse do MCP (`can_write`) ANTES sequer de chegar à gate fina do CF7 —
uma limitação prática: a capability CF7 nativa nunca é realmente exercida nesta implementação
porque a gate MCP é mais restritiva.
**CF7 não guarda submissões** (sem addon Flamingo/DB próprio) — por isso não há operações de
"entries" (ao contrário do que os 8 plugins Pro de formulários presumivelmente expõem, dado
todos guardarem submissões nativamente).
## 5. SEO — infra-estrutura genérica (`EMCP_Tools_SEO_Integration`)
**Ficheiro:** `includes/abilities/seo/class-seo-integration.php`. **Classe:** base abstracta
`EMCP_Tools_SEO_Integration`. **Desde:** 3.5.0. Directório `seo/` no Free contém apenas ESTE
ficheiro + `class-slimseo-integration.php`.
Distinção documentada no cabeçalho: isto é **diferente** do toolkit Pro SEO & Accessibility
(`audit-page-seo`/`generate-meta-tags`, ver doc 01/02), que ANALISA e GERA em vez de
ler/escrever os dados que um plugin de SEO já guarda.
### 5.1 Contrato abstracto
Mesma forma que Forms: `id()`, `label()`, `is_active()`, `operations()`. Diferenças de
comportamento da base face a Forms:
- **`can_read()`** = `edit_posts`; **`can_write()`** = `edit_posts` (não `manage_options` —
escrever SEO de um post é uma operação de editor normal, não administrativa).
- **Meta annotations do tool write**: `readonly=false, destructive=false, idempotent=false` —
ao contrário de Forms, aqui `destructive=false` por omissão (escritas de metadados SEO nunca
apagam dados irrecuperáveis por si mesmas).
- **Change-ledger automático (feature exclusiva da base SEO, ausente em Forms/ACF/Meta Box)**:
para qualquer operação de modo `write` cujos `arguments` incluam `post_id` ou `term_id`, a base
chama `recordable_meta_keys($object)` (que cada integração concreta sobrepõe — devolve as meta
keys que ela escreve para esse tipo de objecto; vazio por omissão = sem registo automático),
tira um `snapshot_meta()` (before-image dessas keys) ANTES de correr o executor, e — se o
resultado não for `WP_Error` e `EMCP_Tools_Change_Log` existir — grava uma entrada
`{domain:'seo', action:'update', target:'<id>:<object>:<obj_id>', rollback:{type:'meta-before-image', object, id, before}}`.
Isto dá rollback automático a TODAS as integrações SEO que declarem as suas meta keys, sem cada
uma ter de implementar a lógica de undo.
### 5.2 Abilities MCP e a mesma gate de confirmação
A `dispatch()` reutiliza a MESMA forma de Forms: catálogo sem `operation`, `plugin_inactive`
(409) se `is_active()` falhar, `unknown_operation` (404), `forbidden` (403) por `$op['perm']`,
`confirmation_required` (400) se `$op['confirm']===true` e `arguments.confirm` não for `true`.
## 6. Slim SEO (integração Free com ficheiro dedicado)
**Ficheiro:** `includes/abilities/seo/class-slimseo-integration.php`. **Classe:**
`EMCP_Tools_SlimSEO_Integration extends EMCP_Tools_SEO_Integration`. **Desde:** 3.5.0.
**Condição de registo:**
```php
if ( class_exists( 'EMCP_Tools_SlimSEO_Integration' ) ) {
$seo_integrations[] = new EMCP_Tools_SlimSEO_Integration();
}
... // depois, para todos os $seo_integrations, mesma forma que Forms:
foreach ( $seo_integrations as $seo_integration ) {
if ( $seo_integration->is_available() ) { $seo_integration->register(); ... }
}
```
`is_active()` = `defined('SLIM_SEO_VER')`. Slim SEO é a ÚNICA integração de SEO incluída no build
Free. Armazenamento confirmado ao vivo (comentário "Verified live" no cabeçalho): um único array
meta `slim_seo` por post/termo (chaves: `title, description, canonical, noindex, nofollow,
facebook_image, twitter_image`), mais uma option `slim_seo` para as definições do site.
### 6.1 Abilities MCP registadas (2, via a base)
| Ability | `permission_callback` |
|---|---|
| `emcp-tools/slimseo-read` | `can_read()` = `edit_posts` |
| `emcp-tools/slimseo-write` | `can_write()` = `edit_posts` |
### 6.2 As 5 operações internas
| operação | modo | `perm` | Descrição |
|---|---|---|---|
| `get-post-seo` | read | `edit_posts` | Metadados SEO de um post por `{ post_id }`: `title, description, canonical, noindex, nofollow, og_image, twitter_image` (view unificada — ver mapeamento abaixo). |
| `get-term-seo` | read | `edit_posts` | Idem para `{ term_id }`. |
| `get-settings` | read | `manage_options` | Definições SEO do site (a option `slim_seo`). |
| `update-post-seo` | write | `edit_posts` | Actualiza por merge campo-a-campo: `{ post_id, title?, description?, canonical?, noindex?, nofollow?, og_image?, twitter_image? }`. |
| `update-term-seo` | write | `edit_posts` | Idem para `{ term_id, ... }`. |
`recordable_meta_keys()` sobreposto para devolver `['slim_seo']` (a única meta key, para
qualquer `$object`) — activa o registo automático no Change Ledger da base para AMBAS as
operações de escrita.
### 6.3 Camada de mapeamento field↔meta-key
A classe mantém um `map(): array<string,string>` que traduz o vocabulário unificado do EMCP
(`title, description, canonical, noindex, nofollow, og_image, twitter_image`) para as chaves
reais do array `slim_seo` do plugin (`title, description, canonical, noindex, nofollow,
**facebook_image**, twitter_image` — nota: só `og_image`→`facebook_image` difere). `read_view()`
e `apply()` usam este mapa nos dois sentidos; `noindex`/`nofollow` são forçados a booleano
(`!empty()`), o resto passa por `(string)` quando escalar.
## 7. WooCommerce e demais integrações Pro-only — não presentes neste build
O `class-ability-registrar.php` referencia 18 classes de integração via `class_exists()` que
**não existem em nenhum ficheiro desta árvore Free** — confirmado por listagem directa de
`includes/abilities/` (topo), `includes/abilities/forms/`, `includes/abilities/seo/`, e
`includes/` (topo, todas as subpastas), sem grep recursivo disponível nesta sessão (as
ferramentas `glob`/`grep` não recursam paths `ssh://`; a confirmação foi feita listando cada
directório candidato e comparando os nomes de ficheiro presentes contra os nomes de classe
referenciados). Nenhum destes ficheiros existe nesta instalação — não há schemas nem
comportamento a documentar; só a metadata de gating, copiada verbatim do registrar.
| Classe (referência no registrar) | Plugin de terceiros integrado | Condição de gating exacta (copiada do registrar) |
|---|---|---|
| `EMCP_Tools_Woo_Integration` | WooCommerce | `class_exists( 'EMCP_Tools_Woo_Integration' ) && EMCP_Tools_Woo_Integration::woo_active()` — comentário no código: "WooCommerce abilities (Pro) — only when WooCommerce is active." |
| `EMCP_Tools_WPForms_Integration` | WPForms | `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` (licença Pro) **E** `class_exists($classe)` **E** depois `$integration->is_available()` (= `is_active()`, plugin alvo activo) — comentário: "CF7 is free; the five [sic, na prática oito] entry-storing plugins are Pro. Each registers only when its plugin is active." |
| `EMCP_Tools_GravityForms_Integration` | Gravity Forms | idem (mesmo loop, mesma tripla gate: licença Pro + ficheiro presente + plugin activo) |
| `EMCP_Tools_FluentForms_Integration` | Fluent Forms | idem |
| `EMCP_Tools_NinjaForms_Integration` | Ninja Forms | idem |
| `EMCP_Tools_Formidable_Integration` | Formidable Forms | idem |
| `EMCP_Tools_MetForm_Integration` | MetForm | idem |
| `EMCP_Tools_SureForms_Integration` | SureForms | idem |
| `EMCP_Tools_Forminator_Integration` | Forminator | idem |
| `EMCP_Tools_Yoast_Integration` | Yoast SEO | `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` **E** `class_exists($classe)` **E** `$integration->is_available()` — comentário: "Slim SEO is free; the other 6 are Pro. Each registers only when its SEO plugin is active." |
| `EMCP_Tools_RankMath_Integration` | Rank Math | idem |
| `EMCP_Tools_AIOSEO_Integration` | All in One SEO | idem |
| `EMCP_Tools_SeoPress_Integration` | SEOPress | idem |
| `EMCP_Tools_SEOFramework_Integration` | The SEO Framework | idem |
| `EMCP_Tools_SureRank_Integration` | SureRank | idem |
| `EMCP_Tools_EssentialAddons_Integration` | Essential Addons for Elementor | Só `class_exists( 'EMCP_Tools_EssentialAddons_Integration' )` **E** `$addon_pack->is_available()` — **sem** o gate explícito `emcp_tools_fs()->can_use_premium_code()` no laço (ao contrário de Forms/SEO); comentário: "Elementor addon widget packs (Pro). Each pack contributes ONE read tool for discovery + curation; widgets are placed with the generic add-free-widget tool, so there is deliberately no write tool here." O ficheiro simplesmente não existe fora do build Pro, logo `class_exists()` é sempre falso no Free independentemente de licença. |
| `EMCP_Tools_PremiumAddons_Integration` | Premium Addons for Elementor | idem (mesmo laço, mesma ausência de gate de licença explícito no registrar) |
| `EMCP_Tools_UAE_Integration` | Ultimate Addons for Elementor (anteriormente Header Footer Elementor) | `class_exists( 'EMCP_Tools_UAE_Integration' )` **E** `$emcp_uae->is_available()` — comentário: "Ultimate Addons for Elementor (Pro)... Both a widget pack AND a data plugin, so unlike the pure packs it keeps the house read/write dispatcher pair: discovery + templates on read, templates on write." |
**Nota adicional confirmada pelo registrar** (fora do escopo directo desta tarefa mas
adjacente): outras integrações de "Themes-tab" também Pro-only e ausentes desta árvore —
`EMCP_Tools_GeneratePress_Integration`, `EMCP_Tools_GenerateBlocks_Integration`,
`EMCP_Tools_Blocksy_Blocks_Integration`, `EMCP_Tools_Blocksy_Extensions_Integration` — todas só
`class_exists()` + `is_available()`, sem gate de licença explícito no laço (mesmo padrão de
EssentialAddons/PremiumAddons/UAE). Não fazem parte do escopo pedido (ACF/MetaBox/Forms/SEO/Woo)
mas são citadas aqui por confirmarem o padrão: **todas** as integrações Pro deste plugin usam
`class_exists()` simples como gate primário — a distinção Free/Pro é feita por PRESENÇA DE
FICHEIRO (a pasta `emcp-pro/` inclui-os, `emcp-tools/` não), não por uma verificação de licença
em runtime dentro de cada laço de registo individual (excepto forms/SEO, que adicionam
explicitamente `emcp_tools_fs()->can_use_premium_code()` como segunda camada — provavelmente
porque esses 14 ficheiros de facto existem no zip Pro mas o acesso à FUNCIONALIDADE ainda
depende de licença activa, ao contrário dos addon-packs/UAE, que talvez sejam gratuitos dentro do
próprio Pro sem sub-gate de licença adicional).
## 8. Blueprint para réplica
**Copiar quase 1:1:**
- **O padrão de dispatcher de dois braços** (`<domínio>-read`/`<domínio>-write`, catálogo sem
`operation`, despacho por `operation`+`arguments`) — é a peça que mantém o catálogo total de
tools pequeno com dezenas de integrações possíveis. Vale a pena ter DUAS variantes conforme a
complexidade: dispatcher "à mão" para integrações com estado rico e reaproveitamento pesado
(ACF, Meta Box — cada uma tem a sua própria noção de "target"/"field index"), e uma classe base
abstracta partilhada para famílias homogéneas de integrações simples-mas-numerosas (Forms, SEO)
onde cada concreta só define `operations()` + os `run` callables.
- **A gate de confirmação genérica da base** (`confirm: true` obrigatório em `arguments` para
operações marcadas `confirm=>true`) — mecanismo barato e reutilizável para qualquer operação
irreversível futura (delete de entrada de formulário, delete de redirect, etc.), sem cada
integração ter de reimplementar a checagem.
- **O change-ledger automático da base SEO** (`recordable_meta_keys()` + snapshot before-image +
`EMCP_Tools_Change_Log::record()`) é o exemplo mais elegante deste documento: dá rollback
automático a qualquer integração que declare as suas meta keys, com ZERO código extra por
integração concreta. **Recomendação forte**: subir este padrão para a base Forms também (hoje
ausente lá) e generalizar para ACF/Meta Box (hoje cada uma implementa o seu próprio snapshot
ad-hoc — ACF via `EMCP_Tools_Change_Recorder::record_acf_fields`, Meta Box sem qualquer
registo). Um único mecanismo de "snapshot de meta keys antes de escrever" partilhado por TODAS
as integrações de terceiros pouparia manutenção e cobriria o gap do Meta Box.
- **Escrita sempre por identificador estável** (ACF: sempre por `field_key`, nunca por `name`) —
é uma lição de bug real ("silently fails on targets that have no stored value yet"), replicar
esta disciplina em qualquer integração própria que tenha um par nome-humano/chave-estável.
- **Imutabilidade de slug/tipo em updates** (ACF post-type/taxonomy slug; CF7 sem rename de
formulário) — recusar explicitamente mudanças que orfanariam dados existentes é barato e evita
uma classe inteira de bugs de integridade referencial silenciosa.
**Simplificar:**
- **O gate duplo de licença Pro em Forms/SEO** (`can_use_premium_code()` + `class_exists()` +
`is_available()`) é redundante para uma réplica que não tem modelo de negócio Free/Pro — uma
reescrita própria só precisa da terceira camada (`is_active()`/plugin alvo instalado).
- **A normalização de valores por tipo de objecto WP** (`WP_Post`/`WP_User`/`WP_Term` →
resumos compactos) está duplicada quase verbatim entre ACF e Meta Box (`normalize_value()`
em cada classe, com pequenas variações de forma de imagem/ficheiro). Vale a pena extrair um
helper único partilhado (`EMCP_Tools_Value_Normalizer::normalize($value)`) parametrizável por
"assinatura de imagem/ficheiro" (ACF: `ID+url+mime_type`; Meta Box: `ID+url` + uma de
`full_url`/`path`/`mime_type`) em vez de reimplementar a árvore recursiva duas vezes.
**Deixar de fora (ou adiar bastante):**
- **CPT/taxonomia "managed by ACF"** (ACF 6.1+) é uma feature de nicho (a maioria dos sites
regista CPTs por código, não pela UI do ACF) — só vale a pena replicar se o público-alvo da
réplica usar isto activamente. É também a parte mais frágil (7 das 15 operações ACF, gate por
versão, slugs reservados) — maior superfície de manutenção por unidade de valor entregue.
- **Options pages ACF** — feature exclusivamente Pro do plugin de terceiros; sem ACF Pro
instalado esta parte do código é sempre um array vazio. Só vale a pena manter o "stub" que
devolve `{pro: false, pages: []}` graciosamente, não investir em lógica adicional.
**Risco/gotcha não óbvio encontrado no código (citação directa):**
- `class-ability-registrar.php`, comentário no `try/catch` de `register_all()`: "Ability
registration runs on every admin page load and every REST request, so an exception here is a
site-wide fatal: wp-admin becomes unreachable... (issue #100, where a host malware scanner had
quarantined one of our class files, leaving `require_once` satisfied but the class undeclared).
No single tool group is worth locking an admin out of their own site." — **implicação directa
para as 5 integrações deste documento**: cada bloco de registo (ACF, Meta Box, cada form/SEO
integration) corre dentro deste `try/catch` amplo a nível de `register_groups()`; um erro fatal
ao construir QUALQUER uma destas classes (ex. uma versão incompatível de ACF que remova uma
função que o código assume existir) não derruba o site — simplesmente essa família de tools
fica ausente do catálogo, silenciosamente, com log via `error_log`. Uma réplica DEVE envolver o
registo de cada grupo de integração de terceiros no mesmo tipo de guarda — nunca deixar uma
dependência externa instável poder tirar o wp-admin do ar.
- ACF `execute_update_fields()`: comentário explícito sobre a armadilha de escrever por `name`
em vez de `key` — já citado acima, mas vale reforçar como o exemplo mais concreto de "testámos
em produção e partiu" que este documento encontrou.
- Meta Box `resolve_target()`: a normalização silenciosa de `{object_type:'post', object_id:N}`
para o caminho `post_id` é subtil — sem ela, alguém podia contornar o `edit_post` gate passando
o post por `object_type`+`object_id` em vez de `post_id` directo (dois caminhos de input para o
mesmo alvo, só um gated correctamente, se não fosse esta normalização).
## Fonte
Leitura directa (19-08-2026) de:
`includes/abilities/class-acf-abilities.php` (1627 linhas, completo),
`includes/abilities/class-metabox-abilities.php` (completo),
`includes/abilities/forms/class-form-integration.php` (completo),
`includes/abilities/forms/class-cf7-integration.php` (completo),
`includes/abilities/seo/class-seo-integration.php` (completo),
`includes/abilities/seo/class-slimseo-integration.php` (completo),
`includes/abilities/class-ability-registrar.php` (completo, 480+ linhas — todo o `register_groups()`).
Listagem directa (não grep recursivo, indisponível para paths `ssh://` nesta sessão) dos
directórios `includes/abilities/`, `includes/abilities/forms/`, `includes/abilities/seo/` e
`includes/` (topo, todas as subpastas) para confirmar a AUSÊNCIA física de qualquer ficheiro das
18 classes Pro-only listadas na secção 7. Cruzado com `docs/00-ARQUITECTURA.md` (mesma sessão de
documentação) para o contrato de `emcp_tools_register_ability()` e o padrão de dispatcher de
compact tool mode ao nível do servidor.
+654
View File
@@ -0,0 +1,654 @@
# 09 — Stock Images, EMCP Cloud e Servidor OAuth
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cobre 3 subsistemas relacionados mas independentes entre si: (1) busca e
sideload de imagens de stock, (2) sincronização com o serviço SaaS "EMCP Cloud"
(`emcptools.com`), e (3) um servidor de autorização OAuth 2.1 completo, próprio do plugin,
que serve de mecanismo de autenticação alternativo ao Application Password para clientes
MCP remotos. Este terceiro subsistema **não é exclusivo do Cloud** — é infra-estrutura MCP
genérica, e o Cloud é apenas um dos consumidores (via `EMCP_Tools_Gateway_Credential`, ver
§2.2.6).
---
## 1. Stock Images — busca e sideload de imagens (Unsplash / Pexels / Pixabay)
### 1.1 `EMCP_Tools_Stock_Image_Abilities` — `includes/abilities/class-stock-image-abilities.php`
**Condição de registo** (excerto exacto de `class-ability-registrar.php`) — as 2 tools de
provider registam-se sempre (não dependem do Elementor); a 3ª só quando o Elementor está
activo:
```php
// Stock-image provider tools (search-images + sideload-image) — pure WP core
// (a stock-provider search + a Media Library sideload), no Elementor needed,
// so they register on any site. add-stock-image (adds a widget) is gated below.
$stock_images = new EMCP_Tools_Stock_Image_Abilities( $this->data, $this->factory );
$stock_images->register_provider_tools();
$this->ability_names = array_merge( $this->ability_names, $stock_images->provider_tool_names() );
// ---- Elementor-dependent groups: only when Elementor is active ----
if ( $elementor_active ) {
// ... (outros grupos) ...
// Stock images: the add-stock-image widget tool (provider search + sideload
// registered unconditionally above); this one adds an image widget so it
// needs Elementor.
$stock_images->register_widget_tool();
$this->ability_names[] = 'emcp-tools/add-stock-image';
}
```
| Ability | `input_schema` (resumido) | O que faz | `permission_callback` | Anotações |
|---|---|---|---|---|
| `search-images` | `query` (str, obrig.), `provider` (enum `unsplash`\|`pexels`\|`pixabay`, opcional — omitido usa o 1º provider com chave configurada), `page` (int), `page_size` (int), `aspect_ratio` (enum `tall`\|`wide`\|`square`) | Pesquisa um provider de stock e devolve resultados normalizados: `{provider, result_count, page, page_count, results:[{id,title,url,thumbnail,width,height,creator,creator_url,license,license_url,attribution,source,foreign_landing_url}]}`. Delega em `EMCP_Tools_Stock_Image_Providers::resolve()` para escolher o cliente, depois num `run_search()` privado partilhado com `add-stock-image` que mapeia o resultado do provider para a forma estável do output_schema. | `check_read_permission()` → `current_user_can('edit_posts')` | `readonly:true, destructive:false, idempotent:true` |
| `sideload-image` | `url` (str, obrig. — deve ser o URL **exacto** devolvido por `search-images`, nunca construído/editado à mão), `title`, `alt_text`, `caption`, `attribution`, `convert_webp` (bool) | Descarrega um URL externo para a Media Library via `EMCP_Tools_Url_Guard::safe_download()` (SSRF-guarded — ver §4.2) e devolve `{attachment_id, url, title}`. Antes de descarregar, se o URL for o endpoint de tracking do Unsplash (`api.unsplash.com/.../download`), resolve-o automaticamente para o URL de imagem real via `EMCP_Tools_Unsplash_Client::resolve_download()` — ver gotcha nº1 no blueprint. Em caso de falha, a mensagem de erro inclui uma sugestão accionável específica (URL da API do Unsplash / 404 / 401-403) para corrigir o comportamento de um agente que insista no mesmo URL errado. `convert_webp:false` desactiva a compressão/WebP do módulo Image Optimization só para este upload (via filtro `emcp_tools_optimize_attachment`). | `check_upload_permission()` → `current_user_can('upload_files')` | `readonly:false, destructive:false, idempotent:false` |
| `add-stock-image` | `post_id` (int, obrig.), `parent_id` (str, obrig. — ID do container Elementor), `query` (str, obrig.), `provider`, `index` (int, 0=melhor resultado), `position` (int, -1=append), `image_size` (enum), `align` (enum `left`\|`center`\|`right`), `caption`, `aspect_ratio` (default `wide`), `alt_text`, `link_to` (enum `none`\|`file`\|`custom`), `convert_webp` | Compõe `search-images` → escolhe o resultado no `index` → dispara `trigger_download()` do provider (guideline Unsplash) → `sideload-image` → cria um widget `image` Elementor e insere-o em `parent_id` via `EMCP_Tools_Data::insert_element()` + `save_page_data()`. Devolve `{attachment_id, image_url, element_id, original_url, attribution, provider}`. Por omissão filtra por `aspect_ratio=wide` (paisagem) — melhor compatibilidade de layout. | `check_combined_permission()` → `edit_posts` **e** `upload_files` **e** (se `post_id` presente) `edit_post($post_id)` | `readonly:false, destructive:false, idempotent:false` |
### 1.2 `EMCP_Tools_Stock_Image_Providers` — `includes/class-stock-image-providers.php`
Registry + resolver estático, sem estado próprio. `map()` define os 3 providers e a
**ordem de prioridade de fallback** (`unsplash` → `pexels` → `pixabay`) quando nenhum é
pedido explicitamente. Cada entrada do mapa só tem `label` + nome da classe cliente.
- `has_key($id)` → delega em `{Client}::has_key()` estático.
- `available()` → lista de ids com chave configurada, pela ordem do mapa.
- `resolve($requested = '')` → devolve `[id, client]` ou `WP_Error`:
- se `$requested` não vazio: valida que existe no mapa e que tem chave, senão erro
`unknown_provider` / `no_api_key` (mensagem inclui o link para obter chave grátis).
- se vazio: usa o primeiro de `available()`, ou `no_api_key` se nenhum estiver configurado.
Este é o único ponto de acoplamento entre as 3 tools e os 3 clientes — trocar/adicionar um
provider é adicionar uma entrada ao `map()`.
### 1.3 Clientes HTTP dos 3 providers — `includes/class-{unsplash,pexels,pixabay}-client.php`
Todos seguem o mesmo contrato implícito (duck-typed, sem interface PHP formal):
`const OPTION` (nome da option em `wp_options`), `access_key()` estático (constante PHP
vence, senão a option — sempre decifrada via `EMCP_Tools_Secret::decrypt_if_needed()`,
ver §4.1), `has_key()`, instância `search_images(array $params)`, instância
`trigger_download(string $download_location)`.
| Cliente | Endpoint base | Auth header | Opção wp_options / constante PHP | `orientation`/aspect_ratio mapping | Particularidades |
|---|---|---|---|---|---|
| `EMCP_Tools_Unsplash_Client` | `https://api.unsplash.com` | `Authorization: Client-ID <key>` | `emcp_tools_unsplash_access_key` / `EMCP_TOOLS_UNSPLASH_ACCESS_KEY` | `wide→landscape`, `tall→portrait`, `square→squarish` | Único com `trigger_download()` REAL (guideline obrigatória da API Unsplash: disparar o `links.download_location` do resultado escolhido). `resolve_download()` resolve o endpoint de tracking `api.unsplash.com/photos/<id>/download` para o URL de imagem real (ver gotcha §Blueprint). `url` devolvido é `urls.regular` (~1080px), não `full`/`raw`. Attribution obrigatória: `creator_url` recebe sempre `?utm_source=emcp_tools&utm_medium=referral` (guideline Unsplash). 401→`invalid_key`; 403→`rate_limited` ("demo apps allow 50 requests/hour"). |
| `EMCP_Tools_Pexels_Client` | `https://api.pexels.com/v1` | `Authorization: <key>` (SEM prefixo `Bearer`) | `emcp_tools_pexels_api_key` / `EMCP_TOOLS_PEXELS_API_KEY` | `wide→landscape`, `tall→portrait`, `square→square` | `trigger_download()` é no-op (Pexels não tem endpoint de tracking). `url` = `src.large2x` (fallback `large`/`original`). 401/403→`invalid_key`; 429→`rate_limited` ("200 requests/hour on the free tier"). |
| `EMCP_Tools_Pixabay_Client` | `https://pixabay.com/api/` | **chave na query string** (`key=`), não em header | `emcp_tools_pixabay_api_key` / `EMCP_TOOLS_PIXABAY_API_KEY` | `wide→horizontal`, `tall→vertical` (sem `square` — Pixabay não tem essa orientação) | `per_page` restrito a 3-200 (min 3, diferente dos outros 2). Fixa sempre `image_type=photo&safesearch=true`. `trigger_download()` no-op; nota no cabeçalho do ficheiro: "Pixabay's terms require downloading/caching images rather than hotlinking — the sideload step satisfies that." 400/401→`invalid_key`; 429→`rate_limited` ("100 requests/minute"). |
Todos: `TIMEOUT=15`, `user-agent: Elementor-MCP/<versão> (WordPress/<versão>)`, resposta
normalizada para a mesma forma de campo (`normalize_photo()` privado por classe) antes de
chegar às abilities — as abilities nunca lidam com a forma nativa de nenhuma API.
---
## 2. EMCP Cloud — sincronização remota com `emcptools.com`
### 2.1 `EMCP_Tools_Cloud_Abilities` — `includes/abilities/class-cloud-abilities.php`
Cabeçalho do próprio ficheiro: *"Free tree. Registered only when the site is connected to
EMCP Cloud (the Cloud module is active and a token bundle is stored)."* — ou seja, ao
contrário da maioria dos outros grupos Pro-only deste plugin, **isto é uma feature Free**,
só gated pelo estado de ligação, não por licença.
**Condição de registo** (excerto exacto):
```php
// EMCP Cloud sync tools — only when the site is connected to a cloud account.
if ( class_exists( 'EMCP_Tools_Cloud_Abilities' ) && class_exists( 'EMCP_Tools_Cloud_Module' )
&& EMCP_Tools_Cloud_Module::is_enabled() && EMCP_Tools_Cloud::is_connected() ) {
$cloud_sync = new EMCP_Tools_Cloud_Abilities();
$cloud_sync->register();
$this->ability_names = array_merge( $this->ability_names, $cloud_sync->get_ability_names() );
}
```
`EMCP_Tools_Cloud_Module::is_enabled()` lê `emcp_tools_active_modules` (módulo `cloud`,
`default_active()=true` — activo por omissão em todos os sites). `EMCP_Tools_Cloud::is_connected()`
exige um token bundle guardado (ver §2.2.1) — nos 3 sites do ecossistema Descomplicar
verificados em `skill://emcp-tools`, nenhum está ligado a uma conta Cloud, logo estas 7
tools nunca aparecem na prática nesse ambiente.
| Ability | `input_schema` (resumido) | O que faz | Anotações |
|---|---|---|---|
| `cloud-status` | `{}` | `GET /api/cloud/v1/me` — plano, limites e uso da conta ligada. | `readonly:true, destructive:false` |
| `cloud-backup` | `kind` (enum `block`\|`widget`\|`snippet`, obrig.), `id` (int, obrig.) | Serializa um artefacto de sandbox local (ver doc 06) num "bundle" com checksum e faz `PUT /api/cloud/v1/artifacts`. | `readonly:false, destructive:false, idempotent:true` |
| `cloud-list` | `kind` (opcional) | `GET /api/cloud/v1/artifacts[?kind=]` — lista artefactos já guardados na conta. | `readonly:true, destructive:false` |
| `cloud-pull` | `artifact_uuid` (str, obrig.), `kind` (opcional) | `GET /api/cloud/v1/artifacts/{uuid}`, decodifica o bundle e importa-o como **novo draft local inactivo** (nunca substitui um artefacto existente). | `readonly:false, destructive:false` |
| `cloud-config-sync` | `type` (enum `settings`\|`brand_kit`\|`tool_toggles`, obrig.), `direction` (enum `push`\|`pull`, obrig.), `data` (objecto, só para `push`) | Push/pull de um blob de configuração arbitrário via `/api/cloud/v1/config/{type}`. Usado internamente também por `EMCP_Tools_Settings_Sync` (§2.2.7) com `type=settings`. | `readonly:false, destructive:false` |
| `cloud-marketplace-list` | `category` (opcional) | `GET /api/cloud/v1/marketplace[?category=]` — navega listings publicados (públicos, não exige ligação embora a tool em si exija `manage_options`). | `readonly:true, destructive:false` |
| `cloud-marketplace-install` | `slug` (str, obrig.) | `POST /api/cloud/v1/marketplace/{slug}/install`, decodifica o bundle devolvido e importa como novo draft local. | `readonly:false, destructive:false` |
Todas as 7 exigem `current_user_can('manage_options')`. Todas passam pelo padrão comum
`execute_*($input) → EMCP_Tools_Cloud_Sync::<método>() → is_wp_error() ? $r : (array) $r`.
### 2.2 Serviços de suporte (`includes/cloud/`)
#### 2.2.1 `EMCP_Tools_Cloud` — `class-cloud.php` (config + storage estático, sem rede)
- `base_url()` — constante `EMCP_TOOLS_CLOUD_URL` > option `emcp_tools_cloud_base_url` >
default `https://emcptools.com`; filtrável (`emcp_tools_cloud_base_url`) para
staging/self-host.
- `site_uuid()` — UUID v4 estável por site, `wp_generate_uuid4()`, mintado lazy no
primeiro `get_option()` e persistido em `emcp_tools_site_uuid`.
- `save_connection(array $bundle)` / `get_connection()` — o bundle `{access_token,
refresh_token, access_expires_at, client_id, connected_at}` é serializado em JSON e
guardado **cifrado** (`EMCP_Tools_Secret::encrypt()`, §4.1) na option
`emcp_tools_cloud_connection`.
- `is_connected()` — `!empty(access_token) || !empty(refresh_token)`.
- `SCOPES = 'openid cloud offline_access'` — pedidos ao IdP da Cloud (não confundir com o
`SCOPE='mcp'` do servidor OAuth deste plugin, §3 — são dois sistemas OAuth distintos: um
em que este site é **cliente** do IdP da Cloud, outro em que este site **é** o servidor).
#### 2.2.2 `EMCP_Tools_Cloud_Connect` — `class-cloud-connect.php` (cliente OAuth do site contra a Cloud como IdP)
Este site actua como **cliente PKCE público** contra o servidor OAuth de `emcptools.com`
(o mesmo padrão — DCR → authorize → PKCE S256 → token — usado por `EMCP_Tools_OAuth_*`
quando o papel é o inverso, ver §3). Sequência completa:
1. `register_client()` — DCR: `POST {cloud}/api/auth/oauth2/register` com
`redirect_uris=[redirect_uri()]` (= `admin-post.php?action=emcp_tools_cloud_callback`),
`token_endpoint_auth_method=none`, `client_name=bloginfo('name')`.
2. `authorize_url($client_id, $verifier, $csrf)` — constrói o URL de autorização com
`code_challenge` S256 derivado do `$verifier`, e `state` = base64url de um JSON
`{site_uuid, name, csrf}` (o `$csrf` embutido no state protege contra CSRF sem precisar
de um cookie de sessão — porque o browser navega para outro domínio e volta).
3. `handle_connect()` (admin-post, nonce-protegido) — faz DCR, gera verifier+csrf, guarda
num **transient de 600s** (`emcp_tools_cloud_pending`), e redireciona o browser. Como
`wp_safe_redirect()` bloqueia hosts externos por omissão, adiciona o host da Cloud a
`allowed_redirect_hosts` só para este redirect deliberado.
4. `handle_callback()` (admin-post) — valida o `state` devolvido em **tempo constante**
(`EMCP_Tools_OAuth_Util::secure_equals`) contra o CSRF guardado, troca o `code` por
tokens (`exchange_code()`), e — se o utilizador marcou o opt-in de gateway no formulário
— provisiona automaticamente uma `EMCP_Tools_Gateway_Credential` (§2.2.6), best-effort
(uma falha aqui nunca transforma a ligação Cloud, já bem-sucedida, num erro visível).
5. `refresh()` — ver bloco dedicado abaixo, é a peça mais elaborada do ficheiro.
6. `handle_disconnect()` — desprovisiona o gateway, revoga remotamente
(`revoke_remote()`), limpa a ligação local.
**`refresh()` — mitigação de corrida em rotação de refresh token.** Comentário extenso no
próprio código explica o problema: o IdP da Cloud (Better Auth) **rota** o refresh token a
cada uso — cada sucesso emite um novo refresh token e invalida o anterior. Duas requests
WordPress concorrentes (segundo separador de admin, um heartbeat, uma chamada MCP) que
ambas vejam o access token expirado apresentariam o MESMO refresh token; a primeira ganha e
rota-o, a segunda é rejeitada com `invalid_grant` — e ingenuamente marcaria a ligação como
"unhealthy", sobrescrevendo o bundle recém-rodado com o token morto (bug real que se
manifestaria como "Reconnect needed" espúrio). Mitigação em 4 camadas:
(1) mutex best-effort via `SELECT GET_LOCK()` do MySQL (`db_lock`/`db_unlock`, degradação
graciosa para no-op se `$wpdb` ausente — testes unitários); (2) double-checked locking —
volta a ler o bundle depois de obter o lock e sai cedo se outra request já refrescou;
(3) uma rejeição de auth que coincide com uma rotação concorrente (refresh_token guardado
mudou, ou o access token voltou a estar fresco) é tratada como **sucesso**, nunca
sobrescreve o bundle bom; (4) falhas de rede/5xx são tratadas como transitórias e NUNCA
marcam a ligação como unhealthy.
#### 2.2.3 `EMCP_Tools_Cloud_Http` — `class-cloud-http.php` (transporte fino)
`post_json()`, `post_form()`, `request($method,...)` — todos delegam num `send()` privado
que usa `wp_remote_post`/`wp_remote_request` (timeout=20, sslverify=true). Tem um **seam de
teste injectável** (`set_transport(callable)`) que permite mockar toda a rede em testes
unitários sem tocar em `wp_remote_*`.
#### 2.2.4 `EMCP_Tools_Cloud_Sync` — `class-cloud-sync.php` (camada aplicacional)
Traduz operações de negócio para chamadas ao `Cloud_Client` autenticado (§2.2.5):
`status()`, `list_remote()`, `backup()`, `pull()`, `push_config()`, `pull_config()`,
`marketplace_list()`, `marketplace_install()`, `marketplace_publish()`,
`marketplace_state()`, `push_update()`. Note-se `abilities(): EMCP_Tools_Sandbox_Cloud_Abilities`
— um nome **confuso por semelhança**: esta classe (documentada em detalhe no doc 06, não
aqui) não é uma ability MCP, é o helper local de serialização de bundle (`to_bundle()`,
`apply_bundle()`, `uuid()`) partilhado entre as tools locais `export-sandbox-artifact`/
`import-sandbox-artifact` (sem rede, doc 06) E as tools de rede `cloud-backup`/`cloud-pull`
(este doc) — o mesmo formato de bundle serve os dois casos de uso.
`bulk_backup(array $kinds = [])` — o contraparte em massa de `backup()`; itera todos os
posts das 3 CPTs de sandbox (`kind_post_types()`: snippet/widget/block →
`emcp_php_snippet`/`emcp_widget`/`emcp_block`) e chama `backup()` um a um. **Não está
exposta como MCP tool** (não há `cloud-bulk-backup` em `Cloud_Abilities`) — só é usada pela
UI de admin.
`marketplace_publish()`/`marketplace_state()`/`push_update()` também não estão expostas
como MCP tools — só `marketplace_list`/`marketplace_install` o estão; o resto é
funcionalidade de admin (submissão de listings ao marketplace).
#### 2.2.5 `EMCP_Tools_Cloud_Client` — `class-cloud-client.php` (REST autenticado)
`valid_access_token()` — devolve o access token guardado, refrescando primeiro
(`Cloud_Connect::refresh()`) se estiver a menos de `LEEWAY=60s` de expirar. `get/put/delete/
request()` genéricos, todos `Authorization: Bearer <token>` + JSON. Nota no código: "Astro's
form-CSRF guard exempts JSON, so no Origin header is needed here (unlike the token
endpoint)" — ou seja o `Cloud_Connect` usa `Origin` header nos POSTs form-encoded ao token
endpoint, mas este cliente usa corpo JSON e não precisa. `put_gateway_credential()`/
`delete_gateway_credential()` são específicos do fluxo Gateway (§2.2.6).
#### 2.2.6 `EMCP_Tools_Gateway_Credential` — `class-gateway-credential.php` ("Phase 1 hosted multi-site gateway")
**Esta é a ponte directa entre o subsistema Cloud e o subsistema OAuth (§3).** Comentário no
cabeçalho: *"Phase 1 of the hosted multi-site gateway: each site can self-issue a revocable
refresh token against its OWN OAuth server, bound to a single, idempotently-provisioned
client... Reuses the existing OAuth persistence layer (EMCP_Tools_OAuth_Store)."*
Mecanismo: o site cria (ou reusa, se já existir por nome+redirect_uris — mesmo padrão de
dedup de `create_client()`, §3.3.5) um client OAuth estável chamado `"EMCP Gateway"` **no
seu próprio servidor OAuth** (o de §3, não o da Cloud), e **auto-emite** um refresh token de
longuíssima duração (`REFRESH_TTL = 315360000` — 10 anos, "efectivamente não-expirante")
ligado a um utilizador WordPress específico, através de `EMCP_Tools_OAuth_Store::issue_token()`
directamente (sem passar pelo fluxo `/authorize` normal — é um self-issue administrativo).
Depois faz upload desse `{client_id, refresh_token, site_uuid, token_endpoint}` para a Cloud
via `PUT /api/cloud/v1/gateway/credential`. Objectivo (Phase 2, ainda do lado da Cloud, não
implementado neste build): a Cloud poder actuar como um **gateway multi-site** que troca
este refresh token pelos seus próprios access tokens contra o servidor OAuth de CADA site
ligado, sem o site ter de expor Application Passwords a um serviço terceiro.
`provision($user_id)` é best-effort e limpo: se o upload para a Cloud falhar, revoga
imediatamente o token recém-emitido localmente (não deixa um token órfão vivo). `deprovision()`
faz o inverso (delete remoto best-effort + revoke local incondicional — "offline-proof kill
switch"). `handle_client_revoked($client_id)` é chamado pelo painel "Authorized Apps" do
próprio site (revogação manual de qualquer client OAuth) para também limpar o lado Cloud se
o client revogado for justamente o Gateway.
#### 2.2.7 `EMCP_Tools_Settings_Sync` — `class-settings-sync.php` (feature paga, "Turnkey settings sync")
`entitled()` — gate por entitlement `syncSettings` do plano Cloud ligado (lê
`Cloud_Sync::status()`, cacheado estaticamente por request). `sync_keys()` — **allowlist
explícita e filtrável** (`emcp_tools_settings_sync_keys`) de 10 chaves `wp_options`
sincronizáveis: `emcp_tools_disabled_tools`, `elementor_mcp_disabled_tools` (chave legacy),
`emcp_tools_active_modules`, `emcp_tools_dispatcher_mode`, `emcp_tools_strict_schemas`,
`emcp_tools_content_mirror_enabled`, `emcp_tools_context_settings`,
`emcp_tools_module_themer_force_render`, `emcp_tools_memory_require_approval`,
`emcp_tools_memory_auto_summarize`. Comentário no cabeçalho: **"Never touches secrets or
site-specific keys."** — nunca inclui tokens, a ligação Cloud, o UUID do site, logs de
auditoria, ou estado de notificações. `apply()` só escreve chaves da allowlist (defesa
contra um blob adulterado). `push()`/`pull_and_apply()` delegam em
`Cloud_Sync::push_config()`/`pull_config()` com `type='settings'` — reusa o MESMO endpoint
genérico que `cloud-config-sync` expõe como MCP tool, mas esta classe **não tem tool MCP
própria** (é só UI de admin).
#### 2.2.8 `EMCP_Tools_Cloud_Module` — `includes/modules/class-cloud-module.php` (gating)
`id()='cloud'`, `tier()='free'`, `default_active()=true`. `register()` só chama
`EMCP_Tools_Cloud_Connect::init()` (regista os 3 handlers `admin_post_*`). `is_enabled()`
estático lê directamente a option `emcp_tools_active_modules` (evita depender do boot do
módulo em `init:5`, porque as abilities registam-se em `wp_abilities_api_init`, que pode
correr antes).
---
## 3. Servidor OAuth — infra-estrutura MCP genérica (`includes/oauth/`)
**Não é uma peça do EMCP Cloud.** É um servidor de autorização OAuth 2.1 completo
(Authorization Code + PKCE obrigatório, só clientes públicos — sem client secret),
implementado inteiramente dentro do WordPress, que serve de **alternativa ao Application
Password** para qualquer cliente MCP remoto (Claude Desktop, VS Code, Cursor, CLIs como
OpenClaw) se ligar ao endpoint MCP deste site sem o utilizador ter de gerar e colar uma
Application Password manualmente. O único consumidor interno do plugin é
`EMCP_Tools_Gateway_Credential` (§2.2.6) — tudo o resto é para clientes MCP externos
genéricos.
### 3.1 Endpoints
| Documento/endpoint | Método | Rota | Tipo | RFC |
|---|---|---|---|---|
| Protected Resource Metadata | GET | `/.well-known/oauth-protected-resource` | **não-REST** (`parse_request`) | RFC 9728 |
| Authorization Server Metadata | GET | `/.well-known/oauth-authorization-server` | **não-REST** (`parse_request`) | RFC 8414 |
| Authorize + consentimento | GET/POST | `/emcp-oauth/authorize` | **não-REST** (`parse_request`, front-end normal) | RFC 6749 §4.1.1 |
| Dynamic Client Registration | POST | `/wp-json/emcp-tools/oauth/v1/register` | REST (`permission_callback: __return_true`) | RFC 7591 |
| Token (code exchange + refresh) | POST | `/wp-json/emcp-tools/oauth/v1/token` | REST (`__return_true`) | RFC 6749 |
| Revoke | POST | `/wp-json/emcp-tools/oauth/v1/revoke` | REST (`__return_true`) | RFC 7009 |
### 3.2 `EMCP_Tools_OAuth_Server` — `class-oauth-server.php` (orquestrador)
- `is_available()` — `HTTPS OK` (via `is_ssl()`, `home_url()` a começar por `https://`, ou
host local `localhost`/`127.0.0.1`/`::1`/`*.test`/`*.local`/`*.localhost`), filtrável via
`emcp_tools_oauth_available` (para hosting atrás de um proxy que termina TLS antes do PHP).
- `option_enabled()` — option `emcp_tools_oauth_enabled`: se nunca definida explicitamente,
o default é **ON sempre que `is_available()`** (decisão de produto documentada em
comentário: "OAuth sign-in is a free, core connectivity feature... enabled wherever it is
available").
- `is_enabled() = is_available() && option_enabled()` — o gate único que todo o resto do
subsistema verifica.
- Em `init:20`, se `is_available()`: instala as tabelas (`OAuth_Store::maybe_install()`) e
agenda um WP-Cron diário de garbage-collection (`gc_hook`) — corre **mesmo que o toggle
esteja OFF**, para limpar tokens residuais de quando esteve ligado. Se `is_enabled()`:
regista os documentos de discovery, as rotas REST, o endpoint de authorize, e um filtro
em `rest_post_dispatch` que emite o desafio `WWW-Authenticate` (§3.3.6).
- `base_url()` usa `EMCP_Tools_Site_Context::rest_endpoint()` (não `rest_url()` cru) — honra
um eventual override de "Server URL" no admin, para manter issuer/resource/token
consistentes num site atrás de um domínio provisório/proxy.
### 3.3 Fluxo completo
#### 3.3.1 Discovery — `class-oauth-metadata.php`
`issuer()`/`resource()` usam `EMCP_Tools_Site_Context::public_base_url()` (mesmo motivo do
`base_url()` acima). `path_matches()` aceita tanto o path exacto do well-known como uma
**variante "resource-scoped"** (RFC 9728 §3.1) — ex.
`/.well-known/oauth-protected-resource/wp-json/mcp/emcp-tools-server` — porque clientes MCP
reais fazem esse pedido específico (comentário: "Match... the resource-scoped variant
clients build by appending the resource path"; sem isto o discovery falha silenciosamente
para esses clientes). Documento devolvido com `Access-Control-Allow-Origin: *` + cache 1h —
é discovery público, sem dados sensíveis.
#### 3.3.2 Dynamic Client Registration — `class-oauth-clients.php`
`POST /register` totalmente aberto (`permission_callback: __return_true` — RFC 7591 prevê
registo aberto para clientes públicos). Validação de `redirect_uris`: array não vazio, cada
URI **https absoluto**, OU **http só em loopback** (`127.0.0.1`/`::1`/`localhost`, RFC 8252
§7.3), OU um esquema custom de app privada (ex. `claude://`, RFC 8252 §7.1 — aceite sem
restrição adicional, para clientes nativos). Nunca aceita URI com fragment component (RFC
6749 §3.1.2). `client_name` default `'MCP Client'` se ausente. Resposta:
`{client_id, client_name, redirect_uris, token_endpoint_auth_method:'none', grant_types:
['authorization_code','refresh_token'], response_types:['code'], client_id_issued_at}`.
#### 3.3.3 Authorize + consentimento — `class-oauth-authorize.php`
**Decisão de design não óbvia**: este endpoint é servido como um pedido **front-end normal**
via `parse_request` (prioridade 0) — **deliberadamente NÃO é uma rota REST**. Motivo (do
próprio código): uma rota REST precisaria de um nonce que o browser do cliente MCP (que abre
este URL numa aba/janela) não tem forma de fornecer, e a autenticação por cookie de sessão
WordPress só funciona no fluxo normal de página.
`GET`: valida `client_id`+`redirect_uri` **PRIMEIRO**, antes de confiar em `redirect_uri`
como alvo de qualquer redirect de erro — protecção contra open-redirect via um `client_id`
malicioso ou desconhecido. Só depois valida `response_type=code` e
`code_challenge_method=S256` (obrigatório; `plain` nunca é aceite). Se não autenticado,
redirige para `wp_login_url()` com retorno para si mesmo. Exige `current_user_can('manage_options')`
(filtrável via `emcp_tools_oauth_authorize_cap`) para poder aprovar — só administradores
autorizam ligações MCP. Renderiza um ecrã de consentimento HTML autónomo (CSS inline, sem
dependências do tema), mostrando o nome do client, o site, e o utilizador autenticado.
`POST`: valida nonce `_emcp_oauth_nonce` (acção `emcp_oauth_consent`), revalida
client+redirect, se `action != 'approve'` redirige com `error=access_denied`. Se aprovado,
emite o código via `OAuth_Store::issue_code()` (payload:
`{client_id, user_id, redirect_uri, code_challenge, scopes}`) e redirige de volta com
`?code=&state=`.
#### 3.3.4 Token + Revoke — `class-oauth-token.php`
`POST /token` dispatcher por `grant_type`:
- **`authorization_code`**: `OAuth_Store::consume_code()` (single-use, apaga o transient no
consumo), valida `client_id` match, `redirect_uri` match, e PKCE S256
(`hash_equals` sobre o `code_verifier` recomputado) — tudo em `validate_code_exchange()`
(pura, testável isoladamente). Emite par access+refresh.
- **`refresh_token`**: procura o token, valida `client_id` match, e **rota** o refresh token
velho — mas NÃO com apagamento imediato:
- `OAuth_Store::rotate_out_refresh($id, self::refresh_grace())` — o access token antigo
ligado a esse refresh **não é cascade-deletado** (ao contrário de uma revogação
explícita); sobrevive até expirar pela sua própria TTL. Justificação no código: "an
in-flight MCP request may still be carrying [it]... invalidating it 401s those requests
the instant the client refreshes... surfaced as connections dropping mid-chat" — cita
RFC 6749 §1.5, que permite explicitamente este comportamento.
- o refresh token rodado fica ainda utilizável por uma **janela de graça**
(`REFRESH_GRACE=120s`, filtrável via `emcp_tools_oauth_refresh_grace` ou constante
`EMCP_TOOLS_OAUTH_REFRESH_GRACE`) em vez de morrer instantaneamente — cobre o caso de um
cliente cuja resposta do refresh anterior se perdeu na rede e reenvia o mesmo refresh
token: em vez de `invalid_grant`, roda de novo com sucesso.
- `ACCESS_TTL=3600s` (1h, filtrável/constante — útil para testar o fluxo de refresh em
minutos em vez de esperar uma hora), `REFRESH_TTL=2592000s` (30 dias).
`POST /revoke` — **sempre devolve 200**, mesmo para um token desconhecido (RFC 7009,
previne enumeração). Procura o token como access OU refresh e revoga a linha encontrada.
#### 3.3.5 Persistência — `class-oauth-store.php`
2 tabelas próprias criadas via `dbDelta` (idempotente, `DB_VERSION=2` — v2 mudou os
timestamps para `BIGINT` "2038-safe" e adicionou índice em `refresh_of`):
```sql
CREATE TABLE wp_emcp_oauth_clients (
client_id VARCHAR(64) NOT NULL,
client_name VARCHAR(191) NOT NULL,
redirect_uris TEXT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
PRIMARY KEY (client_id)
);
CREATE TABLE wp_emcp_oauth_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
token_hash CHAR(64) NOT NULL, -- SHA-256, ÚNICO
token_type VARCHAR(10) NOT NULL, -- 'access' | 'refresh'
client_id VARCHAR(64) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
scopes VARCHAR(191) NOT NULL DEFAULT '',
expires_at BIGINT NOT NULL,
refresh_of BIGINT UNSIGNED NULL DEFAULT NULL, -- liga o access token ao seu refresh
created_at BIGINT NOT NULL,
PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash),
KEY client_id (client_id), KEY user_id (user_id),
KEY expires_at (expires_at), KEY refresh_of (refresh_of)
);
```
Tokens **nunca guardados em claro** — só o hash SHA-256 (sem sal; justificado porque os
tokens já têm 32 bytes de entropia aleatória, um hash simples é indexável e adequado).
Códigos de autorização vivem em **transients**, não na tabela (`CODE_TTL=300s` — 5 min, mais
generoso que os ~60s típicos porque clientes CLI como OpenClaw exigem copy-paste manual do
código e 60s era fácil de perder).
`create_client()` é **deduplicado**: `find_client_by_registration()` procura por
nome+redirect_uris normalizados antes de criar — comentário: "MCP clients (Claude, Codex)
re-run Dynamic Client Registration each time they connect; without this the clients table
grows unbounded (one dead row per connect)". Seguro porque são clientes públicos (sem
segredo) e os tokens ficam ligados ao utilizador autorizador, não ao client em si.
`gc()` — apaga tokens expirados, depois clients órfãos (sem nenhum token e criados há mais
de `ORPHAN_CLIENT_GRACE=1 dia` — protege um client recém-registado que ainda não terminou o
fluxo de autorização). `gc_throttled()` corre `gc()` no máximo 1x por
`GC_THROTTLE_INTERVAL=900s` (transient-guarded), e é chamado directamente do **hot-path de
validação Bearer** (§3.3.6) — "clean at validation time": qualquer site com tráfego MCP
activo mantém as tabelas limpas em minutos, sem depender só do cron diário como backstop.
#### 3.3.6 Bearer — `class-oauth-bearer.php` (validação no transporte MCP)
Ligado como `transport_permission_callback` do servidor MCP (`Plugin::register_mcp_server()`,
doc 00 §3) — **é aqui que o servidor OAuth se conecta ao resto do plugin.**
`permission_callback($request)`:
1. Extrai o Bearer do header `Authorization` (com fallback para `HTTP_AUTHORIZATION` /
`REDIRECT_HTTP_AUTHORIZATION` de `$_SERVER`, para hosts que despem o header antes de
chegar ao PHP — comum em CGI/FastCGI).
2. Se presente: `gc_throttled()`, procura o access token na store; se válido faz
`wp_set_current_user()` e devolve `true`; se inválido/expirado devolve `false`
(**401 fail-closed — não cai para outro método de auth**).
3. Se **ausente**: cai para o comportamento default do adapter (`Application Password` /
cookie), via o filtro `mcp_adapter_default_transport_permission_user_capability` —
**os dois métodos de auth coexistem sem se excluir mutuamente.**
`maybe_challenge()` (hook `rest_post_dispatch`) — em qualquer resposta 401/403 na rota
`mcp/emcp-tools-server`, adiciona `WWW-Authenticate: Bearer resource_metadata="<url>"`
(RFC 9728 §5.1) — é o mecanismo pelo qual um cliente MCP genérico **descobre
automaticamente**, sem configuração manual, que este site suporta OAuth e onde começar o
fluxo de discovery.
#### 3.3.7 Primitivas puras — `class-oauth-util.php`
Zero dependências de WordPress/BD — testável isoladamente. `base64url_encode/decode`
(RFC 4648 §5, sem padding), `generate_token()` (32 bytes aleatórios → 43 chars),
`generate_code_verifier()` (mesmo gerador), `code_challenge_s256()`, `generate_client_id()`
(`'emcp_' + 24 hex`), `hash_token()` (SHA-256 simples), `verify_pkce()` (só aceita `S256`,
nunca `plain`; exige verifier de 43-128 chars RFC 7636 §4.1; comparação em tempo constante),
`secure_equals()` (`hash_equals` wrapper), `redirect_uri_matches()` — match exacto OU a
**excepção de loopback nativo** (RFC 8252 §7.3): para `http://127.0.0.1`/`http://[::1]`/
`http://localhost`, a porta pode diferir entre o registado e o apresentado, porque
aplicações nativas fazem bind a uma porta local efémera.
---
## 4. Serviços transversais (usados pelos 3 subsistemas)
### 4.1 `EMCP_Tools_Secret` — `includes/class-secret.php`
Encriptação simétrica genérica para segredos em repouso — usada pelas 3 chaves API de stock
image (§1.3) e pelo bundle de ligação Cloud (§2.2.1). Chave de 32 bytes **derivada por-site**
a partir de `AUTH_KEY`+`SECURE_AUTH_KEY` (salts do `wp-config.php`) via
`sodium_crypto_generichash` (fallback SHA-256) — **nunca guardada na base de dados**, deriva
sempre em runtime. Prefere `libsodium` (`secretbox`, bundled desde PHP 7.2), fallback
`OpenSSL AES-256-GCM`. Prefixo `emcps1:` marca valores encriptados, permitindo pass-through
transparente de valores legacy/constantes em claro (`decrypt()` devolve o valor original se
não tiver o prefixo). `decrypt_if_needed()` desembrulha repetidamente (guard de 8 iterações)
para tolerar um bug de dupla-encriptação (ex. um callback de sanitização do Settings API que
dispare duas vezes).
**Consequência prática:** um dump da base de dados sozinho nunca expõe uma chave API de
stock-image nem um refresh token Cloud — precisa também do `wp-config.php`.
### 4.2 `EMCP_Tools_Url_Guard` — `includes/class-url-guard.php`
Guarda anti-SSRF usada por `sideload-image` (§1.1) e por outras tools que descarregam
conteúdo remoto. Duas APIs distintas para dois níveis de rigor:
- **`is_safe_remote_url()` + `safe_download()`** (versão "leniente", usada no
`sideload-image`) — valida esquema http(s), usa `wp_http_validate_url()` do core como
primeira camada, e complementa com um `gethostbyname()` + `filter_var(...,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` porque o core **não cobre**
`169.254.0.0/16` (link-local — inclui o endpoint de metadata de cloud
`169.254.169.254`, um alvo clássico de SSRF) nem endereços IPv6 internos.
`safe_download()` força `reject_unsafe_urls=true` (WP_Http revalida CADA hop de
redirect) e limita `redirection` a 2 hops.
- **`validate()`** (versão "estrita", adicionada em 3.2.0 para a tool de AI Chat
`web_fetch` — não coberta por esta série de docs, é Pro) — resolve **TODOS** os
registos A **e** AAAA de um host (não só o primeiro, como `gethostbyname()` faz) e
bloqueia se **qualquer** um for privado — cobre o caso de um host que publica um
endereço público e um interno em simultâneo. Bloqueia também portas fora de
`{80,443}` e URLs com credenciais embutidas (`user:pass@host`). Listas CIDR bloqueadas
explícitas para IPv4 (`0.0.0.0/8`, `10/8`, `100.64/10` CGNAT, `127/8`, `169.254/16`,
`172.16/12`, `192.168/16`, `224/4` multicast, `240/4` reservado) e IPv6
(`::/128`, `::1/128`, `fc00::/7` ULA, `fe80::/10` link-local), incluindo o caso de
endereço IPv4-mapped em IPv6 (`::ffff:127.0.0.1`).
Nota de honestidade técnica no próprio código: mesmo a versão estrita mantém uma **janela
TOCTOU** entre a validação DNS e o connect TCP real (DNS rebinding) — WordPress liga por
nome de host, não por IP pinado; fechar completamente exigiria `CURLOPT_RESOLVE`.
---
## Blueprint para réplica
**Reutilizar quase 1:1:**
1. **O servidor OAuth inteiro (§3)** — ~1500 linhas de infra-estrutura MCP genérica, sem
NENHUMA dependência do EMCP Cloud. É a peça de maior valor deste documento para uma
réplica: dá autenticação Bearer remota sem obrigar o utilizador a gerar/colar
Application Passwords manualmente, e resolve sozinho toda a dança de discovery RFC
9728/8414 que clientes MCP modernos esperam.
2. **`EMCP_Tools_Secret` (§4.1)** — padrão de encriptação at-rest derivado dos salts do
próprio site é elegante e trivial de copiar; não precisa de gestão de chave adicional
nem de um KMS externo.
3. **`EMCP_Tools_Url_Guard` (§4.2)** — a distinção entre modo "leniente" (sideload) e
"estrito" (fetch de conteúdo para um modelo) é uma lição de segurança valiosa por si só;
copiar tal e qual, incluindo a lista de CIDRs bloqueados e a nota honesta sobre o TOCTOU
window de DNS rebinding.
4. **`EMCP_Tools_Stock_Image_Providers` (§1.2)** — o padrão registry+resolver com fallback
automático por ordem de prioridade é um bom desenho, trivialmente extensível a mais
providers (ex. Openverse, que este plugin usava antes da v3.1.0 segundo os comentários
dos clientes).
5. **O padrão de rotação de refresh token com janela de graça** (`OAuth_Token::REFRESH_GRACE`,
§3.3.4) **e o padrão de mutex/anti-corrida** (`Cloud_Connect::refresh()`, §2.2.2) — ambos
resolvem bugs reais de concorrência já vividos em produção pelo autor original; aplicáveis
a qualquer implementação própria de OAuth, tanto do lado servidor como cliente.
**Simplificar:**
- **Todo o subsistema Cloud (§2.2)** está acoplado a um SaaS de terceiros
(`emcptools.com`) que uma réplica não vai ter por omissão. Vale a pena extrair só o
**padrão arquitectural**, não o código ligado ao domínio: (a) `Cloud_Connect` é um bom
template de "como SER cliente PKCE de outro servidor OAuth" (complementar ao §3, que
documenta "como SER o servidor"); (b) o padrão de bundle+checksum para export/import de
artefactos é reutilizável mesmo sem nuvem nenhuma, só para backup/restore local (ver
doc 06). Sem um serviço cloud próprio planeado, esta secção inteira (~1200 linhas) é
dispensável.
- **`EMCP_Tools_Gateway_Credential` (§2.2.6)** é explicitamente uma feature "Phase 1"
incompleta do lado deles (o próprio comentário do código diz "Phase 2 concern" para o
lado da Cloud) — não vale a pena replicar sem um caso de uso concreto de "gateway
multi-site" próprio.
**Deixar de fora:**
- `cloud-marketplace-*` (list/install/publish) — depende inteiramente de um marketplace
SaaS de terceiros.
- `EMCP_Tools_Settings_Sync` — feature paga ("Turnkey settings sync"), sem sentido numa
réplica sem modelo de billing.
**Gotchas não óbvios (achados de código, não de documentação):**
1. **Heurística Unsplash download-tracking.** Agentes de IA passam frequentemente a URL
`api.unsplash.com/photos/<id>/download` (parece um URL de imagem, mas é o endpoint de
tracking que exige API key e devolve 401 sem ela) em vez do URL directo devolvido por
`search-images`. O código resolve isto automaticamente (`resolve_download()`, §1.3) e
`sideload-image` tem lógica dedicada de mensagem de erro para este caso específico — o
próprio comentário chama-lhe "a common weak-model loop". Vale a pena replicar esta
heurística de correcção accionável de erro, e generalizar o padrão (detectar a classe
de erro mais comum de um agente e devolver uma sugestão específica, não só a mensagem
crua da API).
2. **Rotação de refresh token que se auto-sabota sem cuidado.** O comentário em
`Cloud_Connect::refresh()` explica que um IdP com rotação estrita (Better Auth) invalida
TODA a família de tokens se detectar reuso de um refresh token já rodado — o que
transforma uma simples corrida entre duas requests concorrentes numa desconexão
completa e forçada ("Reconnect needed"). A mitigação de 4 camadas (mutex, double-check,
tratar rejeição concorrente como sucesso, nunca marcar unhealthy em falha transitória)
é um padrão geral aplicável a qualquer cliente OAuth contra qualquer IdP com rotação.
3. **A mesma lição, ao contrário, no servidor próprio.** `OAuth_Store::rotate_out_refresh()`
com `REFRESH_GRACE` explicitamente NÃO apaga o access token antigo na rotação (evita 401
a meio de uma conversa MCP em curso) e mantém o refresh token rodado utilizável por 120s
extra (evita 401 num retry de resposta perdida). Cita RFC 6749 §1.5 como justificação —
é o tipo de detalhe que só se aprende com utilizadores reais a queixarem-se de ligações a
cair a meio.
4. **Dedup de client OAuth em DCR.** `create_client()` reusa um client existente com o
mesmo nome+redirect_uris em vez de criar sempre um novo — sem isto, clientes MCP que
refazem DCR a cada ligação (confirmado no código: "Claude, Codex re-run Dynamic Client
Registration each time they connect") fariam crescer a tabela de clients sem limite.
Aplica-se tanto ao servidor OAuth do plugin (§3.3.5) como ao `Gateway_Credential`
(§2.2.6), que idem reusa o client `"EMCP Gateway"` por nome.
5. **`/authorize` como request front-end, não REST.** Decisão deliberada e não óbvia
(§3.3.3) — uma rota REST exigiria um nonce que o browser do cliente MCP não tem como
fornecer, e cookie auth de sessão só funciona no fluxo normal de navegação de página.
Preservar esta decisão tal e qual numa réplica.
6. **Ordem de validação anti-open-redirect.** Em `OAuth_Authorize::handle_get()`,
`client_id`+`redirect_uri` são validados **antes** de qualquer outra coisa,
especificamente para nunca usar um `redirect_uri` não confiável como alvo de um
redirect de erro — protecção directa contra um vector de open-redirect via um
`client_id` malicioso ou inexistente.
7. **`normalize_result()` (doc 00 §5) protege também as tools Cloud.** O output de
`cloud-status`/`cloud-list` (devolvido "as array" directamente do JSON decodificado da
API remota) passa pelo mesmo wrapper de normalização de resultado do ability registrar
— o que protege contra a mesma classe de bug documentada para o WooCommerce
(`report-products-totals` devolve array de topo) caso a API da Cloud alguma vez faça o
mesmo. Confirma que a camada de `class-schema-compat.php` deve ser aplicada
**universalmente**, mesmo a tools que parecem "seguras" por delegarem numa API JSON
bem-comportada.
---
## Fonte
Leitura directa (19-08-2026) de, em `/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
- `includes/abilities/class-ability-registrar.php` (gating de todos os grupos)
- `includes/abilities/class-stock-image-abilities.php`
- `includes/class-stock-image-providers.php`
- `includes/class-unsplash-client.php`
- `includes/class-pexels-client.php`
- `includes/class-pixabay-client.php`
- `includes/abilities/class-cloud-abilities.php`
- `includes/cloud/class-cloud.php`
- `includes/cloud/class-cloud-connect.php`
- `includes/cloud/class-cloud-http.php`
- `includes/cloud/class-cloud-sync.php`
- `includes/cloud/class-gateway-credential.php`
- `includes/cloud/class-cloud-client.php`
- `includes/cloud/class-settings-sync.php`
- `includes/modules/class-cloud-module.php`
- `includes/oauth/class-oauth-metadata.php`
- `includes/oauth/class-oauth-authorize.php`
- `includes/oauth/class-oauth-server.php`
- `includes/oauth/class-oauth-store.php`
- `includes/oauth/class-oauth-bearer.php`
- `includes/oauth/class-oauth-util.php`
- `includes/oauth/class-oauth-clients.php`
- `includes/oauth/class-oauth-token.php`
- `includes/class-secret.php`
- `includes/class-url-guard.php`
Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral do plugin, cadeia de arranque,
`emcp_tools_register_ability()`) e `skill://emcp-tools` (auditoria de postura de segurança,
16-08-2026) para contexto de gating e postura por site.
+661
View File
@@ -0,0 +1,661 @@
# 10 — Sistema de módulos (Modules tab) e inventário Pro-only (metadata)
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
19-08-2026. Cruzado com `docs/00-ARQUITECTURA.md` (mesma batch) e `skill://emcp-tools`.
Este documento tem duas partes independentes: **Parte 1** documenta o sistema de "módulos" — a
camada de toggles on/off do Modules tab, que é ortogonal ao sistema de abilities/MCP tools
(uma ability pode existir sempre, só um módulo; um módulo pode não ter nenhuma ability associada;
ver Themer/Redirect para o padrão onde módulo E abilities coexistem com gating cruzado). **Parte 2**
é o inventário definitivo — para cada classe referenciada com `class_exists()` em
`class-ability-registrar.php::register_groups()` que não seja confirmada Free por outro documento
desta batch, confirma-se aqui, por leitura directa (listagem de directório + tentativa de leitura
de ficheiro), se existe ou não na árvore Free instalada.
---
# PARTE 1 — Sistema de módulos
## 1.1 Classe base — `EMCP_Tools_Module`
Ficheiro: `includes/modules/class-module.php` (~100 linhas). Classe abstracta; contrato mínimo
que todo módulo tem de implementar:
| Método | Abstracto? | Contrato |
|---|---|---|
| `id(): string` | sim | id estável (`a-z0-9-`) — usado como valor no array `emcp_tools_active_modules` e como infixo das option keys próprias do módulo |
| `title(): string` | sim | título humano para o card no Modules tab |
| `description(): string` | sim | descrição de uma linha para o card |
| `tier(): string` | sim | `'free'` \| `'pro'` — controla o badge de tier na UI |
| `default_active(): bool` | sim | se o módulo arranca activo por omissão (seeded uma única vez, ver §1.2) |
| `register(): void` | sim | liga os hooks do módulo. Só chamado pela registry quando activo **e** disponível |
| `render_settings(): void` | sim | desenha os knobs do módulo dentro do seu card (mostrado quando activo) |
| `is_available(): bool` | não (default `true`) | sonda de dependência/capacidade — override para gate em features do servidor (ex.: suporte WebP) ou em licença Pro |
| `settings_fields(): array` | não (default `[]`) | mapa `option_key => ['type','default','sanitize_callback']` para registo sanitizado no grupo de settings do módulo |
| `is_active(): bool` | concreto | `in_array($this->id(), get_option('emcp_tools_active_modules', []), true)` |
| `settings_group(): string` | concreto | `'emcp_tools_module_' . str_replace('-','_',$this->id()) . '_settings'` — grupo Settings API próprio, para o form do módulo gravar independentemente dos toggles de outros módulos |
| `has_settings(): bool` | concreto | `[] !== $this->settings_fields()` |
| `settings_url(): string` | não (default `''`) | quando definido, o card mostra um link "Configure →" para uma página admin dedicada, em vez de um overlay inline |
Constante: `OPTION_ACTIVE = 'emcp_tools_active_modules'` — a ÚNICA option que guarda quais módulos
estão activos (array de ids).
## 1.2 Registry — `EMCP_Tools_Modules_Registry`
Ficheiro: `includes/modules/class-modules-registry.php` (~115 linhas). Singleton
(`instance()` / `reset_for_tests()` para testes).
- **`register(EMCP_Tools_Module $module)`** — idempotente por id (`$this->modules[$module->id()] = $module`).
- **`all()` / `get($id)` / `active()`** — leitura simples; `active()` filtra pelos que têm `is_active()===true`.
- **`apply_defaults()`** — mecanismo de seeding. Option `emcp_tools_modules_seeded` (const `OPTION_SEEDED`)
guarda uma lista de ids **já considerados** para seeding (não um booleano único). Para cada módulo
registado ainda não na lista `seeded`: marca-o como seeded e, se `default_active()===true` e ainda
não estiver em `active`, adiciona-o a `active`. Grava as duas options só se algo mudou.
**Porquê lista por-módulo e não um marcador booleano:** um módulo novo introduzido numa versão
posterior do plugin é seeded no load seguinte sem re-seedar — ou remover — o que o utilizador já
tinha alterado manualmente nos módulos existentes. É o desenho correcto para migração aditiva sem
tocar em estado do utilizador.
- **`boot_active()`** — chamado em `init` (presume-se prioridade 5, confirmado pelos comentários nos
módulos Redirect/Themer/Agent-Skills que dizem "abilities register on wp_abilities_api_init, antes
do módulo arrancar em init:5"). Para cada módulo em `active()`: se `is_available()===true`, chama
`register()`.
**Consequência de desenho importante:** os grupos de abilities MCP de um módulo (quando existem)
**não podem depender de `register()` ter corrido**, porque `wp_abilities_api_init` dispara ANTES de
`init:5`. Por isso todo módulo com abilities associadas expõe um **helper estático** `is_enabled()`
(lê `get_option(OPTION_ACTIVE)` directamente, sem depender da instância da registry) que o
`class-ability-registrar.php` chama em vez de `$module->is_active()`. Ver Redirect, Cloud, Themer,
Agent-Skills, Image-Optimization (`module_is_active()`), Memory, Migrate — todos seguem este padrão.
## 1.3 Os 7 módulos "tab-only" / leves
Todos vivem em `includes/modules/class-{id}-module.php`. Cinco são efectivamente free
(Prompts, Brand Kits, Redirect, Cloud, Themer); dois têm o **ficheiro de metadata presente na
árvore Free mas `tier()==='pro'`** (Templates, Agent Skills) — um padrão deliberado do autor,
explicitado no comentário do código de Agent Skills: "This class carries no Pro logic and lives
safely in the free tree, like EMCP_Tools_Templates_Module."
### Prompts (`id: 'prompts'`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | `true` |
| `is_available()` | não sobreposto (sempre `true`) |
| `settings_url()` | `admin.php?page={PAGE_SLUG}-prompts` |
| `register()` | **no-op** |
Feature "tab-only": o módulo só controla se a tab admin Prompts (e o respectivo stat card) aparece;
quem lê `is_active()` é a classe de admin para mostrar/esconder a tab. O conteúdo free/Pro DENTRO
da tab (amostras bundled vs biblioteca premium) é inalterado por este toggle — é outra camada de
gating, não deste módulo.
### Brand Kits (`id: 'brand-kits'`)
Idêntico em estrutura ao Prompts: `tier()=free`, `default_active()=true`, `register()` no-op,
`settings_url()` aponta para `-brand-kits`. Controla apenas a visibilidade da tab; os 10 kits
bundled free (`EMCP_Tools_Free_Brand_Kits`, §1.6) vs 50+ kits Pro por licença são geridos por outro
mecanismo, não por este toggle.
### Templates (`id: 'templates'`) — **tier Pro, ficheiro em Free**
| Campo | Valor |
|---|---|
| `tier()` | **`pro`** |
| `default_active()` | `true` |
| `is_available()` | **sobreposto**: `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` |
| `settings_url()` | `admin.php?page={PAGE_SLUG}-templates` |
| `register()` | **no-op** |
Comentário do autor no ficheiro: "Pro tier — free users see a locked card in Modules and the
standalone tab is hidden. [...] This class is metadata only (no Pro logic), so it lives safely in
the free tree." Ou seja: o ficheiro `.php` desta classe existe fisicamente no build Free (por isso
não aparece na Parte 2 como "ausente"), mas `is_available()` bloqueia sempre que não há licença
activa — o resultado prático é indistinguível de um módulo Pro-only, só a UI "locked card" difere.
### Redirect Manager (`id: 'redirects'`, const `ID`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | `true` |
| `settings_url()` | `admin.php?page={PAGE_SLUG}-redirects` |
| `is_enabled(): bool` (estático) | lê `OPTION_ACTIVE` directamente — usado pelo registrar |
| `register()` | `EMCP_Tools_Redirect_Store::init()` (instala tabela em `init:20`) + `EMCP_Tools_Redirect_Handler::init()` (handler 301/302 no front-end), ambos condicionais a `class_exists()` |
Comentário do autor: desactivar este módulo é um **verdadeiro kill switch** — o handler de
redirect pára, as MCP tools caem (gated no registrar via `is_enabled()`), a tab admin esconde-se, e
delete/rename deixa de emitir sugestões de redirect. Ver Doc05 para `Redirect_Abilities`/
`Redirect_Store`/`Redirect_Handler` em detalhe.
### EMCP Cloud (`id: 'cloud'`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | `true` |
| `settings_url()` | `admin.php?page=emcp-tools-connection#emcp-conn-main` |
| `is_enabled(): bool` (estático) | idem padrão Redirect |
| `register()` | `EMCP_Tools_Cloud_Connect::init()` (arranca o fluxo admin do cliente OAuth) |
O grupo `EMCP_Tools_Cloud_Abilities` (ficheiro `includes/abilities/class-cloud-abilities.php`,
**confirmado presente na árvore Free**) só regista quando **ambas** as condições são verdadeiras:
`EMCP_Tools_Cloud_Module::is_enabled()` **e** `EMCP_Tools_Cloud::is_connected()` — isto é, o gate
não é licença, é estado de ligação (o site tem de estar ligado a uma conta EMCP Cloud). Toda a
infra-estrutura de suporte (`includes/cloud/`: `class-cloud.php`, `class-cloud-client.php`,
`class-cloud-connect.php`, `class-cloud-http.php`, `class-cloud-sync.php`,
`class-gateway-credential.php`, `class-settings-sync.php`) está presente e é free. Ver Doc09.
### Themer (`id: 'themer'`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | `true` |
| `settings_url()` | `edit.php?post_type={Themer_CPT::POST_TYPE}` |
| `is_enabled(): bool` (estático) | idem padrão Redirect/Cloud |
| `register()` | ver abaixo — o mais rico dos 7 módulos leves |
`register()` (chamado só em `init:5`, activo+disponível):
1. `(new EMCP_Tools_Themer_CPT())->register()` — regista o CPT.
2. Se `class_exists('EMCP_Tools_Themer_HFE_Conflict')`: `::init()` — aviso de conflito com o Header
Footer Elementor (que constrói os mesmos slots header/footer); até o admin escolher um sistema,
Themer ganha deterministicamente.
3. `EMCP_Tools_Themer_Index::register_hooks()` — hooks de rebuild do índice de condições.
4. **Heal one-time**: se `get_option('emcp_tools_themer_index_healed') !== '1'`, chama
`EMCP_Tools_Themer_Index::rebuild()` e grava o marcador. **Comentário do autor, citado**: "a prior
build could leave the condition index empty (the rebuild raced the metabox meta writes), so
existing templates silently stopped applying." É a reparação de uma race condition real de dados
já corrompidos em sites existentes — o marcador de option garante que corre exactamente uma vez
por upgrade, sem o admin ter de voltar a gravar cada template manualmente.
5. Se `! is_admin()`: `(new EMCP_Tools_Themer_Render_Controller())->init()` (front-end).
6. Se `is_admin() && class_exists('EMCP_Tools_Themer_Metabox')`: `(new EMCP_Tools_Themer_Metabox())->init()`.
7. Se `class_exists('EMCP_Tools_Themer_Blocks')`: `::init()` — blocos Gutenberg dinâmicos.
8. Se `class_exists('EMCP_Tools_Themer_Widgets')`: `::init()` — widgets clássicos dinâmicos.
9. Se `class_exists('EMCP_Tools_Themer_PHP')`: `(new EMCP_Tools_Themer_PHP())->init()` — feature de
templates PHP puro (confirmado presente: `includes/themer/php/class-themer-php.php`). Nota: o
grupo de abilities `EMCP_Tools_Themer_PHP_Abilities` (também confirmado presente) tem o SEU
PRÓPRIO toggle independente (`EMCP_Tools_Themer_PHP::enabled()`), separado do toggle base do
Themer — ver Doc04.
Comentário do autor: desactivar o módulo pára o CPT, a tomada de controlo do front-end e a tab; o
registrar omite as tools também — kill switch total. Ver Doc04 para detalhe completo do subsistema
`includes/themer/`.
### Agent Skills (`id: 'agent-skills'`) — **tier Pro, ficheiro em Free**
| Campo | Valor |
|---|---|
| `tier()` | **`pro`** |
| `default_active()` | `true` |
| `is_available()` | **sobreposto**: `function_exists('emcp_tools_fs') && emcp_tools_fs()->can_use_premium_code()` |
| `settings_url()` | `admin.php?page={PAGE_SLUG}-skills` |
| `register()` | **no-op** |
| `is_enabled(): bool` (estático) | idem padrão Redirect/Cloud/Themer, usado por DOIS consumidores |
Controla a exposição em **runtime** das skills bundled a agentes de IA ligados: as tools MCP
`list-skills` / `get-skill` e o catálogo `## Skills` injectado no contexto de discovery. Desligar
remove ambos (e o footprint de ~900 tokens da injecção) **sem tocar** no caminho de
instalação-local na tab Skills (i.e. skills já descarregadas para disco continuam lá, só deixam de
ser expostas a agentes MCP).
Dois consumidores estáticos de `is_enabled()`:
1. `class-ability-registrar.php` — gate de `EMCP_Tools_Skill_Abilities` (confirmado **ausente** da
árvore Free, ver Parte 2).
2. `EMCP_Tools_Skill_Catalog::discovery_catalog()` — gate da injecção do catálogo no contexto MCP.
**Nuance importante:** o ficheiro desta classe **existe** no build Free instalado (contraste com
Memory/Migrate, cujos módulos nem sequer têm ficheiro na árvore Free — ver Parte 2 §2.2). É por
isso que `EMCP_Tools_Agent_Skills_Module` NÃO aparece na tabela final de "classes ausentes" — mas
funcionalmente comporta-se como Pro, porque `is_available()` exige licença e a única ability que
depende dela (`Skill_Abilities`) está de qualquer forma ausente do ficheiro-sistema Free.
## 1.4 Image Optimization — o módulo opt-in mais substancial (deep dive)
Ficheiros em `includes/modules/image-optimization/`: `class-image-optimization-module.php`,
`class-image-optimizer.php`, `class-image-resizer.php`, `class-webp-generator.php`,
`class-webp-rewriter.php`, `class-bulk-optimizer.php`, `settings-fields.php`.
### `EMCP_Tools_Image_Optimization_Module` (id `'image-optimization'`, const `ID`; option prefix `PREFIX = 'emcp_tools_module_image_optimization_'`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | **`false`** — único dos 9 módulos (contando os 2 de deep-dive) que é **opt-in por omissão**, além do SVG Support |
| `is_available()` | **sobreposto**: `(new EMCP_Tools_Webp_Generator(82))->is_available()` — delega para `wp_image_editor_supports(['mime_type'=>'image/webp'])`; se o editor de imagem do servidor (GD/Imagick) não sabe exportar WebP, o módulo não fica disponível de todo |
| `module_is_active(): bool` (estático) | usado pelo registrar para condicionar a ability `resize-media` |
`settings_fields()` — 6 option keys, todas sob `PREFIX`:
| Key | Tipo | Default |
|---|---|---|
| `compress` | bool (`'1'`/`'0'`) | `1` |
| `webp` | bool | `1` |
| `webp_serve` | bool | `1` |
| `quality` | int | `60` |
| `max_dimension` | int | `0` (0 = sem cap) |
| `keep_originals` | bool | `1` |
`current_settings()` resolve estas options num array tipado (quality passa por
`EMCP_Tools_Image_Optimizer::clamp_quality()` — clamp 1-100 partilhado, single source of truth).
`register()` (chamado só activo+disponível):
- Se `compress` OU `webp`: instancia `EMCP_Tools_Image_Optimizer($settings)`, hook em
`wp_generate_attachment_metadata` (prioridade 20) → `on_generate_metadata()`.
- Se `webp`: `(new EMCP_Tools_Webp_Rewriter($settings['webp_serve']))->register()`.
- Se `is_admin()`: `(new EMCP_Tools_Bulk_Optimizer($settings))->register()`.
`render_settings()` faz `include` de `settings-fields.php` com `$settings` em scope (view partial
pura: switches para compress/webp/webp_serve, slider 1-100 para quality, number input para
max_dimension, switch para keep_originals; usa um closure local `$emcp_io_toggle` para DRY do HTML).
### `EMCP_Tools_Image_Optimizer` — pipeline compress-on-upload
- Const `META_KEY = '_emcp_optim'` — meta do post attachment onde fica o resultado.
- Construtor normaliza settings (bool/bool/int-clamp/int/bool).
- `clamp_quality(int): int` (estático) — clamp 1-100, partilhado com `Image_Resizer`.
- `sizes_to_process(metadata, basedir): string[]` — caminhos absolutos do full-size + todos os
sub-sizes gerados.
- `backup_path(file, upload): string` (estático) — espelha o caminho relativo às uploads sob
`uploads/emcp-originals/`.
- `should_skip(optim): bool` — `true` se `optim['status']==='done'` (idempotência).
- `on_generate_metadata($metadata, $attachment_id)` — o hook callback:
1. guarda: `$metadata` tem de ser array; settings tem de ter `compress` OU `webp`;
2. **`apply_filters('emcp_tools_optimize_attachment', true, $attachment_id)`** — opt-out
per-attachment. Usado por `sideload-image`/`add-stock-image` quando o chamador passa
`convert_webp:false` (conversão a dar timeout em shared hosting) — é o ÚNICO ponto de
extensibilidade externo deste pipeline;
3. mime tem de ser `image/jpeg` ou `image/png` (WebP e GIF **não** entram no pipeline de
compressão — só os dois formatos de origem processados);
4. se já `status=done` no meta existente, salta;
5. resolve `wp_upload_dir()`, calcula `sizes_to_process()`, chama `process_files()`, grava o
resultado em `_emcp_optim`.
- `process_files($files, $upload, $full_rel, $basedir): array` — o loop real por ficheiro:
- mede tamanho "antes"; se `keep_originals`, faz backup (skip se já existir — idempotente);
- `wp_get_image_editor()`; `set_quality()`; **só** o full-size (`$file === $full_abs`) recebe o
cap `max_dimension` via `resize($max,$max,false)` (scale-to-fit, nunca crop) — os sub-sizes
NUNCA são redimensionados por este cap;
- se `compress`: `editor->save($file)` (re-encode IN PLACE, mesmo caminho);
- mede tamanho "depois";
- se `webp_ok`: `generator->generate($file)` → sibling;
- devolve agregado: `status=done, original_bytes, optimized_bytes, webp_bytes, backups[], webps[]`.
### `EMCP_Tools_Webp_Generator`
- `sibling_path(file): string` (estático) — `"$file.webp"` (ex.:
`name-800x600.jpg.webp` — a extensão original é **preservada**, `.webp` é anexado como extensão
composta, para o rewriter encontrar deterministicamente sem precisar de índice).
- `is_available()` — `wp_image_editor_supports(['mime_type'=>'image/webp'])`.
- `generate(file)` — skip se sibling já existe (idempotente); `wp_get_image_editor()`;
`set_quality()`; `save($sibling, 'image/webp')`.
### `EMCP_Tools_Webp_Rewriter`
- Construtor: `serve_frontend=true` por omissão; captura `basedir`/`baseurl` de `wp_upload_dir()`.
- `register()` — hooks `wp_get_attachment_url`, `wp_get_attachment_image_src`,
`wp_calculate_image_srcset` (todos prioridade 20).
- `should_rewrite($accept, $is_rest, $serve_frontend): bool` (estático) — **REST/CLI/cron
qualifica-se SEMPRE** (as MCP media tools resolvem sempre para WebP, independentemente do toggle
frontend); frontend requer adicionalmente o toggle `serve_frontend` **e** header
`Accept: image/webp`.
- `webp_url(url): string` (estático) — regex troca `.jpg`/`.jpeg`/`.png` (antes de qualquer query
string) pelo sibling `.webp`; outras extensões passam inalteradas.
- `is_rest_context()` — `REST_REQUEST` const OU `WP_CLI` const OU `wp_doing_cron()`.
- `url_to_path(url)` — mapeia URL de uploads de volta a caminho absoluto, só dentro do `baseurl`
(scoping de segurança).
- `maybe_rewrite(url)` — decisão final por URL: só reescreve se `allowed()` **e** a URL webp
difere **e** o ficheiro `.webp` existe mesmo em disco (nunca devolve uma URL para um ficheiro
inexistente).
### `EMCP_Tools_Bulk_Optimizer` — processador resumível da biblioteca existente
- Consts: `ACTION_BATCH='emcp_tools_optimize_batch'`, `ACTION_RESTORE='emcp_tools_optimize_restore'`,
`NONCE='emcp_tools_modules'`, `OPTION_CURSOR='emcp_tools_module_image_optimization_bulk_cursor'`.
- `register()` — 2 handlers `wp_ajax_*`.
- `batch_size(n): int` (estático) — clamp 1-50, default 10 se `<=0`.
- `progress(total, processed): array` (estático, pura) — `{total,processed,remaining,percent,done}`.
- `ajax_batch()` — nonce + `manage_options`; query de TODOS os attachment IDs jpeg/png ordenados
por ID ASC; lê cursor de `OPTION_CURSOR`; fatia o batch a partir do cursor; para cada ID:
metadata → `sizes_to_process()` → `process_files()` → grava `_emcp_optim`; avança o cursor; reset
do cursor a 0 quando `progress.done`; devolve JSON de progresso.
- `ajax_restore()` — nonce + `manage_options`; percorre `uploads/emcp-originals/` recursivamente
(`RecursiveIteratorIterator`); para cada backup: copia de volta sobre o caminho vivo, apaga o
sibling `.webp` do destino, incrementa `restored`; reset do cursor; devolve `{restored}`.
## 1.5 SVG Support — o módulo com maior superfície de segurança (deep dive)
Ficheiros: `includes/modules/svg-support/class-svg-support-module.php`,
`includes/modules/svg-support/class-svg-sanitizer.php`.
### `EMCP_Tools_SVG_Support_Module` (id `'svg-support'`, const `ID`; prefix `PREFIX = 'emcp_tools_module_svg_support_'`)
| Campo | Valor |
|---|---|
| `tier()` | `free` |
| `default_active()` | **`false`** — opt-in, comentário explícito "a security surface" |
| `is_available()` | **sobreposto**: `EMCP_Tools_SVG_Sanitizer::library_available()` — fail-closed se a biblioteca de sanitização não estiver disponível |
WordPress bloqueia uploads SVG por omissão (SVG é XML e pode transportar scripts). Elementor já
permite SVG para utilizadores autorizados via o seu próprio handling de unfiltered-upload — este
módulo é dirigido a sites **sem** Elementor (ou onde o mime `svg` não está registado por outra via).
`svg_already_supported(): bool` (estático) — verifica `get_allowed_mime_types()` por `'svg'` ou
`'svg|svgz'` — se já suportado por outro plugin/tema, mostra uma nota informativa em
`render_settings()` (o módulo continua a sanitizar de qualquer forma quando activo).
`settings_fields()` — UMA option: `admin_only` (bool, default `'0'`).
`required_capability(): string` — resolve para `manage_options` se `admin_only` estiver ligado,
senão `upload_files`; filtrável via `emcp_tools_svg_upload_capability`.
`register()` liga:
1. `upload_mimes` → `allow_svg_mime()` — adiciona `'svg'=>'image/svg+xml'` só se
`current_user_can(required_capability())`.
2. `wp_check_filetype_and_ext` (prioridade 10, 4 args) → `fix_svg_filetype()` — **"the piece most
SVG plugins miss"** (comentário do autor, citado): a sniff real-content de mime type do
WordPress (via `finfo`) frequentemente reporta `text/plain` ou `image/svg` para SVGs e rejeita o
upload; esta correcção resolve explicitamente `ext`/`type` para ficheiros `.svg` quando o
utilizador tem a capability — é o que faz uploads REST/sideload funcionarem, não só o
`media-new.php` clássico.
3. `wp_handle_upload_prefilter` + `wp_handle_sideload_prefilter` → ambos `sanitize_upload()`.
4. `admin_head` → `media_thumbnail_css()`, só se `is_admin()`.
`sanitize_upload($file)` — o gate real: se a extensão não for `svg`, passa; se o utilizador não
tem a capability, rejeita com mensagem de erro; senão corre `(new EMCP_Tools_SVG_Sanitizer())
->sanitize_file($tmp_name)` — se falhar, rejeita **fail-closed** ("could not be sanitized and was
rejected for security").
`media_thumbnail_css()` — injecção CSS mínima para as thumbnails SVG renderizarem corretamente
dimensionadas na grelha/lista da Media Library.
### `EMCP_Tools_SVG_Sanitizer` — wrapper fino sobre `enshrined/svg-sanitize` (vendorizada)
Mesma biblioteca que o plugin "Safe SVG" usa. SVG é XML — pode transportar script, event handlers,
referências externas e payloads XXE; permitir upload SVG cru sem sanitizar é um vector de
stored-XSS.
`library_available(): bool` — 3 estratégias de carregamento em cascata:
1. classe já carregada (`class_exists('\enshrined\svgSanitize\Sanitizer')`);
2. `EMCP_Tools_Adapter_Bootstrap::ensure()` (**o mesmo mecanismo de preload do Jetpack Autoloader
usado para o MCP adapter vendorizado**, ver `00-ARQUITECTURA.md` §2.1) ou fallback directo a
`vendor/autoload_packages.php`;
3. **fallback próprio**: `register_fallback_autoloader()` — regista um autoloader PSR-4 escopado
directamente contra `vendor/enshrined/svg-sanitize/src/`, para a sanitização SVG continuar a
funcionar "even if the Jetpack classmap wasn't regenerated" (comentário do autor) — redundância
defensiva deliberada contra um modo de falha real de geração de classmap Composer/Jetpack.
`sanitize(string $svg): string|false`:
- instancia `\enshrined\svgSanitize\Sanitizer`;
- `minify(false)`;
- `removeRemoteReferences(true)` — **endurecimento SSRF/XSS explícito** para lá da configuração
por omissão da biblioteca (remove `xlink:href` remoto, `use@href` remoto);
- `false` em vazio/falha.
`sanitize_file(path): bool` — ler → sanitizar → sobrescrever in-place; `false` se ilegível ou
sanitização falhar.
## 1.6 `EMCP_Tools_Free_Brand_Kits` — serviço de suporte (não é módulo)
Ficheiro: `includes/class-free-brand-kits.php` (nível topo de `includes/`, não em `modules/`).
Contraparte free do serviço `EMCP_Tools_Pro_Brand_Kits`. Onde o Pro busca 50+ kits de
`emcptools.com` atrás de licença, este lê um conjunto pequeno e curado embutido no plugin
(`assets/brand-kits/free-brand-kits.json`) — disponível a todos, sem licença, o mesmo modelo dos 5
prompts de amostra bundled.
- `get_bundle(): array` — parse cacheado em memória (`self::$bundle`) do JSON; forma:
`['categories' => [['slug','label','kits' => [{kit}, ...]]]]`. Para cada kit, se existir
`assets/brand-kits/{slug}.svg` (previews pré-renderizadas, fontes outlined), injecta
`thumbnail_url` + `preview.thumbnail_url` com a URL do plugin (o JSON não pode saber a URL do
plugin). **Nunca devolve `WP_Error`** — os dados estão bundled, por isso estão sempre disponíveis
(devolve bundle vazio se o ficheiro faltar).
- `find_kit(kit_slug, category_slug='')` — procura linear.
- `count_kits(): int` — total, para a barra de stats do admin.
**Facto crucial para o blueprint**: esta classe só **PROVIDENCIA** os dados do kit. A **aplicação**
(escrever cores/tipografia no Elementor kit activo) passa sempre pelo
`EMCP_Tools_System_Kit_Writer` PARTILHADO (confirmado presente em `includes/class-system-kit-writer.php`)
e pelo `EMCP_Tools_Kit_Backup_Store` (backups reversíveis, confirmado presente em
`includes/class-kit-backup-store.php`) — **exactamente o mesmo caminho usado pelo Pro**. Ou seja: a
funcionalidade de "aplicar um kit" via UI admin funciona **sem licença nenhuma** (kits free + writer
partilhado); é só a **ferramenta MCP** para o fazer programaticamente
(`EMCP_Tools_System_Kit_Abilities`) que está totalmente ausente do build Free (ver Parte 2).
---
# PARTE 2 — Inventário Pro definitivo
## 2.1 Metodologia
1. Leitura integral de `includes/abilities/class-ability-registrar.php::register_groups()`
(627 linhas) — extracção de TODAS as chamadas `class_exists('EMCP_Tools_...')` que condicionam
o registo de um grupo de abilities ou de um pack de integração.
2. Cruzamento com listagens de directório completas e literais (equivalente a `ls`) de:
- `includes/abilities/` (47 ficheiros `.php` + subpastas `forms/`, `seo/`);
- `includes/abilities/forms/` (2 ficheiros: `class-cf7-integration.php`,
`class-form-integration.php` — só a base + CF7);
- `includes/abilities/seo/` (2 ficheiros: `class-seo-integration.php`,
`class-slimseo-integration.php` — só a base + SlimSEO);
- `includes/modules/` (7 ficheiros de módulo + `class-module.php` + `class-modules-registry.php`
+ subpastas `image-optimization/`, `svg-support/` — **sem** `class-memory-module.php` nem
`class-migrate-module.php`).
3. Para cada classe candidata a Pro-only (i.e. referenciada no registrar mas ausente das listagens
acima), **tentativa directa de leitura** do ficheiro esperado (equivalente a `test -f`) —
20 tentativas, cada uma resultando em `head: impossível abrir '...' para leitura: No such file
or directory` (SSH remoto, `head` a falhar por ausência do ficheiro). Nenhuma presunção — cada
linha da tabela abaixo tem confirmação de ausência por, no mínimo, listagem de directório
completa, e a maioria tem confirmação dupla (listagem + tentativa de leitura directa).
Classes confirmadas **presentes** (portanto free, excluídas desta tabela por já estarem cobertas
noutros documentos da batch): `EMCP_Tools_Image_Resize_Abilities`, `EMCP_Tools_ACF_Abilities`,
`EMCP_Tools_Meta_Box_Abilities`, `EMCP_Tools_CF7_Integration`, `EMCP_Tools_SlimSEO_Integration`,
`EMCP_Tools_Active_Theme_Integration`, `EMCP_Tools_Astra_Integration`,
`EMCP_Tools_Spectra_Integration`, `EMCP_Tools_Kadence_Integration`,
`EMCP_Tools_Kadence_Blocks_Integration`, `EMCP_Tools_PHP_Snippet_Abilities`,
`EMCP_Tools_Sandbox_Cloud_Abilities`, `EMCP_Tools_Cloud_Abilities`, `EMCP_Tools_Cloud_Module`,
`EMCP_Tools_Global_Classes_Abilities`, `EMCP_Tools_Global_Classes_Write_Abilities`,
`EMCP_Tools_Themer_Abilities`, `EMCP_Tools_Themer_Module`, `EMCP_Tools_Themer_PHP_Abilities`,
`EMCP_Tools_Themer_PHP`, `EMCP_Tools_Redirect_Module`, `EMCP_Tools_Redirect_Abilities`,
`EMCP_Tools_Image_Optimization_Module`, `EMCP_Tools_Agent_Skills_Module` (presente mas `tier=pro`,
ver §1.3).
## 2.2 Tabela final — Classes referenciadas no registrar mas ausentes do build Free (Pro-only)
30 classes, agrupadas por família funcional. Coluna "Condição de gating" reproduz literalmente a
lógica de `register_groups()` (nomes de variáveis simplificados por clareza).
| Classe | Grupo funcional | Condição de gating (registrar) | Módulo Pro associado |
|---|---|---|---|
| `EMCP_Tools_Woo_Integration` | Integração WooCommerce (CRUD produtos/encomendas/etc.) | `class_exists(...) && EMCP_Tools_Woo_Integration::woo_active()` | — (gate próprio: plugin WooCommerce activo; sem module toggle dedicado) |
| `EMCP_Tools_WPForms_Integration` | Integração de formulários — leitura de entries (Pro) | `emcp_tools_fs()->can_use_premium_code() && class_exists(...)`, depois `$integration->is_available()` | — (tab "Forms"; CF7 é a única integração free) |
| `EMCP_Tools_GravityForms_Integration` | idem | idem | — |
| `EMCP_Tools_FluentForms_Integration` | idem | idem | — |
| `EMCP_Tools_NinjaForms_Integration` | idem | idem | — |
| `EMCP_Tools_Formidable_Integration` | idem | idem | — |
| `EMCP_Tools_MetForm_Integration` | idem | idem | — |
| `EMCP_Tools_SureForms_Integration` | idem | idem | — |
| `EMCP_Tools_Forminator_Integration` | idem | idem | — |
| `EMCP_Tools_Yoast_Integration` | Integração SEO — leitura/escrita meta SEO (Pro) | `emcp_tools_fs()->can_use_premium_code() && class_exists(...)`, depois `$integration->is_available()` | — (tab "SEO"; SlimSEO é a única integração free) |
| `EMCP_Tools_RankMath_Integration` | idem | idem | — |
| `EMCP_Tools_AIOSEO_Integration` | idem | idem | — |
| `EMCP_Tools_SeoPress_Integration` | idem | idem | — |
| `EMCP_Tools_SEOFramework_Integration` | idem | idem | — |
| `EMCP_Tools_SureRank_Integration` | idem | idem | — |
| `EMCP_Tools_GeneratePress_Integration` | Integração de tema/framework (Pro) | `class_exists(...)` — comentário do código: "classes only present when Pro loaded" — depois `$integration->is_available()` | — |
| `EMCP_Tools_GenerateBlocks_Integration` | idem | idem | — |
| `EMCP_Tools_Blocksy_Blocks_Integration` | Integração Blocksy — blocos | `class_exists(...)`, depois `is_available()` | — |
| `EMCP_Tools_Blocksy_Extensions_Integration` | Integração Blocksy — Companion extensions | `class_exists(...)`, depois `is_available()` | — |
| `EMCP_Tools_EssentialAddons_Integration` | Pack de widgets Elementor de terceiros — Essential Addons (Pro) | `class_exists(...)`, depois `is_available()` ("contributes ONE read tool for discovery + curation") | — |
| `EMCP_Tools_PremiumAddons_Integration` | Pack de widgets Elementor — Premium Addons (Pro) | idem | — |
| `EMCP_Tools_UAE_Integration` | Ultimate Addons for Elementor (ex-Header Footer Elementor) — widget pack + data plugin | `class_exists(...)`, depois `is_available()` — único pack que mantém o par read/write dispatcher (discovery+templates na leitura, templates na escrita) | — |
| `EMCP_Tools_Block_Builder_Abilities` | Construtor de blocos Gutenberg via MCP (Pro) | `class_exists(...)` — comentário: "self-guards on license" — Gutenberg, NÃO gated por Elementor activo | — |
| `EMCP_Tools_System_Kit_Abilities` | Brand Kit / System Kit — ferramenta MCP (Pro) | `class_exists(...)` dentro do bloco `if ($elementor_active)` — "self-guards on license" | Módulos "Brand Kits" (free) e "Templates" (Pro) são metadata-only; a ferramenta MCP para aplicar kits é sempre Pro, mesmo com os 10 kits free disponíveis via UI (§1.6) |
| `EMCP_Tools_Seo_Abilities` | Toolkit SEO on-page — ferramenta MCP (Pro) | idem, dentro de `$elementor_active` | — |
| `EMCP_Tools_A11y_Abilities` | Toolkit de acessibilidade — ferramenta MCP (Pro) | idem | — |
| `EMCP_Tools_Widget_Builder_Abilities` | Construtor de widgets Elementor custom — ferramenta MCP (Pro) | idem | — |
| `EMCP_Tools_Skill_Abilities` | Ferramentas MCP `list-skills`/`get-skill` (Pro) | `class_exists('EMCP_Tools_Skill_Abilities') && class_exists('EMCP_Tools_Agent_Skills_Module') && EMCP_Tools_Agent_Skills_Module::is_enabled()` | **Agent Skills** — módulo PRESENTE na árvore Free (`tier()='pro'`, ver §1.3); a ability em si está sempre ausente independentemente do toggle |
| `EMCP_Tools_Memory_Abilities` | Project Memory — ferramenta MCP (Pro) | `class_exists('EMCP_Tools_Memory_Abilities') && class_exists('EMCP_Tools_Memory_Module') && EMCP_Tools_Memory_Module::is_enabled()` | **Memory** — módulo TAMBÉM ausente da árvore Free (ver linha seguinte) |
| `EMCP_Tools_Memory_Module` | Módulo "Memory" (toggle no Modules tab) | `class_exists('EMCP_Tools_Memory_Module')`, usado em conjunto com `Memory_Abilities` acima | Pro — ao contrário de Agent Skills/Templates, nem o ficheiro de metadata do módulo está na árvore Free; não há card "Memory" a mostrar-se bloqueado na Modules tab de um site Free |
| `EMCP_Tools_Migrate_Abilities` | Backup / Migrate / Sync — ferramenta MCP (Pro) | `class_exists('EMCP_Tools_Migrate_Abilities') && class_exists('EMCP_Tools_Migrate_Module') && EMCP_Tools_Migrate_Module::is_enabled()`. As duas tools destrutivas deste grupo vêm desactivadas por omissão mesmo quando disponíveis. | **Migrate** — módulo TAMBÉM ausente (linha seguinte) |
| `EMCP_Tools_Migrate_Module` | Módulo "Migrate" (toggle no Modules tab) | idem padrão de `Memory_Module` | Pro — mesmo padrão: nem o ficheiro de metadata existe na árvore Free |
**Nota sobre `Memory_Module`/`Migrate_Module` vs `Templates_Module`/`Agent_Skills_Module`:** o
código tem DOIS padrões distintos para features Pro sem equivalente free algum:
- **Padrão "card bloqueado"** (Templates, Agent Skills): o ficheiro `.php` do módulo VIVE na árvore
Free, `tier()==='pro'`, `is_available()` exige licença — o utilizador Free VÊ o card na Modules
tab, mas bloqueado/locked, como incentivo de upsell visível.
- **Padrão "invisível"** (Memory, Migrate): nem o ficheiro do módulo existe no build Free — não há
card nenhum a mostrar-se, a feature é completamente invisível a um utilizador Free até subir para
o build Pro. Só as próprias abilities (também ausentes) e o módulo referenciam estas classes; sem
card na Modules tab não há sequer superfície de descoberta da feature no admin.
---
## Blueprint para réplica
### Sistema de módulos (`class-module.php` + `class-modules-registry.php`)
**Copiar quase verbatim** — é uma pequena máquina de estado limpa (~215 linhas as duas classes
juntas), sem lógica Pro nenhuma. O único detalhe de desenho que vale a pena preservar
deliberadamente é a **lista `seeded` por-módulo** (em vez de um marcador booleano único) em
`apply_defaults()` — é o que permite adicionar um módulo novo numa versão futura sem re-seedar nem
tocar no estado que o utilizador já ajustou nos módulos existentes. E o padrão do **helper estático
`is_enabled()`** em todo módulo com abilities associadas — necessário porque `wp_abilities_api_init`
dispara antes de `init:5`, então o gate das MCP tools nunca pode depender de `register()` já ter
corrido.
### Prompts / Brand Kits (módulos "tab-only")
Sem lógica nenhuma (`register()` no-op) — só interessam se formos replicar a UI de tabs
correspondente. Podem ser omitidos por completo numa primeira réplica sem perda funcional MCP
nenhuma.
### Redirect / Cloud / Themer (módulos-wrapper)
**Vale a pena adoptar o padrão inteiro**, não só o wrapper: um módulo que arranca a infra-estrutura
pesada (store, handler, CPT) em `init:5` E gate as MCP tools associadas via um helper estático
separado é o mecanismo real de "kill switch" — desliga tudo (runtime + tools) a partir de UMA
option, sem desregistar código morto por todo o lado. O heal-on-upgrade do Themer
(`emcp_tools_themer_index_healed`) é um padrão geral a reter: sempre que se corrige um bug de
corrupção de dados, adicionar também um marcador de reparação única para sites JÁ afectados —
corrigir só o código novo deixa instalações existentes permanentemente partidas.
### Templates / Agent Skills (módulos "metadata Pro")
**Ignorar** numa réplica 100% free/open — só fazem sentido se construirmos o nosso próprio sistema
de licenciamento/tiering. Se algum dia quisermos essa separação, o padrão exacto a copiar é:
`tier()` + `is_available()` a checar um SDK de licença (equivalente Freemius), com a classe de
metadata do módulo a viver sempre no build "free" para mostrar um card bloqueado como incentivo de
upsell — decisão de UX deliberada, não acidental (o próprio comentário do autor cita este padrão
explicitamente ao comparar Agent Skills com Templates).
### Image Optimization
**Copiar de perto — é a feature opt-in mais bem desenhada e mais valiosa de toda a árvore Free, sem
nenhuma dependência Pro.** Decisões de desenho a preservar:
- **Idempotência** via marcador de estado `_emcp_optim` em post meta — evita reprocessar em cada
regeneração de metadata.
- **Reversibilidade** via espelho de backup em `uploads/emcp-originals/` — nunca destruir sem saída
de emergência.
- O filtro `emcp_tools_optimize_attachment` como seam de extensibilidade explícito — manter um
ponto de opt-out per-attachment para outras ferramentas (sideload/stock-image) é um padrão a
reter mesmo fora deste contexto específico.
- **REST/CLI sempre-WebP vs frontend condicional ao header `Accept`** é um comportamento subtil mas
importante — as MCP tools devem sempre receber o asset optimizado, independentemente da política
pública de servir WebP do site.
- O processador em lote resumível (cursor em option + batch pequeno, 1-50) é o padrão standard
WP-admin-ajax para evitar timeout em bibliotecas grandes — sem surpresas, replicar tal-e-qual.
- **Gotcha a preservar**: o clamp de quality (1-100) é partilhado entre `Optimizer` e `Resizer` via
método estático único — evitar duplicar essa lógica de clamp em dois sítios.
### SVG Support
**Copiar de perto, com cuidado redobrado — é a feature com maior superfície de segurança de toda a
árvore Free.** Coisas que NÃO se podem saltar:
- **Sanitização fail-closed**: um SVG que não consiga ser limpo tem de ser rejeitado, nunca deixado
passar silenciosamente.
- A correcção de `wp_check_filetype_and_ext` **não é opcional** — a maioria das implementações
ingénuas de "basta adicionar svg a upload_mimes" esquecem-se disto e o upload SVG falha
silenciosamente no caminho REST/sideload (comentário explícito do autor: "the piece most SVG
plugins miss" — é exactamente o tipo de gotcha não óbvio que vale a pena citar e replicar).
- `removeRemoteReferences(true)` explícito — não confiar nos defaults da biblioteca de
sanitização, configurar o endurecimento SSRF/XSS deliberadamente.
- O autoloader PSR-4 escopado como fallback próprio para a biblioteca vendorizada — um padrão de
redundância defensiva a reter sempre que se vendoriza um pacote Composer dentro de um plugin
WordPress (geração de classmap Jetpack/Composer é um footgun real e conhecido).
### Free Brand Kits
Vale a pena copiar o **padrão** (bundle de um dataset JSON+SVG pequeno e gratuito, partilhando o
caminho de escrita/aplicação com qualquer tier pago) mesmo que não cheguemos a construir kits Pro
nós próprios — significa que a UX de "aplicar um kit inicial" via admin funciona sem infra-estrutura
de licenciamento nenhuma.
### Inventário Pro (Parte 2)
Para uma réplica 100% free/aberta, estas 30 classes **simplesmente não se constroem** — representam
~30 dos ~165 tools totais (~18%) e estão inteiramente ausentes do que seria preciso reimplementar
para um clone só-free. Se algum dia quisermos uma separação de monetização própria, o mecanismo
exacto a copiar é: (a) gate `class_exists()` no ponto de chamada do registrar (o ficheiro da classe
Pro literalmente não existe a menos que a pasta do plugin pago esteja activa — mesmo modelo de
Free/Pro como duas pastas de plugin distintas usado por este vendor, ver `00-ARQUITECTURA.md` §7);
(b) um toggle de módulo SECUNDÁRIO e opcional (`is_enabled()`) para o punhado de tools que também
querem um interruptor admin independente da licença (Skill/Memory/Migrate); (c) `is_available()` na
própria classe de módulo a verificar um SDK de licença, para os módulos "metadata-only" com card
bloqueado (Templates/Agent-Skills).
---
## Fonte
Leitura directa (19-08-2026) de:
- `includes/modules/class-module.php`
- `includes/modules/class-modules-registry.php`
- `includes/modules/class-prompts-module.php`
- `includes/modules/class-brand-kits-module.php`
- `includes/modules/class-templates-module.php`
- `includes/modules/class-redirect-module.php`
- `includes/modules/class-cloud-module.php`
- `includes/modules/class-themer-module.php`
- `includes/modules/class-agent-skills-module.php`
- `includes/modules/image-optimization/class-image-optimization-module.php`
- `includes/modules/image-optimization/class-image-optimizer.php`
- `includes/modules/image-optimization/class-image-resizer.php`
- `includes/modules/image-optimization/class-webp-generator.php`
- `includes/modules/image-optimization/class-webp-rewriter.php`
- `includes/modules/image-optimization/class-bulk-optimizer.php`
- `includes/modules/image-optimization/settings-fields.php`
- `includes/modules/svg-support/class-svg-support-module.php`
- `includes/modules/svg-support/class-svg-sanitizer.php`
- `includes/class-free-brand-kits.php`
- `includes/abilities/class-ability-registrar.php` (627 linhas, integral — extracção de todas as
chamadas `class_exists()`)
Listagens de directório completas (equivalente a `ls`), 19-08-2026:
- `includes/abilities/` (47 ficheiros + `forms/`, `seo/`)
- `includes/abilities/forms/`
- `includes/abilities/seo/`
- `includes/modules/` (7 módulos + base + registry + `image-optimization/`, `svg-support/`)
- `includes/modules/image-optimization/`
- `includes/modules/svg-support/`
- `includes/themer/` e `includes/themer/php/`
- `includes/cloud/`
- `includes/` (nível topo)
Tentativas directas de leitura de ficheiro (equivalente a `test -f`), todas devolvendo "No such
file or directory" — confirmação de ausência, 19-08-2026, para: `class-woo-integration.php`,
`class-memory-module.php`, `class-migrate-module.php`, `class-skill-abilities.php`,
`class-memory-abilities.php`, `class-migrate-abilities.php`, `class-system-kit-abilities.php`,
`class-seo-abilities.php`, `class-a11y-abilities.php`, `class-widget-builder-abilities.php`,
`class-block-builder-abilities.php`, `class-essential-addons-integration.php`,
`class-premium-addons-integration.php`, `class-uae-integration.php`,
`class-generatepress-integration.php`, `class-generateblocks-integration.php`,
`class-blocksy-blocks-integration.php`, `class-blocksy-extensions-integration.php`,
`forms/class-wpforms-integration.php`, `seo/class-yoast-integration.php` (spot-checks
representativos das famílias Forms/SEO Pro, cuja ausência integral é também confirmada pela
listagem completa dos respectivos subdirectórios).
Cruzado com `docs/00-ARQUITECTURA.md` (mesma batch, 19-08-2026) e `skill://emcp-tools` (auditoria
de postura de segurança, 16-08-2026).
+232
View File
@@ -0,0 +1,232 @@
# EMCP Tools — Mapeamento completo e blueprint de réplica
Índice e síntese de 11 documentos (~6 070 linhas), produzidos 19-08-2026 por leitura directa do
código-fonte `emcp-tools` v3.12.1 (build Free, `msrbuilds/elementor-mcp`, GPL-2.0-or-later),
instalado em `emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`).
Cruzado com `skill://emcp-tools` (auditoria de postura de segurança viva nos 3 sites do
ecossistema: `descomplicar.pt`, `emanuelalmeida.pt`, `starter.descomplicar.pt`).
**Objectivo desta série:** especificação funcional completa, ao nível de "o que este código faz e
porquê", para servir de base a uma réplica própria — seja fork (GPL permite) seja reescrita limpa.
Nenhum documento é resumo de superfície; cada um lê o código-fonte real, cita comentários do autor
quando revelam decisões não óbvias, e termina numa secção "Blueprint para réplica" com veredicto
copiar-1:1 / simplificar / deixar de fora.
---
## 1. Mapa dos documentos
| Doc | Assunto | Linhas | Abilities (aprox.) | Dependência |
|---|---|---|---|---|
| [00-ARQUITECTURA](00-ARQUITECTURA.md) | Bootstrap, ability registrar, dispatcher compact mode, `register_mcp_server`, guarda Free⇄Pro | 277 | n/a (estrutural) | — (fundação de tudo) |
| [01-ELEMENTOR-CLASSICO](01-ELEMENTOR-CLASSICO.md) | Páginas, layout, widgets (catálogo), templates, global settings/classes, custom code, SVG, composite | 632 | ~35 | Elementor activo |
| [02-ATOMIC-V4-GUTENBERG](02-ATOMIC-V4-GUTENBERG.md) | Elementor Atomic v4 (`$$type` props, widgets, flexbox/div-block) + Gutenberg nativo (sempre-on) | 515 | ~25 | Elementor 4.0+ atomic (widgets/layout) · nenhuma (Gutenberg) |
| [03-WORDPRESS-CORE-TEMAS](03-WORDPRESS-CORE-TEMAS.md) | Posts/media/settings/plugins/temas/users/menus + integrações de tema (Astra/Spectra/Kadence) | 494 | ~30 | Nenhuma (core puro) |
| [04-THEMER](04-THEMER.md) | CPT header/footer/single/archive/search/404, sistema de condições, render pipeline, Themer PHP | 687 | ~13 | Módulo `themer` (free, on por omissão) |
| [05-REDIRECTS-SEARCH-LEDGER](05-REDIRECTS-SEARCH-LEDGER.md) | Redirects 301/302, broken-links, **change ledger unificado** (rollback), content-mirror | 727 | ~14 | Módulo `redirects` (redirects) · nenhuma (ledger) |
| [06-SANDBOX-CUSTOM-CODE](06-SANDBOX-CUSTOM-CODE.md) | PHP snippets com aprovação humana, export/import de artefactos, Widget Builder (Pro, ausente) | 336 | ~20 | Nenhuma (snippets free) |
| [07-SYSTEM-OPS](07-SYSTEM-OPS.md) | Filesystem, base de dados directa, WP-CLI, Security Scanner, Performance Analyzer | 629 | ~15 | Nenhuma — **maior risco do plugin** |
| [08-INTEGRACOES-TERCEIROS](08-INTEGRACOES-TERCEIROS.md) | ACF, Meta Box, formulários (CF7 free), SEO (SlimSEO free) | 457 | ~30 (free) | Plugin de terceiros instalado |
| [09-STOCK-IMAGES-CLOUD-OAUTH](09-STOCK-IMAGES-CLOUD-OAUTH.md) | Unsplash/Pexels/Pixabay, EMCP Cloud (ligação/sync), servidor OAuth para MCP remoto | 654 | ~17 | Nenhuma |
| [10-MODULOS-E-INVENTARIO-PRO](10-MODULOS-E-INVENTARIO-PRO.md) | Sistema de módulos (toggles), Image Optimization, SVG Support, **inventário definitivo das 30 classes Pro-only** | 661 | n/a + 30 ausentes | — (estrutural + auditoria) |
Total: **~6 070 linhas de documentação** cobrindo **~165 abilities** (número confirmado ao vivo em
`skill://emcp-tools` §7) sobre **~128 465 linhas de PHP** do plugin (`~50k` das quais são o SDK
Freemius bundled, irrelevante para réplica).
---
## 2. O achado mais importante de toda a série
**A peça MCP não é do fornecedor do plugin — é do próprio WordPress core team.** O EMCP Tools
depende de `wordpress/mcp-adapter` + `wordpress/php-mcp-schema`, dois pacotes Composer oficiais
`make.wordpress.org/ai`, GPL-2.0-or-later, e da Abilities API nativa do WordPress 6.9+/7.0
(`wp_register_ability()`). **Não há protocolo nenhum para reimplementar.** O trabalho real de uma
réplica é 100% aplicação: registar as nossas próprias abilities com o nosso prefixo, e chamar
`$mcp_adapter->create_server()` com o nosso `server_id`. Ver `00-ARQUITECTURA.md` §0.
Consequência prática: a pergunta "construir um MCP para WordPress do zero" tem uma resposta muito
mais barata do que pareceria — `composer require wordpress/mcp-adapter` dá a máquina toda
(JSON-RPC, sessão, transporte HTTP/stdio). O valor real deste plugin (e desta documentação) está
no **catálogo de ~165 abilities bem desenhadas** por cima dessa máquina, não na máquina em si.
---
## 3. Os 5 subsistemas de maior valor para copiar quase verbatim
Ordenados por relação esforço-de-reescrita ÷ risco-de-reintroduzir-bugs-já-corrigidos (o critério
mais citado em todos os 10 documentos de subsistema):
1. **`EMCP_Tools_Atomic_Props`** (doc 02, 973 linhas) — o sistema `$$type` do Elementor 4.0+.
Sete issues numeradas do próprio autor (#36, #56, #74, #97, #101, #102, #111) já corrigidas;
`is_atomic_supported()` usa introspecção de tipos registados, nunca `version_compare()`
(o Elementor reporta versão 3.x com atomic já activo como experiment). Reescrever do zero
arrisca reproduzir literalmente 2 anos de bugs de produção documentados.
2. **O change ledger unificado** (doc 05 §4: `Change_Log` + `Change_Recorder` + `Change_Blobs`) —
é o mecanismo de rollback partilhado por **praticamente todos os outros subsistemas**
(Elementor, Gutenberg, filesystem, DB, ACF, redirects, posts). Onze tipos de rollback
diferentes (`elementor-data`, `file-backup`, `db-before-image`, `post-fields`, `user-create`,
etc.), cada um com o seu applier. Construir isto PRIMEIRO numa réplica poupa reimplementar
undo ad-hoc em cada subsistema individual — é exactamente o padrão que `08-INTEGRACOES-TERCEIROS.md`
recomenda generalizar ainda mais (a base SEO já o faz com zero código extra por integração).
3. **`EMCP_Tools_Filesystem_Guard`/`Database_Guard`/`WPCLI_Validator`** (doc 07) — os três guards
de maior superfície de segurança do plugin. `resolve_path()` (confinamento a `ABSPATH`,
comparação de prefixo correcta), `is_read_only_sql()` (scanner char-a-char anti-ReDoS, com dois
bugs de "quase-passou" já documentados nos comentários — o truque `/*!` do MySQL e o regex de
`LOAD_FILE(` sem `\b` a fechar), e o tokenizador consciente de aspas do WP-CLI validator.
4. **`EMCP_Tools_Block_Tree`** (doc 02, ~330 linhas) — transformações puras sobre
`parse_blocks()`/`serialize_blocks()`, endereçamento por PATH de índices. Três guards de
segurança em `move()` (auto-referência, alvo-dentro-da-subárvore, correcção de deslocamento de
índice) e a reconstrução de `innerContent` em `inner_content_for()` são precisamente o tipo de
lógica subtil que se parte silenciosamente ao reescrever sem os mesmos casos de teste.
5. **O sistema de condições do Themer** (doc 04 §4) — Schema/Matcher-Registry/Conditions/Context/
Resolver com algoritmo de desempate de 3 níveis, mais o índice cacheado (com o bug histórico de
ordem save/priority-99 já corrigido e um mecanismo de heal one-time para sites já afectados). É
o núcleo mais reutilizável fora do domínio Elementor — qualquer sistema de "mostra X só quando Y"
pode reaproveitar a mesma arquitectura.
---
## 4. Padrões arquitecturais que atravessam todo o plugin
Encontrados de forma independente em múltiplos documentos — não são acidente, são a assinatura de
um autor disciplinado:
- **Kill switch por módulo + `is_enabled()` estático** (doc 10 §1.2) — porque
`wp_abilities_api_init` corre ANTES de `init:5`, as MCP tools de um módulo nunca podem depender
de `$module->is_active()` (a instância só existe depois); todo módulo com abilities associadas
expõe um helper estático que lê a option directamente. Ver Redirect/Cloud/Themer/Agent-Skills.
- **Construção lazy de motores pesados/frágeis** (doc 07 §4, issue #100) — o Security Scanner só
instancia os 4 audits na primeira `scan()` real, nunca no registo da ability (que corre em TODO
pedido admin/REST). O mesmo princípio aparece em `register_all()` do registrador global
(doc 00 §3), envolto em `try/catch`.
- **Argv array, nunca string interpolada** — a regra de ouro para qualquer invocação de processo
externo (`WPCLI_Runner::run_shell`, `WPCLI_Jobs::spawn`). Citada explicitamente como "a regra
arquitectural mais importante" do doc 07.
- **Fragmentação de assinaturas próprias** (doc 07 §4.1) — o scanner de malware fragmenta as
suas próprias regras de detecção em tokens concatenados em runtime, para não ser confundido com
malware por scanners do host (Imunify360, Wordfence). O achado mais inesperado de toda a série.
- **Zero tool MCP de "attach" para código PHP executável** (doc 04 §11) — o Themer PHP tem
deliberadamente nenhuma ability que torne um template PHP activo; só um humano no dropdown da
metabox pode fazê-lo. Combinado com manifest-only lookup + path containment guard + tamper guard
sha256 + shutdown fatal-recovery handler, é o modelo de segurança de 4 camadas mais valioso
documentado para "código gerado por IA que corre no servidor".
- **Snapshot-before-write como contrato universal, não por-ability** (doc 05 §4.4, doc 08) — o
Recorder é uma fachada fina; cada write-site decide o que capturar, mas o mecanismo de
persistência/rollback é sempre o mesmo. A base SEO (doc 08) prova que isto pode ser tão genérico
a ponto de dar rollback automático a integrações que nunca escrevem uma linha de código de undo.
- **Guarda Free⇄Pro single-instance** (doc 00 §7) — Free e Pro são o mesmo código-fonte em duas
pastas de plugin; a guarda impede fatal de redeclaração se ambas activarem por engano. Não
relevante para réplica (housekeeping de distribuição), mas explica a estrutura de ficheiros.
---
## 5. Sequência de construção recomendada para uma réplica
Ordem por dependência real (não por número de documento) — cada fase assume as anteriores feitas:
### Fase 0 — Fundação (sem isto, nada mais tem rollback nem é seguro)
1. Wrapper de registo (`emcp_tools_register_ability()` equivalente — doc 00 §5): `sanitize()` +
`normalize_result()` no mínimo; `strictify()` só se formos alvo de clientes OpenAI-strict.
2. `register_mcp_server()` + dispatcher de compact tool mode (doc 00 §3-4) — ~250 linhas triviais,
não vale a pena simplificar mais.
3. Change ledger unificado (doc 05 §4) — `Change_Log`/`Change_Recorder`/`Change_Blobs`, mesmo que
inicialmente só com 2-3 tipos de rollback (`post-fields`, `option`, `file-backup`) e expansível
depois.
4. Os 3 guards de maior risco (doc 07): `Filesystem_Guard::resolve_path()`,
`Database_Guard::is_read_only_sql()`, `WPCLI_Validator`.
### Fase 1 — Conteúdo WordPress puro (sem dependência de Elementor)
5. `03-WORDPRESS-CORE-TEMAS`: posts/media/settings/users/menus/taxonomias. É a base útil mesmo
sem nenhuma outra fase — um site sem Elementor já ganha valor MCP real aqui.
6. Gutenberg (doc 02 §3 + `Block_Tree`) — também sem dependência de Elementor, sempre-on.
### Fase 2 — Elementor (o núcleo de "construir páginas")
7. `01-ELEMENTOR-CLASSICO`: `Element_Factory`, `Elementor_Data` (com os ~14 gotchas numerados do
próprio código — copiar a lógica de save/verify/fallback tal-e-qual), páginas/layout/widgets
catalog-backed/templates.
8. `02-ATOMIC-V4-GUTENBERG` (parte atomic): `Atomic_Props` (copiar quase inteiro),
`Atomic_Widget_Map`, `Atomic_Styles`.
### Fase 3 — Camadas de produto sobre o conteúdo
9. Sistema de módulos (doc 10 §1.1-1.2) — a máquina de toggles + `apply_defaults()` com seeding
por-módulo.
10. `04-THEMER` — sistema de condições primeiro (é o mais reutilizável), depois CPT/render/blocos.
11. `05-REDIRECTS-SEARCH-LEDGER` (parte redirects) — 301/302 + broken-links + shadow warning.
### Fase 4 — Superfície de risco elevado, opt-in
12. `07-SYSTEM-OPS` restante: WP-CLI runner (**simplificável** — se o deployment for sempre
SSH+STDIO como este ecossistema, `is_cli_context()` é sempre verdadeiro e todo o caminho shell
+ jobs detached pode ser cortado), Security Scanner, Performance Analyzer (ambos activos por
omissão, só leitura — bom ROI inicial).
13. `06-SANDBOX-CUSTOM-CODE` — PHP snippets com aprovação humana; **replicar o padrão de
aprovação, não pular directo para execução automática**.
14. Image Optimization + SVG Support (doc 10 §1.4-1.5) — os dois módulos opt-in mais bem
desenhados de toda a árvore Free, sem dependência Pro nenhuma.
### Fase 5 — Integrações e periferia (valor incremental, não bloqueante)
15. `08-INTEGRACOES-TERCEIROS` — ACF/Meta Box/CF7/SlimSEO, só se esses plugins fizerem parte do
stack alvo.
16. `09-STOCK-IMAGES-CLOUD-OAUTH` — Unsplash/Pexels/Pixabay + EMCP Cloud + OAuth (só se for
preciso MCP remoto autenticado; Application Password/cookie admin cobre o caso local).
### Explicitamente fora de âmbito (doc 10 §2)
As **30 classes Pro-only** (WooCommerce completo, 8 integrações de formulários adicionais, 6 de
SEO adicionais, 2 packs de widgets Elementor de terceiros, Ultimate Addons, Brand/System Kit MCP,
SEO/A11y toolkit, Widget/Block Builder MCP, Skill/Memory/Migrate) representam **~18% do catálogo
total** e simplesmente não se constroem numa réplica 100% free/aberta — o mecanismo de gate
(`class_exists()` + ficheiro fisicamente ausente da árvore) só é replicável se quisermos a mesma
separação de monetização.
---
## 6. Tabela de risco consolidada
| Categoria | Activo por omissão? | Nº de tools destrutivas | Mecanismo de defesa principal |
|---|---|---|---|
| Filesystem (doc 07 §1) | **Não** (write/edit/delete) | 3 | `resolve_path()` confinamento a ABSPATH + backup automático |
| Base de dados directa (doc 07 §2) | **Não** (insert/update/delete) | 3 | `is_read_only_sql()` + `valid_table()` contra `SHOW TABLES` + before-image |
| WP-CLI (doc 07 §3) | **Não** (as 4, incl. as 2 readonly) | 2 (run/dispatch) | Blocklist comando/subcomando/flag + argv array nunca shell string |
| Security Scanner (doc 07 §4) | **Sim** — só leitura | 0 | — |
| Performance Analyzer (doc 07 §5) | **Sim** — só leitura | 0 | — |
| Themer PHP (doc 04 §11) | Módulo on, mas **zero tool de attach** | 0 (nenhuma torna código executável) | Aprovação humana obrigatória no dropdown da metabox |
| Sandbox PHP Snippets (doc 06) | Módulo on, execução gated | 0 (activação sempre humana) | Aprovação humana + manifest hash-verificado |
| Elementor/Gutenberg escrita (docs 01-02) | Sim | Várias (delete-*, remove-*) | Change ledger com rollback via `rollback-change` |
| Base de dados via ACF/Meta Box (doc 08) | Depende do plugin instalado | Poucas | Snapshot before-image (ACF); **gap identificado**: Meta Box sem registo algum |
**Gap de segurança mais citado nesta série** (doc 08 §Blueprint): Meta Box não tem nenhum
mecanismo de snapshot/rollback, ao contrário de ACF (`record_acf_fields`) e da base SEO
(`recordable_meta_keys()` automático). Uma réplica que use o padrão de "declarar meta keys
recordáveis" generalizado (em vez de cada integração implementar o seu próprio undo ad-hoc) fecha
este gap de origem.
---
## 7. Como usar esta documentação
- **Fork/rebranding rápido**: ler `00-ARQUITECTURA.md` §0 e §7 primeiro (namespace, guarda
Free⇄Pro, licenciamento GPL), depois usar os docs 01-10 como mapa de onde cada coisa vive no
código real para renomear/remover Freemius/ajustar prefixo de abilities.
- **Reescrita limpa**: seguir a sequência da secção 5 acima; cada documento de subsistema tem uma
secção "Blueprint para réplica" com veredicto explícito copiar/simplificar/omitir por peça, e uma
secção "Fonte" com os ficheiros exactos lidos (para re-verificar contra o código original em
caso de dúvida).
- **Apenas auditoria/entendimento** (sem construir nada): `skill://emcp-tools` continua a ser a
referência operacional para a instalação viva nos 3 sites Descomplicar (deny-list, módulos
activos, postura de segurança); esta série documenta o CÓDIGO, não a CONFIGURAÇÃO de nenhum
site específico.
---
## Metodologia
Cada um dos 10 documentos de subsistema foi produzido por um subagente `code-explorer`
independente, com leitura directa via `ssh://server` dos ficheiros PHP reais da instalação em
`emanuelalmeida.pt`, sem overlap de ficheiros entre agentes (coordenado via `hub` IRC quando
necessário — ver `history://Doc07SystemOps` para um exemplo de coordenação real). Nenhum conteúdo
foi inferido a partir de nomes de classe ou convenções assumidas sem confirmação directa no
código-fonte; onde a leitura foi parcial (ex. catálogos Pro de 1000+ linhas), o documento
correspondente declara explicitamente o que foi e não foi lido (ver `02-ATOMIC-V4-GUTENBERG.md`
§4.5 para o exemplo mais claro desta prática). Revisão cruzada final (secção 2 do processo desta
sessão) confirmou consistência de nomes de classe, tipos de rollback, e condições de gating entre
todos os 11 documentos — nenhuma contradição encontrada.