From 75e5825b05c928d300f705bdedec799c73937df2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 23 Sep 2026 14:04:43 -0300 Subject: [PATCH] =?UTF-8?q?refactor(S27-FOLLOWUP-DRY):=20dono=20=C3=BAnico?= =?UTF-8?q?=20do=20OG=20image=20de=20fallback=20do=20frontend=20p=C3=BAbli?= =?UTF-8?q?co=20(Closes=20#1276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O fallback de imagem OG (própria → global `metadata` → null, sempre absoluto no deployment origin) deixa de estar copiado em 6–7 páginas públicas e ganha o dono server-only `src/utilities/ogImageReads.ts` (`resolveOgImage(configured) → { url, media }`), com `resolveShareLinkOgImageUrl` delegando (sem twin) e os 8 consumidores migrados — 2 do S27, jingles, corte, abaixo-assinado ×2, artigo e layout. `seo.ts` segue puro; sem migration, sem Consent/access, Impeccable A. - unit novo do resolver (8 casos) + pin do módulo em codebaseConventions; int do shareLink verde sem tocar asserção; e2e frontendConteudos/frontendJingles/ campaignSpeechCut/frontendShareLink/frontend verdes, com asserts novos de og:image (helper `metaContent` extraído do spec do share-link) - dono novo entra no E2E_AFFECTED_MANIFEST nas 4 entradas de OG públicas - deltas intencionais registrados: deployment origin no lugar do canônico em corte/abaixo-assinado/artigo e OG absoluto no layout --- docs/changelog/2026-09-23-s27-followup-dry.md | 1 + docs/plans/escala-dry-pos-s27-impl.md | 153 ++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 10 ++ .../[type]/[category]/[slug]/page.tsx | 43 ++--- .../(frontend)/abaixo-assinado/[id]/page.tsx | 33 +--- .../(frontend)/conteudos/(catalog)/page.tsx | 29 +--- src/app/(frontend)/conteudos/[slug]/page.tsx | 31 +--- src/app/(frontend)/corte/[id]/page.tsx | 15 +- src/app/(frontend)/jingles/page.tsx | 29 +--- src/app/(frontend)/layout.tsx | 20 +-- src/utilities/ogImageReads.ts | 76 +++++++++ src/utilities/shareLinkReads.ts | 40 +---- tests/e2e/campaignSpeechCut.e2e.spec.ts | 6 + tests/e2e/frontendJingles.e2e.spec.ts | 7 + tests/e2e/frontendShareLink.e2e.spec.ts | 10 +- tests/helpers/metaContent.ts | 8 + tests/unit/codebaseConventions.unit.spec.ts | 4 + tests/unit/ogImage.unit.spec.ts | 143 ++++++++++++++++ 18 files changed, 463 insertions(+), 195 deletions(-) create mode 100644 docs/changelog/2026-09-23-s27-followup-dry.md create mode 100644 docs/plans/escala-dry-pos-s27-impl.md create mode 100644 src/utilities/ogImageReads.ts create mode 100644 tests/helpers/metaContent.ts create mode 100644 tests/unit/ogImage.unit.spec.ts diff --git a/docs/changelog/2026-09-23-s27-followup-dry.md b/docs/changelog/2026-09-23-s27-followup-dry.md new file mode 100644 index 00000000..a621802c --- /dev/null +++ b/docs/changelog/2026-09-23-s27-followup-dry.md @@ -0,0 +1 @@ +**Dono único do OG image de fallback do frontend público (S27-FOLLOWUP-DRY, 2026-09-23):** o fallback de imagem OG (imagem própria da página → imagem do global `metadata`, populando o id quando numérico → `null`) deixou de estar copiado em 6–7 páginas públicas e passou a viver num dono único server-only, `src/utilities/ogImageReads.ts` (`resolveOgImage(configured) → { url, media }`): a fonte própria (URL relativa, URL absoluta externa como o thumbnail do YouTube, ou `Media`/id cru de leitura depth-0) vence quando tem URL utilizável; senão o global resolve o id em `media`; a URL final é absoluta contra o origin de deploy (`resolveDeploymentOrigin`), nunca contra o domínio canônico do global — o proxy `/api/media/file/…` só existe no deployment e URL relativa o crawler do WhatsApp não resolve. O resolver reusa `resolveSiteMetadata`/`resolveDeploymentOrigin`/`toAbsoluteUrl` (o `seo.ts` segue puro, sem I/O) e devolve a `Media` escolhida junto da URL para os call sites que emitem width/height/alt/mimeType (artigo, petição ×2, layout). Migraram os 8 consumidores — os 2 do S27 (`/conteudos` e `/conteudos/`), `jingles`, `corte/[id]`, `abaixo-assinado/[id]` (metadata + JSON-LD), `[type]/[category]/[slug]` (o helper local morreu) e o `layout.tsx` — e `resolveShareLinkOgImageUrl` passou a delegar, sem twin; `artigos/page.tsx` ficou fora (fonte é `home.image`). Deltas intencionais: `corte`/`abaixo-assinado`/artigo absolutizam no deployment origin em vez do `siteUrl` canônico quando os dois divergem, e o `layout.tsx` deixa de emitir OG relativo; canonical e JSON-LD `url` seguem no canônico. Sem migration, sem Consent/access e sem mudança de superfície visual (Impeccable A). Verificação: unit novo do resolver (8 casos — sem imagem → `null`, própria relativa no origin de deploy, absoluta sem origin, `Media` própria, id cru, fallback objeto/id, `Media` sem url cai no global, relativa sem origin → `null`), int do shareLink verde sem tocar nas asserções (23 casos), e2e `frontendConteudos`/`frontendJingles`/`campaignSpeechCut`/`frontendShareLink`/`frontend` verdes (com asserts novos de `og:image` em jingles e corte, via `tests/helpers/metaContent.ts` extraído do spec do share-link), `gate:fast` (lint/typecheck/4421 unit), `pnpm test:int` (918) e `knip`/`check:cycles`/`format:check` limpos; o dono novo entrou no `E2E_AFFECTED_MANIFEST` nas 4 entradas de OG públicas. diff --git a/docs/plans/escala-dry-pos-s27-impl.md b/docs/plans/escala-dry-pos-s27-impl.md new file mode 100644 index 00000000..cc4622fc --- /dev/null +++ b/docs/plans/escala-dry-pos-s27-impl.md @@ -0,0 +1,153 @@ +# Impl: Escala e DRY pós-S27 — dono único do OG image de fallback do frontend público + +Status: aprovado +Atualizado em: 2026-09-23 +Issue: #1276 +Intenção: docs/plans/escala-dry-pos-s27.md +Appetite restante: herdado (~0,5 dia) +Aprovação: auto-aprovada pela sessão em modo `--auto` (work-issue) em 2026-09-23 — sem gate humano nesta etapa. + +## Leitura da intenção + +- **Outcome:** o fallback de imagem OG do frontend público passa a viver num dono único server-only — resolve a imagem configurada (quando houver), senão a imagem do global `metadata` (populando o id quando numérico), senão `null`, sempre absoluta contra o origin de deploy (`resolveDeploymentOrigin`) — e os 8 consumidores atuais (2 do S27 + pré-existentes) migram sem mudança observável nas URLs de OG. +- **O que NÃO negociar:** + - `src/utilities/seo.ts` permanece **puro** (sem I/O); o resolver com I/O nasce ao lado dos reads de global. + - `artigos/page.tsx` fica **fora** (fonte `home.image`, não o global). + - Sem migration; sem mudança de comportamento visível; `pnpm gate:fast` + int + e2e das rotas públicas verdes. + - Origin de deploy, **nunca** o domínio canônico do global, para URL de mídia/OG. + - `resolveShareLinkOgImageUrl` passa a delegar — sem twin. +- **O que reavaliar (hipóteses do explorador):** + - `{ url, media }` cobre os 3 formatos de own (string relativa, string absoluta externa, `Media`) e os 2 perfis de consumo (URL-only vs metadados). + - `layout.tsx` compartilha o fallback (é só-global, sem own) e hoje emite URL relativa. + - A delegação do shareLink não altera o int test (mesma URL em `https://site.test`, mesmo `null`, mesmo fallback). + - F3 não é viável para layout/abaixo-assinado sem seed do global `metadata` image — só dá para pinar own-image paths onde o e2e já semeia. + - Aceitar `number` na fonte configurada é robustez barata (paridade com o ramo do global) ou escopo a mais — decisão 1. + +## Abordagem recomendada + +```mermaid +flowchart LR + P["generateMetadata das páginas públicas"] --> R["resolveOgImage(configured)"] + R --> O{"own tem URL utilizável?"} + O -- sim --> A["absolutiza no deployment origin"] + O -- não --> G["getCachedGlobal('metadata')"] + G --> S["resolveSiteMetadata → siteUrl"] + S --> D["resolveDeploymentOrigin"] + D --> F{"global.image"} + F -- number --> M["getCachedDocumentById('media')"] + F -- Media --> N["Media"] + M --> U["absolutiza"] + N --> U + A --> V["{ url, media }"] + U --> V + V --> OG["openGraph.images / JSON-LD"] + L["resolveShareLinkOgImageUrl"] --> R +``` + +**Opções consideradas:** A | B | C +**Recomendação:** A — um dono novo (`src/utilities/ogImageReads.ts`, server-only) exportando `resolveOgImage(configured) → { url, media }`; `resolveShareLinkOgImageUrl` delega. Um read por página serve os dois consumidores, e é a única opção em que o fallback não pode divergir. +**Rejeitadas:** B (duas funções URL-only/Media-only) porque duplica a leitura do global/mídia em cada página e cria dois lugares para o fallback divergir; C (URL-only + resolução de `Media` por fora) porque reintroduz exatamente o bloco que o item veio apagar nos call sites que precisam de metadados (artigo, petição, layout) — twin. + +### Componentes / mudanças + +- **`resolveOgImage`** (`src/utilities/ogImageReads.ts`, novo): `import 'server-only'` + doc-comment; exporta `OgImageSource = Media | number | string | null | undefined` e `resolveOgImage(configured): Promise<{ url: string | null; media: Media | null }>`. Regra única: a fonte configurada vence quando tem URL utilizável; senão lê o global `metadata` (`getCachedGlobal`), resolve `number → getCachedDocumentById('media')`, e devolve `{ url: absolutizada | null, media }`. Reusa `resolveSiteMetadata`/`resolveDeploymentOrigin`/`toAbsoluteUrl` de `seo.ts`; `mediaOf`/`resolveMediaSource` internos (não exportados). O guard `codebaseConventions.unit.spec.ts:833-853` é satisfeito por construção (chama `resolveSiteMetadata` no mesmo arquivo). +- **`resolveShareLinkOgImageUrl`** (`src/utilities/shareLinkReads.ts:87-104`): vira `(await resolveOgImage(link.image)).url`; saem os imports de `getCachedDocumentById`, `getCachedGlobal`, `resolveDeploymentOrigin`, `resolveSiteMetadata`, `toAbsoluteUrl`, o `mediaOf` local e o tipo `Media`; a doc-comment origin-vs-canônico migra para o dono e o wrapper fica com um ponteiro curto. +- **Call sites migrados (F1):** `conteudos/(catalog)/page.tsx:47-100` (own = `firstImage.file.path`, string; alt `CATALOG_TITLE`) e `conteudos/[slug]/page.tsx:31-90` (own = `item.file.path` com o gate `mimeType.startsWith('image/')`; alt `item.title`). +- **Call sites migrados (F2):** `jingles/page.tsx:30-70` (own = `items[0].coverUrl`; alt `items[0]?.coverAlt ?? JINGLES_TITLE`); `corte/[id]/page.tsx:66-100` (own = `youtubeCoverUrl(cut)`, absoluta externa); `abaixo-assinado/[id]/page.tsx` nas duas cópias (`generateMetadata` e `Page`/JSON-LD; own = `extractFirstImageFromLexical(petition.body)`; metadados da `Media`); `[type]/[category]/[slug]/page.tsx:46-64,87,158` (deleta o helper local `resolveOgImage`/`Metadatum`; own = cover → body image, política do post fica no call site); `layout.tsx:20-68` (own = `null`; `url` absoluta + `media?.width/height/alt`). +- **Pin de convenções** (`tests/unit/codebaseConventions.unit.spec.ts:423-564`): registrar `'ogImageReads.ts'` em `pinnedTopLevel` com comentário-razão (dono único do OG fallback; irmão de `globalReads.ts`/`documentReads.ts`). +- **Teste novo (F1):** unit do resolver, mockando `@/utilities/globalReads`/`@/utilities/documentReads` por caminho de módulo (mesmo padrão de `tests/unit/siteMetadataFallback.unit.spec.ts`), cobrindo sem imagem → `null`; global numérico → `media` populada; URL relativa → absoluta no deployment origin; absoluta passa sem origin. +- **Migration:** nenhuma. +- **Access / Consent:** n/a — leitura pública read-only, nenhuma collection/access tocado, nenhum opt-in/PII. +- **UI:** n/a — Impeccable A; meta tags são ``, nenhuma superfície visual muda. + +### Dados → forma (se aplicável) + +n/a — nenhuma superfície de dados/KPI; só URLs de `og:image`/JSON-LD, já no formato atual. + +## Fases verificáveis + +1. **F1 — dono único + os 2 call sites do S27** (~60% do appetite). Criar `ogImageReads.ts` + pin + unit; migrar catálogo e peça; delegar `resolveShareLinkOgImageUrl`. Prova: unit do resolver verde; `frontendConteudos` (og:image da peça) verde; `tests/int/shareLink.int.spec.ts:385-432` verde sem tocar na asserção. +2. **F2 — migrar as cópias pré-existentes.** `jingles`, `corte/[id]`, `abaixo-assinado/[id]` (×2), `[type]/[category]/[slug]` e `layout.tsx`. Prova: `frontendJingles`/`campaignSpeechCut`/`frontend`/`frontendShareLink` verdes sem mudança de asserção. +3. **F3 — apertar a rede (primeira a cair se o appetite estourar).** Assert de `og:image` nos e2e que já cobrem a página e semeiam a fonte: `frontendJingles` (capa própria → absoluta em `/api/media/file/`) e `campaignSpeechCut` (thumbnail do YouTube). Nenhum teste novo além do unit de F1; layout/abaixo-assinado ficam sem assert (o global image não é semeado nos e2e). +4. **Gates.** `pnpm gate:fast`; `pnpm exec knip` (imports órfãos da delegação morrem no mesmo delivery); int + e2e citados; `pnpm push`; entrada em `docs/changelog/2026-09-23-s27-followup-dry.md`. + +## Rabbit holes / Não escopo (engenharia) + +- `artigos/page.tsx` (fonte `home.image`) — fora, como manda a intenção. +- Mover I/O para `seo.ts` (rejeitado: o módulo é puro por contrato) ou renomear/realocar `seo.ts`. +- Exportar `mediaOf`/`resolveMediaSource` do dono para reuso em `corte/[id]` (helper de 1 uso; ver decisão 5). +- Unificar `alt`, `secureUrl` ou dimensões entre call sites além do que cada um já emite — `alt` permanece no call site. +- `contentPieceReads` depth, `contentPieceDownloadFilename`, `firstValue`, wrapper WhatsApp, shell de jingles — defers já registrados na intenção. +- Adicionar campo ao global `metadata` (não existe `contentMedia`; schema intocado) ou seed do global image em e2e só para F3. +- Mexer em canonical/JSON-LD `url` (seguem no `siteUrl` canônico). + +## Riscos e mitigação + +- **Regressão silenciosa de URL OG em rotas sem e2e de og:image** (layout, abaixo-assinado): unit do resolver cobre os ramos; o int do shareLink pina a URL absoluta e o fallback; F3 adiciona assert de own-image onde o e2e já cobre. +- **Delta intencional origin vs canônico** em corte/abaixo-assinado/artigo (fallback/own absolutizados no deployment origin) e layout (relativa → absoluta): registrar na descrição do PR; nos testes os dois coincidem (`playwright.config.ts:290-296` injeta `NEXT_PUBLIC_SITE_URL` = `baseURL`/`CANONICAL_E2E_ORIGIN`) e as asserções pinam forma/path, não host. +- **`Media` própria sem `url`** passa a cair no fallback global (regra única), diferente do comportamento atual de petição/artigo nesse edge: registrado como decisão; nenhum teste depende do comportamento antigo. +- **Twin residual no `shareLinkReads`**: a delegação acontece no mesmo delivery; `knip` denuncia qualquer import órfão. +- **Guards de convenção** (`server-only`, pin, metadata→`resolveSiteMetadata`) quebram o CI se esquecidos: fazem parte do aceite de F1. +- **Estouro de appetite:** cortar F3 primeiro; se F2 revelar call site com política própria, ele fica fora em vez de ganhar flag. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto — dono único server-only com a assinatura configurada → global (number→media) → `null`, sempre no deployment origin; `artigos` fora. +- [ ] Invariantes AGENTS/engineering-standards — "Edit the owner, don't twin" (delegação do shareLink, sem twin); `server-only`; sem migration; sem `as never`; identificadores em inglês. +- [ ] Testes de domínio previstos — unit do resolver (sem imagem → `null`; global numérico → media populada; relativa → absoluta no origin); `tests/int/shareLink.int.spec.ts` verde sem mudar asserção; e2e `frontendConteudos`/`frontendJingles`/`campaignSpeechCut`/`frontend`/`frontendShareLink` verdes. +- [ ] Sem migration; sem access/Consent; Impeccable A; UI n/a. +- [ ] `pnpm gate:fast` + `pnpm exec knip` limpos; `pnpm push`; entrada em `docs/changelog/`. + +## Decisões de engenharia + +### 1. Dono e assinatura do resolver + +**Opções:** A) um dono novo `src/utilities/ogImageReads.ts` devolvendo `{ url, media }`, com `resolveShareLinkOgImageUrl` delegando | B) duas funções (URL-only + Media-only) com leitura duplicada | C) URL-only e cada call site de metadados resolve a `Media` por fora. + +**Recomendação:** A. `resolveOgImage(configured: Media | number | string | null | undefined)` devolve `{ url: string | null; media: Media | null }`, onde `media` é a `Media` resolvida quando a fonte escolhida é `Media`, senão `null`. Aceitar `number` na configurada é robustez deliberada: mesma defesa de depth-0/cache envenenado que o ramo do global já exige, custo de um `resolveMediaSource` interno (3 linhas) e evita a armadilha do próximo call site que passar um upload cru. Regra de escolha: "URL utilizável, senão fallback" — uma só regra para todos. +**Alternativas rejeitadas:** B porque dobra os reads de global/mídia por página e cria dois donos do fallback; C porque reintroduz o bloco duplicado nos call sites de metadados (artigo/petição/layout); D (aceitar `number` só no global) porque perde a defesa barata e mantém a armadilha para o próximo consumidor. Também rejeitada a preservação da divergência atual (petição/artigo não caem para o global quando a `Media` própria existe sem `url`; shareLink cai) — o edge só aparece com mídia quebrada e a regra única é o ponto do dono. + +### 2. Delegação de `resolveShareLinkOgImageUrl` + +**Opções:** A) delegar para o dono | B) manter o bloco atual em `shareLinkReads.ts` | C) mover a função inteira (com o tipo `ShareLink`) para o novo módulo. + +**Recomendação:** A. `resolveShareLinkOgImageUrl(link)` vira `(await resolveOgImage(link.image)).url`; `shareLinkReads.ts` deixa de importar `getCachedDocumentById`/`getCachedGlobal`/helpers de `seo.ts` e perde o `mediaOf` local; a doc-comment origin-vs-canônico passa ao dono e o wrapper fica com um ponteiro. O int test (`tests/int/shareLink.int.spec.ts:385-432`) permanece verde sem tocar na asserção: mesma URL `https://site.test...`, mesmo `null` sem default, mesmo fallback. +**Alternativas rejeitadas:** B é o twin que o item veio apagar; C puxa `ShareLink` e a semântica de card para o módulo de imagem — acoplamento sem ganho. + +### 3. Origin de deploy vs domínio canônico + +**Opções:** A) sempre `resolveDeploymentOrigin(siteUrl)` | B) manter `siteUrl` canônico nos call sites legados | C) usar o origin só quando o global "parecer externo". + +**Recomendação:** A. O proxy `/api/media/file/…` só existe no origin do deployment e OG exige URL absoluta. Deltas **intencionais e corretos**: `corte`/`abaixo-assinado`/`[type]/[category]/[slug]` absolutizam own/fallback no deployment origin quando `metadata.URL ≠ NEXT_PUBLIC_SITE_URL`; `layout.tsx` passa de relativa para absoluta. Canonical e JSON-LD `url` continuam no `siteUrl` canônico. Prova sem quebrar e2e: os specs injetam `NEXT_PUBLIC_SITE_URL` (`playwright.config.ts:290-296`) e as asserções de OG existentes pinam forma/path, não host. +**Alternativas rejeitadas:** B mantém o 404 latente no crawler quando o canônico é servido por outra plataforma (o motivo do comentário em `shareLinkReads.ts:77-86`); C é heurística sem contrato — o global é canônico por definição, o origin é o deployment. + +### 4. Own absoluta sem origin (YouTube/corte) + +**Opções:** A) absoluta passa; relativa exige origin | B) exigir origin sempre, mesmo para absoluta. + +**Recomendação:** A. `toAbsoluteUrl` já devolve absoluta intacta; a checagem extra é só "sem origin, relativa → `null` e cai no fallback". Preserva o thumbnail do YouTube do corte (o e2e `campaignSpeechCut` asserta `https://i.ytimg.com/...` no HTML) sem ramo extra. +**Alternativas rejeitadas:** B faria o thumbnail sumir num ambiente sem env, sem contrapartida de segurança (URL absoluta de terceiro não passa pelo proxy). + +### 5. `mediaOf` duplicado + +**Opções:** A) o dono tem `mediaOf` local e o de `corte/[id]` fica | B) exportar um `mediaOf` compartilhado do dono | C) absorver o de corte no dono. + +**Recomendação:** A, pelo depth check: o `mediaOf` de `shareLinkReads.ts:74` morre com a delegação; o de `corte/[id]/page.tsx:27` lê `cut.media` para o vídeo/filename da página — outra responsabilidade, outra assinatura. Exportar helper de 1 uso é cerimônia. +**Alternativas rejeitadas:** B acopla o player de corte ao módulo de OG por uma linha; C mistura duas políticas (mídia da página vs imagem de preview). + +### 6. Ordem das fases e corte + +**Opções:** A) F1 → F2 → F3, com F3 cortável | B) só F1+F2 | C) começar pelos pré-existentes. + +**Recomendação:** A. F1 entrega o dono e os call sites do S27 (o ROI e o gatilho do item); F2 é migração mecânica; F3 aperta a rede e é a primeira a cair se o appetite estourar. Se F2 revelar um call site com política própria (ex.: `artigos`), ele fica fora em vez de ganhar flag. +**Alternativas rejeitadas:** B perde a caracterização barata do OG nos e2e existentes; C adia a validação do contrato e deixa o S27 (motivo do item) por último. + +## Self-score (decision-quality) + +1. Decisões caras com rejeitadas? **Sim** — dono/assinatura, delegação, origin vs canônico, absoluta sem origin, `mediaOf` e ordem/corte, todas com rejeitadas; nada de access/schema/PII entra. +2. Cabe no appetite? **Sim** — ~0,5 dia: 1 módulo (~40 linhas), 8 consumidores migrados mecanicamente, 1 unit novo; sem migration. +3. Rabbit holes nomeados? **Sim** — `artigos`, I/O no `seo.ts`, seed do global para F3, helper exportado, unificação de `alt`/dimensões. +4. Depth check? **Sim** — reusa `getCachedGlobal`/`getCachedDocumentById`/`resolveSiteMetadata`/`resolveDeploymentOrigin`/`toAbsoluteUrl`; o novo módulo encapsula conhecimento que hoje vaza em 8 arquivos; nenhum pass-through novo. +5. Outcome do S27 intocado? **Sim** — só estrutura interna; nenhuma URL de OG muda nos e2e (os deltas de host são intencionais e registrados; canonical/JSON-LD intocados). + +**Score: 5/5.** diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index be1536cc..018ec246 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -91,6 +91,10 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/shareLink', 'src/lib/calendarEvent.ts', 'src/utilities/shareLinkReads.ts', + // S27-FOLLOWUP-DRY — the single owner of the public OG image fallback + // (delegated by the share-link resolver and the pages below), so a diff + // there must wake every spec that pins an og:image. + 'src/utilities/ogImageReads.ts', // The tag/revalidation vocabulary owner for the shareLinks listing. 'src/utilities/documents.ts', 'src/collections/ShareLink.ts', @@ -135,6 +139,8 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/jingle', 'src/lib/radio', 'src/utilities/jingleReads.ts', + // S27-FOLLOWUP-DRY — the owner of the page's og:image fallback. + 'src/utilities/ogImageReads.ts', // The tag/revalidation vocabulary owner for the jingles listing. 'src/utilities/documents.ts', 'src/collections/Jingle.ts', @@ -154,6 +160,8 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/lib/contentPieceCatalog', 'src/lib/contentPieceShare', 'src/utilities/content/contentPieceReads.ts', + // S27-FOLLOWUP-DRY — the owner of the page's og:image fallback. + 'src/utilities/ogImageReads.ts', // The tag/revalidation vocabulary owner for the content pieces listing. 'src/utilities/documents.ts', 'src/collections/ContentPiece.ts', @@ -452,6 +460,8 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/app/(frontend)/corte', 'src/components/SpeechCutShareActions.tsx', 'src/components/CopyLinkButton.tsx', + // S27-FOLLOWUP-DRY — the owner of the page's og:image fallback. + 'src/utilities/ogImageReads.ts', ], specs: ['campaignSpeechCut'], }, diff --git a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx index 7f7c6219..c8d4a258 100644 --- a/src/app/(frontend)/[type]/[category]/[slug]/page.tsx +++ b/src/app/(frontend)/[type]/[category]/[slug]/page.tsx @@ -2,9 +2,9 @@ import type { Crumb } from '@/components/SiteHeader' import { SiteHeader } from '@/components/SiteHeader' import { Badge } from '@/components/ui/Badge' import type { Media, Post } from '@/payload-types' -import { getCachedDocumentById } from '@/utilities/documentReads' import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical' import { getCachedGlobal } from '@/utilities/globalReads' +import { resolveOgImage } from '@/utilities/ogImageReads' import { POST_TYPE_LABELS, formatPostDate, @@ -18,7 +18,7 @@ import { isPostType, isPostVisible, } from '@/utilities/posts' -import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo' +import { absoluteSitePath, resolveSiteMetadata, truncate } from '@/utilities/seo' import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' import type { Metadata } from 'next' import Image from 'next/image' @@ -43,24 +43,15 @@ export async function generateStaticParams(): Promise { .filter((entry): entry is RouteParams => entry !== null) } -type Metadatum = Awaited>>> - -/** Resolve the OG image: cover image first, then first body image, then the global fallback. */ -async function resolveOgImage(post: Post, globalImage: Metadatum['image']): Promise { +/** + * The post's own OG image: a populated cover image first, then the first body + * image. A bare cover id (depth-0 read) is skipped, same as the previous + * policy — the chain stays cover → body → global fallback. + */ +const postOgImage = (post: Post): Media | null => { const cover = typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null - if (cover) return cover - - const bodyImage = post.body ? extractFirstImageFromLexical(post.body) : null - if (bodyImage) return bodyImage - - if (globalImage) { - return typeof globalImage === 'number' - ? await getCachedDocumentById('media', String(globalImage))() - : globalImage - } - - return null + return cover ?? (post.body ? extractFirstImageFromLexical(post.body) : null) } export async function generateMetadata({ @@ -84,8 +75,8 @@ export async function generateMetadata({ const canonicalPath = getPostCanonicalPath(post) const canonicalUrl = canonicalPath ? absoluteSitePath(siteUrl, canonicalPath) : undefined - const image = await resolveOgImage(post, globalMetadata.image) - const imageUrl = image?.url && siteUrl ? toAbsoluteUrl(image.url, siteUrl) : undefined + const image = await resolveOgImage(postOgImage(post)) + const imageUrl = image.url ?? undefined const description = post.subtitle ? truncate(post.subtitle, MAX_DESCRIPTION_LENGTH) @@ -100,10 +91,10 @@ export async function generateMetadata({ { url: imageUrl, secureUrl: imageUrl, - width: image?.width ?? undefined, - height: image?.height ?? undefined, - alt: image?.alt, - type: image?.mimeType ?? undefined, + width: image.media?.width ?? undefined, + height: image.media?.height ?? undefined, + alt: image.media?.alt, + type: image.media?.mimeType ?? undefined, }, ] : [] @@ -155,8 +146,8 @@ export default async function Page({ params }: { params: Promise }) const { siteUrl, siteName } = resolveSiteMetadata(globalMetadata) const canonicalUrl = absoluteSitePath(siteUrl, canonicalPath) - const ogImage = await resolveOgImage(post, globalMetadata.image) - const ogImageUrl = ogImage?.url && siteUrl ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined + const ogImage = await resolveOgImage(postOgImage(post)) + const ogImageUrl = ogImage.url ?? undefined const categoryName = getCategoryName(post) const categorySlug = getCategorySlug(post) diff --git a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx index 06fa5188..73c55872 100644 --- a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx +++ b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx @@ -3,11 +3,11 @@ import { MetaPixel } from '@/components/MetaPixel' import { PetitionForm } from '@/components/PetitionForm' import { SignatureCounter } from '@/components/SignatureCounter' import { normalizeFacebookPixelId } from '@/lib/facebookPixel' -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 { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata, truncate } from '@/utilities/seo' import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' import type { Metadata } from 'next' import Link from 'next/link' @@ -45,31 +45,22 @@ export async function generateMetadata({ const canonicalUrl = absoluteSitePath(siteUrl, `/abaixo-assinado/${petition.id}`) const bodyImage = extractFirstImageFromLexical(petition.body) - let fallbackImage: Media | null = null - if (!bodyImage && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - const image = bodyImage ?? fallbackImage + const { url: imageUrl, media } = await resolveOgImage(bodyImage) const description = truncate(petition.subtitle, MAX_DESCRIPTION_LENGTH) const title = `${petition.title} | ${siteName}` const keywords = [...baseKeywords, 'abaixo-assinado', 'petição', petition.title] - const imageUrl = image?.url && siteUrl ? toAbsoluteUrl(image.url, siteUrl) : undefined - const ogImages = imageUrl ? [ { url: imageUrl, secureUrl: imageUrl, - width: image?.width ?? undefined, - height: image?.height ?? undefined, - alt: image?.alt, - type: image?.mimeType ?? undefined, + width: media?.width ?? undefined, + height: media?.height ?? undefined, + alt: media?.alt, + type: media?.mimeType ?? undefined, }, ] : [] @@ -113,15 +104,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> const canonicalUrl = absoluteSitePath(siteUrl, `/abaixo-assinado/${petition.id}`) const bodyImage = extractFirstImageFromLexical(petition.body) - let fallbackImage: Media | null = null - if (!bodyImage && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - const ogImage = bodyImage ?? fallbackImage - const ogImageUrl = ogImage?.url && siteUrl ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined + const { url: ogImageUrl } = await resolveOgImage(bodyImage) const jsonLd: WithContext
= { '@context': 'https://schema.org', diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx index 94451d23..3173601c 100644 --- a/src/app/(frontend)/conteudos/(catalog)/page.tsx +++ b/src/app/(frontend)/conteudos/(catalog)/page.tsx @@ -17,17 +17,11 @@ import { type ContentPieceCatalogSearchParams, type ContentPiecePublicItem, } from '@/lib/contentPieceCatalog' -import type { Media } from '@/payload-types' import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads' -import { getCachedDocumentById } from '@/utilities/documentReads' import { getCachedGlobal } from '@/utilities/globalReads' import { hasPublishedJingles } from '@/utilities/jingleReads' -import { - absoluteSitePath, - resolveDeploymentOrigin, - resolveSiteMetadata, - toAbsoluteUrl, -} from '@/utilities/seo' +import { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' import type { Metadata } from 'next' const CATALOG_TITLE = 'Central de Conteúdos — Peça voto pra Solla 1313' @@ -51,27 +45,10 @@ export async function generateMetadata(): Promise { ]) const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) const canonicalUrl = absoluteSitePath(siteUrl, CONTENT_PIECE_CATALOG_PATH) - const deploymentOrigin = resolveDeploymentOrigin(siteUrl) const firstImage = items.find(isImagePiece) - const ownImageUrl = - firstImage?.file && deploymentOrigin - ? toAbsoluteUrl(firstImage.file.path, deploymentOrigin) - : undefined + const { url: imageUrl } = await resolveOgImage(firstImage?.file?.path) - let fallbackImage: Media | null = null - if (!ownImageUrl && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - - const imageUrl = - ownImageUrl ?? - (fallbackImage?.url && deploymentOrigin - ? toAbsoluteUrl(fallbackImage.url, deploymentOrigin) - : undefined) const title = `${CATALOG_TITLE} | ${siteName}` const images = imageUrl ? [{ url: imageUrl, alt: CATALOG_TITLE }] : [] diff --git a/src/app/(frontend)/conteudos/[slug]/page.tsx b/src/app/(frontend)/conteudos/[slug]/page.tsx index ddc1b3fc..8892f824 100644 --- a/src/app/(frontend)/conteudos/[slug]/page.tsx +++ b/src/app/(frontend)/conteudos/[slug]/page.tsx @@ -2,18 +2,11 @@ import { CampaignFooter } from '@/components/CampaignFooter' import { ContentPieceDetail } from '@/components/conteudos/ContentPieceDetail' import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader' import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog' -import type { Media } from '@/payload-types' import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads' -import { getCachedDocumentById } from '@/utilities/documentReads' import { getCachedGlobal } from '@/utilities/globalReads' import { hasPublishedJingles } from '@/utilities/jingleReads' -import { - absoluteSitePath, - resolveDeploymentOrigin, - resolveSiteMetadata, - toAbsoluteUrl, - truncate, -} from '@/utilities/seo' +import { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata, truncate } from '@/utilities/seo' import type { Metadata } from 'next' import { notFound } from 'next/navigation' @@ -42,26 +35,10 @@ export async function generateMetadata({ const globalMetadata = await getCachedGlobal('metadata')() const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) const canonicalUrl = absoluteSitePath(siteUrl, item.publicPath) - const deploymentOrigin = resolveDeploymentOrigin(siteUrl) - const ownImageUrl = - item.file?.mimeType?.startsWith('image/') && deploymentOrigin - ? toAbsoluteUrl(item.file.path, deploymentOrigin) - : undefined + const ownImage = item.file?.mimeType?.startsWith('image/') ? item.file.path : null + const { url: imageUrl } = await resolveOgImage(ownImage) - let fallbackImage: Media | null = null - if (!ownImageUrl && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - - const imageUrl = - ownImageUrl ?? - (fallbackImage?.url && deploymentOrigin - ? toAbsoluteUrl(fallbackImage.url, deploymentOrigin) - : undefined) const title = `${item.title} | ${siteName}` const description = truncate( item.description ?? fallbackDescription(item.title), diff --git a/src/app/(frontend)/corte/[id]/page.tsx b/src/app/(frontend)/corte/[id]/page.tsx index 603c914f..003183d0 100644 --- a/src/app/(frontend)/corte/[id]/page.tsx +++ b/src/app/(frontend)/corte/[id]/page.tsx @@ -11,7 +11,8 @@ import { speechCoverUrl } from '@/lib/speechVod' import type { Media, Speech, SpeechCut } from '@/payload-types' import { getCachedDocumentById, isNotFoundError } from '@/utilities/documentReads' import { getCachedGlobal } from '@/utilities/globalReads' -import { absoluteSitePath, resolveSiteMetadata, toAbsoluteUrl, truncate } from '@/utilities/seo' +import { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata, truncate } from '@/utilities/seo' import type { Metadata } from 'next' import { notFound } from 'next/navigation' @@ -77,18 +78,8 @@ export async function generateMetadata({ const globalMetadata = await getCachedGlobal('metadata')() const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) const canonicalUrl = absoluteSitePath(siteUrl, speechCutPublicPath(cut.id)) - const youtubeCover = youtubeCoverUrl(cut) - let fallbackImage: Media | null = null - if (!youtubeCover && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - const imageUrl = - youtubeCover ?? - (fallbackImage?.url && siteUrl ? toAbsoluteUrl(fallbackImage.url, siteUrl) : undefined) + const { url: imageUrl } = await resolveOgImage(youtubeCoverUrl(cut)) const title = `${cut.title} | ${siteName}` const description = truncate(cut.description, MAX_DESCRIPTION_LENGTH) const images = imageUrl ? [{ url: imageUrl, alt: cut.title }] : [] diff --git a/src/app/(frontend)/jingles/page.tsx b/src/app/(frontend)/jingles/page.tsx index 97142707..deb0b7a8 100644 --- a/src/app/(frontend)/jingles/page.tsx +++ b/src/app/(frontend)/jingles/page.tsx @@ -3,17 +3,11 @@ import { CampaignPageHeader } from '@/components/CampaignPageHeader' import { JingleEmptyState } from '@/components/jingles/JingleEmptyState' import { JingleIntro } from '@/components/jingles/JingleIntro' import { JinglePlayer } from '@/components/jingles/JinglePlayer' -import type { Media } from '@/payload-types' import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads' -import { getCachedDocumentById } from '@/utilities/documentReads' import { getCachedGlobal } from '@/utilities/globalReads' import { getPublishedJingleItems } from '@/utilities/jingleReads' -import { - absoluteSitePath, - resolveDeploymentOrigin, - resolveSiteMetadata, - toAbsoluteUrl, -} from '@/utilities/seo' +import { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' import type { Metadata } from 'next' const JINGLES_PATH = '/jingles' @@ -34,26 +28,9 @@ export async function generateMetadata(): Promise { ]) const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) const canonicalUrl = absoluteSitePath(siteUrl, JINGLES_PATH) - const deploymentOrigin = resolveDeploymentOrigin(siteUrl) - // The media proxy only exists on the deployment origin (the global URL may be - // a canonical domain served elsewhere), same rule as the share-link OG image. - const coverUrl = - items[0] && deploymentOrigin ? toAbsoluteUrl(items[0].coverUrl, deploymentOrigin) : undefined + const { url: imageUrl } = await resolveOgImage(items[0]?.coverUrl) - let fallbackImage: Media | null = null - if (!coverUrl && globalMetadata.image) { - fallbackImage = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - } - - const imageUrl = - coverUrl ?? - (fallbackImage?.url && deploymentOrigin - ? toAbsoluteUrl(fallbackImage.url, deploymentOrigin) - : undefined) const title = `${JINGLES_TITLE} | ${siteName}` const images = imageUrl ? [{ url: imageUrl, alt: items[0]?.coverAlt ?? JINGLES_TITLE }] : [] diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index a39a4329..f838aa01 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -1,8 +1,8 @@ import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave' import { ThemeProvider } from '@/components/ThemeProvider' import { isStagingSite } from '@/lib/siteEnvironment' -import { getCachedDocumentById } from '@/utilities/documentReads' import { getCachedGlobal } from '@/utilities/globalReads' +import { resolveOgImage } from '@/utilities/ogImageReads' import { resolveSiteMetadata } from '@/utilities/seo' import { Analytics } from '@vercel/analytics/next' import { SpeedInsights } from '@vercel/speed-insights/next' @@ -22,11 +22,7 @@ export async function generateMetadata(): Promise { const { siteUrl, title, description, siteName, twitterCreator, twitterDescription, keywords } = resolveSiteMetadata(payload) - let image = payload.image - - if (typeof image === 'number') { - image = await getCachedDocumentById('media', String(payload.image))() - } + const { url: imageUrl, media } = await resolveOgImage(null) return { title, @@ -46,13 +42,13 @@ export async function generateMetadata(): Promise { siteName, title, description, - images: image?.url + images: imageUrl ? [ { - url: image.url, - width: image.width!, - height: image.height!, - alt: image.alt, + url: imageUrl, + width: media?.width ?? undefined, + height: media?.height ?? undefined, + alt: media?.alt, }, ] : [], @@ -62,7 +58,7 @@ export async function generateMetadata(): Promise { title, description: twitterDescription, creator: twitterCreator, - images: image?.url ? [image.url] : [], + images: imageUrl ? [imageUrl] : [], }, } } diff --git a/src/utilities/ogImageReads.ts b/src/utilities/ogImageReads.ts new file mode 100644 index 00000000..d8ac4040 --- /dev/null +++ b/src/utilities/ogImageReads.ts @@ -0,0 +1,76 @@ +import 'server-only' + +import type { Media } from '@/payload-types' +import { getCachedDocumentById } from '@/utilities/documentReads' +import { getCachedGlobal } from '@/utilities/globalReads' +import { resolveDeploymentOrigin, resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo' + +/** + * A page's own OG image: an upload (`Media`), a bare upload id (depth-0 reads + * and poisoned cache entries) or an already-resolved URL string. + */ +export type OgImageSource = Media | number | string | null | undefined + +export type ResolvedOgImage = { + /** + * Absolute URL — app paths resolved on the deployment origin; an + * already-absolute URL (e.g. a YouTube thumbnail) passes through. `null` + * when nothing resolved. + */ + url: string | null + /** The chosen `Media` when the source is an upload; `null` for URL sources. */ + media: Media | null +} + +const mediaOf = (value: OgImageSource): Media | null => + typeof value === 'object' && value !== null ? value : null + +const resolveMediaSource = async (value: OgImageSource): Promise => + typeof value === 'number' ? getCachedDocumentById('media', String(value))() : mediaOf(value) + +/** + * Absolute URL on the deployment origin. An already-absolute URL (e.g. a + * YouTube thumbnail) passes through; a relative path needs an origin — never a + * relative URL the WhatsApp crawler cannot resolve. + */ +const absolutize = (url: string | null | undefined, origin: string | null): string | null => { + const trimmed = url?.trim() + if (!trimmed) return null + if (origin) return toAbsoluteUrl(trimmed, origin) + return /^https?:\/\//i.test(trimmed) ? trimmed : null +} + +/** + * Single owner of the public frontend's OG image fallback: the page's own + * image when it has a usable URL, otherwise the site default image of the + * `metadata` global (resolving the id when the read came back at depth 0). + * + * Media lives behind this app's `/api/media/file/…` proxy, so asset URLs are + * built on the deployment origin (`NEXT_PUBLIC_SITE_URL`), never on the + * `metadata` global `URL`: the global can be a canonical domain served by + * another platform (today the legacy WordPress), which 404s the proxy path and + * makes WhatsApp drop the thumbnail. The global `siteUrl` is the fallback when + * the env is missing. + * + * `media` is the resolved upload when the chosen source is one, so call sites + * that emit width/height/alt/mimeType keep the full document; URL-only sources + * return `media: null`. Canonical and JSON-LD `url` are NOT this module's + * business — they keep the global-first chain of `resolveSiteMetadata`. + */ +export const resolveOgImage = async (configured: OgImageSource): Promise => { + const globalMetadata = await getCachedGlobal('metadata')() + const { siteUrl } = resolveSiteMetadata(globalMetadata) + const deploymentOrigin = resolveDeploymentOrigin(siteUrl) + + const configuredMedia = await resolveMediaSource(configured) + const configuredUrl = absolutize( + typeof configured === 'string' ? configured : configuredMedia?.url, + deploymentOrigin, + ) + if (configuredUrl) return { url: configuredUrl, media: configuredMedia } + + const fallbackMedia = await resolveMediaSource(globalMetadata.image) + const fallbackUrl = absolutize(fallbackMedia?.url, deploymentOrigin) + + return { url: fallbackUrl, media: fallbackUrl ? fallbackMedia : null } +} diff --git a/src/utilities/shareLinkReads.ts b/src/utilities/shareLinkReads.ts index af5d48b3..eb66d02c 100644 --- a/src/utilities/shareLinkReads.ts +++ b/src/utilities/shareLinkReads.ts @@ -1,15 +1,13 @@ import 'server-only' import { resolveLiveShareLinkDestination, type ShareLinkLiveTarget } from '@/lib/shareLink' -import { getCachedDocumentById } from '@/utilities/documentReads' import { getCollectionListingTag } from '@/utilities/documents' -import { getCachedGlobal } from '@/utilities/globalReads' -import { resolveDeploymentOrigin, resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo' +import { resolveOgImage } from '@/utilities/ogImageReads' import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' -import type { Media, ShareLink } from '@/payload-types' +import type { ShareLink } from '@/payload-types' // Config-dependent cached read of a published share link by slug. Kept apart // from `documents.ts` (the tag vocabulary/revalidation contract collections @@ -71,34 +69,12 @@ export const loadPublishedShareLinkLiveTarget = async ( return resolveLiveShareLinkDestination(link.destinations) } -const mediaOf = (value: Media | number | null | undefined): Media | null => - typeof value === 'object' && value !== null ? value : null - /** * The card image: the link's own upload first; without it the site default - * image of the `metadata` global — never a relative URL the WhatsApp crawler - * cannot resolve. Media lives behind this app's `/api/media/file/…` proxy, so - * the absolute URL is built on the deployment origin (`NEXT_PUBLIC_SITE_URL`), - * not on the `metadata` global `URL`: the global can be a canonical domain - * served by another platform (today the legacy WordPress), which 404s the - * proxy path and makes WhatsApp drop the thumbnail. The global `siteUrl` is - * the fallback when the env is missing. + * image of the `metadata` global. Delegates to the single OG owner + * (`ogImageReads.ts`), which absolutizes on the deployment origin — never on + * the global `URL` (a canonical domain served elsewhere 404s the media proxy + * and makes WhatsApp drop the thumbnail). */ -export const resolveShareLinkOgImageUrl = async (link: ShareLink): Promise => { - const globalMetadata = await getCachedGlobal('metadata')() - const { siteUrl } = resolveSiteMetadata(globalMetadata) - const origin = resolveDeploymentOrigin(siteUrl) - if (!origin) return null - - const configured = mediaOf(link.image)?.url - if (configured) return toAbsoluteUrl(configured, origin) - - const fallback = - typeof globalMetadata.image === 'number' - ? await getCachedDocumentById('media', String(globalMetadata.image))() - : globalMetadata.image - const fallbackUrl = mediaOf(fallback)?.url - if (!fallbackUrl) return null - - return toAbsoluteUrl(fallbackUrl, origin) -} +export const resolveShareLinkOgImageUrl = async (link: ShareLink): Promise => + (await resolveOgImage(link.image)).url diff --git a/tests/e2e/campaignSpeechCut.e2e.spec.ts b/tests/e2e/campaignSpeechCut.e2e.spec.ts index 7b8ad3ba..9c2fa47f 100644 --- a/tests/e2e/campaignSpeechCut.e2e.spec.ts +++ b/tests/e2e/campaignSpeechCut.e2e.spec.ts @@ -4,6 +4,7 @@ import type { APIRequestContext } from '@playwright/test' import { request as playwrightRequest } from '@playwright/test' import { adminHeaders } from '../helpers/adminApi' +import { metaContent } from '../helpers/metaContent' import { seedTestUser } from '../helpers/seedUser' import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js' import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js' @@ -153,6 +154,11 @@ test.describe('Acervo speech cuts (C167)', () => { // body gradient this page used to inherit; the attribute is the guard. expect(html).toContain('data-theme="editorial"') expect(html).toContain('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg') + // S27-FOLLOWUP-DRY — the OG meta carries the same absolute thumbnail + // (the cut's own image wins over the global fallback) via the single owner. + expect(metaContent(html, 'property', 'og:image')).toBe( + 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg', + ) expect(html).toContain('Ver sessão no YouTube') } finally { await anonymous.dispose() diff --git a/tests/e2e/frontendJingles.e2e.spec.ts b/tests/e2e/frontendJingles.e2e.spec.ts index 0e0d7272..a5644e65 100644 --- a/tests/e2e/frontendJingles.e2e.spec.ts +++ b/tests/e2e/frontendJingles.e2e.spec.ts @@ -158,6 +158,13 @@ test.describe('Frontend jingles (S21)', () => { await expect(page.getByText('Jingles oficiais')).toBeVisible() await expect(page.locator('article[data-jingle]')).toHaveCount(2) + // S27-FOLLOWUP-DRY — the OG preview uses the first cover through the + // single owner: absolute on the deployment origin, never a relative path. + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + 'content', + /^https?:\/\/.+\/api\/media\/file\//, + ) + // The card never fetches the audio before the play (gate note). await expect(page.locator('audio[preload="none"]')).toHaveCount(2) expect(audioRequests).toHaveLength(0) diff --git a/tests/e2e/frontendShareLink.e2e.spec.ts b/tests/e2e/frontendShareLink.e2e.spec.ts index edb1b000..3f87549f 100644 --- a/tests/e2e/frontendShareLink.e2e.spec.ts +++ b/tests/e2e/frontendShareLink.e2e.spec.ts @@ -11,6 +11,7 @@ import { } from '../../src/lib/campaignTime.js' import { formatICalDate } from '../../src/lib/ical.js' import { adminHeaders } from '../helpers/adminApi' +import { metaContent } from '../helpers/metaContent' import { seedTestUser } from '../helpers/seedUser' import { expect, test } from './fixtures/e2eTest' @@ -67,15 +68,6 @@ type ShareLinkInput = { image?: number } -/** Reads a meta tag by one attribute and returns its `content` (attribute-order tolerant). */ -const metaContent = (html: string, attribute: string, value: string): string | null => { - for (const tag of html.match(/]*>/g) ?? []) { - if (!tag.includes(`${attribute}="${value}"`)) continue - return tag.match(/content="([^"]*)"/)?.[1] ?? null - } - return null -} - const createMedia = async ( request: APIRequestContext, headers: Record, diff --git a/tests/helpers/metaContent.ts b/tests/helpers/metaContent.ts new file mode 100644 index 00000000..e9cbe9ff --- /dev/null +++ b/tests/helpers/metaContent.ts @@ -0,0 +1,8 @@ +/** Reads a meta tag by one attribute and returns its `content` (attribute-order tolerant). */ +export const metaContent = (html: string, attribute: string, value: string): string | null => { + for (const tag of html.match(/]*>/g) ?? []) { + if (!tag.includes(`${attribute}="${value}"`)) continue + return tag.match(/content="([^"]*)"/)?.[1] ?? null + } + return null +} diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 3c4830cf..435e2023 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -521,6 +521,10 @@ describe('src/utilities top-level is pinned', () => { // OPS52 — media storage env resolution; shared by payload.config.ts // (fail-closed S3/Garage switch) and its unit spec. 'mediaStorage.ts', + // S27-FOLLOWUP-DRY — single owner of the public frontend's OG image + // fallback (own upload/URL first, then the `metadata` global, absolutized + // on the deployment origin); the sibling of `globalReads.ts`. + 'ogImageReads.ts', 'onda0Provision.ts', 'organizationData.ts', 'payloadTransaction.ts', diff --git a/tests/unit/ogImage.unit.spec.ts b/tests/unit/ogImage.unit.spec.ts new file mode 100644 index 00000000..782bd278 --- /dev/null +++ b/tests/unit/ogImage.unit.spec.ts @@ -0,0 +1,143 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getCachedGlobal: vi.fn(), + getCachedDocumentById: vi.fn(), +})) + +vi.mock('@/utilities/globalReads', () => ({ + getCachedGlobal: mocks.getCachedGlobal, +})) + +vi.mock('@/utilities/documentReads', () => ({ + getCachedDocumentById: mocks.getCachedDocumentById, +})) + +import type { Media } from '@/payload-types' +import { resolveOgImage } from '@/utilities/ogImageReads' + +const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL + +const media = (overrides: Partial = {}): Media => + ({ + id: 9, + alt: 'Imagem padrão do site', + url: '/api/media/file/site-default.png', + width: 1200, + height: 630, + updatedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }) as Media + +describe('resolveOgImage (single owner of the public OG fallback)', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getCachedGlobal.mockReturnValue(async () => ({})) + mocks.getCachedDocumentById.mockReturnValue(async () => null) + process.env.NEXT_PUBLIC_SITE_URL = 'https://deploy.test' + }) + + afterEach(() => { + if (originalSiteUrl === undefined) { + delete process.env.NEXT_PUBLIC_SITE_URL + } else { + process.env.NEXT_PUBLIC_SITE_URL = originalSiteUrl + } + }) + + it('resolves nothing when neither the page nor the global has an image', async () => { + expect(await resolveOgImage(null)).toEqual({ url: null, media: null }) + }) + + it('absolutizes a relative own URL on the deployment origin, never on the canonical global URL', async () => { + mocks.getCachedGlobal.mockReturnValue(async () => ({ + URL: 'https://canonical.example', + image: media(), + })) + + expect(await resolveOgImage('/conteudos/peca/midia')).toEqual({ + url: 'https://deploy.test/conteudos/peca/midia', + media: null, + }) + }) + + it('keeps an already absolute own URL even without a deployment origin', async () => { + delete process.env.NEXT_PUBLIC_SITE_URL + + expect(await resolveOgImage('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')).toEqual({ + url: 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg', + media: null, + }) + }) + + it('resolves an own Media upload and returns the document alongside the URL', async () => { + const own = media({ id: 11, url: '/api/media/file/peca.png' }) + + expect(await resolveOgImage(own)).toEqual({ + url: 'https://deploy.test/api/media/file/peca.png', + media: own, + }) + }) + + it('populates an own bare id (depth-0 read) like the global fallback does', async () => { + const own = media({ id: 11, url: '/api/media/file/peca.png' }) + mocks.getCachedDocumentById.mockReturnValue(async () => own) + + expect(await resolveOgImage(own.id)).toEqual({ + url: 'https://deploy.test/api/media/file/peca.png', + media: own, + }) + expect(mocks.getCachedDocumentById).toHaveBeenCalledWith('media', String(own.id)) + }) + + it('falls back to the global media object when the page has no own image', async () => { + const fallback = media() + mocks.getCachedGlobal.mockReturnValue(async () => ({ + URL: 'https://canonical.example', + image: fallback, + })) + + expect(await resolveOgImage(undefined)).toEqual({ + url: 'https://deploy.test/api/media/file/site-default.png', + media: fallback, + }) + }) + + it('populates the global fallback when the read came back with a bare id', async () => { + const fallback = media() + mocks.getCachedGlobal.mockReturnValue(async () => ({ + URL: 'https://canonical.example', + image: fallback.id, + })) + mocks.getCachedDocumentById.mockReturnValue(async () => fallback) + + expect(await resolveOgImage(null)).toEqual({ + url: 'https://deploy.test/api/media/file/site-default.png', + media: fallback, + }) + expect(mocks.getCachedDocumentById).toHaveBeenCalledWith('media', String(fallback.id)) + }) + + it('falls back to the global when the own Media has no usable URL', async () => { + const fallback = media() + mocks.getCachedGlobal.mockReturnValue(async () => ({ + URL: 'https://canonical.example', + image: fallback, + })) + + expect(await resolveOgImage(media({ url: null }))).toEqual({ + url: 'https://deploy.test/api/media/file/site-default.png', + media: fallback, + }) + }) + + it('drops a relative image when there is no origin at all — never a broken preview', async () => { + delete process.env.NEXT_PUBLIC_SITE_URL + mocks.getCachedGlobal.mockReturnValue(async () => ({ image: media() })) + + expect(await resolveOgImage(null)).toEqual({ url: null, media: null }) + }) +})