fix(S19): imagem do cartão no origem do deploy, não no domínio canônico

O resolveShareLinkOgImageUrl absolutizava /api/media/file/… contra o siteUrl do global metadata (hoje https://jorgesolla.com.br, servido pelo WordPress antigo), que responde 404 no proxy de mídia — o WhatsApp lia título/descrição mas derrubava a miniatura (caso real do cartão plenaria-cultura, da plenária de 19/09).

- resolveDeploymentOrigin (novo em seo.ts): NEXT_PUBLIC_SITE_URL primeiro (inlinado no build de produção, mesmo carrier do isStagingSite), fallback no URL do global; canonical/og:url seguem a cadeia global-first documentada — só a URL do asset muda.
- Unit do resolver (env vence/fallback/vazio); int pina o env sobre um URL de global estrangeiro.
This commit is contained in:
Francisco Solla 2026-09-19 16:08:27 -03:00
parent 5be9a1af4d
commit 63e75776d7
5 changed files with 64 additions and 9 deletions

View file

@ -0,0 +1 @@
**S19 — Cartão de compartilhamento: imagem no origem do deploy (2026-09-19):** o `resolveShareLinkOgImageUrl` passa a absolutizar a mídia (`/api/media/file/…`, o proxy servido pelo próprio app) contra `resolveDeploymentOrigin` — novo em `src/utilities/seo.ts`, com `NEXT_PUBLIC_SITE_URL` na frente (o build de produção o inlina, mesmo carrier do `isStagingSite`) e fallback no `siteUrl` do global `metadata` —, nunca contra o domínio canônico do global. Motivo: o global aponta para `https://jorgesolla.com.br`, hoje servido pelo WordPress antigo, que responde 404 no caminho da mídia; o crawler do WhatsApp lia título/descrição do HTML de `jorgesolla1313.com.br/plenaria-cultura` mas não encontrava a imagem, e o cartão saía só com texto (caso real da Plenária da Cultura de 19/09). Canonical e `og:url` seguem a cadeia global-first documentada em `docs/plans/build-resiliente-metadata-ausente.md`; só a URL do asset muda. Cobertura: unit do novo resolver (env vence, fallback, vazio) e int pinando o env sobre um `URL` de global estrangeiro. Sem migration, Consent ou access.

View file

@ -58,6 +58,23 @@ const resolveSiteUrl = (source: SiteMetadataSource): string | null => {
return null
}
/**
* Public origin of the running deployment — the only host guaranteed to serve
* this app's own asset paths (`/api/media/file/…`, the media proxy). Real
* deployments always carry `NEXT_PUBLIC_SITE_URL` (staging/production fail the
* deploy without it; worktrees and CI set their local origin; the production
* build inlines it, same carrier as `isStagingSite`), while the `metadata`
* global `URL` can be a canonical content domain served by another platform.
* Asset URLs (OG images) must use this origin; canonical URLs keep the
* global-first chain of `resolveSiteMetadata`.
*/
export const resolveDeploymentOrigin = (fallbackSiteUrl: string | null): string | null => {
const fromEnv = nonEmpty(process.env.NEXT_PUBLIC_SITE_URL)
if (fromEnv) return stripTrailingSlash(fromEnv)
return fallbackSiteUrl
}
const flattenKeywords = (source: SiteMetadataSource): string[] =>
(source.keywords ?? [])
.map((entry) => nonEmpty(typeof entry === 'string' ? entry : entry?.keyword))

View file

@ -3,7 +3,7 @@ import 'server-only'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCollectionListingTag } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globalReads'
import { resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo'
import { resolveDeploymentOrigin, resolveSiteMetadata, toAbsoluteUrl } from '@/utilities/seo'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
@ -50,22 +50,28 @@ const mediaOf = (value: Media | number | null | undefined): Media | 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. Same fallback contract as `/corte/[id]` and the root layout;
* `siteUrl` comes from `resolveSiteMetadata` (empty global never crashes).
* 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.
*/
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 && siteUrl) return toAbsoluteUrl(configured, siteUrl)
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 || !siteUrl) return null
if (!fallbackUrl) return null
return toAbsoluteUrl(fallbackUrl, siteUrl)
return toAbsoluteUrl(fallbackUrl, origin)
}

View file

@ -255,7 +255,7 @@ describe('shareLink', () => {
expect(revalidateTagMock).toHaveBeenCalledWith('shareLinks')
})
it('resolves the configured image and falls back to the site default', async () => {
it('absolutizes the image against the deployment origin and falls back to the site default', async () => {
const media = await payload.create({
collection: 'media',
data: { alt: 'Imagem do link' },
@ -282,7 +282,10 @@ describe('shareLink', () => {
id: withImage.id,
depth: 1,
})
getCachedGlobalMock.mockReturnValue(async () => ({}))
// The global carries the canonical domain (served elsewhere, e.g. the legacy
// WordPress): the media proxy only exists on the deployment origin, so the
// image URL must come from `NEXT_PUBLIC_SITE_URL`, never from `URL`.
getCachedGlobalMock.mockReturnValue(async () => ({ URL: 'https://canonical.example' }))
expect(await resolveShareLinkOgImageUrl(populated)).toBe(expectedUrl)
const withoutImage = await createShareLink({
@ -295,6 +298,7 @@ describe('shareLink', () => {
expect(await resolveShareLinkOgImageUrl(withoutImage)).toBeNull()
getCachedGlobalMock.mockReturnValue(async () => ({
URL: 'https://canonical.example',
image: { url: '/api/media/file/site-default.png' },
}))
expect(await resolveShareLinkOgImageUrl(withoutImage)).toBe(

View file

@ -2,7 +2,12 @@
import { afterEach, describe, expect, it } from 'vitest'
import { SITE_METADATA_DEFAULTS, absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import {
SITE_METADATA_DEFAULTS,
absoluteSitePath,
resolveDeploymentOrigin,
resolveSiteMetadata,
} from '@/utilities/seo'
const originalSiteUrl = process.env.NEXT_PUBLIC_SITE_URL
@ -101,3 +106,25 @@ describe('absoluteSitePath', () => {
expect(absoluteSitePath('https://example.com', 'noticia')).toBe('https://example.com/noticia')
})
})
describe('resolveDeploymentOrigin', () => {
it('prefers NEXT_PUBLIC_SITE_URL over the global fallback and strips trailing slashes', () => {
process.env.NEXT_PUBLIC_SITE_URL = 'https://deploy.example///'
expect(resolveDeploymentOrigin('https://global.example')).toBe('https://deploy.example')
})
it('falls back to the global site URL when env is missing or blank', () => {
delete process.env.NEXT_PUBLIC_SITE_URL
expect(resolveDeploymentOrigin('https://global.example')).toBe('https://global.example')
process.env.NEXT_PUBLIC_SITE_URL = ' '
expect(resolveDeploymentOrigin('https://global.example')).toBe('https://global.example')
})
it('returns null when neither source has a usable URL', () => {
delete process.env.NEXT_PUBLIC_SITE_URL
expect(resolveDeploymentOrigin(null)).toBeNull()
})
})