[{"data":1,"prerenderedAt":1304},["ShallowReactive",2],{"blog-wordpress-headless-mu-plugins":3},{"id":4,"title":5,"about":6,"author":7,"body":8,"date":1269,"description":1270,"extension":1271,"faq":1272,"image":1288,"mentions":6,"meta":1289,"navigation":110,"path":1290,"seo":1291,"seoDescription":1292,"seoTitle":1293,"stem":1294,"tags":1295,"updated":6,"__hash__":1303},"blog\u002Fblog\u002Fwordpress-headless-mu-plugins.md","WordPress Headless: mu-plugins ou tema filho? onde ficam as customizações",null,"Larissa Santos",{"type":9,"value":10,"toc":1252},"minimark",[11,23,26,29,32,41,47,64,70,151,158,185,269,278,281,283,287,294,297,314,317,320,325,328,331,338,376,382,386,391,394,409,413,416,419,498,511,521,551,554,721,731,742,792,801,809,812,814,818,821,824,842,848,852,863,873,876,923,932,936,962,975,984,987,991,997,1046,1052,1055,1057,1061,1064,1089,1107,1114,1117,1159,1176,1186,1188,1192,1198,1204,1210,1213,1215,1219,1248],[12,13,14,15,22],"p",{},"No ",[16,17,21],"a",{"href":18,"rel":19},"https:\u002F\u002Fwww.larisantos.com.br\u002Fblog\u002Fnuxt-wordpress-headless-nuxt",[20],"nofollow","primeiro artigo sobre esse projeto"," contei a decisão de arquitetura: o site da Teciklar virou headless, com um Nuxt 4 na Vercel na frente e o WordPress atrás, servindo só conteúdo pela REST API. Ninguém nunca vê uma página renderizada pelo WordPress.",[12,24,25],{},"Aquele texto terminou na estrutura montada e funcionando. Este conta o que veio depois, quando fui fazer manutenção e descobri que a fundação do site estava guardada no lugar errado. E que, na primeira vez que fui corrigir isso, eu tirei o código de um lugar errado e coloquei em outro.",[12,27,28],{},"São três situações, na ordem em que apareceram. A segunda só foi descoberta porque fui resolver a primeira, e a terceira é uma correção da resposta que dei à segunda.",[30,31],"hr",{},[33,34,36,37],"h2",{"id":35},"o-painel-exigia-digitar-wp-admin","O painel exigia digitar ",[38,39,40],"code",{},"\u002Fwp-admin",[12,42,43,44,46],{},"O CMS tinha acabado de ser movido para um subdomínio dedicado. Mesmo assim, era preciso digitar ",[38,45,40],{}," no fim da URL para chegar ao painel. A raiz do subdomínio servia o tema público do WordPress, uma página sem uso nenhum aqui: o site de verdade é o Nuxt, em outro domínio.",[12,48,49,50,52,53,56,57,56,60,63],{},"A primeira ideia que aparece é mover o painel para a raiz. Isso não existe. O ",[38,51,40],{}," é um diretório físico do core, com dezenas de arquivos (",[38,54,55],{},"edit.php",", ",[38,58,59],{},"post.php",[38,61,62],{},"options-general.php"," e por aí vai). Mover aquilo não é suportado, quebra a atualização do core e é fonte clássica de site quebrado.",[12,65,66,67,69],{},"O que dá para fazer é redirecionar. Quem acessa a raiz cai no painel, a URL final continua sendo ",[38,68,40],{},", e ninguém precisa digitar. Como o WordPress ali não tem front-end útil, fui além da home: qualquer URL pública do WordPress leva ao painel.",[71,72,77],"pre",{"className":73,"code":74,"language":75,"meta":76,"style":76},"language-php shiki shiki-themes material-theme-lighter github-dark github-dark","add_action('template_redirect', function () {\n    if (is_admin() || wp_doing_ajax() || wp_doing_cron() || (defined('REST_REQUEST') && REST_REQUEST)) {\n        return;\n    }\n\n    if (is_robots() || is_feed()) {\n        return;\n    }\n\n    wp_safe_redirect(admin_url(), 302);\n    exit;\n});\n","php","",[38,78,79,87,93,99,105,112,118,123,128,133,139,145],{"__ignoreMap":76},[80,81,84],"span",{"class":82,"line":83},"line",1,[80,85,86],{},"add_action('template_redirect', function () {\n",[80,88,90],{"class":82,"line":89},2,[80,91,92],{},"    if (is_admin() || wp_doing_ajax() || wp_doing_cron() || (defined('REST_REQUEST') && REST_REQUEST)) {\n",[80,94,96],{"class":82,"line":95},3,[80,97,98],{},"        return;\n",[80,100,102],{"class":82,"line":101},4,[80,103,104],{},"    }\n",[80,106,108],{"class":82,"line":107},5,[80,109,111],{"emptyLinePlaceholder":110},true,"\n",[80,113,115],{"class":82,"line":114},6,[80,116,117],{},"    if (is_robots() || is_feed()) {\n",[80,119,121],{"class":82,"line":120},7,[80,122,98],{},[80,124,126],{"class":82,"line":125},8,[80,127,104],{},[80,129,131],{"class":82,"line":130},9,[80,132,111],{"emptyLinePlaceholder":110},[80,134,136],{"class":82,"line":135},10,[80,137,138],{},"    wp_safe_redirect(admin_url(), 302);\n",[80,140,142],{"class":82,"line":141},11,[80,143,144],{},"    exit;\n",[80,146,148],{"class":82,"line":147},12,[80,149,150],{},"});\n",[12,152,153,154,157],{},"O ",[38,155,156],{},"template_redirect"," dispara depois que o WordPress já decidiu qual conteúdo servir, mas antes de renderizar, então é o ponto certo para interceptar.",[12,159,160,161,56,164,56,167,56,170,173,174,177,178,181,182,184],{},"Os guards que fazem esse trabalho vêm do próprio WordPress: ",[38,162,163],{},"is_admin()",[38,165,166],{},"wp_doing_ajax()",[38,168,169],{},"wp_doing_cron()",[38,171,172],{},"is_robots()"," e ",[38,175,176],{},"is_feed()"," são funções do core, e ",[38,179,180],{},"REST_REQUEST"," é uma constante definida quando a requisição entra pela REST API. O trabalho foi decidir quais entram na lista. Cada uma protege alguma coisa que o redirect quebraria, e a de ",[38,183,180],{}," protege o site inteiro, porque a API que alimenta o Nuxt passa pelo mesmo hook:",[186,187,188,201],"table",{},[189,190,191],"thead",{},[192,193,194,198],"tr",{},[195,196,197],"th",{},"Guard",[195,199,200],{},"O que protege",[202,203,204,214,227,236,248,260],"tbody",{},[192,205,206,211],{},[207,208,209],"td",{},[38,210,163],{},[207,212,213],{},"o próprio painel",[192,215,216,220],{},[207,217,218],{},[38,219,166],{},[207,221,222,223,226],{},"o ",[38,224,225],{},"admin-ajax.php",", usado pelo editor",[192,228,229,233],{},[207,230,231],{},[38,232,169],{},[207,234,235],{},"tarefas agendadas",[192,237,238,242],{},[207,239,240],{},[38,241,180],{},[207,243,244,247],{},[38,245,246],{},"\u002Fwp-json",", ou seja, todo o conteúdo do site",[192,249,250,254],{},[207,251,252],{},[38,253,172],{},[207,255,222,256,259],{},[38,257,258],{},"robots.txt"," do subdomínio",[192,261,262,266],{},[207,263,264],{},[38,265,176],{},[207,267,268],{},"feeds RSS",[12,270,153,271,274,275,277],{},[38,272,273],{},"wp-login.php"," não passa por ",[38,276,156],{},", então o login continua acessível sem precisar de exceção.",[12,279,280],{},"Um efeito colateral bom: nenhuma URL pública do WordPress fica indexável, o que evita conteúdo duplicado competindo com o site real no Google.",[30,282],{},[33,284,286],{"id":285},"as-customizações-estavam-em-terreno-que-sobrescreve","As customizações estavam em terreno que sobrescreve",[12,288,289,290,293],{},"Fui abrir o ",[38,291,292],{},"functions.php"," para adicionar esse redirect e encontrei uma coisa bem pior que o problema original.",[12,295,296],{},"O site usa um tema comercial pago. E todas as customizações de que o front dependia estavam escritas dentro do tema:",[298,299,300,304,311],"ul",{},[301,302,303],"li",{},"um resolver que transforma IDs de mídia do ACF em URL na resposta da API;",[301,305,306,307,310],{},"o registro do campo ",[38,308,309],{},"blog_destaque",", que marca quais posts aparecem na home;",[301,312,313],{},"os headers de CORS que permitem o Nuxt consumir a API de outro domínio.",[12,315,316],{},"O WordPress sobrescreve o diretório inteiro do tema ao atualizar. No dia em que alguém clicasse em \"atualizar\" no tema, os três sumiriam de uma vez.",[12,318,319],{},"O que torna isso especialmente ruim num site headless é o sintoma. Não aparece erro. O front simplesmente para de receber imagem e dado, e o repositório do front não mudou nada. É uma quebra que parece vir do nada e custa horas para diagnosticar.",[321,322,324],"h3",{"id":323},"a-resposta-que-fez-sentido-na-hora-tema-filho","A resposta que fez sentido na hora: tema filho",[12,326,327],{},"Tema filho é o mecanismo oficial do WordPress para isso: uma pasta separada que herda tudo do tema pai e nunca é tocada quando o pai atualiza.",[12,329,330],{},"Adianto que essa resposta estava incompleta, e a última seção conta por quê. Ela resolve o risco descrito aqui, e só ele. Leia o resto desta seção assim: o diagnóstico continua válido, o destino do código mudou depois.",[12,332,333,334,337],{},"Um tema filho precisa só de um ",[38,335,336],{},"style.css"," com um cabeçalho declarando de quem ele herda:",[71,339,343],{"className":340,"code":341,"language":342,"meta":76,"style":76},"language-css shiki shiki-themes material-theme-lighter github-dark github-dark","\u002F*\nTheme Name: Site\nDescription: Child theme holding the headless CMS customizations.\nTemplate: nome-do-tema-pai\nVersion: 1.0.0\n*\u002F\n","css",[38,344,345,351,356,361,366,371],{"__ignoreMap":76},[80,346,347],{"class":82,"line":83},[80,348,350],{"class":349},"sutJx","\u002F*\n",[80,352,353],{"class":82,"line":89},[80,354,355],{"class":349},"Theme Name: Site\n",[80,357,358],{"class":82,"line":95},[80,359,360],{"class":349},"Description: Child theme holding the headless CMS customizations.\n",[80,362,363],{"class":82,"line":101},[80,364,365],{"class":349},"Template: nome-do-tema-pai\n",[80,367,368],{"class":82,"line":107},[80,369,370],{"class":349},"Version: 1.0.0\n",[80,372,373],{"class":82,"line":114},[80,374,375],{"class":349},"*\u002F\n",[12,377,153,378,381],{},[38,379,380],{},"Template"," é a linha que faz o vínculo, e o valor precisa ser exatamente o nome da pasta do tema pai. Se estiver errado, o WordPress recusa o tema.",[321,383,385],{"id":384},"uma-ordem-de-instalação-que-derrubaria-o-site","Uma ordem de instalação que derrubaria o site",[12,387,153,388,390],{},[38,389,292],{}," do tema filho carrega antes do pai, e os dois são carregados. Então, se você copiar as funções que já existem para o filho e ativar o filho sem antes limpar o pai, o PHP encontra a mesma função declarada duas vezes e morre com \"Cannot redeclare\". O resultado é tela branca no painel e API fora do ar, os dois ao mesmo tempo.",[12,392,393],{},"A sequência correta é:",[395,396,397,400,406],"ol",{},[301,398,399],{},"subir a pasta do tema filho;",[301,401,402,403,405],{},"antes de ativar, remover as customizações do ",[38,404,292],{}," do tema pai;",[301,407,408],{},"só então ativar o tema filho.",[321,410,412],{"id":411},"o-que-esse-código-faz","O que esse código faz",[12,414,415],{},"São três peças, além do redirect da seção anterior.",[12,417,418],{},"A primeira é o resolver de mídia do ACF. Dependendo de como o campo foi configurado, o ACF devolve imagem como ID numérico em vez de URL, e o front não tem o que fazer com um número. O filtro varre os campos da resposta e troca número por URL:",[71,420,422],{"className":73,"code":421,"language":75,"meta":76,"style":76},"function cms_resolve_acf_media($value){\n    if (is_array($value)) {\n        foreach ($value as $key => $item) {\n            $value[$key] = cms_resolve_acf_media($item);\n        }\n        return $value;\n    }\n\n    if (is_numeric($value)) {\n        $attachment_url = wp_get_attachment_url((int) $value);\n        return $attachment_url ? $attachment_url : $value;\n    }\n\n    return $value;\n}\n",[38,423,424,429,434,439,444,449,454,458,462,467,472,477,481,486,492],{"__ignoreMap":76},[80,425,426],{"class":82,"line":83},[80,427,428],{},"function cms_resolve_acf_media($value){\n",[80,430,431],{"class":82,"line":89},[80,432,433],{},"    if (is_array($value)) {\n",[80,435,436],{"class":82,"line":95},[80,437,438],{},"        foreach ($value as $key => $item) {\n",[80,440,441],{"class":82,"line":101},[80,442,443],{},"            $value[$key] = cms_resolve_acf_media($item);\n",[80,445,446],{"class":82,"line":107},[80,447,448],{},"        }\n",[80,450,451],{"class":82,"line":114},[80,452,453],{},"        return $value;\n",[80,455,456],{"class":82,"line":120},[80,457,104],{},[80,459,460],{"class":82,"line":125},[80,461,111],{"emptyLinePlaceholder":110},[80,463,464],{"class":82,"line":130},[80,465,466],{},"    if (is_numeric($value)) {\n",[80,468,469],{"class":82,"line":135},[80,470,471],{},"        $attachment_url = wp_get_attachment_url((int) $value);\n",[80,473,474],{"class":82,"line":141},[80,475,476],{},"        return $attachment_url ? $attachment_url : $value;\n",[80,478,479],{"class":82,"line":147},[80,480,104],{},[80,482,484],{"class":82,"line":483},13,[80,485,111],{"emptyLinePlaceholder":110},[80,487,489],{"class":82,"line":488},14,[80,490,491],{},"    return $value;\n",[80,493,495],{"class":82,"line":494},15,[80,496,497],{},"}\n",[12,499,500,501,56,504,173,507,510],{},"A recursão existe porque campos do ACF aninham: grupo dentro de grupo, lista dentro de grupo. Repare no efeito colateral dessa abordagem, que aparece mais tarde em outro assunto: ela troca qualquer valor numérico. Se um campo devolvesse um objeto com ",[38,502,503],{},"id",[38,505,506],{},"width",[38,508,509],{},"height",", esses números virariam URL de anexo também.",[12,512,513,514,516,517,520],{},"A segunda é o campo ",[38,515,309],{},", um booleano que marca quais posts aparecem na home. Ele só existe na API porque foi registrado com ",[38,518,519],{},"show_in_rest",":",[71,522,524],{"className":73,"code":523,"language":75,"meta":76,"style":76},"register_post_meta('post', 'blog_destaque', [\n    'show_in_rest' => true,\n    'single' => true,\n    'type' => 'boolean',\n]);\n",[38,525,526,531,536,541,546],{"__ignoreMap":76},[80,527,528],{"class":82,"line":83},[80,529,530],{},"register_post_meta('post', 'blog_destaque', [\n",[80,532,533],{"class":82,"line":89},[80,534,535],{},"    'show_in_rest' => true,\n",[80,537,538],{"class":82,"line":95},[80,539,540],{},"    'single' => true,\n",[80,542,543],{"class":82,"line":101},[80,544,545],{},"    'type' => 'boolean',\n",[80,547,548],{"class":82,"line":107},[80,549,550],{},"]);\n",[12,552,553],{},"A terceira é o CORS. O Nuxt roda em outro domínio, então o navegador bloqueia a chamada sem os headers certos:",[71,555,557],{"className":73,"code":556,"language":75,"meta":76,"style":76},"function cms_allowed_origins()\n{\n    return [\n        'https:\u002F\u002Fwww.exemplo.com.br',\n        'https:\u002F\u002Fexemplo.com.br',\n        'http:\u002F\u002Flocalhost:3000',\n    ];\n}\n\nadd_action('rest_api_init', function () {\n    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');\n\n    add_filter('rest_pre_serve_request', function ($value) {\n        header('Vary: Origin', false);\n\n        $origin = get_http_origin();\n        $allowed = cms_allowed_origins();\n        $match = array_search($origin, $allowed, true);\n\n        if ($match === false) {\n            return $value;\n        }\n\n        header('Access-Control-Allow-Origin: ' . $allowed[$match]);\n        header('Access-Control-Allow-Methods: GET, OPTIONS');\n        header('Access-Control-Allow-Headers: Content-Type');\n        header('Access-Control-Max-Age: 600');\n\n        return $value;\n    });\n}, 15);\n",[38,558,559,564,569,574,579,584,589,594,598,602,607,612,616,621,626,630,636,642,648,653,659,665,670,675,681,687,693,699,704,709,715],{"__ignoreMap":76},[80,560,561],{"class":82,"line":83},[80,562,563],{},"function cms_allowed_origins()\n",[80,565,566],{"class":82,"line":89},[80,567,568],{},"{\n",[80,570,571],{"class":82,"line":95},[80,572,573],{},"    return [\n",[80,575,576],{"class":82,"line":101},[80,577,578],{},"        'https:\u002F\u002Fwww.exemplo.com.br',\n",[80,580,581],{"class":82,"line":107},[80,582,583],{},"        'https:\u002F\u002Fexemplo.com.br',\n",[80,585,586],{"class":82,"line":114},[80,587,588],{},"        'http:\u002F\u002Flocalhost:3000',\n",[80,590,591],{"class":82,"line":120},[80,592,593],{},"    ];\n",[80,595,596],{"class":82,"line":125},[80,597,497],{},[80,599,600],{"class":82,"line":130},[80,601,111],{"emptyLinePlaceholder":110},[80,603,604],{"class":82,"line":135},[80,605,606],{},"add_action('rest_api_init', function () {\n",[80,608,609],{"class":82,"line":141},[80,610,611],{},"    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');\n",[80,613,614],{"class":82,"line":147},[80,615,111],{"emptyLinePlaceholder":110},[80,617,618],{"class":82,"line":483},[80,619,620],{},"    add_filter('rest_pre_serve_request', function ($value) {\n",[80,622,623],{"class":82,"line":488},[80,624,625],{},"        header('Vary: Origin', false);\n",[80,627,628],{"class":82,"line":494},[80,629,111],{"emptyLinePlaceholder":110},[80,631,633],{"class":82,"line":632},16,[80,634,635],{},"        $origin = get_http_origin();\n",[80,637,639],{"class":82,"line":638},17,[80,640,641],{},"        $allowed = cms_allowed_origins();\n",[80,643,645],{"class":82,"line":644},18,[80,646,647],{},"        $match = array_search($origin, $allowed, true);\n",[80,649,651],{"class":82,"line":650},19,[80,652,111],{"emptyLinePlaceholder":110},[80,654,656],{"class":82,"line":655},20,[80,657,658],{},"        if ($match === false) {\n",[80,660,662],{"class":82,"line":661},21,[80,663,664],{},"            return $value;\n",[80,666,668],{"class":82,"line":667},22,[80,669,448],{},[80,671,673],{"class":82,"line":672},23,[80,674,111],{"emptyLinePlaceholder":110},[80,676,678],{"class":82,"line":677},24,[80,679,680],{},"        header('Access-Control-Allow-Origin: ' . $allowed[$match]);\n",[80,682,684],{"class":82,"line":683},25,[80,685,686],{},"        header('Access-Control-Allow-Methods: GET, OPTIONS');\n",[80,688,690],{"class":82,"line":689},26,[80,691,692],{},"        header('Access-Control-Allow-Headers: Content-Type');\n",[80,694,696],{"class":82,"line":695},27,[80,697,698],{},"        header('Access-Control-Max-Age: 600');\n",[80,700,702],{"class":82,"line":701},28,[80,703,111],{"emptyLinePlaceholder":110},[80,705,707],{"class":82,"line":706},29,[80,708,453],{},[80,710,712],{"class":82,"line":711},30,[80,713,714],{},"    });\n",[80,716,718],{"class":82,"line":717},31,[80,719,720],{},"}, 15);\n",[12,722,153,723,726,727,730],{},[38,724,725],{},"remove_filter"," na primeira linha tira o handler padrão do WordPress antes de instalar o próprio, para os dois não brigarem pelo mesmo header. O ",[38,728,729],{},"Vary: Origin"," é o par obrigatório da allowlist: sem ele, um cache no caminho pode guardar a resposta com o header de uma origem e entregar para outra.",[12,732,733,734,737,738,741],{},"A primeira versão desse bloco liberava ",[38,735,736],{},"Access-Control-Allow-Origin: *"," e anunciava ",[38,739,740],{},"Authorization"," nos headers aceitos. Antes de restringir, confirmei por busca no repositório do front que nada ali escreve nem autentica, em vez de confiar na memória:",[71,743,747],{"className":744,"code":745,"language":746,"meta":76,"style":76},"language-bash shiki shiki-themes material-theme-lighter github-dark github-dark","grep -rn \"method\\s*:\\|body\\s*:\" app\u002Fcomposables\u002F server\u002F\ngrep -rn \"\u003Cform\\|\u003CUForm\\|\u003Cinput\\|\u003Ctextarea\\|@submit\" app\u002F\n","bash",[38,748,749,776],{"__ignoreMap":76},[80,750,751,755,759,763,767,770,773],{"class":82,"line":83},[80,752,754],{"class":753},"soiBB","grep",[80,756,758],{"class":757},"sSJ72"," -rn",[80,760,762],{"class":761},"sF_wb"," \"",[80,764,766],{"class":765},"s0vBq","method\\s*:\\|body\\s*:",[80,768,769],{"class":761},"\"",[80,771,772],{"class":765}," app\u002Fcomposables\u002F",[80,774,775],{"class":765}," server\u002F\n",[80,777,778,780,782,784,787,789],{"class":82,"line":89},[80,779,754],{"class":753},[80,781,758],{"class":757},[80,783,762],{"class":761},[80,785,786],{"class":765},"\u003Cform\\|\u003CUForm\\|\u003Cinput\\|\u003Ctextarea\\|@submit",[80,788,769],{"class":761},[80,790,791],{"class":765}," app\u002F\n",[12,793,794,795,173,798,800],{},"As duas voltaram vazias, então ",[38,796,797],{},"POST",[38,799,740],{}," saíram.",[12,802,803,804,808],{},"Uma coisa que essa restrição não faz é tornar o conteúdo privado. Quem abrir a URL da API direto continua vendo o JSON, e deve continuar, porque é um site institucional. O que muda é quem consegue ler a resposta ",[805,806,807],"strong",{},"de dentro de outra página",", no navegador.",[12,810,811],{},"Se a lista de origens estiver errada, o modo de falha engana: o SSR continua funcionando, porque servidor para servidor não passa por CORS. A página abre no primeiro carregamento e quebra só na navegação client-side.",[30,813],{},[33,815,817],{"id":816},"tema-filho-protege-de-um-risco-e-existem-três","Tema filho protege de um risco, e existem três",[12,819,820],{},"Tempo depois, revisando o que tinha ficado, fiz a pergunta que não tinha feito antes: por que o CORS está num tema?",[12,822,823],{},"A situação anterior me levou a tirar as customizações do tema pai, porque atualizar o tema apagaria tudo. Isso estava certo. Só que eu tratei \"sair do tema pai\" como se fosse o critério, quando era apenas um dos riscos. Tema filho resolve a atualização do pai e deixa dois de pé:",[298,825,826,832],{},[301,827,828,831],{},[805,829,830],{},"trocar de tema."," Um tema novo, ou o WordPress caindo no tema padrão depois de um erro fatal, e a API do site inteiro vai junto;",[301,833,834,837,838,841],{},[805,835,836],{},"camada errada."," CORS é header HTTP, ",[38,839,840],{},"register_post_meta"," é modelo de dados, meta box é interface de painel. Nada disso é apresentação. Tema é a camada que renderiza páginas, e este site não renderiza nenhuma: o redirect da primeira seção manda toda URL pública para o painel.",[12,843,844,845,847],{},"Olhando o ",[38,846,292],{}," do tema filho, sobrava quase nada de tema. Era um plugin morando dentro de um arquivo de tema.",[321,849,851],{"id":850},"para-onde-o-código-foi","Para onde o código foi",[12,853,854,857,858,862],{},[38,855,856],{},"wp-content\u002Fmu-plugins\u002F"," é a pasta de ",[859,860,861],"em",{},"must-use plugins",". Um arquivo ali é carregado antes dos plugins normais, não aparece com botão de desativar no painel, e não tem relação nenhuma com o tema ativo.",[12,864,865,866,869,870,872],{},"O detalhe irônico é que a pasta já existia no site, com o ",[38,867,868],{},"headless-config.php"," que mostrei no primeiro artigo, aquele que desliga o editor de blocos e os comentários. O lugar certo estava criado e em uso. O código foi para o tema filho por inércia: eu copiei o bloco do ",[38,871,292],{}," antigo inteiro, em vez de perguntar de cada peça a que camada ela pertencia.",[12,874,875],{},"A organização ficou assim:",[71,877,881],{"className":878,"code":879,"language":880,"meta":76,"style":76},"language-txt shiki shiki-themes material-theme-lighter github-dark github-dark","wp-content\u002Fmu-plugins\u002F\n├── headless-config.php     # desliga editor, comments e block editor\n├── cms-core.php       # loader\n└── cms\u002F\n    ├── rest-api.php        # CORS, resolver de mídia do ACF, blog_destaque\n    ├── media-field.php     # campo de mídia múltipla\n    ├── redirect.php        # a raiz vai para \u002Fwp-admin\n    └── assets\u002F\n","txt",[38,882,883,888,893,898,903,908,913,918],{"__ignoreMap":76},[80,884,885],{"class":82,"line":83},[80,886,887],{},"wp-content\u002Fmu-plugins\u002F\n",[80,889,890],{"class":82,"line":89},[80,891,892],{},"├── headless-config.php     # desliga editor, comments e block editor\n",[80,894,895],{"class":82,"line":95},[80,896,897],{},"├── cms-core.php       # loader\n",[80,899,900],{"class":82,"line":101},[80,901,902],{},"└── cms\u002F\n",[80,904,905],{"class":82,"line":107},[80,906,907],{},"    ├── rest-api.php        # CORS, resolver de mídia do ACF, blog_destaque\n",[80,909,910],{"class":82,"line":114},[80,911,912],{},"    ├── media-field.php     # campo de mídia múltipla\n",[80,914,915],{"class":82,"line":120},[80,916,917],{},"    ├── redirect.php        # a raiz vai para \u002Fwp-admin\n",[80,919,920],{"class":82,"line":125},[80,921,922],{},"    └── assets\u002F\n",[12,924,925,926,928,929,931],{},"O tema filho continua ativo, porque o WordPress exige um tema. Mas ficou só com o ",[38,927,336],{},", sem ",[38,930,292],{}," nenhum.",[321,933,935],{"id":934},"três-detalhes-que-quebram-em-silêncio","Três detalhes que quebram em silêncio",[12,937,938,949,950,953,954,957,958,961],{},[805,939,940,941,944,945,948],{},"O WordPress só carrega os ",[38,942,943],{},".php"," na raiz de ",[38,946,947],{},"mu-plugins\u002F","."," Ele não varre subpasta. Um módulo em ",[38,951,952],{},"cms\u002F"," simplesmente não roda, e não avisa. Por isso o ",[38,955,956],{},"cms-core.php"," na raiz, que só faz ",[38,959,960],{},"require_once"," dos módulos.",[12,963,964,967,968,971,972,948],{},[805,965,966],{},"Caminho de asset não pode vir do tema."," O enqueue do campo de mídia usava ",[38,969,970],{},"get_stylesheet_directory_uri()",", que aponta para a pasta do tema. Dentro de um mu-plugin isso resolve para o lugar errado, e o campo apareceria sem CSS e sem arrastar, sem nenhum erro no console. Virou ",[38,973,974],{},"plugin_dir_url(__FILE__)",[12,976,977,980,981,983],{},[805,978,979],{},"A ordem de subida importa, de novo."," O mesmo \"Cannot redeclare\" espreita aqui, porque mu-plugin e tema são carregados os dois. Subi o código novo primeiro e só depois apaguei o ",[38,982,292],{}," do servidor, conferindo antes que nenhuma função estivesse declarada nos dois lugares.",[12,985,986],{},"Uma observação sobre esse \"subi\" e esse \"apaguei\", que um leitor acostumado a esteira de deploy vai estranhar: aqui o WordPress é um site que já existia e foi adaptado, sem pipeline e sem ambiente de homologação. É arquivo enviado para o servidor, na mão. Isso muda a forma de trabalhar: a ordem das etapas vira parte da solução, e o teste tem que ser verificável de fora, por requisição, porque não existe pipeline reprovando nada antes. Do lado do Nuxt a história é outra, com repositório e deploy na Vercel.",[321,988,990],{"id":989},"o-critério-que-eu-deveria-ter-usado-desde-o-começo","O critério que eu deveria ter usado desde o começo",[12,992,993,994],{},"A pergunta não é \"onde isso fica protegido de update?\". É ",[805,995,996],{},"do que essa customização depende para existir?",[186,998,999,1009],{},[189,1000,1001],{},[192,1002,1003,1006],{},[195,1004,1005],{},"Tipo",[195,1007,1008],{},"Lugar",[202,1010,1011,1020,1029,1038],{},[192,1012,1013,1016],{},[207,1014,1015],{},"Contrato da API REST (endpoints, campos, headers\u002FCORS)",[207,1017,1018],{},[38,1019,947],{},[192,1021,1022,1025],{},[207,1023,1024],{},"Modelo de dados (post meta, custom post types, taxonomias)",[207,1026,1027],{},[38,1028,947],{},[192,1030,1031,1034],{},[207,1032,1033],{},"Interface do painel (meta boxes, campos, assets de admin)",[207,1035,1036],{},[38,1037,947],{},[192,1039,1040,1043],{},[207,1041,1042],{},"Apresentação do front do WordPress (templates, layout)",[207,1044,1045],{},"tema filho",[12,1047,1048,1049,1051],{},"Num site headless a última linha é vazia. Então, na prática, tudo vai para ",[38,1050,947],{},", e o tema filho é a exceção.",[12,1053,1054],{},"Uma ressalva honesta, para não apresentar mu-plugin como escolha sem custo: ele não tem botão de desativar. Isso é bom, porque ninguém desliga sem querer, e é ruim, porque se o código quebrar a saída é apagar o arquivo por FTP, não clicar no painel.",[30,1056],{},[33,1058,1060],{"id":1059},"como-verifiquei","Como verifiquei",[12,1062,1063],{},"Depois de qualquer uma dessas mudanças, o teste não é \"o painel abriu\". É conferir peça por peça, por fora, sem depender de olhar o site:",[71,1065,1067],{"className":878,"code":1066,"language":880,"meta":76,"style":76},"GET      \u002F                             -> 302 para \u002Fwp-admin\u002F\nGET      \u002Fwp-json\u002Fwp\u002Fv2\u002Fposts          -> 200, JSON, não HTML\nOPTIONS  com Origin permitido          -> 200, access-control-allow-origin: \u003Co domínio>\nOPTIONS  com Origin desconhecido       -> 200, sem access-control-allow-origin\n",[38,1068,1069,1074,1079,1084],{"__ignoreMap":76},[80,1070,1071],{"class":82,"line":83},[80,1072,1073],{},"GET      \u002F                             -> 302 para \u002Fwp-admin\u002F\n",[80,1075,1076],{"class":82,"line":89},[80,1077,1078],{},"GET      \u002Fwp-json\u002Fwp\u002Fv2\u002Fposts          -> 200, JSON, não HTML\n",[80,1080,1081],{"class":82,"line":95},[80,1082,1083],{},"OPTIONS  com Origin permitido          -> 200, access-control-allow-origin: \u003Co domínio>\n",[80,1085,1086],{"class":82,"line":101},[80,1087,1088],{},"OPTIONS  com Origin desconhecido       -> 200, sem access-control-allow-origin\n",[12,1090,1091,1092,1095,1096,1098,1099,1102,1103,1106],{},"O preflight é o teste que faltou na primeira vez. Um ",[38,1093,1094],{},"curl"," simples devolve o JSON mesmo sem CORS nenhum, porque ",[38,1097,1094],{}," não é navegador e não valida origem. Pedir ",[38,1100,1101],{},"OPTIONS"," com ",[38,1104,1105],{},"Origin"," é o que reproduz de verdade o que o Nuxt faz.",[12,1108,1109,1110,1113],{},"A última linha é a que prova que a restrição existe. Com ",[38,1111,1112],{},"*"," no lugar da allowlist, as três primeiras passariam exatamente igual.",[12,1115,1116],{},"Por isso o teste de CORS é um par de comandos, nunca um comando só:",[71,1118,1120],{"className":744,"code":1119,"language":746,"meta":76,"style":76},"curl -sI -H \"Origin: https:\u002F\u002Fwww.exemplo.com.br\" ...\u002Fwp-json\u002Fwp\u002Fv2\u002Fposts\ncurl -sI -H \"Origin: https:\u002F\u002Forigem-desconhecida.com\" ...\u002Fwp-json\u002Fwp\u002Fv2\u002Fposts\n",[38,1121,1122,1142],{"__ignoreMap":76},[80,1123,1124,1126,1129,1132,1134,1137,1139],{"class":82,"line":83},[80,1125,1094],{"class":753},[80,1127,1128],{"class":757}," -sI",[80,1130,1131],{"class":757}," -H",[80,1133,762],{"class":761},[80,1135,1136],{"class":765},"Origin: https:\u002F\u002Fwww.exemplo.com.br",[80,1138,769],{"class":761},[80,1140,1141],{"class":765}," ...\u002Fwp-json\u002Fwp\u002Fv2\u002Fposts\n",[80,1143,1144,1146,1148,1150,1152,1155,1157],{"class":82,"line":89},[80,1145,1094],{"class":753},[80,1147,1128],{"class":757},[80,1149,1131],{"class":757},[80,1151,762],{"class":761},[80,1153,1154],{"class":765},"Origin: https:\u002F\u002Forigem-desconhecida.com",[80,1156,769],{"class":761},[80,1158,1141],{"class":765},[12,1160,1161,1162,1165,1166,173,1169,1172,1173,1175],{},"O primeiro precisa trazer ",[38,1163,1164],{},"access-control-allow-origin"," com o domínio. O segundo precisa responder ",[38,1167,1168],{},"200",[805,1170,1171],{},"não"," trazer o header. Rodar só o primeiro não distingue uma allowlist funcionando de um ",[38,1174,1112],{}," esquecido.",[12,1177,1178,1179,1182,1183,1185],{},"E o ",[38,1180,1181],{},"302"," na raiz virou prova dupla: como o ",[38,1184,292],{}," do tema não existe mais, ele só pode estar vindo do mu-plugin.",[30,1187],{},[33,1189,1191],{"id":1190},"conclusões","Conclusões",[12,1193,1194,1197],{},[805,1195,1196],{},"Num site headless, o código que sustenta a API não pertence ao tema."," CORS, formato de dados e campos personalizados não são apresentação. Tema pai é uma pasta que o sistema se propõe a substituir, tema filho é uma pasta que o administrador pode trocar num clique, e nenhuma das duas é fundação.",[12,1199,1200,1203],{},[805,1201,1202],{},"Um risco resolvido não é o risco resolvido."," Eu tirei o código do tema pai, confirmei que a atualização não apagava mais nada, e dei o assunto por encerrado. Faltava perguntar quais outros jeitos aquilo tinha de sumir. O critério bom quase nunca é \"isto está a salvo do problema que eu vi\", é \"de que isto depende para existir\".",[12,1205,1206,1209],{},[805,1207,1208],{},"Customização que o front depende precisa ser verificável por fora."," Todas as peças aqui têm um comando que prova que estão no ar, e nenhuma delas depende de abrir o navegador e achar que a página está com jeito de certa.",[12,1211,1212],{},"No próximo artigo da série conto o campo de galeria múltipla que escrevi para substituir dez campos numerados do ACF, sem comprar o ACF PRO, e como migrei o conteúdo dos posts com o site no ar.",[30,1214],{},[33,1216,1218],{"id":1217},"leituras-relacionadas","Leituras relacionadas",[298,1220,1221,1227,1234,1241],{},[301,1222,1223],{},[16,1224,1226],{"href":18,"rel":1225},[20],"WordPress Headless com Nuxt: quando faz sentido e como estruturar",[301,1228,1229],{},[16,1230,1233],{"href":1231,"rel":1232},"https:\u002F\u002Fdeveloper.wordpress.org\u002Fadvanced-administration\u002Fplugins\u002Fmu-plugins\u002F",[20],"Must-use Plugins (WordPress Developer Resources)",[301,1235,1236],{},[16,1237,1240],{"href":1238,"rel":1239},"https:\u002F\u002Fdeveloper.wordpress.org\u002Fthemes\u002Fadvanced-topics\u002Fchild-themes\u002F",[20],"Child Themes (WordPress Theme Handbook)",[301,1242,1243],{},[16,1244,1247],{"href":1245,"rel":1246},"https:\u002F\u002Fwww.larisantos.com.br\u002Fblog\u002Fseo-geo-aeo-nuxt",[20],"SEO, GEO e AEO no Nuxt",[1249,1250,1251],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sutJx, html code.shiki .sutJx{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#6A737D;--shiki-default-font-style:inherit;--shiki-dark:#6A737D;--shiki-dark-font-style:inherit}html pre.shiki code .soiBB, html code.shiki .soiBB{--shiki-light:#E2931D;--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .sSJ72, html code.shiki .sSJ72{--shiki-light:#91B859;--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sF_wb, html code.shiki .sF_wb{--shiki-light:#39ADB5;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .s0vBq, html code.shiki .s0vBq{--shiki-light:#91B859;--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}",{"title":76,"searchDepth":89,"depth":89,"links":1253},[1254,1256,1261,1266,1267,1268],{"id":35,"depth":89,"text":1255},"O painel exigia digitar \u002Fwp-admin",{"id":285,"depth":89,"text":286,"children":1257},[1258,1259,1260],{"id":323,"depth":95,"text":324},{"id":384,"depth":95,"text":385},{"id":411,"depth":95,"text":412},{"id":816,"depth":89,"text":817,"children":1262},[1263,1264,1265],{"id":850,"depth":95,"text":851},{"id":934,"depth":95,"text":935},{"id":989,"depth":95,"text":990},{"id":1059,"depth":89,"text":1060},{"id":1190,"depth":89,"text":1191},{"id":1217,"depth":89,"text":1218},"2026-07-19T10:15:00-03:00","Como CORS, campos personalizados e formato de dados saíram do tema do WordPress e foram para mu-plugins num site headless com Nuxt, e por que tema filho resolvia só um dos três riscos.","md",[1273,1276,1279,1282,1285],{"question":1274,"answer":1275},"Tema filho é suficiente para guardar customizações de um WordPress headless?","Não. Tema filho protege de um risco só, a atualização do tema pai sobrescrever os arquivos. Ele continua vulnerável à troca de tema e ao WordPress cair no tema padrão depois de um erro fatal. Num site headless, CORS, campos personalizados e formato de resposta da API não são apresentação, então o lugar deles é mu-plugins.",{"question":1277,"answer":1278},"O que são mu-plugins no WordPress?","São must-use plugins: arquivos PHP em wp-content\u002Fmu-plugins\u002F que o WordPress carrega sempre, antes dos plugins normais, sem precisar de ativação no painel e sem botão de desativar. A pasta não existe por padrão, basta criá-la. É onde fica a configuração que não pode depender do tema ativo nem de alguém lembrar de ligar.",{"question":1280,"answer":1281},"Por que meu mu-plugin não está sendo carregado?","Provavelmente ele está numa subpasta. O WordPress só carrega os arquivos .php que estão na raiz de wp-content\u002Fmu-plugins\u002F, sem varrer subdiretórios, e não emite nenhum aviso quando o arquivo é ignorado. A solução é deixar um arquivo loader na raiz que faz require_once dos módulos internos.",{"question":1283,"answer":1284},"Devo liberar Access-Control-Allow-Origin com asterisco na API de um WordPress headless?","Não é necessário. O correto é uma lista de origens permitidas, devolvendo no header a origem que fez o pedido quando ela está na lista, acompanhada de Vary: Origin para que nenhum cache entregue a resposta de uma origem para outra. Se o front só lê conteúdo, os métodos ficam em GET e OPTIONS e o header Authorization não precisa ser anunciado. Isso não torna o conteúdo privado: quem abrir a URL da API direto continua vendo o JSON, porque CORS controla quem lê a resposta de dentro de outra página, não quem acessa o endpoint.",{"question":1286,"answer":1287},"Como redirecionar a home do WordPress para o painel num site headless?","Com um hook em template_redirect chamando wp_safe_redirect(admin_url()). O cuidado está nos guards: é preciso sair da função quando a requisição for do painel, do admin-ajax, do cron, da REST API, do robots.txt ou de um feed. Sem o guard de REST_REQUEST o redirect derruba a API que alimenta o front.","\u002Fimages\u002Fblog\u002Fwordpress-headless-mu-plugins.jpg",{},"\u002Fblog\u002Fwordpress-headless-mu-plugins",{"title":5,"description":1270},"Por que CORS e campos personalizados de um WordPress headless não pertencem ao tema, e o critério para decidir entre mu-plugins e tema filho.","WordPress Headless: mu-plugins ou tema filho?","blog\u002Fwordpress-headless-mu-plugins",[1296,1297,1298,75,1299,1300,1301,1302],"wordpress","headless","nuxt","acf","cors","arquitetura","mu-plugins","adF-2cy-uwC2bRnV5hzEF3LXoJnXyIB-Fca9Al2f08I",1787183108011]