Compare commits

...
4 Commits
31 changed files with 682 additions and 123 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Field } from 'payload'; import type { Field } from 'payload';
/** /**
* General site identity fields. * General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon). * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
*/ */
export declare const generalFields: Field[]; export declare const generalFields: Field[];
+11 -4
View File
@@ -1,7 +1,7 @@
import { validateFaviconField } from '../../../modules/seo/index.js'; import { validateFaviconField } from '../../../modules/seo/index.js';
/** /**
* General site identity fields. * General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon). * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
*/ export const generalFields = [ */ export const generalFields = [
{ {
name: 'siteName', name: 'siteName',
@@ -12,6 +12,14 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
localized: true, localized: true,
required: true required: true
}, },
{
name: 'siteDescription',
type: 'textarea',
admin: {
description: 'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.'
},
localized: true
},
{ {
name: 'titleOrder', name: 'titleOrder',
type: 'select', type: 'select',
@@ -64,7 +72,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
name: 'logo', name: 'logo',
type: 'upload', type: 'upload',
admin: { admin: {
description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).' description: 'Primary site logo.'
}, },
relationTo: 'media' relationTo: 'media'
}, },
@@ -83,8 +91,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js';
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.' description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
}, },
hooks: { hooks: {
// Warns the editor at save time if the favicon is too small (<48×48) or // Warn the editor if the favicon is too small / not square for Google.
// not square — Google won't display such favicons in search results.
beforeValidate: [ beforeValidate: [
validateFaviconField validateFaviconField
] ]
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"} {"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'siteDescription',\n type: 'textarea',\n admin: {\n description:\n 'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.',\n },\n localized: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warn the editor if the favicon is too small / not square for Google.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAC,WAAW;IACb;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,uEAAuE;YACvEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
+1 -1
View File
@@ -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 { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js'; export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, 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';
+1 -1
View File
@@ -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 { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js'; export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, 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';
+1 -1
View File
File diff suppressed because one or more lines are too long
+22 -16
View File
@@ -1,8 +1,14 @@
import type { BasePayload, SanitizedConfig } from 'payload'; import type { BasePayload, SanitizedConfig } from 'payload';
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'; import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';
import type { I18nConfig } from '../i18n/index.js'; import type { I18nConfig } from '../i18n/index.js';
import type { SitemapEntry, RobotsRules } from '../seo/index.js'; import type { RobotsRules, SitemapEntry } from '../seo/index.js';
type CreateContentHelpersArgs = { type CreateContentHelpersArgs = {
/**
* Absolute site origin for sitemap/robots URLs. Falls back to
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
* reject, so set one in production).
*/
baseUrl?: string;
/** /**
* The client's payload config promise (the default export of payload.config). * The client's payload config promise (the default export of payload.config).
* Passed in because the plugin never imports the client's config directly. * Passed in because the plugin never imports the client's config directly.
@@ -10,21 +16,15 @@ type CreateContentHelpersArgs = {
config: Promise<SanitizedConfig> | SanitizedConfig; config: Promise<SanitizedConfig> | SanitizedConfig;
/** 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;
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string;
/** /**
* i18n config. Required only if you want the ready-made `sitemap` / `robots` * i18n config. Required only if you want the ready-made `sitemap` / `robots`
* handlers — they need the locale list to emit hreflang. * handlers — they need the locale list to emit hreflang.
*/ */
i18n?: I18nConfig; i18n?: I18nConfig;
/** /** Pages collection slug. Defaults to 'pages'. */
* Absolute site origin for sitemap/robots URLs. Falls back to pagesSlug?: string;
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers /** SiteSettings global slug. Defaults to 'site-settings'. */
* reject, so set one in production). settingsSlug?: string;
*/
baseUrl?: string;
}; };
/** /**
* Bundles the per-request data helpers a frontend needs — the same cached * Bundles the per-request data helpers a frontend needs — the same cached
@@ -52,13 +52,19 @@ type CreateContentHelpersArgs = {
* a URL is, fetching gets the listing. Metadata generation needs the first and * a URL is, fetching gets the listing. Metadata generation needs the first and
* not the second, and a page component composes them in two obvious lines. * not the second, and a page component composes them in two obvious lines.
*/ */
export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): { export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): {
generateStaticParams: () => Promise<Array<{
locale: string;
slug: string[];
} | {
slug: string[];
}>>;
getCachedPayload: () => Promise<BasePayload>; getCachedPayload: () => Promise<BasePayload>;
getConfiguredLocales: () => Promise<string[]>; getConfiguredLocales: () => Promise<string[]>;
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>; getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
sitemap: () => Promise<SitemapEntry[]>; getSettings: (locale: string) => Promise<import("payload").JsonObject>;
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
robots: () => RobotsRules; robots: () => RobotsRules;
sitemap: () => Promise<SitemapEntry[]>;
}; };
export {}; export {};
+105 -16
View File
@@ -1,7 +1,7 @@
import { cache } from 'react';
import { getPayload } from 'payload'; import { getPayload } from 'payload';
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'; import { cache } from 'react';
import { buildSitemapEntries, buildRobots } from '../seo/index.js'; import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
/** /**
* Bundles the per-request data helpers a frontend needs — the same cached * Bundles the per-request data helpers a frontend needs — the same cached
* wrappers every project was writing by hand (getPayload, settings, locale * wrappers every project was writing by hand (getPayload, settings, locale
@@ -27,7 +27,7 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js';
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what * `resolveRoute` and `getEntries` are separate on purpose: routing decides what
* a URL is, fetching gets the listing. Metadata generation needs the first and * a URL is, fetching gets the listing. Metadata generation needs the first and
* not the second, and a page component composes them in two obvious lines. * not the second, and a page component composes them in two obvious lines.
*/ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) { */ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) {
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''; const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
const getCachedPayload = cache(async ()=>getPayload({ const getCachedPayload = cache(async ()=>getPayload({
config: await config config: await config
@@ -40,29 +40,29 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js';
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return payload.findGlobal({ return payload.findGlobal({
slug: settingsSlug, slug: settingsSlug,
locale: locale, depth: 2,
depth: 2 locale: locale
}); });
}); });
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{ /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return resolveRouteRaw({ return resolveRouteRaw({
payload,
locale,
segments,
page,
content, content,
locale,
page,
pagesSlug, pagesSlug,
payload,
segments,
settingsSlug settingsSlug
}); });
}); });
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{ /** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return getArchiveEntries({ return getArchiveEntries({
payload,
collection, collection,
locale, locale,
page, page,
payload,
perPage perPage
}); });
}); });
@@ -92,11 +92,11 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js';
} }
try { try {
return await buildSitemapEntries({ return await buildSitemapEntries({
payload: await getCachedPayload(),
config: i18n,
baseUrl: origin, baseUrl: origin,
config: i18n,
content, content,
pagesSlug, pagesSlug,
payload: await getCachedPayload(),
settingsSlug settingsSlug
}); });
} catch (error) { } catch (error) {
@@ -117,14 +117,103 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js';
*/ const robots = ()=>buildRobots({ */ const robots = ()=>buildRobots({
baseUrl: origin baseUrl: origin
}); });
/**
* Next.js generateStaticParams for the [[...slug]] route (or
* [locale]/[[...slug]]). Returns every routable page as a params object, so
* Next PRE-RENDERS them as static (SSG) instead of dynamic.
*
* Why this matters beyond convenience: an optional catch-all with no
* generateStaticParams is treated as a DYNAMIC route (ƒ), which streams
* metadata into <body> (crawlers miss it). Providing generateStaticParams
* compiles routes as SSG (●) — the <head> is synchronous and complete. This is
* the strongest fix for the metadata-in-head problem (stronger than ISR alone).
*
* Handles automatically:
* - pages collection + content collections (with their archive prefix)
* - excludes the homepage (maps to { slug: [] } — the root)
* - excludes drafts, 404/500/system slugs, and meta.noindex docs
* - single-locale → { slug }[]; multi-locale → { locale, slug }[]
*
* Wire it in the project:
* // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])
* export { generateStaticParams } from '@/lib/content'
*/ const generateStaticParams = async ()=>{
const payload = await getCachedPayload();
const locales = i18n ? i18n.locales.map((l)=>l.code) : [
undefined
];
const singleLocale = !i18n || i18n.locales.length === 1;
// Home slug per locale, to exclude the homepage (it's the root, slug []).
const settings = await payload.findGlobal({
slug: settingsSlug,
depth: 1,
locale: 'all'
}).catch(()=>null);
const homeId = settings?.homepage?.id;
const EXCLUDED = new Set([
'404',
'500',
'error',
'not-found'
]);
const params = [];
for (const locale of locales){
const result = await payload.find({
collection: pagesSlug,
depth: 0,
limit: 1000,
locale: locale ?? 'all',
where: {
_status: {
not_equals: 'draft'
}
}
});
for (const raw of result.docs){
if (raw._status && raw._status !== 'published') {
continue;
}
if (raw.meta?.noindex) {
continue;
}
if (homeId && raw.id === homeId) {
// Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.
const empty = singleLocale ? {
slug: []
} : {
slug: [],
locale: locale
};
if (!params.some((p)=>JSON.stringify(p) === JSON.stringify(empty))) {
params.push(empty);
}
continue;
}
const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
if (!slug || EXCLUDED.has(slug)) {
continue;
}
// Multi-level slugs ('atrakcje/telefon') → array segments.
const segments = slug.split('/').filter(Boolean);
params.push(singleLocale ? {
slug: segments
} : {
slug: segments,
locale: locale
});
}
}
return params;
};
return { return {
generateStaticParams,
getCachedPayload, getCachedPayload,
getConfiguredLocales, getConfiguredLocales,
getEntries,
getSettings, getSettings,
resolveRoute, resolveRoute,
getEntries, robots,
sitemap, sitemap
robots
}; };
} }
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
import type { BasePayload } from 'payload';
import type { I18nConfig } from '../i18n/index.js';
type BuildLlmsTxtArgs = {
/** Absolute site URL (https://…). */
baseUrl: string;
config: I18nConfig;
/** Locale for names/descriptions. Defaults to config.defaultLocale. */
locale?: string;
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string;
payload: BasePayload;
/** Site settings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
};
/**
* Builds the body of /llms.txt — a Markdown file describing the site for AI
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
* buildSitemapEntries: the plugin already knows the site's name, description,
* and pages, so it can generate this automatically.
*
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
* key pages as Markdown links with short descriptions. Agents read this to
* understand the site quickly without crawling everything.
*
* Wire it as a route that returns text/plain:
*
* // app/llms.txt/route.ts
* import { buildLlmsTxt } from '@intecion/ipal-kit'
* import { getCachedPayload } from '@/lib/content'
* import { i18nConfig } from '@/i18n.config'
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const body = await buildLlmsTxt({
* payload: await getCachedPayload(),
* config: i18nConfig,
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
* })
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
* }
*
* Data comes from the panel (siteName, siteDescription, pages) — nothing
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
*/
export declare function buildLlmsTxt({ baseUrl, config, locale, pagesSlug, payload, settingsSlug, }: BuildLlmsTxtArgs): Promise<string>;
export {};
+90
View File
@@ -0,0 +1,90 @@
import { buildLocalizedPath } from '../i18n/index.js';
/**
* Builds the body of /llms.txt — a Markdown file describing the site for AI
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
* buildSitemapEntries: the plugin already knows the site's name, description,
* and pages, so it can generate this automatically.
*
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
* key pages as Markdown links with short descriptions. Agents read this to
* understand the site quickly without crawling everything.
*
* Wire it as a route that returns text/plain:
*
* // app/llms.txt/route.ts
* import { buildLlmsTxt } from '@intecion/ipal-kit'
* import { getCachedPayload } from '@/lib/content'
* import { i18nConfig } from '@/i18n.config'
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const body = await buildLlmsTxt({
* payload: await getCachedPayload(),
* config: i18nConfig,
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
* })
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
* }
*
* Data comes from the panel (siteName, siteDescription, pages) — nothing
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
*/ export async function buildLlmsTxt({ baseUrl, config, locale, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
const loc = locale ?? config.defaultLocale;
const origin = baseUrl.replace(/\/$/, '');
const settings = await payload.findGlobal({
slug: settingsSlug,
depth: 0,
locale: loc
});
const name = settings.siteName?.trim() || 'Website';
const description = settings.siteDescription?.trim();
const result = await payload.find({
collection: pagesSlug,
depth: 0,
limit: 1000,
locale: loc,
where: {
_status: {
not_equals: 'draft'
}
}
});
const lines = [
`# ${name}`,
''
];
if (description) {
lines.push(`> ${description}`, '');
}
const pageLinks = [];
for (const raw of result.docs){
if (raw._status && raw._status !== 'published') {
continue;
}
if (raw.meta?.noindex) {
continue;
}
const title = typeof raw.title === 'string' ? raw.title : undefined;
const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
if (!title || !slug) {
continue;
}
const path = buildLocalizedPath({
config,
locale: loc,
slugs: {
[loc]: slug
}
});
if (!path) {
continue;
}
const pageDesc = typeof raw.meta?.description === 'string' ? ` — ${raw.meta.description}` : '';
pageLinks.push(`- [${title}](${origin}${path})${pageDesc}`);
}
if (pageLinks.length > 0) {
lines.push('## Strony', '', ...pageLinks, '');
}
return lines.join('\n');
}
//# sourceMappingURL=buildLlmsTxt.js.map
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -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 | Record<string, string>; homeSlug?: Record<string, 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. */
@@ -63,6 +63,12 @@ type BuildMetadataArgs = {
query?: string; query?: string;
/** Separator between page title and site name. Defaults to ' | '. */ /** Separator between page title and site name. Defaults to ' | '. */
separator?: string; separator?: string;
/**
* Global fallback description (from SiteSettings.siteDescription). Used when a
* page has no meta.description — better than emitting no description at all
* (Google would then scrape random on-page text for the snippet).
*/
siteDescription?: null | string;
/** Site name for title composition and OG. */ /** Site name for title composition and OG. */
siteName?: null | string; siteName?: null | string;
/** slug per locale for this document — drives canonical + hreflang. */ /** slug per locale for this document — drives canonical + hreflang. */
@@ -77,5 +83,5 @@ type BuildMetadataArgs = {
* pieces (meta group, site name, image URL, localized slugs) and passes them * pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch. * in — the plugin composes, it doesn't fetch.
*/ */
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata; export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteDescription, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
export {}; export {};
+2 -2
View File
@@ -9,7 +9,7 @@ import { buildHreflangAlternates } from './hreflang.js';
* Designed for use inside Next.js `generateMetadata`. The caller resolves the * Designed for use inside Next.js `generateMetadata`. The caller resolves the
* pieces (meta group, site name, image URL, localized slugs) and passes them * pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch. * in — the plugin composes, it doesn't fetch.
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) { */ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteDescription, siteName, slugs }) {
// Title source priority: titleOverride (exact, wins outright) > meta.title // Title source priority: titleOverride (exact, wins outright) > meta.title
// (SEO title an editor set) > pageTitle (the document's own name). This means // (SEO title an editor set) > pageTitle (the document's own name). This means
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur') // a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
@@ -22,7 +22,7 @@ import { buildHreflangAlternates } from './hreflang.js';
separator, separator,
siteName siteName
}); });
const description = meta?.description?.trim() || undefined; const description = meta?.description?.trim() || siteDescription?.trim() || undefined;
const origin = baseUrl?.replace(/\/$/, '') ?? ''; const origin = baseUrl?.replace(/\/$/, '') ?? '';
const suffix = query ?? ''; const suffix = query ?? '';
const currentPath = buildLocalizedPath({ const currentPath = buildLocalizedPath({
File diff suppressed because one or more lines are too long
+1
View File
@@ -4,6 +4,7 @@ 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';
export { buildLlmsTxt } from './buildLlmsTxt.js';
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'; export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
export { buildMetadata } from './buildMetadata.js'; export { buildMetadata } from './buildMetadata.js';
export type { PageMetadata } from './buildMetadata.js'; export type { PageMetadata } from './buildMetadata.js';
+1
View File
@@ -3,6 +3,7 @@ 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';
export { buildLlmsTxt } from './buildLlmsTxt.js';
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'; export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
export { buildMetadata } from './buildMetadata.js'; export { buildMetadata } from './buildMetadata.js';
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'; export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
+1 -1
View File
@@ -1 +1 @@
{"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"} {"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 { buildLlmsTxt } from './buildLlmsTxt.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","buildLlmsTxt","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,YAAY,QAAQ,oBAAmB;AAChD,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"}
+8 -6
View File
@@ -1,10 +1,6 @@
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 = {
siteName: string | null;
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string;
order?: TitleOrder;
/** /**
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map * 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 * (not a single string) so multilingual homepages collapse to root in EVERY
@@ -12,6 +8,12 @@ export type SiteMetaConfig = {
* instead of '/de', breaking return tags (a real GSC error). * instead of '/de', breaking return tags (a real GSC error).
*/ */
homeSlug?: Record<string, string>; homeSlug?: Record<string, string>;
order?: TitleOrder;
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string;
/** Global fallback meta description. */
siteDescription?: null | string;
siteName: null | 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.
@@ -21,9 +23,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({ payload, locale, settingsSlug, siteNameField, }: { export declare function readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField, }: {
payload: BasePayload;
locale: string; locale: string;
payload: BasePayload;
settingsSlug?: string; settingsSlug?: string;
siteNameField?: string; siteNameField?: string;
}): Promise<SiteMetaConfig>; }): Promise<SiteMetaConfig>;
+15 -10
View File
@@ -5,13 +5,13 @@
* 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({ payload, locale, settingsSlug = 'site-settings', siteNameField = 'siteName' }) { */ export async function readSiteMetaConfig({ locale, payload, 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,
locale: locale, depth: 1,
depth: 1 locale: locale
}); });
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 // Build the per-locale home slug map. The homepage's slug is localized, so a
@@ -22,18 +22,22 @@
if (homepage?.id) { if (homepage?.id) {
try { try {
const homeDoc = await payload.findByID({ const homeDoc = await payload.findByID({
collection: 'pages',
id: homepage.id, id: homepage.id,
locale: 'all', collection: 'pages',
depth: 0 depth: 0,
locale: 'all'
}); });
const slugField = homeDoc.slug; const slugField = homeDoc.slug;
if (slugField && typeof slugField === 'object') { if (slugField && typeof slugField === 'object') {
const map = {}; const map = {};
for (const [loc, value] of Object.entries(slugField)){ for (const [loc, value] of Object.entries(slugField)){
if (typeof value === 'string') map[loc] = value; if (typeof value === 'string') {
map[loc] = value;
}
}
if (Object.keys(map).length > 0) {
homeSlug = map;
} }
if (Object.keys(map).length > 0) homeSlug = map;
} else if (typeof slugField === 'string') { } else if (typeof slugField === 'string') {
// Non-localized slug (same in every language) — key it by current locale; // Non-localized slug (same in every language) — key it by current locale;
// buildLocalizedPath tolerates a partial map. Non-localized homepage // buildLocalizedPath tolerates a partial map. Non-localized homepage
@@ -54,11 +58,12 @@
}; };
} }
return { return {
siteDescription: settings.siteDescription ?? null,
siteName: settings[siteNameField] ?? null, siteName: settings[siteNameField] ?? null,
// The panel stores '|'; titles need it padded. // The panel stores '|'; titles need it padded.
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined, homeSlug,
order: settings.titleOrder ?? undefined, order: settings.titleOrder ?? undefined,
homeSlug separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
}; };
} }
File diff suppressed because one or more lines are too long
+49
View File
@@ -53,3 +53,52 @@ stopce / bannerze cookies bierzesz z `getSystemPagePath({ role: privacyPolicy })
import { ALL_SYSTEM_PAGE_ROLES } from '@intecion/ipal-kit' import { ALL_SYSTEM_PAGE_ROLES } from '@intecion/ipal-kit'
// ['homepage', 'privacyPolicy', 'cookiePolicy'] // ['homepage', 'privacyPolicy', 'cookiePolicy']
``` ```
## KRYTYCZNE: generateStaticParams dla [[...slug]] (SEO + head)
Trasa `[[...slug]]` (opcjonalny catch-all) BEZ `generateStaticParams` jest przez
Next traktowana jako **dynamiczna** (`ƒ Dynamic`). W trybie dynamicznym z React 19
serwer wysyła pusty `<head>`, a metadata streamuje na końcu `<body>` — crawlery
(Lighthouse, Screaming Frog) nie widzą `<meta description>` w head → SEO spada.
**Z `generateStaticParams` trasa kompiluje się jako SSG (`●`)** → synchroniczny,
kompletny `<head>` → SEO 100/100. To najsilniejsze rozwiązanie problemu
metadata-w-head (mocniejsze niż samo ISR/htmlLimitedBots).
Plugin dostarcza gotowy generateStaticParams przez createContentHelpers:
```ts
// lib/content.ts — dodaj do destrukturyzacji
export const {
getCachedPayload, getSettings, resolveRoute,
generateStaticParams, // ← z pluginu
sitemap, robots,
} = createContentHelpers({ config, content: contentConfig, i18n: i18nConfig, baseUrl })
// app/(frontend)/[[...slug]]/page.tsx (albo [locale]/[[...slug]])
export { generateStaticParams } from '@/lib/content'
```
Helper automatycznie: pobiera pages + kolekcje treści, wyklucza homepage (→ root),
drafty, 404/500, noindex; zwraca `{slug}[]` (jednojęzyczny) albo
`{locale, slug}[]` (wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`).
## PUŁAPKA: await searchParams deoptymalizuje ISR
W Next 15/16 `searchParams` to Promise. Odczyt `const { page } = await searchParams`
w komponencie strony **deoptymalizuje ISR** — wymusza dynamiczne renderowanie dla
tego requestu (traci cały zysk SSG/ISR + wraca problem metadata w body).
- **Strona BEZ paginacji** → NIE przekazuj/nie czytaj `searchParams` wcale:
```ts
export default async function Page({ params }) { // bez searchParams
const { locale, slug } = await params
const route = await resolveRoute(locale, slug ?? []) // bez page
}
```
- **Strona Z paginacją** (archiwum) → czytaj searchParams, ale świadomie (ta trasa
będzie dynamiczna). Rozważ osobną trasę dla archiwum z paginacją, żeby zwykłe
strony zostały SSG.
Reguła: `searchParams` tylko tam, gdzie NAPRAWDĘ potrzebujesz (paginacja). Wszędzie
indziej pomiń — inaczej tracisz SSG i SEO.
+43
View File
@@ -842,3 +842,46 @@ const jsonLd = buildArticleJsonLd({
``` ```
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result. Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
## Fallback meta description (siteDescription)
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
SiteSettings. Lepsze niż brak description (Google losowo wyciąga tekst ze strony).
- Pole **siteDescription** w SiteSettings → General (localized, textarea)
- buildMetadata: `meta.description || siteDescription`
- Redaktor wypełnia raz globalnie; strony bez własnego opisu dziedziczą
## llms.txt — opis strony dla agentów AI (GEO)
`buildLlmsTxt` generuje `/llms.txt` (standard llmstxt.org) — plik Markdown
opisujący stronę dla agentów AI / crawlerów LLM. Na wzór buildRobots/sitemap:
plugin zna nazwę, opis i strony, więc generuje automatycznie.
```ts
// app/llms.txt/route.ts
import { buildLlmsTxt } from '@intecion/ipal-kit'
import { getCachedPayload } from '@/lib/content'
import { i18nConfig } from '@/i18n.config'
export const dynamic = 'force-dynamic'
export async function GET() {
const body = await buildLlmsTxt({
payload: await getCachedPayload(),
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
})
return new Response(body, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}
```
Struktura: H1 (nazwa firmy), blockquote (siteDescription), lista stron jako
linki Markdown z opisami. Dane z panelu (siteName, siteDescription, strony),
pomija drafty i noindex. Poprawia widoczność w wyszukiwaniach AI (GEO —
Generative Engine Optimization) i audytach „Agentic Browsing”.
Wymaga wypełnionego siteDescription i sensownych meta.description stron
(inaczej llms.txt będzie ubogi).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.2.6", "version": "1.2.8",
"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": {
+12 -5
View File
@@ -4,7 +4,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js'
/** /**
* General site identity fields. * General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon). * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
*/ */
export const generalFields: Field[] = [ export const generalFields: Field[] = [
{ {
@@ -16,6 +16,15 @@ export const generalFields: Field[] = [
localized: true, localized: true,
required: true, required: true,
}, },
{
name: 'siteDescription',
type: 'textarea',
admin: {
description:
'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.',
},
localized: true,
},
{ {
name: 'titleOrder', name: 'titleOrder',
type: 'select', type: 'select',
@@ -47,8 +56,7 @@ export const generalFields: Field[] = [
name: 'logo', name: 'logo',
type: 'upload', type: 'upload',
admin: { admin: {
description: description: 'Primary site logo.',
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
}, },
relationTo: 'media', relationTo: 'media',
}, },
@@ -68,8 +76,7 @@ export const generalFields: Field[] = [
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.', 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
}, },
hooks: { hooks: {
// Warns the editor at save time if the favicon is too small (<48×48) or // Warn the editor if the favicon is too small / not square for Google.
// not square — Google won't display such favicons in search results.
beforeValidate: [validateFaviconField], beforeValidate: [validateFaviconField],
}, },
relationTo: 'media', relationTo: 'media',
+1
View File
@@ -103,6 +103,7 @@ export {
buildArticleJsonLd, buildArticleJsonLd,
buildFaqJsonLd, buildFaqJsonLd,
buildIconsMetadata, buildIconsMetadata,
buildLlmsTxt,
buildLocalBusinessJsonLd, buildLocalBusinessJsonLd,
buildOrganizationJsonLd, buildOrganizationJsonLd,
buildServiceJsonLd, buildServiceJsonLd,
+105 -27
View File
@@ -1,14 +1,22 @@
import { cache } from 'react'
import type { BasePayload, SanitizedConfig } from 'payload' import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload'
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js' import { getPayload } from 'payload'
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js' import { cache } from 'react'
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'
import type { I18nConfig } from '../i18n/index.js' import type { I18nConfig } from '../i18n/index.js'
import type { SitemapEntry, RobotsRules } from '../seo/index.js' import type { RobotsRules, SitemapEntry } from '../seo/index.js'
import { buildSitemapEntries, buildRobots } from '../seo/index.js'
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'
import { buildRobots, buildSitemapEntries } from '../seo/index.js'
type CreateContentHelpersArgs = { type CreateContentHelpersArgs = {
/**
* Absolute site origin for sitemap/robots URLs. Falls back to
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
* reject, so set one in production).
*/
baseUrl?: string
/** /**
* The client's payload config promise (the default export of payload.config). * The client's payload config promise (the default export of payload.config).
* Passed in because the plugin never imports the client's config directly. * Passed in because the plugin never imports the client's config directly.
@@ -16,21 +24,15 @@ type CreateContentHelpersArgs = {
config: Promise<SanitizedConfig> | SanitizedConfig config: Promise<SanitizedConfig> | SanitizedConfig
/** 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
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
/** /**
* i18n config. Required only if you want the ready-made `sitemap` / `robots` * i18n config. Required only if you want the ready-made `sitemap` / `robots`
* handlers — they need the locale list to emit hreflang. * handlers — they need the locale list to emit hreflang.
*/ */
i18n?: I18nConfig i18n?: I18nConfig
/** /** Pages collection slug. Defaults to 'pages'. */
* Absolute site origin for sitemap/robots URLs. Falls back to pagesSlug?: string
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers /** SiteSettings global slug. Defaults to 'site-settings'. */
* reject, so set one in production). settingsSlug?: string
*/
baseUrl?: string
} }
/** /**
@@ -60,12 +62,12 @@ type CreateContentHelpersArgs = {
* not the second, and a page component composes them in two obvious lines. * not the second, and a page component composes them in two obvious lines.
*/ */
export function createContentHelpers({ export function createContentHelpers({
baseUrl,
config, config,
content, content,
settingsSlug = 'site-settings',
pagesSlug = 'pages',
i18n, i18n,
baseUrl, pagesSlug = 'pages',
settingsSlug = 'site-settings',
}: CreateContentHelpersArgs) { }: CreateContentHelpersArgs) {
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '' const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
const getCachedPayload = cache(async (): Promise<BasePayload> => const getCachedPayload = cache(async (): Promise<BasePayload> =>
@@ -79,7 +81,7 @@ export function createContentHelpers({
const getSettings = cache(async (locale: string) => { const getSettings = cache(async (locale: string) => {
const payload = await getCachedPayload() const payload = await getCachedPayload()
return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 }) return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
}) })
/** What does this URL point at? Routing only — no listing data. */ /** What does this URL point at? Routing only — no listing data. */
@@ -88,9 +90,9 @@ export function createContentHelpers({
locale: string, locale: string,
segments: string[] | undefined, segments: string[] | undefined,
page: number, page: number,
): Promise<ResolvedRoute | null> => { ): Promise<null | ResolvedRoute> => {
const payload = await getCachedPayload() const payload = await getCachedPayload()
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug }) return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
}, },
) )
@@ -103,7 +105,7 @@ export function createContentHelpers({
perPage: number, perPage: number,
): Promise<ArchiveEntries> => { ): Promise<ArchiveEntries> => {
const payload = await getCachedPayload() const payload = await getCachedPayload()
return getArchiveEntries({ payload, collection, locale, page, perPage }) return getArchiveEntries({ collection, locale, page, payload, perPage })
}, },
) )
@@ -134,11 +136,11 @@ export function createContentHelpers({
} }
try { try {
return await buildSitemapEntries({ return await buildSitemapEntries({
payload: await getCachedPayload(),
config: i18n,
baseUrl: origin, baseUrl: origin,
config: i18n,
content, content,
pagesSlug, pagesSlug,
payload: await getCachedPayload(),
settingsSlug, settingsSlug,
}) })
} catch (error) { } catch (error) {
@@ -165,13 +167,89 @@ export function createContentHelpers({
*/ */
const robots = (): RobotsRules => buildRobots({ baseUrl: origin }) const robots = (): RobotsRules => buildRobots({ baseUrl: origin })
/**
* Next.js generateStaticParams for the [[...slug]] route (or
* [locale]/[[...slug]]). Returns every routable page as a params object, so
* Next PRE-RENDERS them as static (SSG) instead of dynamic.
*
* Why this matters beyond convenience: an optional catch-all with no
* generateStaticParams is treated as a DYNAMIC route (ƒ), which streams
* metadata into <body> (crawlers miss it). Providing generateStaticParams
* compiles routes as SSG (●) — the <head> is synchronous and complete. This is
* the strongest fix for the metadata-in-head problem (stronger than ISR alone).
*
* Handles automatically:
* - pages collection + content collections (with their archive prefix)
* - excludes the homepage (maps to { slug: [] } — the root)
* - excludes drafts, 404/500/system slugs, and meta.noindex docs
* - single-locale → { slug }[]; multi-locale → { locale, slug }[]
*
* Wire it in the project:
* // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])
* export { generateStaticParams } from '@/lib/content'
*/
const generateStaticParams = async (): Promise<
Array<{ locale: string; slug: string[] } | { slug: string[] }>
> => {
const payload = await getCachedPayload()
const locales = i18n ? i18n.locales.map((l) => l.code) : [undefined]
const singleLocale = !i18n || i18n.locales.length === 1
// Home slug per locale, to exclude the homepage (it's the root, slug []).
const settings = (await payload
.findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })
.catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null
const homeId = settings?.homepage?.id
const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])
const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []
for (const locale of locales) {
const result = await payload.find({
collection: pagesSlug as never,
depth: 0,
limit: 1000,
locale: (locale ?? 'all') as never,
where: { _status: { not_equals: 'draft' } } as never,
})
for (const raw of result.docs as Array<{
_status?: string
id: number | string
meta?: { noindex?: boolean } | null
slug?: unknown
}>) {
if (raw._status && raw._status !== 'published') {continue}
if (raw.meta?.noindex) {continue}
if (homeId && raw.id === homeId) {
// Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.
const empty = singleLocale ? { slug: [] } : { slug: [], locale: locale as string }
if (!params.some((p) => JSON.stringify(p) === JSON.stringify(empty))) {params.push(empty)}
continue
}
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
if (!slug || EXCLUDED.has(slug)) {continue}
// Multi-level slugs ('atrakcje/telefon') → array segments.
const segments = slug.split('/').filter(Boolean)
params.push(
singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },
)
}
}
return params
}
return { return {
generateStaticParams,
getCachedPayload, getCachedPayload,
getConfiguredLocales, getConfiguredLocales,
getEntries,
getSettings, getSettings,
resolveRoute, resolveRoute,
getEntries,
sitemap,
robots, robots,
sitemap,
} }
} }
+116
View File
@@ -0,0 +1,116 @@
import type { BasePayload } from 'payload'
import type { I18nConfig } from '../i18n/index.js'
import { buildLocalizedPath } from '../i18n/index.js'
type BuildLlmsTxtArgs = {
/** Absolute site URL (https://…). */
baseUrl: string
config: I18nConfig
/** Locale for names/descriptions. Defaults to config.defaultLocale. */
locale?: string
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
payload: BasePayload
/** Site settings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
}
type SettingsShape = {
siteDescription?: null | string
siteName?: null | string
}
type PageRow = {
_status?: string
id: number | string
meta?: { description?: unknown; noindex?: boolean } | null
slug?: unknown
title?: unknown
}
/**
* Builds the body of /llms.txt — a Markdown file describing the site for AI
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
* buildSitemapEntries: the plugin already knows the site's name, description,
* and pages, so it can generate this automatically.
*
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
* key pages as Markdown links with short descriptions. Agents read this to
* understand the site quickly without crawling everything.
*
* Wire it as a route that returns text/plain:
*
* // app/llms.txt/route.ts
* import { buildLlmsTxt } from '@intecion/ipal-kit'
* import { getCachedPayload } from '@/lib/content'
* import { i18nConfig } from '@/i18n.config'
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const body = await buildLlmsTxt({
* payload: await getCachedPayload(),
* config: i18nConfig,
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
* })
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
* }
*
* Data comes from the panel (siteName, siteDescription, pages) — nothing
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
*/
export async function buildLlmsTxt({
baseUrl,
config,
locale,
pagesSlug = 'pages',
payload,
settingsSlug = 'site-settings',
}: BuildLlmsTxtArgs): Promise<string> {
const loc = locale ?? config.defaultLocale
const origin = baseUrl.replace(/\/$/, '')
const settings = (await payload.findGlobal({
slug: settingsSlug as never,
depth: 0,
locale: loc as never,
})) as SettingsShape
const name = settings.siteName?.trim() || 'Website'
const description = settings.siteDescription?.trim()
const result = await payload.find({
collection: pagesSlug as never,
depth: 0,
limit: 1000,
locale: loc as never,
where: { _status: { not_equals: 'draft' } } as never,
})
const lines: string[] = [`# ${name}`, '']
if (description) {
lines.push(`> ${description}`, '')
}
const pageLinks: string[] = []
for (const raw of result.docs as PageRow[]) {
if (raw._status && raw._status !== 'published') {continue}
if (raw.meta?.noindex) {continue}
const title = typeof raw.title === 'string' ? raw.title : undefined
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
if (!title || !slug) {continue}
const path = buildLocalizedPath({ config, locale: loc, slugs: { [loc]: slug } })
if (!path) {continue}
const pageDesc = typeof raw.meta?.description === 'string' ? ` — ${raw.meta.description}` : ''
pageLinks.push(`- [${title}](${origin}${path})${pageDesc}`)
}
if (pageLinks.length > 0) {
lines.push('## Strony', '', ...pageLinks, '')
}
return lines.join('\n')
}
+9 -2
View File
@@ -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 | Record<string, string> homeSlug?: Record<string, 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. */
@@ -67,6 +67,12 @@ type BuildMetadataArgs = {
query?: string query?: string
/** Separator between page title and site name. Defaults to ' | '. */ /** Separator between page title and site name. Defaults to ' | '. */
separator?: string separator?: string
/**
* Global fallback description (from SiteSettings.siteDescription). Used when a
* page has no meta.description — better than emitting no description at all
* (Google would then scrape random on-page text for the snippet).
*/
siteDescription?: null | string
/** Site name for title composition and OG. */ /** Site name for title composition and OG. */
siteName?: null | string siteName?: null | string
/** slug per locale for this document — drives canonical + hreflang. */ /** slug per locale for this document — drives canonical + hreflang. */
@@ -94,6 +100,7 @@ export function buildMetadata({
prefix, prefix,
query, query,
separator, separator,
siteDescription,
siteName, siteName,
slugs, slugs,
}: BuildMetadataArgs): PageMetadata { }: BuildMetadataArgs): PageMetadata {
@@ -105,7 +112,7 @@ export function buildMetadata({
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
const title = const title =
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName }) override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
const description = meta?.description?.trim() || undefined const description = meta?.description?.trim() || siteDescription?.trim() || undefined
const origin = baseUrl?.replace(/\/$/, '') ?? '' const origin = baseUrl?.replace(/\/$/, '') ?? ''
const suffix = query ?? '' const suffix = query ?? ''
+1
View File
@@ -4,6 +4,7 @@ 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'
export { buildLlmsTxt } from './buildLlmsTxt.js'
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js' export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'
export { buildMetadata } from './buildMetadata.js' export { buildMetadata } from './buildMetadata.js'
export type { PageMetadata } from './buildMetadata.js' export type { PageMetadata } from './buildMetadata.js'
+20 -16
View File
@@ -1,18 +1,15 @@
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 = {
siteName: string | null
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string
order?: TitleOrder
/** /**
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map * 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 * (not a single string) so multilingual homepages collapse to root in EVERY
@@ -20,6 +17,12 @@ export type SiteMetaConfig = {
* instead of '/de', breaking return tags (a real GSC error). * instead of '/de', breaking return tags (a real GSC error).
*/ */
homeSlug?: Record<string, string> homeSlug?: Record<string, string>
order?: TitleOrder
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string
/** Global fallback meta description. */
siteDescription?: null | string
siteName: null | string
} }
/** /**
@@ -31,13 +34,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({
payload,
locale, locale,
payload,
settingsSlug = 'site-settings', settingsSlug = 'site-settings',
siteNameField = 'siteName', siteNameField = 'siteName',
}: { }: {
payload: BasePayload
locale: string locale: string
payload: BasePayload
settingsSlug?: string settingsSlug?: string
siteNameField?: string siteNameField?: string
}): Promise<SiteMetaConfig> { }): Promise<SiteMetaConfig> {
@@ -45,8 +48,8 @@ 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,
locale: locale as never,
depth: 1, depth: 1,
locale: locale as never,
})) as SettingsShape })) as SettingsShape
const homepage = const homepage =
@@ -60,18 +63,18 @@ export async function readSiteMetaConfig({
if (homepage?.id) { if (homepage?.id) {
try { try {
const homeDoc = (await payload.findByID({ const homeDoc = (await payload.findByID({
collection: 'pages' as never,
id: homepage.id, id: homepage.id,
locale: 'all' as never, collection: 'pages' as never,
depth: 0, depth: 0,
locale: 'all' as never,
})) as { slug?: unknown } })) as { slug?: unknown }
const slugField = homeDoc.slug const slugField = homeDoc.slug
if (slugField && typeof slugField === 'object') { if (slugField && typeof slugField === 'object') {
const map: Record<string, string> = {} const map: Record<string, string> = {}
for (const [loc, value] of Object.entries(slugField as Record<string, unknown>)) { for (const [loc, value] of Object.entries(slugField as Record<string, unknown>)) {
if (typeof value === 'string') map[loc] = value if (typeof value === 'string') {map[loc] = value}
} }
if (Object.keys(map).length > 0) homeSlug = map if (Object.keys(map).length > 0) {homeSlug = map}
} else if (typeof slugField === 'string') { } else if (typeof slugField === 'string') {
// Non-localized slug (same in every language) — key it by current locale; // Non-localized slug (same in every language) — key it by current locale;
// buildLocalizedPath tolerates a partial map. Non-localized homepage // buildLocalizedPath tolerates a partial map. Non-localized homepage
@@ -90,10 +93,11 @@ export async function readSiteMetaConfig({
} }
return { return {
siteDescription: (settings.siteDescription as string | undefined) ?? null,
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.
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
order: settings.titleOrder ?? undefined,
homeSlug, homeSlug,
order: settings.titleOrder ?? undefined,
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
} }
} }