mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
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.
101 lines
4.2 KiB
TypeScript
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),
|
|
})
|