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
+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.