Compare commits
2
Commits
db2ac80a1e
...
fa4cf20022
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4cf20022 | ||
|
|
2ecfc2e12d |
Vendored
+1
-1
@@ -29,7 +29,7 @@ export type { GlobalQueryOptions } from './modules/payload/index.js';
|
|||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -18,7 +18,7 @@ export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from
|
|||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+19
-13
@@ -8,14 +8,22 @@ import type { I18nConfig } from './types.js';
|
|||||||
*/
|
*/
|
||||||
export type LocalizedSlugs = Record<string, string>;
|
export type LocalizedSlugs = Record<string, string>;
|
||||||
type BuildPathArgs = {
|
type BuildPathArgs = {
|
||||||
config: I18nConfig;
|
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||||
/**
|
slugs: LocalizedSlugs;
|
||||||
* 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;
|
|
||||||
/** Target locale to build the path for */
|
/** Target locale to build the path for */
|
||||||
locale: string;
|
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.
|
* Localized segment the document lives under, e.g.
|
||||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||||
@@ -25,8 +33,6 @@ type BuildPathArgs = {
|
|||||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs;
|
prefix?: LocalizedSlugs;
|
||||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
|
||||||
slugs: LocalizedSlugs;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds a locale-prefixed path for a document in a target locale.
|
* Builds a locale-prefixed path for a document in a target locale.
|
||||||
@@ -50,13 +56,13 @@ type BuildPathArgs = {
|
|||||||
* })
|
* })
|
||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/
|
*/
|
||||||
export declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined;
|
export declare function buildLocalizedPath({ slugs, locale, config, homeSlug, prefix, }: BuildPathArgs): string | undefined;
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
config: I18nConfig;
|
|
||||||
homeSlug?: string;
|
|
||||||
prefix?: LocalizedSlugs;
|
|
||||||
slugs: LocalizedSlugs;
|
slugs: LocalizedSlugs;
|
||||||
targetLocale: string;
|
targetLocale: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
homeSlug?: string | Record<string, string>;
|
||||||
|
prefix?: LocalizedSlugs;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Resolves the equivalent path for the same document in a different locale —
|
* Resolves the equivalent path for the same document in a different locale —
|
||||||
@@ -66,5 +72,5 @@ type SwitchLocaleArgs = {
|
|||||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/
|
*/
|
||||||
export declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string;
|
export declare function switchLocalePath({ slugs, targetLocale, config, homeSlug, prefix, }: SwitchLocaleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+13
-12
@@ -20,7 +20,7 @@ import { isValidLocale } from './helpers.js';
|
|||||||
* config,
|
* config,
|
||||||
* })
|
* })
|
||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/ export function buildLocalizedPath({ config, homeSlug = 'home', locale, prefix, slugs }) {
|
*/ export function buildLocalizedPath({ slugs, locale, config, homeSlug = 'home', prefix }) {
|
||||||
if (!isValidLocale(locale, config)) {
|
if (!isValidLocale(locale, config)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,12 @@ import { isValidLocale } from './helpers.js';
|
|||||||
}
|
}
|
||||||
return `${localeSegment}/${segment}/${slug}`;
|
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.
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
return localeSegment || '/';
|
return localeSegment || '/';
|
||||||
}
|
}
|
||||||
@@ -57,21 +62,17 @@ import { isValidLocale } from './helpers.js';
|
|||||||
* Never dead-ends on a 404. When the document has no slug in the target locale,
|
* Never dead-ends on a 404. When the document has no slug in the target locale,
|
||||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/ export function switchLocalePath({ config, homeSlug = 'home', prefix, slugs, targetLocale }) {
|
*/ export function switchLocalePath({ slugs, targetLocale, config, homeSlug = 'home', prefix }) {
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale: targetLocale,
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
locale: targetLocale,
|
prefix
|
||||||
prefix,
|
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
if (path) {
|
if (path) return path;
|
||||||
return path;
|
|
||||||
}
|
|
||||||
const archiveSegment = prefix?.[targetLocale];
|
const archiveSegment = prefix?.[targetLocale];
|
||||||
if (archiveSegment) {
|
if (archiveSegment) return `/${targetLocale}/${archiveSegment}`;
|
||||||
return `/${targetLocale}/${archiveSegment}`;
|
|
||||||
}
|
|
||||||
return `/${targetLocale}`;
|
return `/${targetLocale}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -16,7 +16,7 @@ type GetSystemPagePathArgs = {
|
|||||||
* page's slug in the target locale equals this, the path collapses to the
|
* page's slug in the target locale equals this, the path collapses to the
|
||||||
* locale root (/pl, /en).
|
* locale root (/pl, /en).
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/** Target locale to build the path for. */
|
/** Target locale to build the path for. */
|
||||||
locale: string;
|
locale: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string | Record<string, string>\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
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 declare function buildArticleJsonLd({ headline, url, description, image, datePublished, dateModified, author, publisherName, publisherLogo, type, }: ArticleJsonLdArgs): {
|
||||||
|
publisher?: {
|
||||||
|
logo?: {
|
||||||
|
'@type': string;
|
||||||
|
url: string | undefined;
|
||||||
|
} | undefined;
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
author?: {
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
dateModified?: string | undefined;
|
||||||
|
datePublished?: string | undefined;
|
||||||
|
image?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
'@context': string;
|
||||||
|
'@type': "Article" | "BlogPosting" | "NewsArticle";
|
||||||
|
headline: string;
|
||||||
|
url: string;
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': string;
|
||||||
|
'@id': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 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' }) {
|
||||||
|
const imageUrl = image?.url;
|
||||||
|
const logoUrl = publisherLogo?.url;
|
||||||
|
const abs = (u)=>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)
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildArticleJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildArticleJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: string | null } | null | undefined\n\ntype ArticleJsonLdArgs = {\n /** Article headline (the title). */\n headline: string\n /** Absolute URL of the article. */\n url: string\n description?: string\n /** Cover/hero image. */\n image?: MediaLike\n /** ISO date the article was first published, e.g. '2026-01-15'. */\n datePublished?: string\n /** ISO date of the last modification. Defaults to datePublished. */\n dateModified?: string\n /** Author name (person or org). */\n author?: string\n /** Publisher (usually the site/organization) name. */\n publisherName?: string\n /** Publisher logo (for the Article rich result). */\n publisherLogo?: MediaLike\n /** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */\n type?: 'Article' | 'BlogPosting' | 'NewsArticle'\n}\n\n/**\n * Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.\n * Enables article rich results in Google (headline, date, author, image). Pairs\n * with the plugin's content collections (blog/archives).\n *\n * Per-article (each post emits its own), data from the document + panel:\n *\n * import { buildArticleJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildArticleJsonLd({\n * headline: post.title, url: postUrl, description: post.excerpt,\n * image: post.coverImage, datePublished: post.publishedAt,\n * author: post.author, publisherName: settings.siteName,\n * publisherLogo: settings.logo, type: 'BlogPosting',\n * })\n *\n * All data from the document/panel — nothing hardcoded. Google requires headline\n * and dates for the rich result; supply them from the post's real fields.\n */\nexport function buildArticleJsonLd({\n headline,\n url,\n description,\n image,\n datePublished,\n dateModified,\n author,\n publisherName,\n publisherLogo,\n type = 'Article',\n}: ArticleJsonLdArgs) {\n const imageUrl = image?.url\n const logoUrl = publisherLogo?.url\n\n const abs = (u: string | null | undefined): string | undefined =>\n u ? (u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}`) : undefined\n\n return {\n '@context': 'https://schema.org',\n '@type': type,\n headline,\n url,\n mainEntityOfPage: { '@type': 'WebPage', '@id': url },\n ...(description ? { description } : {}),\n ...(imageUrl ? { image: abs(imageUrl) } : {}),\n ...(datePublished ? { datePublished } : {}),\n ...(dateModified || datePublished ? { dateModified: dateModified ?? datePublished } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(publisherName\n ? {\n publisher: {\n '@type': 'Organization',\n name: publisherName,\n ...(logoUrl ? { logo: { '@type': 'ImageObject', url: abs(logoUrl) } } : {}),\n },\n }\n : {}),\n }\n}\n"],"names":["buildArticleJsonLd","headline","url","description","image","datePublished","dateModified","author","publisherName","publisherLogo","type","imageUrl","logoUrl","abs","u","startsWith","split","slice","join","undefined","mainEntityOfPage","name","publisher","logo"],"mappings":"AAwBA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,mBAAmB,EACjCC,QAAQ,EACRC,GAAG,EACHC,WAAW,EACXC,KAAK,EACLC,aAAa,EACbC,YAAY,EACZC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,OAAO,SAAS,EACE;IAClB,MAAMC,WAAWP,OAAOF;IACxB,MAAMU,UAAUH,eAAeP;IAE/B,MAAMW,MAAM,CAACC,IACXA,IAAKA,EAAEC,UAAU,CAAC,UAAUD,IAAI,GAAGZ,IAAIc,KAAK,CAAC,KAAKC,KAAK,CAAC,GAAG,GAAGC,IAAI,CAAC,OAAOJ,GAAG,GAAIK;IAEnF,OAAO;QACL,YAAY;QACZ,SAAST;QACTT;QACAC;QACAkB,kBAAkB;YAAE,SAAS;YAAW,OAAOlB;QAAI;QACnD,GAAIC,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAIQ,WAAW;YAAEP,OAAOS,IAAIF;QAAU,IAAI,CAAC,CAAC;QAC5C,GAAIN,gBAAgB;YAAEA;QAAc,IAAI,CAAC,CAAC;QAC1C,GAAIC,gBAAgBD,gBAAgB;YAAEC,cAAcA,gBAAgBD;QAAc,IAAI,CAAC,CAAC;QACxF,GAAIE,SAAS;YAAEA,QAAQ;gBAAE,SAAS;gBAAUc,MAAMd;YAAO;QAAE,IAAI,CAAC,CAAC;QACjE,GAAIC,gBACA;YACEc,WAAW;gBACT,SAAS;gBACTD,MAAMb;gBACN,GAAII,UAAU;oBAAEW,MAAM;wBAAE,SAAS;wBAAerB,KAAKW,IAAID;oBAAS;gBAAE,IAAI,CAAC,CAAC;YAC5E;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||||
Vendored
+1
-1
@@ -31,7 +31,7 @@ type BuildMetadataArgs = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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). */
|
/** Resolved OG image URL (page image or site defaultShareImage). */
|
||||||
imageUrl?: null | string;
|
imageUrl?: null | string;
|
||||||
/** Current locale being rendered. */
|
/** Current locale being rendered. */
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+14
-14
@@ -1,6 +1,6 @@
|
|||||||
import type { BasePayload } from 'payload';
|
import type { BasePayload } from 'payload';
|
||||||
import type { I18nConfig } from '../i18n/index.js';
|
|
||||||
import type { ContentOption } from '../content/index.js';
|
import type { ContentOption } from '../content/index.js';
|
||||||
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||||
*
|
*
|
||||||
@@ -10,31 +10,31 @@ import type { ContentOption } from '../content/index.js';
|
|||||||
* is the common, weaker kind.
|
* is the common, weaker kind.
|
||||||
*/
|
*/
|
||||||
export type SitemapEntry = {
|
export type SitemapEntry = {
|
||||||
url: string;
|
|
||||||
lastModified?: string | Date;
|
|
||||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
||||||
priority?: number;
|
|
||||||
alternates?: {
|
alternates?: {
|
||||||
languages: Record<string, string>;
|
languages: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
|
||||||
|
lastModified?: Date | string;
|
||||||
|
priority?: number;
|
||||||
|
url: string;
|
||||||
};
|
};
|
||||||
type BuildSitemapArgs = {
|
type BuildSitemapArgs = {
|
||||||
payload: BasePayload;
|
|
||||||
config: I18nConfig;
|
|
||||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||||
pagesSlug?: string;
|
config: I18nConfig;
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption;
|
content?: ContentOption;
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
|
||||||
settingsSlug?: string;
|
|
||||||
/**
|
/**
|
||||||
* Slug of the page that is the site root (collapses to /{locale}).
|
* Slug of the page that is the site root (collapses to /{locale}).
|
||||||
* Read from System Pages when omitted.
|
* Read from System Pages when omitted.
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: Record<string, string> | string;
|
||||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
|
pagesSlug?: string;
|
||||||
|
payload: BasePayload;
|
||||||
|
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Collects every public URL — pages and archive entries — as sitemap entries
|
* Collects every public URL — pages and archive entries — as sitemap entries
|
||||||
@@ -57,5 +57,5 @@ type BuildSitemapArgs = {
|
|||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export declare function buildSitemapEntries({ payload, config, baseUrl, pagesSlug, content, settingsSlug, homeSlug, changeFrequency, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+63
-32
@@ -1,7 +1,6 @@
|
|||||||
import { getLocalizedSlugs } from '../i18n/index.js';
|
|
||||||
import { buildLocalizedPath } from '../i18n/index.js';
|
|
||||||
import { buildHreflangAlternates } from './hreflang.js';
|
|
||||||
import { archiveFieldName } from '../content/index.js';
|
import { archiveFieldName } from '../content/index.js';
|
||||||
|
import { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs } from '../i18n/index.js';
|
||||||
|
import { buildHreflangAlternates } from './hreflang.js';
|
||||||
/**
|
/**
|
||||||
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
||||||
* documents (e.g. a '404' page in the Pages collection) but should not be
|
* documents (e.g. a '404' page in the Pages collection) but should not be
|
||||||
@@ -10,22 +9,32 @@ import { archiveFieldName } from '../content/index.js';
|
|||||||
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||||
'404',
|
'404',
|
||||||
'500',
|
'500',
|
||||||
'not-found',
|
'error',
|
||||||
'error'
|
'not-found'
|
||||||
]);
|
]);
|
||||||
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
||||||
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') {
|
if (slug && typeof slug === 'object') {
|
||||||
for (const value of Object.values(slug)){
|
for (const value of Object.values(slug)){
|
||||||
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true;
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||||
if (doc._status && doc._status !== 'published') return false;
|
if (doc._status && doc._status !== 'published') {
|
||||||
if (doc.meta?.noindex) return false;
|
return false;
|
||||||
if (hasExcludedSlug(doc.slug)) return false;
|
}
|
||||||
|
if (doc.meta?.noindex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasExcludedSlug(doc.slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -35,24 +44,26 @@ import { archiveFieldName } from '../content/index.js';
|
|||||||
* every locale (including itself, per Google's guidance).
|
* every locale (including itself, per Google's guidance).
|
||||||
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
|
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
|
||||||
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
|
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
|
||||||
slugField: doc.slug,
|
config,
|
||||||
config
|
slugField: doc.slug
|
||||||
}) : {};
|
}) : {};
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
slugs,
|
|
||||||
locale,
|
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
prefix
|
locale,
|
||||||
|
prefix,
|
||||||
|
slugs
|
||||||
});
|
});
|
||||||
if (!path) return null;
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const origin = baseUrl.replace(/\/$/, '');
|
const origin = baseUrl.replace(/\/$/, '');
|
||||||
const languages = buildHreflangAlternates({
|
const languages = buildHreflangAlternates({
|
||||||
slugs,
|
|
||||||
config,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
prefix
|
prefix,
|
||||||
|
slugs
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
url: `${origin}${path}`,
|
url: `${origin}${path}`,
|
||||||
@@ -89,15 +100,15 @@ import { archiveFieldName } from '../content/index.js';
|
|||||||
* })
|
* })
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/ export async function buildSitemapEntries({ payload, config, baseUrl, pagesSlug = 'pages', content, settingsSlug = 'site-settings', homeSlug, changeFrequency = 'weekly' }) {
|
*/ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
|
||||||
const locales = config.locales.map((l)=>l.code);
|
const locales = config.locales.map((l)=>l.code);
|
||||||
const defaultLocale = config.defaultLocale;
|
const defaultLocale = config.defaultLocale;
|
||||||
// Resolve homeSlug and archive prefixes from System Pages (read once, in all
|
// Resolve homeSlug and archive prefixes from System Pages (read once, in all
|
||||||
// locales so archive prefixes are available per language).
|
// locales so archive prefixes are available per language).
|
||||||
const settings = await payload.findGlobal({
|
const settings = await payload.findGlobal({
|
||||||
slug: settingsSlug,
|
slug: settingsSlug,
|
||||||
locale: 'all',
|
depth: 1,
|
||||||
depth: 1
|
locale: 'all'
|
||||||
});
|
});
|
||||||
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
||||||
// Which collections to walk: pages (no prefix) + each content collection with
|
// Which collections to walk: pages (no prefix) + each content collection with
|
||||||
@@ -121,24 +132,38 @@ import { archiveFieldName } from '../content/index.js';
|
|||||||
// alternates without re-querying per locale.
|
// alternates without re-querying per locale.
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: collection.slug,
|
collection: collection.slug,
|
||||||
locale: 'all',
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
|
locale: 'all',
|
||||||
pagination: false
|
pagination: false
|
||||||
});
|
});
|
||||||
for (const raw of result.docs){
|
for (const raw of result.docs){
|
||||||
if (!isIndexable(raw)) continue;
|
if (!isIndexable(raw)) {
|
||||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
continue;
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
|
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
|
||||||
if (!rel || typeof rel !== 'object') return undefined;
|
if (!rel || typeof rel !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const slug = rel.slug;
|
const slug = rel.slug;
|
||||||
if (typeof slug === 'string') return slug;
|
if (typeof slug === 'string') {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
if (slug && typeof slug === 'object') {
|
if (slug && typeof slug === 'object') {
|
||||||
const v = slug[locale];
|
const v = slug[locale];
|
||||||
return typeof v === 'string' ? v : undefined;
|
return typeof v === 'string' ? v : undefined;
|
||||||
@@ -146,13 +171,19 @@ import { archiveFieldName } from '../content/index.js';
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
||||||
if (!rel || typeof rel !== 'object') return undefined;
|
if (!rel || typeof rel !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const slug = rel.slug;
|
const slug = rel.slug;
|
||||||
if (!slug || typeof slug !== 'object') return undefined;
|
if (!slug || typeof slug !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const locale of locales){
|
for (const locale of locales){
|
||||||
const v = slug[locale];
|
const v = slug[locale];
|
||||||
if (typeof v === 'string') map[locale] = v;
|
if (typeof v === 'string') {
|
||||||
|
map[locale] = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Object.keys(map).length ? map : undefined;
|
return Object.keys(map).length ? map : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
/** Which part comes first in a composed title. */
|
/** Which part comes first in a composed title. */
|
||||||
export type TitleOrder = 'page-first' | 'site-first';
|
export type TitleOrder = 'page-first' | 'site-first';
|
||||||
type ComposeTitleArgs = {
|
type ComposeTitleArgs = {
|
||||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
|
||||||
order?: TitleOrder;
|
|
||||||
/** Page-specific title, e.g. 'About Us'. */
|
/** 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 between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string;
|
separator?: string;
|
||||||
/** Site name, e.g. 'Acme Inc'. */
|
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||||
siteName?: null | string;
|
order?: TitleOrder;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Composes a full document title from a page title and the site name.
|
* Composes a full document title from a page title and the site name.
|
||||||
@@ -21,5 +21,5 @@ type ComposeTitleArgs = {
|
|||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/
|
*/
|
||||||
export declare function composeTitle({ order, pageTitle, separator, siteName, }: ComposeTitleArgs): string;
|
export declare function composeTitle({ pageTitle, siteName, separator, order, }: ComposeTitleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+9
-1
@@ -8,10 +8,18 @@
|
|||||||
* - neither: ""
|
* - neither: ""
|
||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/ export function composeTitle({ order = 'page-first', pageTitle, separator = ' | ', siteName }) {
|
*/ export function composeTitle({ pageTitle, siteName, separator = ' | ', order = 'page-first' }) {
|
||||||
const page = pageTitle?.trim();
|
const page = pageTitle?.trim();
|
||||||
const site = siteName?.trim();
|
const site = siteName?.trim();
|
||||||
if (page && site) {
|
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 order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`;
|
||||||
}
|
}
|
||||||
return page || site || '';
|
return page || site || '';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: null | string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: null | string\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n order = 'page-first',\n pageTitle,\n separator = ' | ',\n siteName,\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","order","pageTitle","separator","siteName","page","trim","site"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,QAAQ,YAAY,EACpBC,SAAS,EACTC,YAAY,KAAK,EACjBC,QAAQ,EACS;IACjB,MAAMC,OAAOH,WAAWI;IACxB,MAAMC,OAAOH,UAAUE;IAEvB,IAAID,QAAQE,MAAM;QAChB,OAAON,UAAU,eAAe,GAAGM,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: string | null\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: string | null\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n pageTitle,\n siteName,\n separator = ' | ',\n order = 'page-first',\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n // Dedup: if the page title already ends with the site name (an editor wrote\n // \"Budowa auta – RCC\" into meta.title), don't append it again → avoid\n // \"Budowa auta – RCC – RCC\". Match the site name at the end, with or without\n // the separator before it.\n const sep = separator.trim()\n if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {\n return page\n }\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","pageTitle","siteName","separator","order","page","trim","site","sep","endsWith"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,SAAS,EACTC,QAAQ,EACRC,YAAY,KAAK,EACjBC,QAAQ,YAAY,EACH;IACjB,MAAMC,OAAOJ,WAAWK;IACxB,MAAMC,OAAOL,UAAUI;IAEvB,IAAID,QAAQE,MAAM;QAChB,4EAA4E;QAC5E,sEAAsE;QACtE,6EAA6E;QAC7E,2BAA2B;QAC3B,MAAMC,MAAML,UAAUG,IAAI;QAC1B,IAAID,SAASE,QAAQF,KAAKI,QAAQ,CAAC,GAAGD,IAAI,CAAC,EAAED,MAAM,KAAKF,KAAKI,QAAQ,CAAC,CAAC,CAAC,EAAEF,MAAM,GAAG;YACjF,OAAOF;QACT;QACA,OAAOD,UAAU,eAAe,GAAGG,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ type CreateMetadataGeneratorArgs = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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.
|
* Resolves the document to build metadata for, given route params.
|
||||||
* The client supplies this (they own the collections and routing); it should
|
* The client supplies this (they own the collections and routing); it should
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string | Record<string, string>\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
||||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ type BuildHreflangArgs = {
|
|||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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),
|
* Localized segment the document lives under (an archive page's slugs),
|
||||||
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n config: I18nConfig\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n slugs,\n config,\n baseUrl,\n homeSlug = 'home',\n prefix,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n // Single-locale sites have no language alternatives — hreflang describes\n // relationships BETWEEN language versions, and there's only one. Emitting\n // hreflang (or x-default) here would be wrong, so return empty: the page keeps\n // its canonical, but no alternate-language links.\n if (config.locales.length === 1) {\n return alternates\n }\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[config.defaultLocale]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","slugs","config","baseUrl","homeSlug","prefix","origin","replace","alternates","locales","length","locale","path","defaultLocalePath","defaultLocale"],"mappings":"AACA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,KAAK,EACLC,MAAM,EACNC,OAAO,EACPC,WAAW,MAAM,EACjBC,MAAM,EACY;IAClB,MAAMC,SAASH,SAASI,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,yEAAyE;IACzE,0EAA0E;IAC1E,+EAA+E;IAC/E,kDAAkD;IAClD,IAAIN,OAAOO,OAAO,CAACC,MAAM,KAAK,GAAG;QAC/B,OAAOF;IACT;IAEA,KAAK,MAAMG,UAAUZ,eAAeG,QAAS;QAC3C,MAAMU,OAAOd,mBAAmB;YAAEG;YAAOU;YAAQT;YAAQE;YAAUC;QAAO;QAC1E,IAAIO,MAAM;YACRJ,UAAU,CAACG,OAAO,GAAG,GAAGL,SAASM,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBL,UAAU,CAACN,OAAOY,aAAa,CAAC;IAC1D,IAAID,mBAAmB;QACrBL,UAAU,CAAC,YAAY,GAAGK;IAC5B;IAEA,OAAOL;AACT"}
|
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n config: I18nConfig\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string | Record<string, string>\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n slugs,\n config,\n baseUrl,\n homeSlug = 'home',\n prefix,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n // Single-locale sites have no language alternatives — hreflang describes\n // relationships BETWEEN language versions, and there's only one. Emitting\n // hreflang (or x-default) here would be wrong, so return empty: the page keeps\n // its canonical, but no alternate-language links.\n if (config.locales.length === 1) {\n return alternates\n }\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[config.defaultLocale]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","slugs","config","baseUrl","homeSlug","prefix","origin","replace","alternates","locales","length","locale","path","defaultLocalePath","defaultLocale"],"mappings":"AACA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,KAAK,EACLC,MAAM,EACNC,OAAO,EACPC,WAAW,MAAM,EACjBC,MAAM,EACY;IAClB,MAAMC,SAASH,SAASI,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,yEAAyE;IACzE,0EAA0E;IAC1E,+EAA+E;IAC/E,kDAAkD;IAClD,IAAIN,OAAOO,OAAO,CAACC,MAAM,KAAK,GAAG;QAC/B,OAAOF;IACT;IAEA,KAAK,MAAMG,UAAUZ,eAAeG,QAAS;QAC3C,MAAMU,OAAOd,mBAAmB;YAAEG;YAAOU;YAAQT;YAAQE;YAAUC;QAAO;QAC1E,IAAIO,MAAM;YACRJ,UAAU,CAACG,OAAO,GAAG,GAAGL,SAASM,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBL,UAAU,CAACN,OAAOY,aAAa,CAAC;IAC1D,IAAID,mBAAmB;QACrBL,UAAU,CAAC,YAAY,GAAGK;IAC5B;IAEA,OAAOL;AACT"}
|
||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js';
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
|||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js';
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildArticleJsonLd } from './buildArticleJsonLd.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildArticleJsonLd","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||||
+11
-6
@@ -1,12 +1,17 @@
|
|||||||
import type { BasePayload } from 'payload';
|
import type { BasePayload } from 'payload';
|
||||||
import type { TitleOrder } from './composeTitle.js';
|
import type { TitleOrder } from './composeTitle.js';
|
||||||
export type SiteMetaConfig = {
|
export type SiteMetaConfig = {
|
||||||
/** Slug of the page assigned as Homepage; collapses to the locale root. */
|
siteName: string | null;
|
||||||
homeSlug?: string;
|
|
||||||
order?: TitleOrder;
|
|
||||||
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
||||||
separator?: string;
|
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>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Reads the site-wide inputs to title and URL composition from SiteSettings.
|
* Reads the site-wide inputs to title and URL composition from SiteSettings.
|
||||||
@@ -16,9 +21,9 @@ export type SiteMetaConfig = {
|
|||||||
* that the separator arrives unpadded, or that the home slug hides inside a
|
* that the separator arrives unpadded, or that the home slug hides inside a
|
||||||
* relationship — it should receive a resolved config.
|
* relationship — it should receive a resolved config.
|
||||||
*/
|
*/
|
||||||
export declare function readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField, }: {
|
export declare function readSiteMetaConfig({ payload, locale, settingsSlug, siteNameField, }: {
|
||||||
locale: string;
|
|
||||||
payload: BasePayload;
|
payload: BasePayload;
|
||||||
|
locale: string;
|
||||||
settingsSlug?: string;
|
settingsSlug?: string;
|
||||||
siteNameField?: string;
|
siteNameField?: string;
|
||||||
}): Promise<SiteMetaConfig>;
|
}): Promise<SiteMetaConfig>;
|
||||||
|
|||||||
+44
-5
@@ -5,21 +5,60 @@
|
|||||||
* whatever an editor set in the panel. Metadata generation shouldn't also know
|
* whatever an editor set in the panel. Metadata generation shouldn't also know
|
||||||
* that the separator arrives unpadded, or that the home slug hides inside a
|
* that the separator arrives unpadded, or that the home slug hides inside a
|
||||||
* relationship — it should receive a resolved config.
|
* relationship — it should receive a resolved config.
|
||||||
*/ export async function readSiteMetaConfig({ locale, payload, settingsSlug = 'site-settings', siteNameField = 'siteName' }) {
|
*/ export async function readSiteMetaConfig({ payload, locale, settingsSlug = 'site-settings', siteNameField = 'siteName' }) {
|
||||||
// depth 1 populates the homepage relationship, so its slug is available
|
// depth 1 populates the homepage relationship, so its slug is available
|
||||||
// without a second query.
|
// without a second query.
|
||||||
const settings = await payload.findGlobal({
|
const settings = await payload.findGlobal({
|
||||||
slug: settingsSlug,
|
slug: settingsSlug,
|
||||||
depth: 1,
|
locale: locale,
|
||||||
locale: locale
|
depth: 1
|
||||||
});
|
});
|
||||||
const homepage = settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null;
|
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;
|
||||||
|
if (homepage?.id) {
|
||||||
|
try {
|
||||||
|
const homeDoc = await payload.findByID({
|
||||||
|
collection: 'pages',
|
||||||
|
id: homepage.id,
|
||||||
|
locale: 'all',
|
||||||
|
depth: 0
|
||||||
|
});
|
||||||
|
const slugField = homeDoc.slug;
|
||||||
|
if (slugField && typeof slugField === 'object') {
|
||||||
|
const map = {};
|
||||||
|
for (const [loc, value] of Object.entries(slugField)){
|
||||||
|
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 {
|
return {
|
||||||
siteName: settings[siteNameField] ?? null,
|
siteName: settings[siteNameField] ?? null,
|
||||||
// The panel stores '|'; titles need it padded.
|
// The panel stores '|'; titles need it padded.
|
||||||
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,
|
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
|
||||||
order: settings.titleOrder ?? undefined,
|
order: settings.titleOrder ?? undefined,
|
||||||
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
|
homeSlug
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+57
@@ -785,3 +785,60 @@ WAŻNE: Q&A musi odpowiadać widocznej treści strony (Google flaguje rozbieżno
|
|||||||
Nie wymyślaj pytań, których nie ma na stronie.
|
Nie wymyślaj pytań, których nie ma na stronie.
|
||||||
|
|
||||||
Wszystkie: dane z panelu (company, bloki), jako `<script type="application/ld+json">`.
|
Wszystkie: dane z panelu (company, bloki), jako `<script type="application/ld+json">`.
|
||||||
|
|
||||||
|
## Wielojęzyczna strona główna — homeSlug per język (naprawione 1.2)
|
||||||
|
|
||||||
|
Gdy strona główna ma RÓŻNE slugi per język (pl: `strona-glowna`, de: `startseite`),
|
||||||
|
plugin obsługuje mapę homeSlug — każdy język zwija się do swojego roota (`/pl`,
|
||||||
|
`/de`), a hreflang wskazuje poprawnie (nie `/de/startseite`).
|
||||||
|
|
||||||
|
- **readSiteMetaConfig** czyta slug homepage per język (`locale: 'all'`) → mapa.
|
||||||
|
- **buildLocalizedPath** przyjmuje `homeSlug: string | Record<string, string>`.
|
||||||
|
- **hreflang** dostaje mapę → poprawne return tags (koniec błędu GSC „Missing
|
||||||
|
return tags”).
|
||||||
|
|
||||||
|
Działa automatycznie przez createPageMetadata. Warunek: strona główna musi mieć
|
||||||
|
slug wypełniony w KAŻDYM języku (panel, per locale).
|
||||||
|
|
||||||
|
## Sitemap wielojęzyczny — <url> per język (naprawione 1.2)
|
||||||
|
|
||||||
|
Sitemap emituje osobny `<url>` dla KAŻDEGO języka (nie tylko domyślnego). Zgodnie
|
||||||
|
z wymogiem Google: każda wersja językowa = osobny `<loc>` + alternates. GSC liczy
|
||||||
|
teraz wszystkie wersje (8×3 = 24, nie 8). Działa automatycznie w buildSitemapEntries.
|
||||||
|
|
||||||
|
## Aliasy strony głównej — 301 zamiast 200 (do wpięcia w projekcie)
|
||||||
|
|
||||||
|
PROBLEM: `/pl/strona-glowna` (pełny slug home) zwraca 200, tak jak `/pl` (root).
|
||||||
|
Google widzi duplikat („Duplikat bez URL kanonicznego”).
|
||||||
|
|
||||||
|
ROZWIĄZANIE (projekt): w page.tsx, jeśli slug odpowiada slugowi strony głównej
|
||||||
|
w danym języku, zrób redirect 301 na root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// page.tsx — po resolveRoute
|
||||||
|
const homeSlugForLocale = /* slug home w tym języku, z settings */
|
||||||
|
if (slug?.length === 1 && slug[0] === homeSlugForLocale) {
|
||||||
|
redirect(`/${locale}`) // 301 do roota, nie serwuj duplikatu
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin nie robi tego automatycznie (redirect to decyzja projektu — Next redirect
|
||||||
|
w page.tsx). Ale warto wpiąć, żeby nie mieć duplikatów w GSC. Alternatywnie:
|
||||||
|
canonical strony `/pl/strona-glowna` wskazujący na `/pl` (mniej czyste niż 301).
|
||||||
|
|
||||||
|
## Article/BlogPosting JSON-LD (blog)
|
||||||
|
|
||||||
|
Dla wpisów blogowych — buildArticleJsonLd generuje Article/BlogPosting rich
|
||||||
|
results (headline, data, autor, obraz):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export {
|
|||||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
export {
|
export {
|
||||||
|
buildArticleJsonLd,
|
||||||
buildFaqJsonLd,
|
buildFaqJsonLd,
|
||||||
buildIconsMetadata,
|
buildIconsMetadata,
|
||||||
buildLocalBusinessJsonLd,
|
buildLocalBusinessJsonLd,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { I18nConfig } from './types.js'
|
import type { I18nConfig } from './types.js'
|
||||||
|
|
||||||
import { isValidLocale } from './helpers.js'
|
import { isValidLocale } from './helpers.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,14 +11,22 @@ import { isValidLocale } from './helpers.js'
|
|||||||
export type LocalizedSlugs = Record<string, string>
|
export type LocalizedSlugs = Record<string, string>
|
||||||
|
|
||||||
type BuildPathArgs = {
|
type BuildPathArgs = {
|
||||||
config: I18nConfig
|
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||||
/**
|
slugs: LocalizedSlugs
|
||||||
* 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
|
|
||||||
/** Target locale to build the path for */
|
/** Target locale to build the path for */
|
||||||
locale: string
|
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.
|
* Localized segment the document lives under, e.g.
|
||||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
* `{ 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.
|
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs
|
prefix?: LocalizedSlugs
|
||||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
|
||||||
slugs: LocalizedSlugs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,11 +61,11 @@ type BuildPathArgs = {
|
|||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/
|
*/
|
||||||
export function buildLocalizedPath({
|
export function buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale,
|
||||||
config,
|
config,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
locale,
|
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
}: BuildPathArgs): string | undefined {
|
}: BuildPathArgs): string | undefined {
|
||||||
if (!isValidLocale(locale, config)) {
|
if (!isValidLocale(locale, config)) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -89,7 +94,13 @@ export function buildLocalizedPath({
|
|||||||
return `${localeSegment}/${segment}/${slug}`
|
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.
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
return localeSegment || '/'
|
return localeSegment || '/'
|
||||||
}
|
}
|
||||||
@@ -98,11 +109,11 @@ export function buildLocalizedPath({
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
config: I18nConfig
|
|
||||||
homeSlug?: string
|
|
||||||
prefix?: LocalizedSlugs
|
|
||||||
slugs: LocalizedSlugs
|
slugs: LocalizedSlugs
|
||||||
targetLocale: string
|
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.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/
|
*/
|
||||||
export function switchLocalePath({
|
export function switchLocalePath({
|
||||||
|
slugs,
|
||||||
|
targetLocale,
|
||||||
config,
|
config,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
targetLocale,
|
|
||||||
}: SwitchLocaleArgs): string {
|
}: SwitchLocaleArgs): string {
|
||||||
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
|
const path = buildLocalizedPath({ slugs, locale: targetLocale, config, homeSlug, prefix })
|
||||||
if (path) {return path}
|
if (path) return path
|
||||||
|
|
||||||
const archiveSegment = prefix?.[targetLocale]
|
const archiveSegment = prefix?.[targetLocale]
|
||||||
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
|
if (archiveSegment) return `/${targetLocale}/${archiveSegment}`
|
||||||
|
|
||||||
return `/${targetLocale}`
|
return `/${targetLocale}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type GetSystemPagePathArgs = {
|
|||||||
* page's slug in the target locale equals this, the path collapses to the
|
* page's slug in the target locale equals this, the path collapses to the
|
||||||
* locale root (/pl, /en).
|
* locale root (/pl, /en).
|
||||||
*/
|
*/
|
||||||
homeSlug?: string
|
homeSlug?: string | Record<string, string>
|
||||||
/** Target locale to build the path for. */
|
/** Target locale to build the path for. */
|
||||||
locale: string
|
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
|
baseUrl?: string
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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). */
|
/** Resolved OG image URL (page image or site defaultShareImage). */
|
||||||
imageUrl?: null | string
|
imageUrl?: null | string
|
||||||
/** Current locale being rendered. */
|
/** Current locale being rendered. */
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { BasePayload } from 'payload'
|
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 { ContentOption } from '../content/index.js'
|
||||||
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
|
||||||
import { archiveFieldName } from '../content/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`.
|
* 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.
|
* is the common, weaker kind.
|
||||||
*/
|
*/
|
||||||
export type SitemapEntry = {
|
export type SitemapEntry = {
|
||||||
url: string
|
|
||||||
lastModified?: string | Date
|
|
||||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
|
||||||
priority?: number
|
|
||||||
alternates?: { languages: Record<string, string> }
|
alternates?: { languages: Record<string, string> }
|
||||||
|
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
|
||||||
|
lastModified?: Date | string
|
||||||
|
priority?: number
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionEntry = {
|
type CollectionEntry = {
|
||||||
slug: string
|
|
||||||
/** Localized segment for entries (archive page slugs), when applicable. */
|
/** Localized segment for entries (archive page slugs), when applicable. */
|
||||||
prefixSlugs?: Record<string, string>
|
prefixSlugs?: Record<string, string>
|
||||||
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildSitemapArgs = {
|
type BuildSitemapArgs = {
|
||||||
payload: BasePayload
|
|
||||||
config: I18nConfig
|
|
||||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
changeFrequency?: SitemapEntry['changeFrequency']
|
||||||
pagesSlug?: string
|
config: I18nConfig
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption
|
content?: ContentOption
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
|
||||||
settingsSlug?: string
|
|
||||||
/**
|
/**
|
||||||
* Slug of the page that is the site root (collapses to /{locale}).
|
* Slug of the page that is the site root (collapses to /{locale}).
|
||||||
* Read from System Pages when omitted.
|
* Read from System Pages when omitted.
|
||||||
*/
|
*/
|
||||||
homeSlug?: string
|
homeSlug?: Record<string, string> | string
|
||||||
changeFrequency?: SitemapEntry['changeFrequency']
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
|
pagesSlug?: string
|
||||||
|
payload: BasePayload
|
||||||
|
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocRow = {
|
type DocRow = {
|
||||||
id: string | number
|
_status?: string
|
||||||
|
id: number | string
|
||||||
|
meta?: { noindex?: boolean } | null
|
||||||
slug?: unknown
|
slug?: unknown
|
||||||
updatedAt?: string
|
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
|
* 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.
|
* 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. */
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */
|
||||||
function hasExcludedSlug(slug: unknown): boolean {
|
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') {
|
if (slug && typeof slug === 'object') {
|
||||||
for (const value of Object.values(slug as Record<string, unknown>)) {
|
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
|
return false
|
||||||
@@ -76,9 +77,9 @@ function hasExcludedSlug(slug: unknown): boolean {
|
|||||||
|
|
||||||
/** Skip drafts, noindex, and system/error pages (404 etc.). */
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */
|
||||||
function isIndexable(doc: DocRow): boolean {
|
function isIndexable(doc: DocRow): boolean {
|
||||||
if (doc._status && doc._status !== 'published') return false
|
if (doc._status && doc._status !== 'published') {return false}
|
||||||
if (doc.meta?.noindex) return false
|
if (doc.meta?.noindex) {return false}
|
||||||
if (hasExcludedSlug(doc.slug)) return false
|
if (hasExcludedSlug(doc.slug)) {return false}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,20 +94,20 @@ function entryFor(
|
|||||||
locale: string,
|
locale: string,
|
||||||
config: I18nConfig,
|
config: I18nConfig,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
homeSlug: string | undefined,
|
homeSlug: Record<string, string> | string | undefined,
|
||||||
prefix: Record<string, string> | undefined,
|
prefix: Record<string, string> | undefined,
|
||||||
changeFrequency: SitemapEntry['changeFrequency'],
|
changeFrequency: SitemapEntry['changeFrequency'],
|
||||||
): SitemapEntry | null {
|
): null | SitemapEntry {
|
||||||
const slugs =
|
const slugs =
|
||||||
doc.slug && typeof doc.slug === 'object'
|
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 })
|
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
||||||
if (!path) return null
|
if (!path) {return null}
|
||||||
|
|
||||||
const origin = baseUrl.replace(/\/$/, '')
|
const origin = baseUrl.replace(/\/$/, '')
|
||||||
const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
|
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `${origin}${path}`,
|
url: `${origin}${path}`,
|
||||||
@@ -138,14 +139,14 @@ function entryFor(
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function buildSitemapEntries({
|
export async function buildSitemapEntries({
|
||||||
payload,
|
|
||||||
config,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
pagesSlug = 'pages',
|
|
||||||
content,
|
|
||||||
settingsSlug = 'site-settings',
|
|
||||||
homeSlug,
|
|
||||||
changeFrequency = 'weekly',
|
changeFrequency = 'weekly',
|
||||||
|
config,
|
||||||
|
content,
|
||||||
|
homeSlug,
|
||||||
|
pagesSlug = 'pages',
|
||||||
|
payload,
|
||||||
|
settingsSlug = 'site-settings',
|
||||||
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
||||||
const locales = config.locales.map((l) => l.code)
|
const locales = config.locales.map((l) => l.code)
|
||||||
const defaultLocale = config.defaultLocale
|
const defaultLocale = config.defaultLocale
|
||||||
@@ -154,8 +155,8 @@ export async function buildSitemapEntries({
|
|||||||
// locales so archive prefixes are available per language).
|
// locales so archive prefixes are available per language).
|
||||||
const settings = (await payload.findGlobal({
|
const settings = (await payload.findGlobal({
|
||||||
slug: settingsSlug as never,
|
slug: settingsSlug as never,
|
||||||
locale: 'all' as never,
|
|
||||||
depth: 1,
|
depth: 1,
|
||||||
|
locale: 'all' as never,
|
||||||
})) as Record<string, unknown>
|
})) as Record<string, unknown>
|
||||||
|
|
||||||
const resolvedHomeSlug =
|
const resolvedHomeSlug =
|
||||||
@@ -178,26 +179,32 @@ export async function buildSitemapEntries({
|
|||||||
// alternates without re-querying per locale.
|
// alternates without re-querying per locale.
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: collection.slug as never,
|
collection: collection.slug as never,
|
||||||
locale: 'all' as never,
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 0, // no pagination — sitemap wants everything
|
limit: 0, // no pagination — sitemap wants everything
|
||||||
|
locale: 'all' as never,
|
||||||
pagination: false as never,
|
pagination: false as never,
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const raw of result.docs as DocRow[]) {
|
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.
|
// 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(
|
const entry = entryFor(
|
||||||
raw,
|
raw,
|
||||||
defaultLocale,
|
locale,
|
||||||
config,
|
config,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
resolvedHomeSlug,
|
resolvedHomeSlug,
|
||||||
collection.prefixSlugs,
|
collection.prefixSlugs,
|
||||||
changeFrequency,
|
changeFrequency,
|
||||||
)
|
)
|
||||||
if (entry) entries.push(entry)
|
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. */
|
/** Pulls a slug string from a populated relationship in a specific locale. */
|
||||||
function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
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
|
const slug = (rel as { slug?: unknown }).slug
|
||||||
if (typeof slug === 'string') return slug
|
if (typeof slug === 'string') {return slug}
|
||||||
if (slug && typeof slug === 'object') {
|
if (slug && typeof slug === 'object') {
|
||||||
const v = (slug as Record<string, unknown>)[locale]
|
const v = (slug as Record<string, unknown>)[locale]
|
||||||
return typeof v === 'string' ? v : undefined
|
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. */
|
/** Builds a locale→slug map from a populated archive relationship. */
|
||||||
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined {
|
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
|
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> = {}
|
const map: Record<string, string> = {}
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
const v = (slug as Record<string, unknown>)[locale]
|
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
|
return Object.keys(map).length ? map : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
export type TitleOrder = 'page-first' | 'site-first'
|
export type TitleOrder = 'page-first' | 'site-first'
|
||||||
|
|
||||||
type ComposeTitleArgs = {
|
type ComposeTitleArgs = {
|
||||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
|
||||||
order?: TitleOrder
|
|
||||||
/** Page-specific title, e.g. 'About Us'. */
|
/** 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 between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string
|
separator?: string
|
||||||
/** Site name, e.g. 'Acme Inc'. */
|
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||||
siteName?: null | string
|
order?: TitleOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,15 +24,23 @@ type ComposeTitleArgs = {
|
|||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/
|
*/
|
||||||
export function composeTitle({
|
export function composeTitle({
|
||||||
order = 'page-first',
|
|
||||||
pageTitle,
|
pageTitle,
|
||||||
separator = ' | ',
|
|
||||||
siteName,
|
siteName,
|
||||||
|
separator = ' | ',
|
||||||
|
order = 'page-first',
|
||||||
}: ComposeTitleArgs): string {
|
}: ComposeTitleArgs): string {
|
||||||
const page = pageTitle?.trim()
|
const page = pageTitle?.trim()
|
||||||
const site = siteName?.trim()
|
const site = siteName?.trim()
|
||||||
|
|
||||||
if (page && site) {
|
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 order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`
|
||||||
}
|
}
|
||||||
return page || site || ''
|
return page || site || ''
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type CreateMetadataGeneratorArgs = {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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.
|
* Resolves the document to build metadata for, given route params.
|
||||||
* The client supplies this (they own the collections and routing); it should
|
* 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. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** 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),
|
* Localized segment the document lives under (an archive page's slugs),
|
||||||
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||||
|
export { buildArticleJsonLd } from './buildArticleJsonLd.js'
|
||||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
||||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import type { BasePayload } from 'payload'
|
import type { BasePayload } from 'payload'
|
||||||
|
|
||||||
import type { TitleOrder } from './composeTitle.js'
|
import type { TitleOrder } from './composeTitle.js'
|
||||||
|
|
||||||
type SettingsShape = {
|
type SettingsShape = {
|
||||||
|
homepage?: { id: string | number; slug?: unknown } | string | number | null
|
||||||
|
titleSeparator?: string | null
|
||||||
|
titleOrder?: TitleOrder | null
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
homepage?: { id: number | string; slug?: unknown } | null | number | string
|
|
||||||
titleOrder?: null | TitleOrder
|
|
||||||
titleSeparator?: null | string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SiteMetaConfig = {
|
export type SiteMetaConfig = {
|
||||||
/** Slug of the page assigned as Homepage; collapses to the locale root. */
|
siteName: string | null
|
||||||
homeSlug?: string
|
|
||||||
order?: TitleOrder
|
|
||||||
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
|
||||||
separator?: string
|
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.
|
* relationship — it should receive a resolved config.
|
||||||
*/
|
*/
|
||||||
export async function readSiteMetaConfig({
|
export async function readSiteMetaConfig({
|
||||||
locale,
|
|
||||||
payload,
|
payload,
|
||||||
|
locale,
|
||||||
settingsSlug = 'site-settings',
|
settingsSlug = 'site-settings',
|
||||||
siteNameField = 'siteName',
|
siteNameField = 'siteName',
|
||||||
}: {
|
}: {
|
||||||
locale: string
|
|
||||||
payload: BasePayload
|
payload: BasePayload
|
||||||
|
locale: string
|
||||||
settingsSlug?: string
|
settingsSlug?: string
|
||||||
siteNameField?: string
|
siteNameField?: string
|
||||||
}): Promise<SiteMetaConfig> {
|
}): Promise<SiteMetaConfig> {
|
||||||
@@ -41,18 +45,55 @@ export async function readSiteMetaConfig({
|
|||||||
// without a second query.
|
// without a second query.
|
||||||
const settings = (await payload.findGlobal({
|
const settings = (await payload.findGlobal({
|
||||||
slug: settingsSlug as never,
|
slug: settingsSlug as never,
|
||||||
depth: 1,
|
|
||||||
locale: locale as never,
|
locale: locale as never,
|
||||||
|
depth: 1,
|
||||||
})) as SettingsShape
|
})) as SettingsShape
|
||||||
|
|
||||||
const homepage =
|
const homepage =
|
||||||
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
|
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 {
|
return {
|
||||||
siteName: (settings[siteNameField] as string | undefined) ?? null,
|
siteName: (settings[siteNameField] as string | undefined) ?? null,
|
||||||
// The panel stores '|'; titles need it padded.
|
// 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,
|
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
|
||||||
|
order: settings.titleOrder ?? undefined,
|
||||||
|
homeSlug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user