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

19 KiB
Raw Permalink Blame History

name, description
name description
wp-font-perf 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.

# 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):

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:

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:

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):

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.

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:

[...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:
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:

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.

# 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:

# 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:

$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):

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):

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:

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:

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:

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.

# 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:
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
  1. 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"):
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
  1. Screenshot do hero e da secção de ícones.
  2. 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.