teqo-backup/src/utilities/seo.ts
Francisco Solla 8b74aa705a 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.
2026-09-19 16:28:32 -03:00

101 lines
4.2 KiB
TypeScript

/** Remove any trailing slash(es) so URLs concatenate predictably. */
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '')
/** Truncate `text` to `max` characters, appending an ellipsis when cut. */
export const truncate = (text: string, max: number): string =>
text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
/** Resolve a possibly-relative URL against the site's base URL. */
export const toAbsoluteUrl = (url: string, siteUrl: string): string =>
/^https?:\/\//i.test(url)
? url
: `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`
/**
* Join a resolved site origin with a path. Returns `undefined` when there is no
* site URL so callers can omit canonical / Open Graph url instead of concatenating
* against `null`.
*/
export const absoluteSitePath = (siteUrl: string | null, path: string): string | undefined => {
if (!siteUrl) return undefined
return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`
}
/**
* Partial shape of the Payload `metadata` global. Fields are optional because a
* fresh DB (or a poisoned `unstable_cache` entry) can return an empty object at
* build time even though the schema marks them required.
*/
export type SiteMetadataSource = {
URL?: string | null
title?: string | null
description?: string | null
keywords?: ({ keyword?: string | null } | string | null)[] | null
openGraph?: { siteName?: string | null } | null
twitter?: { creator?: string | null; description?: string | null } | null
}
/** Defaults shared by every public-site SEO surface when the global is empty. */
export const SITE_METADATA_DEFAULTS = {
title: 'Jorge Solla',
siteName: 'Jorge Solla',
description: 'Notícias, campanhas e a atuação do deputado Jorge Solla pela Bahia.',
twitterCreator: '@jorgesolla',
} as const
const nonEmpty = (value: string | null | undefined): string | null => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
const resolveSiteUrl = (source: SiteMetadataSource): string | null => {
const fromGlobal = nonEmpty(source.URL)
if (fromGlobal) return stripTrailingSlash(fromGlobal)
const fromEnv = nonEmpty(process.env.NEXT_PUBLIC_SITE_URL)
if (fromEnv) return stripTrailingSlash(fromEnv)
return null
}
/**
* 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))
.filter((keyword): keyword is string => keyword !== null)
/**
* Resolve site SEO fields with graceful degradation.
*
* `siteUrl` is `null` when neither the global nor `NEXT_PUBLIC_SITE_URL` has a
* usable URL — callers MUST omit canonical / Open Graph url / JSON-LD url in
* that case rather than concatenate against `undefined`.
*/
export const resolveSiteMetadata = (source: SiteMetadataSource) => ({
siteUrl: resolveSiteUrl(source),
title: nonEmpty(source.title) ?? SITE_METADATA_DEFAULTS.title,
siteName: nonEmpty(source.openGraph?.siteName) ?? SITE_METADATA_DEFAULTS.siteName,
description: nonEmpty(source.description) ?? SITE_METADATA_DEFAULTS.description,
twitterCreator: nonEmpty(source.twitter?.creator) ?? SITE_METADATA_DEFAULTS.twitterCreator,
twitterDescription:
nonEmpty(source.twitter?.description) ??
nonEmpty(source.description) ??
SITE_METADATA_DEFAULTS.description,
keywords: flattenKeywords(source),
})