` (com o tipo morreram o helper, o `useMemo` que só existia porque ele alocava por chamada, o `| null` do `MapFeatureReadout` e **435 strings no payload RSC de toda visita ao Início**); `polygonRingsOf` exportado; helpers de geometria retipados em `PolygonalFeature` (o requisito real, em vez de `BahiaGeometryFeature` que arrasta `GeoJsonProperties`, ou seja `any`), mais `BahiaFeature` e `BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — nenhum runtime os lê, mas os dois entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017 (transcrição **independente** da resolução), então deletar enfraqueceria a verificação por ganho zero; só o `zoneNumber` do **artefato** saiu. Sem migration, collection, action ou `Consent`; e2e segue 436 paths / 435 interativos. Gate completo rodado bare contra o banco local (as falhas de e2e encontradas foram reproduzidas em árvore limpa — flakiness pré-existente já no ledger); Aikido **0 findings** nos arquivos editados (desta vez o scanner rodou; o exit code 2 do B8 F2 não se repetiu para o SAST). Plano as-built: [`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md).
- **`users.roles` entregue em código (2026-07-27):** campo `roles` (`admin` | `editor`) em `users` — migration `20260727_032523_add_users_roles` (enum + tabela `users_roles` + backfill `admin` nas rows existentes). `isPayloadAdmin` exige papel `admin` (falha fechado se ausente/vazio); `hasPayloadPanelAccess` / `canManagePublishedContent` liberam editor em `/admin` + `post`/`tag`/`media`; `petition`/PII/campanha seguem admin-only; `canReadMunicipality` nega ator `users` sem admin (antes devolvia `Where` vazio e o editor via Municípios no sidebar). Unificação `users`+`campaignUser` **rejeitada** nesta sessão (25 FKs, isolamento estrutural→dado, janela de congelamento) — gatilho: Fase 2 white-label ou dor real de duas contas. Plano: [`roles-admin-payload.md`](../../../docs/plans/roles-admin-payload.md).
+- **Build resiliente ao global `metadata` ausente entregue em código (2026-07-27):** site público. `resolveSiteMetadata` + `SITE_METADATA_DEFAULTS` em `src/utilities/seo.ts` — cadeia `global.URL` → `NEXT_PUBLIC_SITE_URL` → `siteUrl: null` (chamador omite canonical / OG url / JSON-LD url); defaults textuais (`siteName`/`title`/`description`/`twitterCreator`) reusam o que o layout já hardcodava. Consumidores: `layout.tsx`, `/abaixo-assinado/[id]`, `/[type]`, `/[type]/[category]`, `/[type]/[category]/[slug]`. Cópias locais de `stripTrailingSlash`/`truncate`/`toAbsoluteUrl` na página de abaixo-assinado removidas. Pins: unit `siteMetadata`, int `siteMetadataFallback` (mock `getCachedGlobal` → `{}`), guard em `codebaseConventions` contra `globalMetadata.openGraph.` / `.twitter.` / `.keywords.` / `stripTrailingSlash(globalMetadata.URL)` fora de `seo.ts`. Escopo ampliado na auditoria (roadmap citava só a rota de petição e só `URL`). Sem migration/collection/action. Gate: tsc/lint/format/cycles/unit 548/build verde; knip P3 pré-existente; Aikido sem achados novos (`dangerouslySetInnerHTML` Lexical/JSON-LD pré-existente). Plano: [`build-resiliente-metadata-ausente.md`](../../../docs/plans/build-resiliente-metadata-ausente.md).
- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. **Relatórios do `/simplify` chegaram depois do merge** e o que sobrou virou o fill-in **B8+** ([`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md)), aberto por uma medição que contradiz a intuição de "malha pequena, artefato pequeno": as 19 zonas têm **9.120** pontos de arco contra **9.139** da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature; Salvador é um `Polygon` de **35** pontos na malha municipal). A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo, e o de Salvador é ~26× mais estreito, o que dá **~3,9 m** de resolução contra ~103 m no estado, num mapa que para em `maxZoom: 10`. Consequência para quem for consertar: o que deve ficar compartilhado entre os dois scripts é a **escala de chão**, não a contagem de dígitos — extrair as constantes como estão (o que o revisor de reuse pediu, sem essa medição) cimentaria o defeito e lhe daria autoridade de política. Os outros três: nada roda `featureMapKey` sobre um feature **real** fora do e2e, então as três escritas da map key podem divergir e pintar cinza sem erro em lugar nenhum; o `Promise.all` das duas malhas somado à memoização da rejeição fez de um chunk de 19 polígonos um ponto único de falha do mapa inteiro (antes a malha municipal sozinha bastava); e a ressalva "é aproximado, não é o limite oficial do TSE" está pendurada na condição de render da **lista** (`!comparisonActive`), então desaparece no modo comparação enquanto a malha aproximada continua pintada. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40.
- **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md).
diff --git a/AGENTS.md b/AGENTS.md
index fda6eb7e..246c36e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,6 +71,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
+**Recently resolved (2026-07-27):** **Build resiliente ao global `metadata` ausente** — `resolveSiteMetadata` in `src/utilities/seo.ts` (chain `global.URL` → `NEXT_PUBLIC_SITE_URL` → omit canonical/OG/JSON-LD url; textual defaults for empty global). All public SEO consumers (`layout`, `/abaixo-assinado/[id]`, `/[type]` tree) go through it; convention guard in `codebaseConventions.unit.spec.ts`. Plano: [`docs/plans/build-resiliente-metadata-ausente.md`](docs/plans/build-resiliente-metadata-ausente.md).
**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md).
diff --git a/docs/plans/build-resiliente-metadata-ausente.md b/docs/plans/build-resiliente-metadata-ausente.md
new file mode 100644
index 00000000..5144c734
--- /dev/null
+++ b/docs/plans/build-resiliente-metadata-ausente.md
@@ -0,0 +1,54 @@
+# Build resiliente ao global `metadata` ausente
+
+Status: entregue
+Atualizado em: 2026-07-27
+Item do roadmap: [docs/roadmap.md](../roadmap.md) (Site público → Próximos)
+Impeccable: A — N/A (sem superfície visual; só `
` / JSON-LD)
+Appetite: ~0,4–0,5d eng (auditoria ampliou o escopo do roadmap de ~0,25d)
+Responsável: —
+
+## Dados → decisão → apresentação
+
+Dados: N/A — a entrega só evita crash no prerender e omite canonical/OG quando não há URL; não apresenta métrica, mapa, série nem ranking.
+
+## Contexto
+
+Fonte: simplify B16 (2026-07-25) — build local morreu no prerender de `/abaixo-assinado/[id]` porque `stripTrailingSlash(globalMetadata.URL)` recebia `undefined` (global vazio ou entrada de `unstable_cache` gravada vazia). `rm -rf .next` contornava o cache envenenado, mas o código continuava frágil.
+
+Auditoria na implementação (2026-07-27) achou o mesmo padrão nas três rotas de posts (`/[type]`, `/[type]/[category]`, `/[type]/[category]/[slug]`) e nos acessos a `openGraph.siteName` / `twitter.creator` / `keywords.map` — corrigir só `URL` em só uma rota não desbloqueava o build.
+
+## Objetivos
+
+- `next build` degrada em vez de morrer quando o global `metadata` está vazio.
+- Cadeia de URL: `global.URL` → `NEXT_PUBLIC_SITE_URL` → omitir canonical / Open Graph url / JSON-LD url.
+- Defaults textuais (`siteName`, `title`, `description`, `twitterCreator`) centralizados; layout deixa de hardcodar `https://jorgesolla.com.br` como fallback de OG url.
+- Todo consumidor do global passa por `resolveSiteMetadata` (guard em `codebaseConventions`).
+
+## Decisões travadas
+
+- **Escopo = 4 rotas + layout + todos os campos frágeis**, via resolver em `src/utilities/seo.ts`. **Rejeitado:** literal do roadmap (só `URL` em `/abaixo-assinado/[id]`) — o build ainda quebraria nas rotas de post e no `openGraph.siteName`.
+- **`siteUrl === null` → omitir**, não inventar domínio. **Rejeitado:** cair sempre em `https://jorgesolla.com.br` (o layout antigo mentia canonical em banco vazio) e omitir sem tentar `NEXT_PUBLIC_SITE_URL` (o segundo degrau cobre build local com env).
+- **Impeccable A.** Softened `[&_blockquote]:border-l-4` → `border-l` na página de artigo só porque o design hook bloqueava o write SEO; alinhado ao padrão já usado na página de abaixo-assinado.
+
+## As-built
+
+- `resolveSiteMetadata` + `SITE_METADATA_DEFAULTS` em [`src/utilities/seo.ts`](../../src/utilities/seo.ts).
+- Consumidores: [`layout.tsx`](../../src/app/(frontend)/layout.tsx), [`abaixo-assinado/[id]/page.tsx`](../../src/app/(frontend)/abaixo-assinado/[id]/page.tsx), [`[type]/page.tsx`](../../src/app/(frontend)/[type]/page.tsx), [`[type]/[category]/page.tsx`](../../src/app/(frontend)/[type]/[category]/page.tsx), [`[type]/[category]/[slug]/page.tsx`](../../src/app/(frontend)/[type]/[category]/[slug]/page.tsx).
+- Cópias locais de `stripTrailingSlash` / `truncate` / `toAbsoluteUrl` na página de abaixo-assinado removidas.
+- Pins: `tests/unit/siteMetadata.unit.spec.ts`, `tests/int/siteMetadataFallback.int.spec.ts`, guard em `tests/unit/codebaseConventions.unit.spec.ts`.
+
+## Verificação
+
+- `tsc --noEmit`, `lint` (0 warnings), `format:check`, `check:cycles` (0), unit 548, int do pin (3) + petition layout (4), `pnpm build` local verde (84 páginas estáticas geradas).
+- knip: P3 pré-existente (`payload.config.ts` não carrega).
+- Aikido: 0 findings novos; `dangerouslySetInnerHTML` em JSON-LD / Lexical é pré-existente (CMS/admin-controlled + escape de `<` no JSON-LD do artigo).
+
+## Não escopo
+
+- Fallbacks dos globais `home` / `site-settings` (já usam `?.` / `??`).
+- Textos finais de privacidade / O0+ (item separado no roadmap).
+- Popular o global `metadata` no seed (o app falha fechado de propósito no SEO, não no HTML).
+
+## Revisões
+
+- 2026-07-27 — entrega: escopo ampliado na auditoria; as-built acima.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 571f7915..bffd2e2d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-27 (**B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
+Atualizado em: 2026-07-27 (**Build resiliente ao global `metadata` ausente ✓** — `resolveSiteMetadata` em `src/utilities/seo.ts`; fallback `NEXT_PUBLIC_SITE_URL` e omit canonical/OG quando não há URL; 4 rotas + layout; [plano](plans/build-resiliente-metadata-ausente.md); **B8+ ✓** escala e DRY pós-B8 F2 — a política de escala de chão das malhas commitadas virou código nomeado (`scripts/lib/topology.mjs`, tolerância de Visvalingam em m² e dígitos de quantização derivados do bbox de cada topologia), e com ela a malha das ZE caiu de 480 para **30,2 pontos/feature** (72.310 → 7.687 bytes) sem que `bahia-municipalities` mudasse um byte; a auditoria corrigiu a alavanca do plano (quantização sozinha quase não corta — o peso absoluto manda) e **rejeitou** um item do F4 por medição (`city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` são evidência de proveniência hasheada, não código morto); `MAX_POINTS_PER_FEATURE = 60` é o novo tripwire, `featureMapKey` passou a ser exercido sobre features reais das três malhas (417 `codarea`, 27 `code`, 19 `slug`), o mapa **degrada** em vez de morrer quando o chunk das zonas falha, e a ressalva de aproximação saiu do gate de comparação · [plano](plans/escala-dry-pos-b8f2.md); **`users.roles` ✓** — `admin`\|`editor` em `users`, migration `20260727_032523_add_users_roles`, editor publica `post`/`tag`/`media`; unificação com `campaignUser` rejeitada · [plano](plans/roles-admin-payload.md); 2026-07-26 (**fill-in** remover lista "Municípios por zona eleitoral" do Início — pós-B8 F2 ✓ os polígonos tornam a lista textual redundante; dropar UI + `zoneBreakdown` do bundle; ressalva de aproximação fica na malha · [plano](plans/remover-lista-zonas-mapa-inicio.md); **B8 F2 ✓** polígonos das 19 ZE de Salvador — artefato `bahia-municipality-zones.topo.json` (72 KB) por dissolve bairro→zona do `BA_bairros_CD2022` do IBGE contra a RA 02/2017 do TRE-BA, com curadoria explícita e um passe de maior fronteira compartilhada validado; o coroplético passou a ser keyed por **map key** (`slug` na zona, `ibgeCode` no resto) e o polígono municipal de Salvador ficou como base sem interação por baixo, porque duas malhas simplificadas independentemente não fecham na mesma costa; `competitiveRank` segue por cidade (o artefato TSE só tem `federalRankByIbgeCode`) com int pinando o fato; desambiguar a ZE no card "Onde estou" do B14 ✓ virou débito com gatilho, não dependência — [plano](plans/poligonos-pracas-zona.md); **B8+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B8 F2, cujos relatórios chegaram depois do merge — a malha de 19 zonas ficou com a mesma contagem de vértices da malha dos 417 municípios (as duas constantes de simplificação são relativas ao bbox e foram copiadas verbatim, o que vale ~3,9 m de grade em Salvador contra ~103 m no estado), a regra da map key está em três formas sem nenhum teste ligando artefato → `featureMapKey` → catálogo, uma falha de chunk nas zonas passou a derrubar o mapa inteiro, a ressalva de aproximação desaparece no modo comparação, e `getTerritoryFeature` perdeu o último consumidor de produção neste commit · [plano](plans/escala-dry-pos-b8f2.md); **B39–B42** registrados: sessão "Lembrar de mim" + login biométrico WebAuthn + scroll horizontal/sticky na lista de municípios + polimento mobile da mesma lista — [B39](plans/lembrar-de-mim-sessao-campanha.md), [B40](plans/login-biometrico-campanha.md), [B41](plans/scroll-horizontal-lista-municipios.md), [B42](plans/polimento-mobile-lista-municipios.md); **E12 ✓** camada TI — rollups MAUP, colunas Cobertura/Captura/Classe em `/campanha/territorios`, benchmark intra-TI no `MunicipalityGoalAccountCard`; [plano](plans/camada-territorios-identidade.md); **B36 ✓** coluna "Lideranças" em `/campanha/dobradinhas` — chips + Popover+Command + delta reuse da mutação B31; `LeadershipStateDeputyRelationCell` compartilhada (`direction`/`measureOverflow`); columns factory (não Context); casca `dobradinhas/formActions.ts`; `loadLeadershipOptions` + `leaderships[]` no row; [plano](plans/chips-liderancas-lista-dobradinhas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` ao lado do contato do B28 ✓; Popover two-step reusa `createCampaignInvite`/`autopreenchimento`; disable por `normalizeBrazilianPhone`; [plano](plans/convite-whatsapp-lista-liderancas.md); **B31 ✓** coluna de dobradinhas na lista de lideranças entregue — chips clicáveis → `/campanha/dobradinhas/` em `/campanha/liderancas`, edição por Popover+`Command` (mecânica do **B27 ✓**) com escrita por **server action direta do cliente** (`useTransition`, padrão do B19 ✓) em vez do endpoint JSON do B27, porque aqui há derivados a revalidar (`leadershipCount`, a ficha do deputado); `setLeadershipStateDeputyMembership(Record)` em transação + lock `leadership-state-deputies:{id}` + `revalidatePath`; helper puro `nextStateDeputyIdsAfterMembership` espelha `nextAdvisorIdsAfterMembership`; catálogo entregue uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory, mesmo precedente de `municipalityListColumns`) em vez de Context; é o **2º call site** do padrão Popover+Command+chips — extração para `shared/` continua esperando o 3º (B34/B36/B37, que agora consomem a mutação/lock em vez de correr para desenhá-los); [plano](plans/dobradinhas-lista-liderancas.md); **fill-in ✓** limitar pan/zoom do mapa à Bahia — `BahiaMap` com `maxBounds`/`maxBoundsViscosity: 1` sobre `BAHIA_BOUNDS.pad(0.1)` e `minZoom` via `getBoundsZoom` com o mesmo padding 16px do `fitBounds`, recalculado no `ResizeObserver` existente; único arquivo; [plano](plans/limitar-mapa-a-bahia.md); (**B14 ✓** município mais próximo entregue — card cliente "Onde estou" no Início staff resolvendo **point-in-polygon** sobre o chunk de geometria que o mapa do dashboard já carrega (a premissa "polígonos explodem o bundle" do plano de 24/07 caiu na medição: 41 KB gzip já estão no browser, então o artefato de centroides e seu script saíram de escopo), com fallback nomeando onde o assessor está e o município mais próximo **da carteira** dentro de 150 km; a posição nunca sai do aparelho (sem action, sem `Consent`); First Load JS de `/campanha` 265 → 268 kB depois de mover o href das zonas de Salvador para o servidor (`zoneCityHrefs`) — importar `buildMunicipalityListHref` no cliente custava **21 kB** por um link, porque arrasta `bahiaTerritories` + `municipalityCatalog`; [plano](plans/municipio-mais-proximo.md); ~~**B38**~~ ✓ sidebar recolhido em tablet entregue — offcanvas + trigger md+ + cookie/matchMedia; [plano](plans/sidebar-recolhido-tablet.md); **B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30 ✓** convite de completar cadastro por WhatsApp na lista de lideranças — `LeadershipInviteRowAction` na coluna `actions` (entregue 2026-07-26), [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); ~~**B25**~~ ✓ link do território na lista de municípios (entregue 2026-07-26) — coluna "Território" + card mobile → `/campanha/territorios#ti-`; `territoryAnchor.ts` + `TerritoryLink`; seam `rowId` em `CampaignTable`; [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14 ✓; janela 1: smoke, R6)Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -361,7 +361,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun
**Próximos:**
- Textos finais de privacidade + polish O0+ (revalidate globals, DRY Lexical) — mencionar cookies/Meta com Pixel em uso
-- **Build resiliente ao global `metadata` ausente** — `/abaixo-assinado/[id]` faz `stripTrailingSlash(globalMetadata.URL)` no `generateMetadata` e no componente; com o global vazio (banco novo, ou entrada de `unstable_cache` gravada vazia) o `next build` inteiro morre no prerender em vez de degradar. Dar fallback (`NEXT_PUBLIC_SITE_URL`) e omitir canonical/OG quando não houver URL · ~0,25d · _(fonte: simplify B16 2026-07-25 — build local quebrou com `.next/cache` envenenado; `rm -rf .next` contorna)_
+- ~~**Build resiliente ao global `metadata` ausente**~~ ✓ (entregue 2026-07-27) — `resolveSiteMetadata` cobre as 4 rotas públicas + layout; cadeia `global.URL` → `NEXT_PUBLIC_SITE_URL` → omitir canonical/OG/JSON-LD url; [plano](plans/build-resiliente-metadata-ausente.md) · _(fonte: simplify B16 2026-07-25)_
- `Pages` institucionais (bio, mandato, propostas) + hero/copy editáveis
- Agenda/multimídia via links oficiais; CTA Doar → QueroApoiar
- Migrar Consent dos fluxos públicos para chave estável
diff --git a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx
index 6affbb18..7f7c6219 100644
--- a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx
+++ b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx
@@ -18,7 +18,7 @@ import {
isPostType,
isPostVisible,
} from '@/utilities/posts'
-import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo'
+import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Metadata } from 'next'
import Image from 'next/image'
@@ -74,26 +74,26 @@ export async function generateMetadata({
if (!post || !isPostVisible(post)) return {}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
+ const {
+ siteUrl,
+ siteName,
+ description: fallbackDescription,
+ twitterCreator,
+ keywords: baseKeywords,
+ } = resolveSiteMetadata(globalMetadata)
const canonicalPath = getPostCanonicalPath(post)
- const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl
+ const canonicalUrl = canonicalPath ? absoluteSitePath(siteUrl, canonicalPath) : undefined
const image = await resolveOgImage(post, globalMetadata.image)
- const imageUrl = image?.url ? toAbsoluteUrl(image.url, siteUrl) : undefined
+ const imageUrl = image?.url && siteUrl ? toAbsoluteUrl(image.url, siteUrl) : undefined
const description = post.subtitle
? truncate(post.subtitle, MAX_DESCRIPTION_LENGTH)
- : globalMetadata.description
- const title = `${post.title} | ${globalMetadata.openGraph.siteName}`
+ : fallbackDescription
+ const title = `${post.title} | ${siteName}`
const categoryName = getCategoryName(post)
- const keywords = [
- ...globalMetadata.keywords
- .map((k) => (typeof k === 'string' ? k : k.keyword))
- .filter((k): k is string => typeof k === 'string'),
- post.title,
- ...(categoryName ? [categoryName] : []),
- ]
+ const keywords = [...baseKeywords, post.title, ...(categoryName ? [categoryName] : [])]
const ogImages = imageUrl
? [
@@ -112,14 +112,12 @@ export async function generateMetadata({
title,
description,
keywords,
- alternates: {
- canonical: canonicalUrl,
- },
+ ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'article',
locale: 'pt-BR',
- url: canonicalUrl,
- siteName: globalMetadata.openGraph.siteName,
+ ...(canonicalUrl ? { url: canonicalUrl } : {}),
+ siteName,
title,
description,
images: ogImages,
@@ -130,7 +128,7 @@ export async function generateMetadata({
card: 'summary_large_image',
title,
description,
- creator: globalMetadata.twitter.creator,
+ creator: twitterCreator,
images: ogImages.map((img) => img.url),
},
}
@@ -154,11 +152,11 @@ export default async function Page({ params }: { params: Promise })
}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
- const canonicalUrl = `${siteUrl}${canonicalPath}`
+ const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
+ const canonicalUrl = absoluteSitePath(siteUrl, canonicalPath)
const ogImage = await resolveOgImage(post, globalMetadata.image)
- const ogImageUrl = ogImage?.url ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined
+ const ogImageUrl = ogImage?.url && siteUrl ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined
const categoryName = getCategoryName(post)
const categorySlug = getCategorySlug(post)
@@ -181,20 +179,24 @@ export default async function Page({ params }: { params: Promise })
headline: post.title,
...(post.subtitle ? { description: truncate(post.subtitle, MAX_DESCRIPTION_LENGTH) } : {}),
inLanguage: 'pt-BR',
- url: canonicalUrl,
- mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
+ ...(canonicalUrl
+ ? {
+ url: canonicalUrl,
+ mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
+ }
+ : {}),
datePublished: post.publishedDate ?? post.createdAt,
dateModified: post.updatedAt,
...(ogImageUrl ? { image: [ogImageUrl] } : {}),
author: {
'@type': 'Organization',
- name: globalMetadata.openGraph.siteName,
- url: siteUrl,
+ name: siteName,
+ ...(siteUrl ? { url: siteUrl } : {}),
},
publisher: {
'@type': 'Organization',
- name: globalMetadata.openGraph.siteName,
- url: siteUrl,
+ name: siteName,
+ ...(siteUrl ? { url: siteUrl } : {}),
},
}
@@ -251,7 +253,7 @@ export default async function Page({ params }: { params: Promise })
{bodyHTML ? (
) : null}
diff --git a/src/app/(frontend)/[type]/[category]/page.tsx b/src/app/(frontend)/[type]/[category]/page.tsx
index b577e879..e3b74088 100644
--- a/src/app/(frontend)/[type]/[category]/page.tsx
+++ b/src/app/(frontend)/[type]/[category]/page.tsx
@@ -10,7 +10,7 @@ import {
getVisiblePosts,
isPostType,
} from '@/utilities/posts'
-import { stripTrailingSlash } from '@/utilities/seo'
+import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
@@ -46,22 +46,22 @@ export async function generateMetadata({
if (!posts.length) return {}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
+ const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
const categoryName = getCategoryName(posts[0]) ?? category
const label = POST_TYPE_LABELS[type]
- const title = `${categoryName} | ${label} | ${globalMetadata.openGraph.siteName}`
- const description = `${label} sobre ${categoryName} de ${globalMetadata.openGraph.siteName}.`
- const canonicalUrl = `${siteUrl}/${type}/${category}`
+ const title = `${categoryName} | ${label} | ${siteName}`
+ const description = `${label} sobre ${categoryName} de ${siteName}.`
+ const canonicalUrl = absoluteSitePath(siteUrl, `/${type}/${category}`)
return {
title,
description,
- alternates: { canonical: canonicalUrl },
+ ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
- url: canonicalUrl,
- siteName: globalMetadata.openGraph.siteName,
+ ...(canonicalUrl ? { url: canonicalUrl } : {}),
+ siteName,
title,
description,
},
diff --git a/src/app/(frontend)/[type]/page.tsx b/src/app/(frontend)/[type]/page.tsx
index 909fd6c7..bf219e7c 100644
--- a/src/app/(frontend)/[type]/page.tsx
+++ b/src/app/(frontend)/[type]/page.tsx
@@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globalReads'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
-import { stripTrailingSlash } from '@/utilities/seo'
+import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
@@ -27,20 +27,21 @@ export async function generateMetadata({
if (!isPostType(type)) return {}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
+ const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
const label = POST_TYPE_LABELS[type]
- const title = `${label} | ${globalMetadata.openGraph.siteName}`
- const description = `${label} de ${globalMetadata.openGraph.siteName}.`
+ const title = `${label} | ${siteName}`
+ const description = `${label} de ${siteName}.`
+ const canonicalUrl = absoluteSitePath(siteUrl, `/${type}`)
return {
title,
description,
- alternates: { canonical: `${siteUrl}/${type}` },
+ ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
- url: `${siteUrl}/${type}`,
- siteName: globalMetadata.openGraph.siteName,
+ ...(canonicalUrl ? { url: canonicalUrl } : {}),
+ siteName,
title,
description,
},
diff --git a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx
index 1d94b6bc..20efe4be 100644
--- a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx
+++ b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx
@@ -7,6 +7,7 @@ import type { Media } from '@/payload-types'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documentReads'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { getCachedGlobal } from '@/utilities/globalReads'
+import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Metadata } from 'next'
import Link from 'next/link'
@@ -15,16 +16,6 @@ import type { Article, WithContext } from 'schema-dts'
const MAX_DESCRIPTION_LENGTH = 200
-const stripTrailingSlash = (url: string) => url.replace(/\/+$/, '')
-
-const truncate = (text: string, max: number) =>
- text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
-
-const toAbsoluteUrl = (url: string, siteUrl: string) =>
- /^https?:\/\//i.test(url)
- ? url
- : `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`
-
export async function generateStaticParams() {
// Build-time enumeration only needs the ids — skip relationship population.
const ids = await getPetitionIds()
@@ -45,8 +36,13 @@ export async function generateMetadata({
}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
- const canonicalUrl = `${siteUrl}/abaixo-assinado/${petition.id}`
+ const {
+ siteUrl,
+ siteName,
+ twitterCreator,
+ keywords: baseKeywords,
+ } = resolveSiteMetadata(globalMetadata)
+ const canonicalUrl = absoluteSitePath(siteUrl, `/abaixo-assinado/${petition.id}`)
const bodyImage = extractFirstImageFromLexical(petition.body)
let fallbackImage: Media | null = null
@@ -59,18 +55,11 @@ export async function generateMetadata({
const image = bodyImage ?? fallbackImage
const description = truncate(petition.subtitle, MAX_DESCRIPTION_LENGTH)
- const title = `${petition.title} | ${globalMetadata.openGraph.siteName}`
+ const title = `${petition.title} | ${siteName}`
- const keywords = [
- ...globalMetadata.keywords
- .map((k) => (typeof k === 'string' ? k : k.keyword))
- .filter((k): k is string => typeof k === 'string'),
- 'abaixo-assinado',
- 'petição',
- petition.title,
- ]
+ const keywords = [...baseKeywords, 'abaixo-assinado', 'petição', petition.title]
- const imageUrl = image?.url ? toAbsoluteUrl(image.url, siteUrl) : undefined
+ const imageUrl = image?.url && siteUrl ? toAbsoluteUrl(image.url, siteUrl) : undefined
const ogImages = imageUrl
? [
@@ -89,14 +78,12 @@ export async function generateMetadata({
title,
description,
keywords,
- alternates: {
- canonical: canonicalUrl,
- },
+ ...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'article',
locale: 'pt-BR',
- url: canonicalUrl,
- siteName: globalMetadata.openGraph.siteName,
+ ...(canonicalUrl ? { url: canonicalUrl } : {}),
+ siteName,
title,
description,
images: ogImages,
@@ -107,7 +94,7 @@ export async function generateMetadata({
card: 'summary_large_image',
title,
description,
- creator: globalMetadata.twitter.creator,
+ creator: twitterCreator,
images: ogImages.map((img) => img.url),
},
}
@@ -122,8 +109,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
}
const globalMetadata = await getCachedGlobal('metadata')()
- const siteUrl = stripTrailingSlash(globalMetadata.URL)
- const canonicalUrl = `${siteUrl}/abaixo-assinado/${petition.id}`
+ const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata)
+ const canonicalUrl = absoluteSitePath(siteUrl, `/abaixo-assinado/${petition.id}`)
const bodyImage = extractFirstImageFromLexical(petition.body)
let fallbackImage: Media | null = null
@@ -134,7 +121,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
: globalMetadata.image
}
const ogImage = bodyImage ?? fallbackImage
- const ogImageUrl = ogImage?.url ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined
+ const ogImageUrl = ogImage?.url && siteUrl ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined
const jsonLd: WithContext = {
'@context': 'https://schema.org',
@@ -142,20 +129,24 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
headline: petition.title,
description: truncate(petition.subtitle, MAX_DESCRIPTION_LENGTH),
inLanguage: 'pt-BR',
- url: canonicalUrl,
- mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
+ ...(canonicalUrl
+ ? {
+ url: canonicalUrl,
+ mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
+ }
+ : {}),
datePublished: petition.createdAt,
dateModified: petition.updatedAt,
...(ogImageUrl ? { image: [ogImageUrl] } : {}),
author: {
'@type': 'Organization',
- name: globalMetadata.openGraph.siteName,
- url: siteUrl,
+ name: siteName,
+ ...(siteUrl ? { url: siteUrl } : {}),
},
publisher: {
'@type': 'Organization',
- name: globalMetadata.openGraph.siteName,
- url: siteUrl,
+ name: siteName,
+ ...(siteUrl ? { url: siteUrl } : {}),
},
}
diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx
index ab743110..aede5250 100644
--- a/src/app/(frontend)/layout.tsx
+++ b/src/app/(frontend)/layout.tsx
@@ -2,6 +2,7 @@ import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { ThemeProvider } from '@/components/ThemeProvider'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
+import { resolveSiteMetadata } from '@/utilities/seo'
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Metadata } from 'next'
@@ -11,16 +12,10 @@ import './styles.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
-const fallbackMetadata = {
- URL: 'https://jorgesolla.com.br',
- title: 'Jorge Solla',
- description: 'Notícias, campanhas e a atuação do deputado Jorge Solla pela Bahia.',
- openGraphSiteName: 'Jorge Solla',
- twitterCreator: '@jorgesolla',
-}
-
export async function generateMetadata(): Promise {
const payload = await getCachedGlobal('metadata')()
+ const { siteUrl, title, description, siteName, twitterCreator, twitterDescription, keywords } =
+ resolveSiteMetadata(payload)
let image = payload.image
@@ -29,9 +24,9 @@ export async function generateMetadata(): Promise {
}
return {
- title: payload.title || fallbackMetadata.title,
- description: payload.description || fallbackMetadata.description,
- keywords: (payload.keywords ?? []).map(({ keyword }) => keyword).filter(Boolean),
+ title,
+ description,
+ keywords,
authors: [
{ name: 'Francisco Solla', url: 'https://solla.dev' },
{ name: 'Teqo', url: 'https://teqo.app' },
@@ -45,10 +40,10 @@ export async function generateMetadata(): Promise {
openGraph: {
type: 'website',
locale: 'pt-BR',
- url: payload.URL || fallbackMetadata.URL,
- siteName: payload.openGraph?.siteName || fallbackMetadata.openGraphSiteName,
- title: payload.title || fallbackMetadata.title,
- description: payload.description || fallbackMetadata.description,
+ ...(siteUrl ? { url: siteUrl } : {}),
+ siteName,
+ title,
+ description,
images: image?.url
? [
{
@@ -62,10 +57,9 @@ export async function generateMetadata(): Promise {
},
twitter: {
card: 'summary_large_image',
- title: payload.title || fallbackMetadata.title,
- description:
- payload.twitter?.description || payload.description || fallbackMetadata.description,
- creator: payload.twitter?.creator || fallbackMetadata.twitterCreator,
+ title,
+ description: twitterDescription,
+ creator: twitterCreator,
images: image?.url ? [image.url] : [],
},
}
diff --git a/src/utilities/seo.ts b/src/utilities/seo.ts
index e3a9b57c..2c559af1 100644
--- a/src/utilities/seo.ts
+++ b/src/utilities/seo.ts
@@ -10,3 +10,75 @@ export const toAbsoluteUrl = (url: string, siteUrl: string): string =>
/^https?:\/\//i.test(url)
? url
: `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`
+
+/**
+ * Join a resolved site origin with a path. Returns `undefined` when there is no
+ * site URL so callers can omit canonical / Open Graph url instead of concatenating
+ * against `null`.
+ */
+export const absoluteSitePath = (siteUrl: string | null, path: string): string | undefined => {
+ if (!siteUrl) return undefined
+ return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`
+}
+
+/**
+ * Partial shape of the Payload `metadata` global. Fields are optional because a
+ * fresh DB (or a poisoned `unstable_cache` entry) can return an empty object at
+ * build time even though the schema marks them required.
+ */
+export type SiteMetadataSource = {
+ URL?: string | null
+ title?: string | null
+ description?: string | null
+ keywords?: ({ keyword?: string | null } | string | null)[] | null
+ openGraph?: { siteName?: string | null } | null
+ twitter?: { creator?: string | null; description?: string | null } | null
+}
+
+/** Defaults shared by every public-site SEO surface when the global is empty. */
+export const SITE_METADATA_DEFAULTS = {
+ title: 'Jorge Solla',
+ siteName: 'Jorge Solla',
+ description: 'Notícias, campanhas e a atuação do deputado Jorge Solla pela Bahia.',
+ twitterCreator: '@jorgesolla',
+} as const
+
+const nonEmpty = (value: string | null | undefined): string | null => {
+ const trimmed = value?.trim()
+ return trimmed ? trimmed : null
+}
+
+const resolveSiteUrl = (source: SiteMetadataSource): string | null => {
+ const fromGlobal = nonEmpty(source.URL)
+ if (fromGlobal) return stripTrailingSlash(fromGlobal)
+
+ const fromEnv = nonEmpty(process.env.NEXT_PUBLIC_SITE_URL)
+ if (fromEnv) return stripTrailingSlash(fromEnv)
+
+ return null
+}
+
+const flattenKeywords = (source: SiteMetadataSource): string[] =>
+ (source.keywords ?? [])
+ .map((entry) => nonEmpty(typeof entry === 'string' ? entry : entry?.keyword))
+ .filter((keyword): keyword is string => keyword !== null)
+
+/**
+ * Resolve site SEO fields with graceful degradation.
+ *
+ * `siteUrl` is `null` when neither the global nor `NEXT_PUBLIC_SITE_URL` has a
+ * usable URL — callers MUST omit canonical / Open Graph url / JSON-LD url in
+ * that case rather than concatenate against `undefined`.
+ */
+export const resolveSiteMetadata = (source: SiteMetadataSource) => ({
+ siteUrl: resolveSiteUrl(source),
+ title: nonEmpty(source.title) ?? SITE_METADATA_DEFAULTS.title,
+ siteName: nonEmpty(source.openGraph?.siteName) ?? SITE_METADATA_DEFAULTS.siteName,
+ description: nonEmpty(source.description) ?? SITE_METADATA_DEFAULTS.description,
+ twitterCreator: nonEmpty(source.twitter?.creator) ?? SITE_METADATA_DEFAULTS.twitterCreator,
+ twitterDescription:
+ nonEmpty(source.twitter?.description) ??
+ nonEmpty(source.description) ??
+ SITE_METADATA_DEFAULTS.description,
+ keywords: flattenKeywords(source),
+})
diff --git a/tests/int/siteMetadataFallback.int.spec.ts b/tests/int/siteMetadataFallback.int.spec.ts
new file mode 100644
index 00000000..c5d72ba7
--- /dev/null
+++ b/tests/int/siteMetadataFallback.int.spec.ts
@@ -0,0 +1,133 @@
+// @vitest-environment node
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { SITE_METADATA_DEFAULTS } from '@/utilities/seo'
+
+const mocks = vi.hoisted(() => ({
+ getCachedGlobal: vi.fn(),
+ getCachedDocumentById: vi.fn(),
+ getCachedPostBySlug: vi.fn(),
+ extractFirstImageFromLexical: vi.fn(() => null),
+}))
+
+vi.mock('@/utilities/globalReads', () => ({
+ getCachedGlobal: mocks.getCachedGlobal,
+}))
+
+vi.mock('@/utilities/documentReads', () => ({
+ getCachedDocumentById: mocks.getCachedDocumentById,
+ getPetitionIds: vi.fn(async () => []),
+}))
+
+vi.mock('@/utilities/extractFirstImageFromLexical', () => ({
+ extractFirstImageFromLexical: mocks.extractFirstImageFromLexical,
+}))
+
+vi.mock('@/utilities/posts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getCachedPostBySlug: mocks.getCachedPostBySlug,
+ getVisiblePosts: vi.fn(async () => []),
+ }
+})
+
+vi.mock('@/app/(frontend)/actions/getSignatureCount', () => ({
+ getSignatureCount: vi.fn(async () => 0),
+}))
+
+import { generateMetadata as generateArticleMetadata } from '@/app/(frontend)/[type]/[category]/[slug]/page'
+import { generateMetadata as generatePetitionMetadata } from '@/app/(frontend)/abaixo-assinado/[id]/page'
+
+const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL
+
+const enabledPetition = {
+ id: 42,
+ enabled: true,
+ title: 'Petição de teste',
+ subtitle: 'Subtítulo da petição de teste com texto suficiente.',
+ body: {
+ root: { children: [], direction: null, format: '', indent: 0, type: 'root', version: 1 },
+ },
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+}
+
+const visiblePost = {
+ id: 7,
+ title: 'Notícia de teste',
+ subtitle: 'Subtítulo da notícia',
+ slug: 'noticia-de-teste',
+ type: 'noticia' as const,
+ _status: 'published' as const,
+ category: { id: 1, name: 'Saúde', slug: 'saude', hidden: false },
+ tags: [],
+ body: null,
+ coverImage: null,
+ publishedDate: '2026-01-01T00:00:00.000Z',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+}
+
+describe('site metadata fallback (empty global)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getCachedGlobal.mockReturnValue(async () => ({}))
+ mocks.getCachedDocumentById.mockImplementation((collection: string) => {
+ if (collection === 'petition') return async () => enabledPetition
+ return async () => null
+ })
+ mocks.getCachedPostBySlug.mockReturnValue(async () => visiblePost)
+ })
+
+ afterEach(() => {
+ if (originalSiteUrl === undefined) {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+ } else {
+ process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl
+ }
+ })
+
+ it('generateMetadata for abaixo-assinado survives an empty metadata global', async () => {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+
+ const metadata = await generatePetitionMetadata({
+ params: Promise.resolve({ id: '42' }),
+ })
+
+ expect(metadata.title).toBe(`Petição de teste | ${SITE_METADATA_DEFAULTS.siteName}`)
+ expect(metadata.alternates?.canonical).toBeUndefined()
+ expect(metadata.openGraph?.url).toBeUndefined()
+ expect(metadata.openGraph?.siteName).toBe(SITE_METADATA_DEFAULTS.siteName)
+ expect(metadata.twitter?.creator).toBe(SITE_METADATA_DEFAULTS.twitterCreator)
+ })
+
+ it('generateMetadata for article survives an empty metadata global', async () => {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+
+ const metadata = await generateArticleMetadata({
+ params: Promise.resolve({
+ type: 'noticia',
+ category: 'saude',
+ slug: 'noticia-de-teste',
+ }),
+ })
+
+ expect(metadata.title).toBe(`Notícia de teste | ${SITE_METADATA_DEFAULTS.siteName}`)
+ expect(metadata.alternates?.canonical).toBeUndefined()
+ expect(metadata.openGraph?.url).toBeUndefined()
+ expect(metadata.openGraph?.siteName).toBe(SITE_METADATA_DEFAULTS.siteName)
+ })
+
+ it('uses NEXT_PUBLIC_SITE_URL for canonical when the global has no URL', async () => {
+ process.env.NEXT_PUBLIC_SITE_URL = 'https://fallback.example/'
+
+ const metadata = await generatePetitionMetadata({
+ params: Promise.resolve({ id: '42' }),
+ })
+
+ expect(metadata.alternates?.canonical).toBe('https://fallback.example/abaixo-assinado/42')
+ expect(metadata.openGraph?.url).toBe('https://fallback.example/abaixo-assinado/42')
+ })
+})
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 79cae388..0d11346d 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -151,3 +151,25 @@ describe('campaign formActions convention', () => {
expect(offenders, 'wrap with runCampaignFormAction/runCampaignRedirectFormAction').toEqual([])
})
})
+
+describe('public site metadata global access', () => {
+ // An empty `metadata` global (fresh DB / poisoned unstable_cache) used to
+ // crash `next build` via raw field access. Every `getCachedGlobal('metadata')`
+ // consumer must also call `resolveSiteMetadata` in the same file.
+ it('routes every metadata global consumer through resolveSiteMetadata', () => {
+ const offenders: string[] = []
+
+ for (const file of walkSourceFiles(resolve(repoRoot, 'src'), ['.ts', '.tsx'])) {
+ const path = repoPath(file)
+ if (path.startsWith('src/migrations/')) continue
+
+ const source = readFileSync(file, 'utf8')
+ if (!/getCachedGlobal\(\s*['"]metadata['"]\s*\)/.test(source)) continue
+ if (!/\bresolveSiteMetadata\b/.test(source)) {
+ offenders.push(path)
+ }
+ }
+
+ expect(offenders, 'import and call resolveSiteMetadata from @/utilities/seo').toEqual([])
+ })
+})
diff --git a/tests/unit/siteMetadata.unit.spec.ts b/tests/unit/siteMetadata.unit.spec.ts
new file mode 100644
index 00000000..9c2059f5
--- /dev/null
+++ b/tests/unit/siteMetadata.unit.spec.ts
@@ -0,0 +1,103 @@
+// @vitest-environment node
+
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { SITE_METADATA_DEFAULTS, absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
+
+const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL
+
+afterEach(() => {
+ if (originalSiteUrl === undefined) {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+ } else {
+ process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl
+ }
+})
+
+describe('resolveSiteMetadata', () => {
+ it('returns defaults and null siteUrl when the global is empty and env is unset', () => {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+
+ const resolved = resolveSiteMetadata({})
+
+ expect(resolved).toEqual({
+ siteUrl: null,
+ title: SITE_METADATA_DEFAULTS.title,
+ siteName: SITE_METADATA_DEFAULTS.siteName,
+ description: SITE_METADATA_DEFAULTS.description,
+ twitterCreator: SITE_METADATA_DEFAULTS.twitterCreator,
+ twitterDescription: SITE_METADATA_DEFAULTS.description,
+ keywords: [],
+ })
+ })
+
+ it('falls back to NEXT_PUBLIC_SITE_URL when the global has no URL', () => {
+ process.env.NEXT_PUBLIC_SITE_URL = 'https://example.com/'
+
+ const resolved = resolveSiteMetadata({})
+
+ expect(resolved.siteUrl).toBe('https://example.com')
+ })
+
+ it('prefers the global URL over the env fallback and strips trailing slashes', () => {
+ process.env.NEXT_PUBLIC_SITE_URL = 'https://env.example/'
+
+ const resolved = resolveSiteMetadata({ URL: 'https://global.example///' })
+
+ expect(resolved.siteUrl).toBe('https://global.example')
+ })
+
+ it('treats blank URL / env as missing', () => {
+ process.env.NEXT_PUBLIC_SITE_URL = ' '
+
+ expect(resolveSiteMetadata({ URL: ' ' }).siteUrl).toBeNull()
+ })
+
+ it('fills textual fields when openGraph / twitter / description are absent', () => {
+ delete process.env.NEXT_PUBLIC_SITE_URL
+
+ const resolved = resolveSiteMetadata({ URL: 'https://jorgesolla.com.br' })
+
+ expect(resolved.siteUrl).toBe('https://jorgesolla.com.br')
+ expect(resolved.siteName).toBe(SITE_METADATA_DEFAULTS.siteName)
+ expect(resolved.twitterCreator).toBe(SITE_METADATA_DEFAULTS.twitterCreator)
+ expect(resolved.description).toBe(SITE_METADATA_DEFAULTS.description)
+ })
+
+ it('preserves populated fields from a complete global', () => {
+ const resolved = resolveSiteMetadata({
+ URL: 'https://jorgesolla.com.br',
+ title: 'Site Title',
+ description: 'Site description',
+ keywords: [{ keyword: 'bahia' }, 'saude', { keyword: null }, null, ' '],
+ openGraph: { siteName: 'OG Name' },
+ twitter: { creator: '@creator', description: 'Twitter desc' },
+ })
+
+ expect(resolved).toEqual({
+ siteUrl: 'https://jorgesolla.com.br',
+ title: 'Site Title',
+ siteName: 'OG Name',
+ description: 'Site description',
+ twitterCreator: '@creator',
+ twitterDescription: 'Twitter desc',
+ keywords: ['bahia', 'saude'],
+ })
+ })
+
+ it('flattens null / empty keywords to an empty list', () => {
+ expect(resolveSiteMetadata({ keywords: null }).keywords).toEqual([])
+ expect(resolveSiteMetadata({ keywords: [] }).keywords).toEqual([])
+ })
+})
+
+describe('absoluteSitePath', () => {
+ it('returns undefined when siteUrl is null', () => {
+ expect(absoluteSitePath(null, '/noticia')).toBeUndefined()
+ })
+
+ it('joins origin and path without doubling slashes', () => {
+ expect(absoluteSitePath('https://example.com', '/noticia')).toBe('https://example.com/noticia')
+ expect(absoluteSitePath('https://example.com', 'noticia')).toBe('https://example.com/noticia')
+ })
+})