Fixed support for multi-langual slugs
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale } from './helpers.js'
|
||||
|
||||
/**
|
||||
@@ -12,14 +11,22 @@ import { isValidLocale } from './helpers.js'
|
||||
export type LocalizedSlugs = Record<string, string>
|
||||
|
||||
type BuildPathArgs = {
|
||||
config: I18nConfig
|
||||
/**
|
||||
* Slug that represents the site root (served at /{locale} with no trailing
|
||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||
*/
|
||||
homeSlug?: string
|
||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||
slugs: LocalizedSlugs
|
||||
/** Target locale to build the path for */
|
||||
locale: string
|
||||
config: I18nConfig
|
||||
/**
|
||||
* Slug(s) representing the site root (served at /{locale} with no trailing
|
||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||
*
|
||||
* Can be a single string (same home slug in every locale) OR a per-locale map
|
||||
* (`{ pl: 'strona-glowna', de: 'startseite', en: 'home' }`). The map form is
|
||||
* REQUIRED for multilingual homepages whose slug differs per language —
|
||||
* otherwise the home page collapses to '/pl' but '/de/startseite' stays
|
||||
* un-collapsed, breaking hreflang return tags (a real GSC error).
|
||||
*/
|
||||
homeSlug?: string | Record<string, string>
|
||||
/**
|
||||
* Localized segment the document lives under, e.g.
|
||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||
@@ -29,8 +36,6 @@ type BuildPathArgs = {
|
||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||
*/
|
||||
prefix?: LocalizedSlugs
|
||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||
slugs: LocalizedSlugs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,11 +61,11 @@ type BuildPathArgs = {
|
||||
* // → '/en/articles/my-post'
|
||||
*/
|
||||
export function buildLocalizedPath({
|
||||
slugs,
|
||||
locale,
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
locale,
|
||||
prefix,
|
||||
slugs,
|
||||
}: BuildPathArgs): string | undefined {
|
||||
if (!isValidLocale(locale, config)) {
|
||||
return undefined
|
||||
@@ -89,7 +94,13 @@ export function buildLocalizedPath({
|
||||
return `${localeSegment}/${segment}/${slug}`
|
||||
}
|
||||
|
||||
if (slug === homeSlug) {
|
||||
// Resolve the home slug for THIS locale. With a per-locale map, each language
|
||||
// has its own home slug (pl: 'strona-glowna', de: 'startseite'), so the check
|
||||
// below correctly collapses each language's home to its root — instead of only
|
||||
// the current-language slug matching and the others staying un-collapsed.
|
||||
const targetHomeSlug = typeof homeSlug === 'object' ? homeSlug[locale] : homeSlug
|
||||
|
||||
if (slug === targetHomeSlug) {
|
||||
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||
return localeSegment || '/'
|
||||
}
|
||||
@@ -98,11 +109,11 @@ export function buildLocalizedPath({
|
||||
}
|
||||
|
||||
type SwitchLocaleArgs = {
|
||||
config: I18nConfig
|
||||
homeSlug?: string
|
||||
prefix?: LocalizedSlugs
|
||||
slugs: LocalizedSlugs
|
||||
targetLocale: string
|
||||
config: I18nConfig
|
||||
homeSlug?: string | Record<string, string>
|
||||
prefix?: LocalizedSlugs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,17 +125,17 @@ type SwitchLocaleArgs = {
|
||||
* to the locale root otherwise — the closest place the visitor would want.
|
||||
*/
|
||||
export function switchLocalePath({
|
||||
slugs,
|
||||
targetLocale,
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
prefix,
|
||||
slugs,
|
||||
targetLocale,
|
||||
}: SwitchLocaleArgs): string {
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
|
||||
if (path) {return path}
|
||||
const path = buildLocalizedPath({ slugs, locale: targetLocale, config, homeSlug, prefix })
|
||||
if (path) return path
|
||||
|
||||
const archiveSegment = prefix?.[targetLocale]
|
||||
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
|
||||
if (archiveSegment) return `/${targetLocale}/${archiveSegment}`
|
||||
|
||||
return `/${targetLocale}`
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type GetSystemPagePathArgs = {
|
||||
* page's slug in the target locale equals this, the path collapses to the
|
||||
* locale root (/pl, /en).
|
||||
*/
|
||||
homeSlug?: string
|
||||
homeSlug?: string | Record<string, string>
|
||||
/** Target locale to build the path for. */
|
||||
locale: string
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
type MediaLike = { url?: string | null } | null | undefined
|
||||
|
||||
type ArticleJsonLdArgs = {
|
||||
/** Article headline (the title). */
|
||||
headline: string
|
||||
/** Absolute URL of the article. */
|
||||
url: string
|
||||
description?: string
|
||||
/** Cover/hero image. */
|
||||
image?: MediaLike
|
||||
/** ISO date the article was first published, e.g. '2026-01-15'. */
|
||||
datePublished?: string
|
||||
/** ISO date of the last modification. Defaults to datePublished. */
|
||||
dateModified?: string
|
||||
/** Author name (person or org). */
|
||||
author?: string
|
||||
/** Publisher (usually the site/organization) name. */
|
||||
publisherName?: string
|
||||
/** Publisher logo (for the Article rich result). */
|
||||
publisherLogo?: MediaLike
|
||||
/** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */
|
||||
type?: 'Article' | 'BlogPosting' | 'NewsArticle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||
* with the plugin's content collections (blog/archives).
|
||||
*
|
||||
* Per-article (each post emits its own), data from the document + panel:
|
||||
*
|
||||
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildArticleJsonLd({
|
||||
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||
* image: post.coverImage, datePublished: post.publishedAt,
|
||||
* author: post.author, publisherName: settings.siteName,
|
||||
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||
* })
|
||||
*
|
||||
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||
* and dates for the rich result; supply them from the post's real fields.
|
||||
*/
|
||||
export function buildArticleJsonLd({
|
||||
headline,
|
||||
url,
|
||||
description,
|
||||
image,
|
||||
datePublished,
|
||||
dateModified,
|
||||
author,
|
||||
publisherName,
|
||||
publisherLogo,
|
||||
type = 'Article',
|
||||
}: ArticleJsonLdArgs) {
|
||||
const imageUrl = image?.url
|
||||
const logoUrl = publisherLogo?.url
|
||||
|
||||
const abs = (u: string | null | undefined): string | undefined =>
|
||||
u ? (u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}`) : undefined
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': type,
|
||||
headline,
|
||||
url,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
|
||||
...(description ? { description } : {}),
|
||||
...(imageUrl ? { image: abs(imageUrl) } : {}),
|
||||
...(datePublished ? { datePublished } : {}),
|
||||
...(dateModified || datePublished ? { dateModified: dateModified ?? datePublished } : {}),
|
||||
...(author ? { author: { '@type': 'Person', name: author } } : {}),
|
||||
...(publisherName
|
||||
? {
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: publisherName,
|
||||
...(logoUrl ? { logo: { '@type': 'ImageObject', url: abs(logoUrl) } } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ type BuildMetadataArgs = {
|
||||
baseUrl?: string
|
||||
config: I18nConfig
|
||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||
homeSlug?: string
|
||||
homeSlug?: string | Record<string, string>
|
||||
/** Resolved OG image URL (page image or site defaultShareImage). */
|
||||
imageUrl?: null | string
|
||||
/** Current locale being rendered. */
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
import { getLocalizedSlugs } from '../i18n/index.js'
|
||||
import { buildLocalizedPath } from '../i18n/index.js'
|
||||
import { buildHreflangAlternates } from './hreflang.js'
|
||||
|
||||
import type { ContentOption } from '../content/index.js'
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
|
||||
import { archiveFieldName } from '../content/index.js'
|
||||
import { buildLocalizedPath, getLocaleCodes , getLocalizedSlugs } from '../i18n/index.js'
|
||||
import { buildHreflangAlternates } from './hreflang.js'
|
||||
|
||||
/**
|
||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||
@@ -15,44 +16,44 @@ import { archiveFieldName } from '../content/index.js'
|
||||
* is the common, weaker kind.
|
||||
*/
|
||||
export type SitemapEntry = {
|
||||
url: string
|
||||
lastModified?: string | Date
|
||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
||||
priority?: number
|
||||
alternates?: { languages: Record<string, string> }
|
||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
|
||||
lastModified?: Date | string
|
||||
priority?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
type CollectionEntry = {
|
||||
slug: string
|
||||
/** Localized segment for entries (archive page slugs), when applicable. */
|
||||
prefixSlugs?: Record<string, string>
|
||||
slug: string
|
||||
}
|
||||
|
||||
type BuildSitemapArgs = {
|
||||
payload: BasePayload
|
||||
config: I18nConfig
|
||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||
baseUrl: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
config: I18nConfig
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/**
|
||||
* Slug of the page that is the site root (collapses to /{locale}).
|
||||
* Read from System Pages when omitted.
|
||||
*/
|
||||
homeSlug?: string
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
homeSlug?: Record<string, string> | string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
payload: BasePayload
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
}
|
||||
|
||||
type DocRow = {
|
||||
id: string | number
|
||||
_status?: string
|
||||
id: number | string
|
||||
meta?: { noindex?: boolean } | null
|
||||
slug?: unknown
|
||||
updatedAt?: string
|
||||
_status?: string
|
||||
meta?: { noindex?: boolean } | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,14 +62,14 @@ type DocRow = {
|
||||
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||
* is an audit finding. Matched against the slug in any locale.
|
||||
*/
|
||||
const EXCLUDED_SITEMAP_SLUGS = new Set(['404', '500', 'not-found', 'error'])
|
||||
const EXCLUDED_SITEMAP_SLUGS = new Set(['404', '500', 'error', 'not-found'])
|
||||
|
||||
/** True if the doc's slug (in any locale) is an excluded system/error slug. */
|
||||
function hasExcludedSlug(slug: unknown): boolean {
|
||||
if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug)
|
||||
if (typeof slug === 'string') {return EXCLUDED_SITEMAP_SLUGS.has(slug)}
|
||||
if (slug && typeof slug === 'object') {
|
||||
for (const value of Object.values(slug as Record<string, unknown>)) {
|
||||
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true
|
||||
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {return true}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -76,9 +77,9 @@ function hasExcludedSlug(slug: unknown): boolean {
|
||||
|
||||
/** Skip drafts, noindex, and system/error pages (404 etc.). */
|
||||
function isIndexable(doc: DocRow): boolean {
|
||||
if (doc._status && doc._status !== 'published') return false
|
||||
if (doc.meta?.noindex) return false
|
||||
if (hasExcludedSlug(doc.slug)) return false
|
||||
if (doc._status && doc._status !== 'published') {return false}
|
||||
if (doc.meta?.noindex) {return false}
|
||||
if (hasExcludedSlug(doc.slug)) {return false}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -93,20 +94,20 @@ function entryFor(
|
||||
locale: string,
|
||||
config: I18nConfig,
|
||||
baseUrl: string,
|
||||
homeSlug: string | undefined,
|
||||
homeSlug: Record<string, string> | string | undefined,
|
||||
prefix: Record<string, string> | undefined,
|
||||
changeFrequency: SitemapEntry['changeFrequency'],
|
||||
): SitemapEntry | null {
|
||||
): null | SitemapEntry {
|
||||
const slugs =
|
||||
doc.slug && typeof doc.slug === 'object'
|
||||
? getLocalizedSlugs({ slugField: doc.slug as Record<string, unknown>, config })
|
||||
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
|
||||
: {}
|
||||
|
||||
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||
if (!path) return null
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
||||
if (!path) {return null}
|
||||
|
||||
const origin = baseUrl.replace(/\/$/, '')
|
||||
const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
|
||||
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
||||
|
||||
return {
|
||||
url: `${origin}${path}`,
|
||||
@@ -138,14 +139,14 @@ function entryFor(
|
||||
* ```
|
||||
*/
|
||||
export async function buildSitemapEntries({
|
||||
payload,
|
||||
config,
|
||||
baseUrl,
|
||||
pagesSlug = 'pages',
|
||||
content,
|
||||
settingsSlug = 'site-settings',
|
||||
homeSlug,
|
||||
changeFrequency = 'weekly',
|
||||
config,
|
||||
content,
|
||||
homeSlug,
|
||||
pagesSlug = 'pages',
|
||||
payload,
|
||||
settingsSlug = 'site-settings',
|
||||
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
||||
const locales = config.locales.map((l) => l.code)
|
||||
const defaultLocale = config.defaultLocale
|
||||
@@ -154,8 +155,8 @@ export async function buildSitemapEntries({
|
||||
// locales so archive prefixes are available per language).
|
||||
const settings = (await payload.findGlobal({
|
||||
slug: settingsSlug as never,
|
||||
locale: 'all' as never,
|
||||
depth: 1,
|
||||
locale: 'all' as never,
|
||||
})) as Record<string, unknown>
|
||||
|
||||
const resolvedHomeSlug =
|
||||
@@ -178,26 +179,32 @@ export async function buildSitemapEntries({
|
||||
// alternates without re-querying per locale.
|
||||
const result = await payload.find({
|
||||
collection: collection.slug as never,
|
||||
locale: 'all' as never,
|
||||
depth: 0,
|
||||
limit: 0, // no pagination — sitemap wants everything
|
||||
locale: 'all' as never,
|
||||
pagination: false as never,
|
||||
})
|
||||
|
||||
for (const raw of result.docs as DocRow[]) {
|
||||
if (!isIndexable(raw)) continue
|
||||
if (!isIndexable(raw)) {continue}
|
||||
|
||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
||||
const entry = entryFor(
|
||||
raw,
|
||||
defaultLocale,
|
||||
config,
|
||||
baseUrl,
|
||||
resolvedHomeSlug,
|
||||
collection.prefixSlugs,
|
||||
changeFrequency,
|
||||
)
|
||||
if (entry) entries.push(entry)
|
||||
// Emit a separate <url> for EACH locale. Google's sitemap spec for
|
||||
// localized sites requires one <url> per language version (each with its
|
||||
// own <loc> plus xhtml:link alternates), not a single default-locale <url>
|
||||
// with the others hidden only in alternates. Emitting only the default
|
||||
// locale makes GSC count 8 URLs instead of 8×3 — a real reporting gap.
|
||||
for (const locale of getLocaleCodes(config)) {
|
||||
const entry = entryFor(
|
||||
raw,
|
||||
locale,
|
||||
config,
|
||||
baseUrl,
|
||||
resolvedHomeSlug,
|
||||
collection.prefixSlugs,
|
||||
changeFrequency,
|
||||
)
|
||||
if (entry) {entries.push(entry)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +213,9 @@ export async function buildSitemapEntries({
|
||||
|
||||
/** Pulls a slug string from a populated relationship in a specific locale. */
|
||||
function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
||||
if (!rel || typeof rel !== 'object') return undefined
|
||||
if (!rel || typeof rel !== 'object') {return undefined}
|
||||
const slug = (rel as { slug?: unknown }).slug
|
||||
if (typeof slug === 'string') return slug
|
||||
if (typeof slug === 'string') {return slug}
|
||||
if (slug && typeof slug === 'object') {
|
||||
const v = (slug as Record<string, unknown>)[locale]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
@@ -218,14 +225,14 @@ function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
||||
|
||||
/** Builds a locale→slug map from a populated archive relationship. */
|
||||
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined {
|
||||
if (!rel || typeof rel !== 'object') return undefined
|
||||
if (!rel || typeof rel !== 'object') {return undefined}
|
||||
const slug = (rel as { slug?: unknown }).slug
|
||||
if (!slug || typeof slug !== 'object') return undefined
|
||||
if (!slug || typeof slug !== 'object') {return undefined}
|
||||
|
||||
const map: Record<string, string> = {}
|
||||
for (const locale of locales) {
|
||||
const v = (slug as Record<string, unknown>)[locale]
|
||||
if (typeof v === 'string') map[locale] = v
|
||||
if (typeof v === 'string') {map[locale] = v}
|
||||
}
|
||||
return Object.keys(map).length ? map : undefined
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
export type TitleOrder = 'page-first' | 'site-first'
|
||||
|
||||
type ComposeTitleArgs = {
|
||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||
order?: TitleOrder
|
||||
/** Page-specific title, e.g. 'About Us'. */
|
||||
pageTitle?: null | string
|
||||
pageTitle?: string | null
|
||||
/** Site name, e.g. 'Acme Inc'. */
|
||||
siteName?: string | null
|
||||
/** Separator between page title and site name. Defaults to ' | '. */
|
||||
separator?: string
|
||||
/** Site name, e.g. 'Acme Inc'. */
|
||||
siteName?: null | string
|
||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||
order?: TitleOrder
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,15 +24,23 @@ type ComposeTitleArgs = {
|
||||
* Pure function — no dependency on Payload or request state.
|
||||
*/
|
||||
export function composeTitle({
|
||||
order = 'page-first',
|
||||
pageTitle,
|
||||
separator = ' | ',
|
||||
siteName,
|
||||
separator = ' | ',
|
||||
order = 'page-first',
|
||||
}: ComposeTitleArgs): string {
|
||||
const page = pageTitle?.trim()
|
||||
const site = siteName?.trim()
|
||||
|
||||
if (page && site) {
|
||||
// Dedup: if the page title already ends with the site name (an editor wrote
|
||||
// "Budowa auta – RCC" into meta.title), don't append it again → avoid
|
||||
// "Budowa auta – RCC – RCC". Match the site name at the end, with or without
|
||||
// the separator before it.
|
||||
const sep = separator.trim()
|
||||
if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {
|
||||
return page
|
||||
}
|
||||
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`
|
||||
}
|
||||
return page || site || ''
|
||||
|
||||
@@ -20,7 +20,7 @@ type CreateMetadataGeneratorArgs = {
|
||||
baseUrl?: string
|
||||
config: I18nConfig
|
||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||
homeSlug?: string
|
||||
homeSlug?: string | Record<string, string>
|
||||
/**
|
||||
* Resolves the document to build metadata for, given route params.
|
||||
* The client supplies this (they own the collections and routing); it should
|
||||
|
||||
@@ -8,7 +8,7 @@ type BuildHreflangArgs = {
|
||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||
baseUrl?: string
|
||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||
homeSlug?: string
|
||||
homeSlug?: string | Record<string, string>
|
||||
/**
|
||||
* Localized segment the document lives under (an archive page's slugs),
|
||||
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||
export { buildArticleJsonLd } from './buildArticleJsonLd.js'
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
|
||||
import type { TitleOrder } from './composeTitle.js'
|
||||
|
||||
type SettingsShape = {
|
||||
homepage?: { id: string | number; slug?: unknown } | string | number | null
|
||||
titleSeparator?: string | null
|
||||
titleOrder?: TitleOrder | null
|
||||
[key: string]: unknown
|
||||
homepage?: { id: number | string; slug?: unknown } | null | number | string
|
||||
titleOrder?: null | TitleOrder
|
||||
titleSeparator?: null | string
|
||||
}
|
||||
|
||||
export type SiteMetaConfig = {
|
||||
/** Slug of the page assigned as Homepage; collapses to the locale root. */
|
||||
homeSlug?: string
|
||||
order?: TitleOrder
|
||||
siteName: string | null
|
||||
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
||||
separator?: string
|
||||
siteName: null | string
|
||||
order?: TitleOrder
|
||||
/**
|
||||
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map
|
||||
* (not a single string) so multilingual homepages collapse to root in EVERY
|
||||
* language — otherwise hreflang points other languages at '/de/startseite'
|
||||
* instead of '/de', breaking return tags (a real GSC error).
|
||||
*/
|
||||
homeSlug?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,13 +31,13 @@ export type SiteMetaConfig = {
|
||||
* relationship — it should receive a resolved config.
|
||||
*/
|
||||
export async function readSiteMetaConfig({
|
||||
locale,
|
||||
payload,
|
||||
settingsSlug = 'site-settings',
|
||||
siteNameField = 'siteName',
|
||||
}: {
|
||||
locale: string
|
||||
payload,
|
||||
locale,
|
||||
settingsSlug = 'site-settings',
|
||||
siteNameField = 'siteName',
|
||||
}: {
|
||||
payload: BasePayload
|
||||
locale: string
|
||||
settingsSlug?: string
|
||||
siteNameField?: string
|
||||
}): Promise<SiteMetaConfig> {
|
||||
@@ -41,18 +45,55 @@ export async function readSiteMetaConfig({
|
||||
// without a second query.
|
||||
const settings = (await payload.findGlobal({
|
||||
slug: settingsSlug as never,
|
||||
depth: 1,
|
||||
locale: locale as never,
|
||||
depth: 1,
|
||||
})) as SettingsShape
|
||||
|
||||
const homepage =
|
||||
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
|
||||
|
||||
// Build the per-locale home slug map. The homepage's slug is localized, so a
|
||||
// single-locale read only gives THIS language's slug. Re-read the homepage
|
||||
// with locale:'all' to get every language's slug — needed so hreflang can
|
||||
// collapse each language's home to its root.
|
||||
let homeSlug: Record<string, string> | undefined
|
||||
if (homepage?.id) {
|
||||
try {
|
||||
const homeDoc = (await payload.findByID({
|
||||
collection: 'pages' as never,
|
||||
id: homepage.id,
|
||||
locale: 'all' as never,
|
||||
depth: 0,
|
||||
})) as { slug?: unknown }
|
||||
const slugField = homeDoc.slug
|
||||
if (slugField && typeof slugField === 'object') {
|
||||
const map: Record<string, string> = {}
|
||||
for (const [loc, value] of Object.entries(slugField as Record<string, unknown>)) {
|
||||
if (typeof value === 'string') map[loc] = value
|
||||
}
|
||||
if (Object.keys(map).length > 0) homeSlug = map
|
||||
} else if (typeof slugField === 'string') {
|
||||
// Non-localized slug (same in every language) — key it by current locale;
|
||||
// buildLocalizedPath tolerates a partial map. Non-localized homepage
|
||||
// slugs are rare, so this branch is a safety net, not the norm.
|
||||
homeSlug = { [locale]: slugField }
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the single-locale slug below.
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the 'all' read didn't yield a map, use the current locale's slug
|
||||
// for at least this locale (better than nothing).
|
||||
if (!homeSlug && typeof homepage?.slug === 'string') {
|
||||
homeSlug = { [locale]: homepage.slug }
|
||||
}
|
||||
|
||||
return {
|
||||
siteName: (settings[siteNameField] as string | undefined) ?? null,
|
||||
// The panel stores '|'; titles need it padded.
|
||||
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,
|
||||
order: settings.titleOrder ?? undefined,
|
||||
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
|
||||
order: settings.titleOrder ?? undefined,
|
||||
homeSlug,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user