teqo-backup/src/utilities/seo.ts
Francisco Ignácio Fontoura Solla bcb58c2162 B33+: close the /simplify rounds on the shared list filter hook
Follows the URL when the committed query changes with no debounce pending, so
Back/Forward and the empty state's clear anchor update the search box without
overwriting text the user is still typing. That removed the last reason for
/campanha/municipios to remount its filters, so the key= (and its now orphaned
buildMunicipalityFiltersKey) is gone and the three lists share one mounting
policy — the dedup guard was dead code on municípios while it remounted.

Reuse: setSearch + scheduleSearch were always called as a pair, so they collapse
into onSearchChange (hook surface 8 -> 6 names); MunicipalityFilters reuses the
buildMunicipalityFilterHref it was reimplementing inline; dobradinhas gains the
sibling buildStateDeputyFilterHref for the four sites pinning page 1 by hand;
municípios gains the active-filter summary its mobile block was missing.

Also drops an export with no consumer that was failing knip in CI (seo.ts).
2026-07-27 09:39:15 -03:00

84 lines
3.3 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
}
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),
})