mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
5be9a1af4d
commit
63e75776d7
5 changed files with 64 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue