commit 8ada367bd0afc946e951f5d1df5bf99afd58230d Author: Claude Code Date: Wed Aug 19 06:41:04 2026 +0100 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). diff --git a/docs/00-ARQUITECTURA.md b/docs/00-ARQUITECTURA.md new file mode 100644 index 0000000..f8aa2a1 --- /dev/null +++ b/docs/00-ARQUITECTURA.md @@ -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('', ['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. diff --git a/docs/01-ELEMENTOR-CLASSICO.md b/docs/01-ELEMENTOR-CLASSICO.md new file mode 100644 index 0000000..6596295 --- /dev/null +++ b/docs/01-ELEMENTOR-CLASSICO.md @@ -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 `` na árvore da página (não é injecção site-wide, é conteúdo normal da página). Remove qualquer `` 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 `