# 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 (`…`). 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': '', 'value': }` — 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: }` | 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:, 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:, tag:('a'), isTargetBlank?:}` | `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:}` | 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--<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--`) 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 '' => [ '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.