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:
parent
4bbd526dfd
commit
fa75c355a0
13 changed files with 476 additions and 102 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
54
docs/plans/build-resiliente-metadata-ausente.md
Normal file
54
docs/plans/build-resiliente-metadata-ausente.md
Normal 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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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] : [],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
133
tests/int/siteMetadataFallback.int.spec.ts
Normal file
133
tests/int/siteMetadataFallback.int.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
103
tests/unit/siteMetadata.unit.spec.ts
Normal file
103
tests/unit/siteMetadata.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue