mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
refactor(S27-FOLLOWUP-DRY): dono único do OG image de fallback do frontend público (Closes #1276)
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
This commit is contained in:
parent
48a01de58b
commit
75e5825b05
18 changed files with 463 additions and 195 deletions
1
docs/changelog/2026-09-23-s27-followup-dry.md
Normal file
1
docs/changelog/2026-09-23-s27-followup-dry.md
Normal file
|
|
@ -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/<slug>`), `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.
|
||||
153
docs/plans/escala-dry-pos-s27-impl.md
Normal file
153
docs/plans/escala-dry-pos-s27-impl.md
Normal file
|
|
@ -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 `<head>`, 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.**
|
||||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<RouteParams[]> {
|
|||
.filter((entry): entry is RouteParams => entry !== null)
|
||||
}
|
||||
|
||||
type Metadatum = Awaited<ReturnType<ReturnType<typeof getCachedGlobal<'metadata'>>>>
|
||||
|
||||
/** Resolve the OG image: cover image first, then first body image, then the global fallback. */
|
||||
async function resolveOgImage(post: Post, globalImage: Metadatum['image']): Promise<Media | null> {
|
||||
/**
|
||||
* 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<RouteParams> })
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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<Article> = {
|
||||
'@context': 'https://schema.org',
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
|||
])
|
||||
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 }] : []
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }] : []
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
|||
])
|
||||
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 }] : []
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
|||
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<Metadata> {
|
|||
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<Metadata> {
|
|||
title,
|
||||
description: twitterDescription,
|
||||
creator: twitterCreator,
|
||||
images: image?.url ? [image.url] : [],
|
||||
images: imageUrl ? [imageUrl] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
src/utilities/ogImageReads.ts
Normal file
76
src/utilities/ogImageReads.ts
Normal file
|
|
@ -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<Media | null> =>
|
||||
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<ResolvedOgImage> => {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<string | null> => {
|
||||
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<string | null> =>
|
||||
(await resolveOgImage(link.image)).url
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(/<meta\b[^>]*>/g) ?? []) {
|
||||
if (!tag.includes(`${attribute}="${value}"`)) continue
|
||||
return tag.match(/content="([^"]*)"/)?.[1] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const createMedia = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
|
|
|
|||
8
tests/helpers/metaContent.ts
Normal file
8
tests/helpers/metaContent.ts
Normal file
|
|
@ -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(/<meta\b[^>]*>/g) ?? []) {
|
||||
if (!tag.includes(`${attribute}="${value}"`)) continue
|
||||
return tag.match(/content="([^"]*)"/)?.[1] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
143
tests/unit/ogImage.unit.spec.ts
Normal file
143
tests/unit/ogImage.unit.spec.ts
Normal file
|
|
@ -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> = {}): 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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue