Make public SEO resilient when the metadata global is empty.

resolveSiteMetadata fails open on missing URL/text so next build no longer crashes on a fresh or poisoned metadata cache.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 08:19:33 -03:00
parent 4bbd526dfd
commit fa75c355a0
13 changed files with 476 additions and 102 deletions

File diff suppressed because one or more lines are too long

View file

@ -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).

View file

@ -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ó `<head>` / 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.

File diff suppressed because one or more lines are too long

View file

@ -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<RouteParams> })
}
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<RouteParams> })
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<RouteParams> })
<section className="mx-auto w-full max-w-3xl px-4 py-12 sm:px-6 lg:px-8 lg:py-14">
{bodyHTML ? (
<article
className="space-y-6 text-lg leading-8 text-foreground/90 [&_a]:font-medium [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-primary/40 [&_blockquote]:pl-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h2]:border-none [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:text-xl [&_h3]:font-semibold [&_img]:rounded-lg [&_li]:ml-5 [&_li]:list-disc [&_p]:text-lg [&_strong]:font-semibold [&_strong]:text-foreground"
className="space-y-6 text-lg leading-8 text-foreground/90 [&_a]:font-medium [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_h1]:text-3xl [&_h1]:font-bold [&_h2]:border-none [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:text-xl [&_h3]:font-semibold [&_img]:rounded-lg [&_li]:ml-5 [&_li]:list-disc [&_p]:text-lg [&_strong]:font-semibold [&_strong]:text-foreground"
dangerouslySetInnerHTML={{ __html: bodyHTML }}
/>
) : null}

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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<Article> = {
'@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 } : {}),
},
}

View file

@ -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<Metadata> {
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<Metadata> {
}
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<Metadata> {
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<Metadata> {
},
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] : [],
},
}

View file

@ -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),
})

View file

@ -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<typeof import('@/utilities/posts')>()
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')
})
})

View file

@ -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([])
})
})

View file

@ -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')
})
})