Files
Claude Code 4a55d51329 feat(wordpress): EMCP Tools workflow skills + widget catalogs + pending wordpress skills
- emcp-page-building, emcp-content-ops, emcp-site-audit: EMCP Tools MCP
  workflows verified live (atomic/legacy interplay, apply-template overwrite
  risk, create-theme-template goes live immediately, false-positive malware
  pattern in scan-security, change ledger + rollback)
- elementor-pro-widgets: 30+5 native Elementor Pro widgets (curated catalog)
- elementskit-widgets / powerpack-widgets: 42 + 97 third-party widgets,
  widgetType extracted from plugin source (not guessed by convention)
- plugin.json bumped 1.2.0 -> 1.3.0, keywords + description updated
- commits pending wordpress skills already present as untracked files
  (emcp-tools, wordfence, wp-activity-log, seguranca-descomplicar,
  webp-express, wp-fastest-cache, wp-font-perf, wp-meteor,
  wp-activity-log, redis-object-cache, app-for-cloudflare) and pending
  edits (rank-math, wp-cli, wp-content-seo-gate)
2026-08-19 03:44:00 +01:00

438 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: wp-font-perf
description: Cortar o peso de webfonts e de imagens em background CSS em sites WordPress/Elementor da frota Descomplicar, com medição real de Performance. Cobre o subsetting de fontes de ícones (IconSax, Font Awesome, eicons, elementskit) com `pyftsubset` e rede de segurança por `unicode-range` complementar, a substituição de folhas do Elementor via `style_loader_tag` (e porque um `wp_dequeue_style` não chega), WebP em `background-image` que o WebP Express não cobre, preload de fonte variável para travar CLS, e as duas armadilhas que partem o site ou invalidam medições (purge do Cloudflare avariado, apagar `wpfc-minified`). Usar quando "performance wordpress", "lighthouse", "LCP alto", "CLS", "fontes pesadas", "subset de fontes", "icon fonts", "webfonts", "peso da página", "optimizar site", "core web vitals", "PageSpeed".
---
# /wp-font-perf — Peso de webfonts e imagens CSS, com medição real
Reduzir peso real de páginas WordPress/Elementor na frota Descomplicar. Validado
em piloto em `emanuelalmeida.pt` a 17-08-2026: fontes de ícones **1256KB → 10KB**,
peso total **3125KB → 1258KB**, Performance mobile **60 → 68**, desktop **79 → 88**,
LCP mobile **14,2s → 6,1s**, CLS **0 estável**.
**Fonte:** `04-Stack/02.04-Sistemas/71.Seguranca/BUNDLE-Excelencia-WP.md` §2.2.1
e §2.2.2 (relato completo, incluindo o incidente).
---
## 0. Antes de tudo: medir Performance a sério
**O `lighthouse_audit` do MCP chrome-devtools NÃO mede a categoria Performance.**
Exclui-a por desenho. Um relatório "100/100/100/100" desse MCP pode conviver com
um LCP de 14 segundos — aconteceu, duas sessões seguidas.
```bash
# Instalar/usar Lighthouse local (não depende da quota da API do PSI)
npx -y lighthouse@12 "https://SITE/" --only-categories=performance \
--form-factor=mobile --screenEmulation.mobile --throttling-method=simulate \
--output=json --output-path=/tmp/lh.json --chrome-flags="--headless=new --no-sandbox" --quiet
# desktop
npx -y lighthouse@12 "https://SITE/" --only-categories=performance \
--form-factor=desktop --screenEmulation.disabled --throttling-method=simulate \
--throttling.rttMs=40 --throttling.throughputKbps=10240 --throttling.cpuSlowdownMultiplier=1 \
--output=json --output-path=/tmp/lhd.json --chrome-flags="--headless=new --no-sandbox" --quiet
```
**Regra de evidência: mediana de 5 corridas, nunca uma.** A variância entre
corridas isoladas da mesma configuração chega a **25 pontos** (medido: 69 e 62
para o mesmo estado). Uma corrida não é evidência de nada.
Peso por tipo (é aqui que se decide onde atacar):
```bash
python3 -c "
import json, collections
lr=json.load(open('/tmp/lh.json'))
r=(lr['audits']['network-requests'].get('details') or {}).get('items',[])
by=collections.Counter(); n=collections.Counter()
for x in r:
t=x.get('resourceType') or 'other'; by[t]+=x.get('transferSize',0); n[t]+=1
print('total %.0fKB / %d pedidos' % (sum(by.values())/1024, len(r)))
for t,v in by.most_common(): print(f' {t:12} {n[t]:>3} {v/1024:>7.0f}KB')
print('perf', round(lr['categories']['performance']['score']*100))
for k in ('largest-contentful-paint','cumulative-layout-shift','first-contentful-paint'):
print(' ', k, lr['audits'][k].get('displayValue'))
"
```
---
## 1. Fontes de ícones: o suspeito número um
Sites Elementor com icon packs acumulam megabytes para desenhar dezenas de
glifos. No piloto: **1,2MB para 27 glifos**.
|Fonte|Original|Subset|Glifos usados / existentes|
|---|---|---|---|
|IconSax|355.632 B **TTF**|3.172 B|12 / 890|
|IconSax-Fino|355.692 B **TTF**|536 B|1 / 906|
|eicons|108.000 B|636 B|3 / 515|
|fa-brands-400|81.612 B|1.028 B|4|
|fa-solid-900|78.196 B|780 B|8|
|elementskit (ekiticons)|252KB|**0** (suprimido)|0 / 12|
Icon packs custom carregados pelo Elementor (`uploads/elementor/custom-icons/`)
vêm frequentemente em **TTF/WOFF sem woff2** — formato sem compressão web.
### 1.1 Levantar os glifos em uso em TODO o site
Nunca só na homepage. Varrer todos os permalinks publicados:
```bash
ssh server "sudo -u USER /usr/local/bin/wp post list --post_type=page,post \
--post_status=publish --field=url --path=WEBROOT 2>/dev/null"
```
Depois `curl` a cada URL e recolher as classes (`icon-sax-*`, `fa-*`, `eicon-*`).
Mapear classe → codepoint nos CSS (`content:"\eXXX"`).
**Método mais fiável, no browser** (apanha o que vem de CSS de widgets e não só
de classes no HTML) — itera os pseudo-elementos e lê o `content` computado:
```js
const want = /Font Awesome|eicons|IconSax|elementskit/i;
const hits = {};
for (const el of document.querySelectorAll('*'))
for (const ps of ['::before','::after']) {
const cs = getComputedStyle(el, ps);
if (!want.test(cs.fontFamily || '')) continue;
for (const ch of (cs.content||'').replace(/^["']|["']$/g,'')) {
const cp = ch.codePointAt(0);
if (cp >= 0xE000) (hits[cs.fontFamily.replace(/["']/g,'').split(',')[0].trim()+'|'+cs.fontWeight] ??= new Set()).add(cp.toString(16));
}
}
```
### 1.2 Gerar os subsets
Requer `fonttools` + `brotli` (`pyftsubset` em `~/.local/bin`):
```bash
pyftsubset FONTE.ttf --unicodes=U+E915,U+E969,... --flavor=woff2 \
--layout-features= --no-hinting --desubroutinize --output-file=NOME.subset.woff2
# fonte completa convertida para woff2 (fallback mais leve que o TTF original)
pyftsubset FONTE.ttf --unicodes=U+E000-F8FF --flavor=woff2 \
--layout-features= --no-hinting --output-file=NOME.full.woff2
```
### 1.3 Rede de segurança obrigatória: `unicode-range` complementar
**Nunca servir só o subset.** Declarar duas `@font-face` para a mesma família:
o subset com o `unicode-range` dos glifos em uso, e a fonte completa com o
`unicode-range` **complementar**. Assim um ícone novo adicionado no Elementor
não fica "tofu" — o browser vai buscar a completa só para esse codepoint — e
enquanto isso não acontecer não se paga um byte por ela.
```python
def complement(used, lo=0xE000, hi=0xF8FF):
u=sorted(int(c,16) for c in used); out=[]; s=lo
for c in u:
if c>s: out.append((s,c-1))
s=c+1
if s<=hi: out.append((s,hi))
return out # formatar como U+XXXX ou U+XXXX-YYYY
```
**Confirmar que a completa não é descarregada:**
```js
[...document.fonts].filter(f=>/IconSax|Font Awesome|eicons/.test(f.family))
.map(f=>f.family+'|'+f.weight+'|'+f.status)
// esperado: subsets 'loaded', completas 'unloaded'
```
### 1.4 Substituir a folha, não acrescentar `@font-face` depois
**Um `wp_dequeue_style` não alcança estas folhas** e um `@font-face` acrescentado
na fase de enqueue perde: o Elementor enfileira as folhas de icon library
**durante o render do body** (há widgets com esses ícones no `_elementor_data`),
pelo que `wp_style_is()` devolve `false`, o CSS nosso sai **antes** do original no
documento, perde o desempate "última declaração vence" e **o TTF continua a ser
descarregado** (medido, não presumido).
Padrão correcto:
1. Copiar a folha do Elementor, **manter todas as regras de classe** e trocar só
os `@font-face`. Verificar que não há `url()` fora dos `@font-face` antes de
cortar (backgrounds embutidos).
2. Enfileirar a cópia com `wp_enqueue_style`.
3. Suprimir a original na **impressão**, imune à ordem de enqueue:
```php
add_filter( 'style_loader_tag', function ( $tag, $handle ) {
if ( ! is_admin() && in_array( $handle, array(
'elementor-icons-IconSax', 'elementor-icons-IconSax-Fino',
'elementor-icons-ekiticons', 'elementor-icons-fa-brands',
'elementor-icons-fa-solid', 'elementor-icons',
), true ) ) { return ''; }
return $tag;
}, 10, 2 );
```
Handles reais confirmam-se no HTML: `grep -oE "id='[^']*(icon|ekit)[^']*-css'"`.
### 1.5 Icon packs 100% mortos
`elementor-icons-ekiticons` traz `elementskit.woff` (252KB) e um CSS que o
Lighthouse dá como 100% não usado: define 12 ícones do **painel** do ElementsKit
(tiktok, x-twitter, scroll-reveal, smart-post-list…). Antes de suprimir,
confirmar que as classes presentes no HTML não têm regra:
```bash
ssh server "grep -rlE '(icon-checked|icon-menu-11):before' WEBROOT/wp-content/plugins/ WEBROOT/wp-content/themes/"
# sem resultado = classes órfãs de versão antiga, não desenham glifo
```
---
## 2. CLS: preload da fonte de TEXTO, não das de ícones
Ao reduzir o preload, o heading do hero volta a re-layoutar quando a fonte de
texto chega (no piloto: **CLS 0,213**, com 0,2055 atribuídos a um único `h4`).
**Não preloadar tudo** — foi o erro que criou o problema de LCP. Preloadar **um**
ficheiro: as Google Fonts modernas são **variáveis**, e um só subset latin cobre
os pesos 200-900.
```bash
# descobrir qual ficheiro serve latin + todos os pesos
ssh server "grep -oE '@font-face\{[^}]*\}' WEBROOT/wp-content/uploads/elementor/google-fonts/css/nunito.css" \
# procurar o que tem unicode-range U+0000-00FF (latin) e font-style: normal
```
39KB resolveram o CLS que 1410KB resolviam antes. Confirmar com **5 corridas**,
não uma.
---
## 3. WebP em `background-image`: ponto cego do WebP Express
**O WebP Express reescreve as tags `<img>` do HTML mas não os `background-image`
do CSS.** E em CWP o **nginx serve os estáticos directamente, sem passar pelo
`.htaccess` do plugin**, pelo que também não há negociação por `Accept`.
Resultado no piloto: `foto-1.png` servia **458KB** (36% da página, e era o recurso
LCP) apesar de existir `webp-express/webp-images/uploads/foto-1.png.webp` com
181KB, gerado pelo plugin e nunca servido.
Detectar:
```bash
# candidatos: imagens grandes referenciadas em CSS do Elementor
ssh server "grep -rl 'NOME.png' WEBROOT/wp-content/uploads/elementor/css/"
ssh server "find WEBROOT/wp-content/webp-express -name 'NOME*' -printf '%s %p\n'"
```
Corrigir com `image-set()` (o PNG fica como segundo candidato para browsers sem
suporte). O `!important` é necessário porque o `post-N.css` do Elementor é
enfileirado depois:
```php
$css = sprintf(
'%s{background-image:image-set(url("%s") type("image/webp"),url("%s") type("image/png")) !important}',
$selector, $webp_url, $png_url
);
wp_register_style( 'desc-lcp-webp', false, array(), '1.0.0' );
wp_enqueue_style( 'desc-lcp-webp' );
wp_add_inline_style( 'desc-lcp-webp', $css );
```
Preloadar também a imagem LCP (`background-image` só é descoberta depois de o CSS
ser descarregado e parseado — Load Delay medido de 8,2s = 87% do LCP):
```php
printf('<link rel="preload" as="image" fetchpriority="high" href="%s">', esc_url($url));
```
---
## 4. Outros cortes verificados
- **`dashicons` no frontend:** 750ms de render-blocking + 35KB para **zero**
glifos `dashicons-*` no HTML. Remover só para visitantes não autenticados (a
admin bar precisa dele). `grep -c 'dashicons-'` na página confirma o uso real.
- **Google Fonts remoto duplicado:** `astra-google-fonts` pede o Nunito a
`fonts.googleapis.com` (render-blocking ~770ms + ligação a terceiros) quando o
Elementor já o serve local. Confirmar que o local cobre os pesos antes de
desenfileirar (`grep -c 'font-weight' nunito.css`).
---
## 5. 🔴 Armadilhas que partem o site ou invalidam medições
### 5.1 O purge do Cloudflare reporta sucesso sem purgar
`wp app-for-cf purge-cache` devolve sempre `Success: Cloudflare cache purged.` e
**não purga**. Medido em `emanuelalmeida.pt` (17-08-2026):
```
antes: cf-cache-status: HIT age: 27
comando: Success: Cloudflare cache purged.
depois: cf-cache-status: HIT age: 34 -> 37 -> 40 # nunca invalidou
```
**É bug do comando, não das credenciais.** O token do plugin verifica `active` e
faz `purge_everything` pela API sem problema (`success: true`, `MISS` na visita
seguinte). Com `cfPageCachingSeconds=21600`, as alterações ficam **até 6 horas
invisíveis** — medições no URL limpo medem HTML pré-alteração.
**Purge que funciona** (ferramenta da frota; lê as credenciais do próprio plugin,
resolve a zona real e confirma o efeito, com exit≠0 se continuar `HIT`):
```bash
04-Stack/02.04-Sistemas/71.Seguranca/tools/cf-purge.py \
--account ealmeida --path /home/ealmeida/emanuelalmeida.pt \
--url https://emanuelalmeida.pt/
```
**⚠️ Armadilha ao diagnosticar: há vários plugins Cloudflare com credenciais no
mesmo site.** Registei duas vezes a causa errada ("token inválido", "falta
permissão `Zone → Cache Purge`") por testar as credenciais do plugin errado.
|Opção|Plugin|
|---|---|
|`app_for_cf.cloudflareAuth.token` (`cfut_…`, 53 car.) + `app_for_cf.cfZoneId`|**App for Cloudflare** — as que contam|
|`swcfpc_config.cf_apitoken` / `cf_zoneid`|Super Page Cache — pode ter token inválido e zona errada|
|`wp_turbosafe_cloudflare_api_token`|wp-turbosafe — cifrado, inutilizável|
**Confirmar sempre no código de onde vem a credencial** antes de a declarar
inválida — `app-for-cf` usa `$this->option('cloudflareAuth.token')` em
`src/DigitalPoint/Cloudflare/Api/Cloudflare.php`. E distinguir os erros da API:
`Invalid API Token` (1000) = o valor não corresponde a nenhum token activo;
falta de permissão tem outro código. Se houver `zone_id` divergentes,
`GET /zones?name=<dominio>` desempata.
### 5.2 CLS: o CSS do Elementor sai **depois** de `</head>`
Em sites Elementor, o CSS de cada widget é enfileirado **durante o render do
body** → o browser pinta o header sem estilos e volta a fazer layout quando as
folhas chegam. Em `emanuelalmeida.pt` eram **78KB em 7 folhas** e dava
**CLS 0,45–1,27 em 8 de 10 cargas** (limite bom = 0,1; pico medido 1,319).
**Diagnóstico em dois comandos.** Primeiro, contar folhas fora do head:
```bash
python3 - <<'EOF'
import re,subprocess,time
h=subprocess.run(['curl','-s',f'https://SITE/?x={int(time.time())}'],capture_output=True,text=True).stdout
he=h.find('</head>')
L=[(m.start(),m.group(0)) for m in re.finditer(r"<link[^>]*rel=['\"]stylesheet['\"][^>]*>",h)]
print("HEAD:",len([1 for p,_ in L if p<he]),"| BODY:",len([1 for p,_ in L if p>he]))
for p,l in L:
if p>he: print(" ", (re.search(r"id='([^']+)'",l) or re.search(r'id="([^"]+)"',l)).group(1))
EOF
```
Depois, medir o CLS **a sério** — não confiar no Lighthouse com
`--throttling-method=simulate`, que devolvia 0 quase sempre neste caso. Usar
`PerformanceObserver`, tab nova por corrida, 6 corridas:
```js
await page.evaluateOnNewDocument(()=>{window.__s=[];
new PerformanceObserver(l=>{for(const e of l.getEntries()){
if(!e.hadRecentInput) window.__s.push({v:e.value,t:Math.round(e.startTime),
src:(e.sources||[]).map(s=>s.node&&s.node.tagName+'.'+String(s.node.className||'').split(' ')[0])});
}}).observe({type:'layout-shift',buffered:true});});
await tab.goto(URL,{waitUntil:'networkidle2'});
await new Promise(r=>setTimeout(r,3000));
```
⚠️ **Dois erros de método a evitar** (cometi ambos): registar `evaluateOnNewDocument`
várias vezes na mesma tab **soma observers** e inflaciona o total (vi 5,15 onde o
shift real era 0,79); e um `first-paint` ~100ms antes do shift, com
`domInteractive` no mesmo instante, é a assinatura de CSS no body — não de fontes.
**Correcção:** mu-plugin `elementor-css-head.php` (cópia canónica em
`04-Stack/02.04-Sistemas/71.Seguranca/mu-plugins/`). Aprende que handles saíram
depois do head (`wp_styles()->done` no fecho do `wp_head` vs. no `wp_footer`),
guarda num transient por URL (12h) e antecipa-os para o head na visita seguinte.
Resultado: **CLS 0 em 6/6**, 0 nas medianas Lighthouse mobile/desktop, 0 na
auditoria MCP.
**❌ Não perder tempo com:** desligar `elementor_experiment-e_optimized_css_loading`
(no Elementor 4.x já não desliga o carregamento condicional — as folhas continuam
no body); e descartar o WP Meteor por A/B antes de acusar o defer de JS (`?wp-meteor-nooptimize=true`
deu o mesmo CLS, logo não era ele).
**🔴 Aquecimento obrigatório de DUAS passagens depois do deploy** — no primeiro
pedido de cada URL o mu-plugin ainda não aprendeu nada, e é esse HTML que fica
em cache:
```bash
wp post list --post_type=page,post --post_status=publish --field=url --path=$SITE > /tmp/urls.txt
ssh server "rm -rf $SITE/wp-content/cache/all/*"
while read -r u; do curl -s -o /dev/null "$u?p1=$(date +%s%N)"; done < /tmp/urls.txt # aprende
ssh server "rm -rf $SITE/wp-content/cache/all/*"
while read -r u; do curl -s -o /dev/null "$u?p2=$(date +%s%N)"; done < /tmp/urls.txt # HTML bom
while read -r u; do curl -s -o /dev/null -w "%{http_code} $u\n" "$u"; done < /tmp/urls.txt
```
**Custo aceite:** as folhas passam a bloquear no head em vez do body — mesmos
bytes, mais cedo. Performance mediana mobile 68→64, desktop 88→86 (dentro da
variância). CLS é Core Web Vital e estava 4× a 12× acima do limite: a troca paga-se.
### 5.3 NUNCA apagar `wpfc-minified`
Com `MinifyCss` do WP Fastest Cache ligado, os `<link>` apontam a
`wp-content/cache/wpfc-minified/<hash>/hcd9a.css`. Apagar essa pasta enquanto
existe HTML cacheado (no WPFC **ou no Cloudflare**) a referenciar os hashes
antigos → **CSS a 404 em massa, site sem estilos**. Foi assim que o piloto
apareceu desfigurado ao utilizador.
```bash
# ERRADO
rm -rf WEBROOT/wp-content/cache/wpfc-minified/*
# CERTO — forçar HTML novo sem tocar nos ficheiros minificados
rm -rf WEBROOT/wp-content/cache/all/*
```
### 5.4 Não filtrar a saída do `wp_head` com `ob_start`
Tentar remover uma tag já emitida com `ob_start`/`ob_get_clean` no `wp_head`
partiu o `<head>`. Se a origem de um preload órfão não se encontra, **deixar
documentado em vez de mexer em buffers** — no piloto eram 38KB, não valia.
---
## 6. Verificação de fecho (obrigatória)
1. **Todas as folhas do HTML devolvem conteúdo** — foi isto que apanhou o
incidente sem ambiguidade:
```bash
python3 - <<'EOF'
import subprocess, re
h=subprocess.run(['curl','-s','https://SITE/'],capture_output=True,text=True).stdout
links=re.findall(r"<link rel='stylesheet' id='([^']+)' href='([^']+)'", h)
bad=[]
for hid,href in links:
u = href if href.startswith('http') else 'https:'+href
o=subprocess.run(['curl','-s','-o','/dev/null','-w','%{http_code} %{size_download}',u],capture_output=True,text=True).stdout.split()
if o[0]!='200' or int(o[1])==0: bad.append((hid,o))
print('folhas:',len(links),'| com problema:',len(bad),bad)
EOF
```
2. **Zero glifos em falta, em várias páginas** — medir a largura de cada
codepoint em uso contra a largura de um codepoint ausente ("tofu"):
```js
const c=document.createElement('canvas').getContext('2d');
const w=(fam,wt,cp)=>{c.font=wt+' 32px "'+fam+'"';return c.measureText(String.fromCharCode(parseInt(cp,16))).width;};
const tofu=w('IconSax','400','2764');
// falha se w(fam,wt,cp) === tofu
```
3. **Screenshot** do hero e da secção de ícones.
4. **CLS em 5 corridas**, não uma.
---
## 7. Estado do rollout
Aplicado só em **`emanuelalmeida.pt`** (piloto, 17-08-2026). Pendente #19 do
`BUNDLE-Excelencia-WP.md`: replicar nos outros 6 sites. O padrão do WebP em
background CSS (§3) e o `dashicons` (§4) são os mais prováveis de existirem em
todos.