Compare commits

..
27 Commits
Author SHA1 Message Date
radoslaw.smolinski 026c2696b6 1.3.0 2026-09-25 12:50:35 +02:00
radoslaw.smolinski 6cb4168f44 Turnstile improvement 2026-09-25 12:50:15 +02:00
radoslaw.smolinski b82ef82f50 Added protecting hooks 2026-09-25 12:42:38 +02:00
radoslaw.smolinski 9a18434f4a 1.2.13 2026-09-19 22:32:36 +02:00
radoslaw.smolinski f126eacf8c Fixed builCsp.ts 2026-09-19 22:32:29 +02:00
radoslaw.smolinski 848b12ce5d 1.2.12 2026-09-19 22:24:42 +02:00
radoslaw.smolinski 3d350bd9e5 Added styled Xml 2026-09-19 22:24:36 +02:00
radoslaw.smolinski e03dd8c5a6 1.2.11 2026-09-19 22:08:02 +02:00
radoslaw.smolinski 9101a5b48e Added security scripts support 2026-09-19 22:07:57 +02:00
radoslaw.smolinski 1186f4f620 1.2.10 2026-09-15 22:16:04 +02:00
radoslaw.smolinski 988fcaf855 fix createContentHelpers.ts 2026-09-15 22:16:01 +02:00
radoslaw.smolinski b27031f7c4 1.2.9 2026-09-15 21:55:51 +02:00
radoslaw.smolinski 7ee60e7313 SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup 2026-09-15 21:55:48 +02:00
radoslaw.smolinski 671090b1b1 1.2.8 2026-09-15 21:16:59 +02:00
radoslaw.smolinski 0f03bb7446 SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup 2026-09-15 21:16:53 +02:00
radoslaw.smolinski 55a1e5bf8f 1.2.7 2026-09-15 15:31:12 +02:00
radoslaw.smolinski 33ae035adc SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup 2026-09-15 15:31:08 +02:00
radoslaw.smolinski 87d575f335 1.2.6 2026-09-09 22:21:09 +02:00
radoslaw.smolinski e4329f76b1 Fixed buildSitemapEntries.ts 2026-09-09 22:21:04 +02:00
radoslaw.smolinski fa4cf20022 1.2.5 2026-09-09 09:57:00 +02:00
radoslaw.smolinski 2ecfc2e12d Fixed support for multi-langual slugs 2026-09-09 09:56:47 +02:00
radoslaw.smolinski db2ac80a1e 1.2.4 2026-09-09 00:06:27 +02:00
radoslaw.smolinski 99490bfc4d Updated docs 2026-09-09 00:06:18 +02:00
radoslaw.smolinski fce17654f8 1.2.3 2026-09-08 18:13:16 +02:00
radoslaw.smolinski 419207ac12 Added support for single localization 2026-09-08 18:12:58 +02:00
radoslaw.smolinski e30ac71044 1.2.2 2026-09-08 14:25:26 +02:00
radoslaw.smolinski 781e348ded Page title generator on browser tab rebuilded. 2026-09-08 14:25:15 +02:00
133 changed files with 3595 additions and 458 deletions
+3 -2
View File
@@ -10,7 +10,8 @@ export { Analytics } from '../modules/analytics/client.js';
*/ */
export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext, } from '../modules/consent/client.js'; export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext, } from '../modules/consent/client.js';
export type { CookieBannerClassNames } from '../modules/consent/client.js'; export type { CookieBannerClassNames } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js';
export type { TurnstileProps } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'; export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
export type { FormNotificationTexts } from '../modules/notifications/types.js'; export type { FormNotificationTexts } from '../modules/notifications/types.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
export type { TurnstileProps } from '../modules/turnstile/client.js';
+2 -1
View File
@@ -9,7 +9,8 @@ export { Analytics } from '../modules/analytics/client.js';
* components. Kept separate from the main entry so server bundles don't pull in * components. Kept separate from the main entry so server bundles don't pull in
* client-only code. * client-only code.
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js'; */ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'; export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
//# sourceMappingURL=client.js.map //# sourceMappingURL=client.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"} {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","resolveFormMessage","Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,kBAAkB,QAAQ,iDAAgD;AAEnF,SAASC,SAAS,QAAQ,iCAAgC;AAC1D,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,iCAAgC"}
+2 -2
View File
@@ -6,11 +6,11 @@ import { notificationsFields } from './fields.js';
*/ export function buildNotifications() { */ export function buildNotifications() {
return { return {
slug: 'notifications', slug: 'notifications',
label: 'Notifications',
access: { access: {
read: ()=>true read: ()=>true
}, },
fields: notificationsFields fields: notificationsFields,
label: 'Notifications'
}; };
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n label: 'Notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","label","access","read","fields"],"mappings":"AACA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,OAAO;QACPC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQN;IACV;AACF"} {"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\n\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n label: 'Notifications',\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","access","read","fields","label"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQL;QACRM,OAAO;IACT;AACF"}
+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"}
+10 -3
View File
@@ -16,25 +16,32 @@ export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js'; export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js';
export { createContentHelpers } from './modules/frontend/index.js'; export { createContentHelpers } from './modules/frontend/index.js';
export { buildPreventDeleteSystemPage, buildRevalidateHook, buildValidateUniqueRole, setPublishedAtHook, trackSlugHistoryHook, } from './modules/hooks/index.js';
export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'; export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js';
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js'; export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js';
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'; export type { LocaleMiddlewareResult } from './modules/i18n/index.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'; export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js'; export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js'; export type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js';
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'; export type { PagesOption, SystemPageRole } from './modules/pages/index.js';
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'; export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
export type { GlobalQueryOptions } from './modules/payload/index.js'; 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 { buildCsp } from './modules/security/index.js';
export type { BuildCspArgs } 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 { 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';
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'; export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js'; export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd, } from './modules/seo/index.js';
export { buildArticleJsonLd } from './modules/seo/index.js';
export { buildSitemapXml } from './modules/seo/index.js';
export { buildLlmsTxt } from './modules/seo/index.js';
export { buildSlugField, toSlug } from './modules/slug/index.js'; export { buildSlugField, toSlug } from './modules/slug/index.js';
export { buildR2Storage } from './modules/storage/index.js'; export { buildR2Storage } from './modules/storage/index.js';
export { ipalKit } from './plugin.js'; export { ipalKit } from './plugin.js';
+9 -6
View File
@@ -10,22 +10,25 @@ export { mailAdapter } from './modules/email/mailAdapter.js';
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'; export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
export { createContentHelpers } from './modules/frontend/index.js'; export { createContentHelpers } from './modules/frontend/index.js';
export { buildPreventDeleteSystemPage, buildRevalidateHook, buildValidateUniqueRole, setPublishedAtHook, trackSlugHistoryHook } from './modules/hooks/index.js';
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js'; export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
// Media — filename normalization hook for upload collections (Media).
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'; export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js'; export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js';
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 { buildCsp } from './modules/security/index.js';
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'; export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js'; export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd } from './modules/seo/index.js';
export { buildArticleJsonLd } from './modules/seo/index.js';
export { buildSitemapXml } from './modules/seo/index.js';
export { buildLlmsTxt } from './modules/seo/index.js';
export { buildSlugField, toSlug } from './modules/slug/index.js'; export { buildSlugField, toSlug } from './modules/slug/index.js';
// Storage — Cloudflare R2 media offload, configured from .env.
export { buildR2Storage } from './modules/storage/index.js'; export { buildR2Storage } from './modules/storage/index.js';
export { ipalKit } from './plugin.js'; export { ipalKit } from './plugin.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 {};
+125 -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,123 @@ 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 and 404/500/system slugs
* - KEEPS noindex pages (they must still render — noindex controls indexing,
* not existence; skipping them would force dynamic rendering)
* - localized slugs (string or per-locale map) both handled
* - 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 ()=>{
try {
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){
// NO where:{_status} filter — collections without drafts enabled don't
// register the _status field, and querying it throws
// "path cannot be queried: _status". We filter drafts in memory below,
// which is safe for every collection (with or without drafts).
const result = await payload.find({
collection: pagesSlug,
depth: 0,
limit: 1000,
locale: locale ?? 'all'
});
for (const raw of result.docs){
// Draft filter in memory (safe whether or not the collection has drafts).
if (raw._status && raw._status !== 'published') {
continue;
}
// NOTE: unlike the sitemap, we do NOT skip meta.noindex here. A noindex
// page (privacy, cookies, terms) still needs to render — users reach it
// from the footer and crawlers read its <meta robots=noindex>. Pre-render
// it as SSG so it's fast and its <head> is complete; noindex controls
// INDEXING, not whether the page exists. Skipping it would force dynamic
// rendering (the very streaming problem we're avoiding).
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;
}
// Slug may be a plain string OR a localized map ({ pl: 'kontakt' }) when
// read with locale:'all' or left unflattened. Handle both, or localized
// pages get silently dropped.
const rawSlug = raw.slug;
const slug = typeof rawSlug === 'string' ? rawSlug : rawSlug && typeof rawSlug === 'object' ? rawSlug[locale ?? ''] ?? Object.values(rawSlug)[0] : undefined;
if (!slug || EXCLUDED.has(slug)) {
continue;
}
// Multi-level slugs ('atrakcje/telefon') → array segments.
const segments = String(slug).split('/').filter(Boolean);
params.push(singleLocale ? {
slug: segments
} : {
slug: segments,
locale: locale
});
}
}
return params;
} catch (err) {
// DB unreachable — typically a container build (Docker/Coolify/CI) with no
// database network. Return [] so the build doesn't crash: Next falls back
// to on-demand rendering for the routes, which fill in once the DB is
// reachable at runtime. Without this every project would need its own
// try/catch here. (Same graceful-degradation as the sitemap handler.)
console.warn('[ipal] generateStaticParams: database not reachable during build ' + '(Docker/CI) — returning empty params; routes render on-demand at runtime:', err);
return [];
}
};
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
+35
View File
@@ -0,0 +1,35 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload';
import type { I18nConfig } from '../i18n/index.js';
type RevalidateFn = (path: string) => void;
type BuildRevalidateHookArgs = {
config: I18nConfig;
/** Home slug (string or per-locale map) — home revalidates the root. */
homeSlug?: Record<string, string> | string;
/**
* next/cache revalidatePath, INJECTED by the project. The plugin never imports
* next/cache itself — that would crash when Payload runs as plain Node
* (generate:importmap). The project passes it: `revalidatePath` from 'next/cache'.
*/
revalidatePath: RevalidateFn;
};
/**
* Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when
* an editor saves or deletes it — so changes appear immediately instead of
* waiting for the revalidate window. Without this, ISR means editors wait; with
* it, ISR is usable for a CMS.
*
* Handles every locale, the root (home), AND a changed slug (revalidates both the
* old and new path so neither goes stale). revalidatePath is injected — the
* plugin never imports next/cache (safe under generate:importmap / plain Node).
*
* // in your Media/Pages collection config, from a project file that CAN import next/cache:
* import { revalidatePath } from 'next/cache'
* import { buildRevalidateHook } from '@intecion/ipal-kit'
* const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig })
* // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] }
*/
export declare function buildRevalidateHook({ config, homeSlug, revalidatePath, }: BuildRevalidateHookArgs): {
afterChange: CollectionAfterChangeHook;
afterDelete: CollectionAfterDeleteHook;
};
export {};
+71
View File
@@ -0,0 +1,71 @@
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
/** Resolve a doc's path in one locale (root for home). Null if no slug there. */ function pathForLocale(doc, locale, config, homeSlug) {
const slugField = doc.slug;
const slug = typeof slugField === 'string' ? slugField : slugField && typeof slugField === 'object' ? slugField[locale] : undefined;
if (!slug) {
return null;
}
return buildLocalizedPath({
config,
homeSlug,
locale,
slugs: {
[locale]: slug
}
}) ?? null;
}
/**
* Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when
* an editor saves or deletes it — so changes appear immediately instead of
* waiting for the revalidate window. Without this, ISR means editors wait; with
* it, ISR is usable for a CMS.
*
* Handles every locale, the root (home), AND a changed slug (revalidates both the
* old and new path so neither goes stale). revalidatePath is injected — the
* plugin never imports next/cache (safe under generate:importmap / plain Node).
*
* // in your Media/Pages collection config, from a project file that CAN import next/cache:
* import { revalidatePath } from 'next/cache'
* import { buildRevalidateHook } from '@intecion/ipal-kit'
* const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig })
* // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] }
*/ export function buildRevalidateHook({ config, homeSlug, revalidatePath }) {
const locales = getLocaleCodes(config);
const afterChange = ({ doc, previousDoc })=>{
const seen = new Set();
for (const locale of locales){
// New path.
const newPath = pathForLocale(doc, locale, config, homeSlug);
if (newPath && !seen.has(newPath)) {
revalidatePath(newPath);
seen.add(newPath);
}
// Old path, if the slug changed — so the old URL doesn't serve stale content.
if (previousDoc) {
const oldPath = pathForLocale(previousDoc, locale, config, homeSlug);
if (oldPath && oldPath !== newPath && !seen.has(oldPath)) {
revalidatePath(oldPath);
seen.add(oldPath);
}
}
}
return doc;
};
const afterDelete = ({ doc })=>{
const seen = new Set();
for (const locale of locales){
const path = pathForLocale(doc, locale, config, homeSlug);
if (path && !seen.has(path)) {
revalidatePath(path);
seen.add(path);
}
}
return doc;
};
return {
afterChange,
afterDelete
};
}
//# sourceMappingURL=buildRevalidateHook.js.map
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
export { normalizeFilenameHook } from '../media/index.js';
export { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js';
export { buildRevalidateHook } from './buildRevalidateHook.js';
export { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js';
export { setPublishedAtHook } from './setPublishedAt.js';
export { trackSlugHistoryHook } from './trackSlugHistory.js';
export { buildValidateUniqueRole } from './validateUniqueRole.js';
+11
View File
@@ -0,0 +1,11 @@
// Re-eksport hooków domenowych (mieszkają w swoich modułach, tu dla przeglądu —
// żeby był jeden katalog "wszystkie hooki pluginu"). Źródło prawdy to ich moduły.
export { normalizeFilenameHook } from '../media/index.js';
export { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js';
export { buildRevalidateHook } from './buildRevalidateHook.js';
export { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js';
export { setPublishedAtHook } from './setPublishedAt.js';
export { trackSlugHistoryHook } from './trackSlugHistory.js';
export { buildValidateUniqueRole } from './validateUniqueRole.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/hooks/index.ts"],"sourcesContent":["// Re-eksport hooków domenowych (mieszkają w swoich modułach, tu dla przeglądu —\n// żeby był jeden katalog \"wszystkie hooki pluginu\"). Źródło prawdy to ich moduły.\nexport { normalizeFilenameHook } from '../media/index.js'\nexport { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js'\nexport { buildRevalidateHook } from './buildRevalidateHook.js'\nexport { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js'\nexport { setPublishedAtHook } from './setPublishedAt.js'\n\nexport { trackSlugHistoryHook } from './trackSlugHistory.js'\nexport { buildValidateUniqueRole } from './validateUniqueRole.js'\n"],"names":["normalizeFilenameHook","buildAutoFillMetaHook","validateFaviconField","buildRevalidateHook","buildPreventDeleteSystemPage","setPublishedAtHook","trackSlugHistoryHook","buildValidateUniqueRole"],"mappings":"AAAA,gFAAgF;AAChF,kFAAkF;AAClF,SAASA,qBAAqB,QAAQ,oBAAmB;AACzD,SAASC,qBAAqB,EAAEC,oBAAoB,QAAQ,kBAAiB;AAC7E,SAASC,mBAAmB,QAAQ,2BAA0B;AAC9D,SAASC,4BAA4B,QAAQ,+BAA8B;AAC3E,SAASC,kBAAkB,QAAQ,sBAAqB;AAExD,SAASC,oBAAoB,QAAQ,wBAAuB;AAC5D,SAASC,uBAAuB,QAAQ,0BAAyB"}
+15
View File
@@ -0,0 +1,15 @@
import type { CollectionBeforeDeleteHook } from 'payload';
/**
* Blocks deletion of a page assigned a System Page role (homepage,
* privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy
* policy or homepage by accident would break routing and compliance links; this
* stops it with a clear error. They must unassign the role first (deliberate).
*
* Reads the role assignments from SiteSettings (which page holds which role).
*
* hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }
*/
export declare function buildPreventDeleteSystemPage(args?: {
roleFields?: string[];
settingsSlug?: string;
}): CollectionBeforeDeleteHook;
+37
View File
@@ -0,0 +1,37 @@
import { APIError } from 'payload';
/**
* Blocks deletion of a page assigned a System Page role (homepage,
* privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy
* policy or homepage by accident would break routing and compliance links; this
* stops it with a clear error. They must unassign the role first (deliberate).
*
* Reads the role assignments from SiteSettings (which page holds which role).
*
* hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }
*/ export function buildPreventDeleteSystemPage(args = {}) {
const settingsSlug = args.settingsSlug ?? 'site-settings';
const roleFields = args.roleFields ?? [
'homepage',
'privacyPolicy',
'cookiePolicy',
'termsOfService'
];
return async ({ id, req })=>{
const settings = await req.payload.findGlobal({
slug: settingsSlug,
depth: 0
}).catch(()=>null);
if (!settings) {
return;
}
for (const field of roleFields){
const assigned = settings[field];
const assignedId = assigned && typeof assigned === 'object' ? assigned.id : assigned;
if (assignedId != null && String(assignedId) === String(id)) {
throw new APIError(`Nie można usunąć strony przypisanej do roli systemowej "${field}". ` + `Najpierw odłącz rolę w Site Settings.`, 400);
}
}
};
}
//# sourceMappingURL=preventDeleteSystemPage.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/hooks/preventDeleteSystemPage.ts"],"sourcesContent":["import type { CollectionBeforeDeleteHook } from 'payload'\n\nimport { APIError } from 'payload'\n\n/**\n * Blocks deletion of a page assigned a System Page role (homepage,\n * privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy\n * policy or homepage by accident would break routing and compliance links; this\n * stops it with a clear error. They must unassign the role first (deliberate).\n *\n * Reads the role assignments from SiteSettings (which page holds which role).\n *\n * hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }\n */\nexport function buildPreventDeleteSystemPage(\n args: { roleFields?: string[]; settingsSlug?: string } = {},\n): CollectionBeforeDeleteHook {\n const settingsSlug = args.settingsSlug ?? 'site-settings'\n const roleFields = args.roleFields ?? [\n 'homepage',\n 'privacyPolicy',\n 'cookiePolicy',\n 'termsOfService',\n ]\n\n return async ({ id, req }) => {\n const settings = (await req.payload\n .findGlobal({ slug: settingsSlug as never, depth: 0 })\n .catch(() => null)) as null | Record<string, unknown>\n if (!settings) {return}\n\n for (const field of roleFields) {\n const assigned = settings[field]\n const assignedId =\n assigned && typeof assigned === 'object' ? (assigned as { id?: unknown }).id : assigned\n if (assignedId != null && String(assignedId) === String(id)) {\n throw new APIError(\n `Nie można usunąć strony przypisanej do roli systemowej \"${field}\". ` +\n `Najpierw odłącz rolę w Site Settings.`,\n 400,\n )\n }\n }\n }\n}\n"],"names":["APIError","buildPreventDeleteSystemPage","args","settingsSlug","roleFields","id","req","settings","payload","findGlobal","slug","depth","catch","field","assigned","assignedId","String"],"mappings":"AAEA,SAASA,QAAQ,QAAQ,UAAS;AAElC;;;;;;;;;CASC,GACD,OAAO,SAASC,6BACdC,OAAyD,CAAC,CAAC;IAE3D,MAAMC,eAAeD,KAAKC,YAAY,IAAI;IAC1C,MAAMC,aAAaF,KAAKE,UAAU,IAAI;QACpC;QACA;QACA;QACA;KACD;IAED,OAAO,OAAO,EAAEC,EAAE,EAAEC,GAAG,EAAE;QACvB,MAAMC,WAAY,MAAMD,IAAIE,OAAO,CAChCC,UAAU,CAAC;YAAEC,MAAMP;YAAuBQ,OAAO;QAAE,GACnDC,KAAK,CAAC,IAAM;QACf,IAAI,CAACL,UAAU;YAAC;QAAM;QAEtB,KAAK,MAAMM,SAAST,WAAY;YAC9B,MAAMU,WAAWP,QAAQ,CAACM,MAAM;YAChC,MAAME,aACJD,YAAY,OAAOA,aAAa,WAAW,AAACA,SAA8BT,EAAE,GAAGS;YACjF,IAAIC,cAAc,QAAQC,OAAOD,gBAAgBC,OAAOX,KAAK;gBAC3D,MAAM,IAAIL,SACR,CAAC,wDAAwD,EAAEa,MAAM,GAAG,CAAC,GACnE,CAAC,qCAAqC,CAAC,EACzC;YAEJ;QACF;IACF;AACF"}
+13
View File
@@ -0,0 +1,13 @@
import type { CollectionBeforeChangeHook } from 'payload';
/**
* Sets `publishedAt` to now the first time a document transitions to published,
* if it isn't already set. Saves editors from filling the date manually and
* keeps blog/article dates accurate for Article JSON-LD and sitemaps.
*
* Attach to collections with drafts enabled (blog, articles):
* hooks: { beforeChange: [setPublishedAtHook] }
*
* Only sets on the published transition; never overwrites an existing date
* (an editor can still backdate manually).
*/
export declare const setPublishedAtHook: CollectionBeforeChangeHook;
+19
View File
@@ -0,0 +1,19 @@
/**
* Sets `publishedAt` to now the first time a document transitions to published,
* if it isn't already set. Saves editors from filling the date manually and
* keeps blog/article dates accurate for Article JSON-LD and sitemaps.
*
* Attach to collections with drafts enabled (blog, articles):
* hooks: { beforeChange: [setPublishedAtHook] }
*
* Only sets on the published transition; never overwrites an existing date
* (an editor can still backdate manually).
*/ export const setPublishedAtHook = ({ data, originalDoc })=>{
const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published';
if (becomingPublished && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
};
//# sourceMappingURL=setPublishedAt.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/hooks/setPublishedAt.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\n/**\n * Sets `publishedAt` to now the first time a document transitions to published,\n * if it isn't already set. Saves editors from filling the date manually and\n * keeps blog/article dates accurate for Article JSON-LD and sitemaps.\n *\n * Attach to collections with drafts enabled (blog, articles):\n * hooks: { beforeChange: [setPublishedAtHook] }\n *\n * Only sets on the published transition; never overwrites an existing date\n * (an editor can still backdate manually).\n */\nexport const setPublishedAtHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => {\n const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published'\n if (becomingPublished && !data.publishedAt) {\n data.publishedAt = new Date().toISOString()\n }\n return data\n}\n"],"names":["setPublishedAtHook","data","originalDoc","becomingPublished","_status","publishedAt","Date","toISOString"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,qBAAiD,CAAC,EAAEC,IAAI,EAAEC,WAAW,EAAE;IAClF,MAAMC,oBAAoBF,KAAKG,OAAO,KAAK,eAAeF,aAAaE,YAAY;IACnF,IAAID,qBAAqB,CAACF,KAAKI,WAAW,EAAE;QAC1CJ,KAAKI,WAAW,GAAG,IAAIC,OAAOC,WAAW;IAC3C;IACA,OAAON;AACT,EAAC"}
+17
View File
@@ -0,0 +1,17 @@
import type { CollectionBeforeChangeHook } from 'payload';
/**
* When a document's slug changes, appends the OLD slug to a `slugHistory` array
* field. The project reads slugHistory to serve a 301 redirect from old URLs to
* the current one — so changing a slug doesn't 404 the old address (a real SEO
* loss / audit finding).
*
* Requires a `slugHistory` field on the collection:
* { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }],
* admin: { readOnly: true } }
*
* hooks: { beforeChange: [trackSlugHistoryHook] }
*
* The project then, in resolveRoute or a redirect check, looks up slugHistory and
* 301s to the current slug. See docs/hooks.md.
*/
export declare const trackSlugHistoryHook: CollectionBeforeChangeHook;
+33
View File
@@ -0,0 +1,33 @@
/**
* When a document's slug changes, appends the OLD slug to a `slugHistory` array
* field. The project reads slugHistory to serve a 301 redirect from old URLs to
* the current one — so changing a slug doesn't 404 the old address (a real SEO
* loss / audit finding).
*
* Requires a `slugHistory` field on the collection:
* { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }],
* admin: { readOnly: true } }
*
* hooks: { beforeChange: [trackSlugHistoryHook] }
*
* The project then, in resolveRoute or a redirect check, looks up slugHistory and
* 301s to the current slug. See docs/hooks.md.
*/ export const trackSlugHistoryHook = ({ data, originalDoc })=>{
const oldSlug = originalDoc?.slug;
const newSlug = data.slug;
if (typeof oldSlug === 'string' && typeof newSlug === 'string' && oldSlug !== newSlug && oldSlug.length > 0) {
const history = Array.isArray(data.slugHistory) ? data.slugHistory : Array.isArray(originalDoc?.slugHistory) ? originalDoc.slugHistory : [];
// Avoid duplicates; don't record the new slug itself.
if (!history.some((h)=>h?.slug === oldSlug)) {
data.slugHistory = [
...history,
{
slug: oldSlug
}
];
}
}
return data;
};
//# sourceMappingURL=trackSlugHistory.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/hooks/trackSlugHistory.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\n/**\n * When a document's slug changes, appends the OLD slug to a `slugHistory` array\n * field. The project reads slugHistory to serve a 301 redirect from old URLs to\n * the current one — so changing a slug doesn't 404 the old address (a real SEO\n * loss / audit finding).\n *\n * Requires a `slugHistory` field on the collection:\n * { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }],\n * admin: { readOnly: true } }\n *\n * hooks: { beforeChange: [trackSlugHistoryHook] }\n *\n * The project then, in resolveRoute or a redirect check, looks up slugHistory and\n * 301s to the current slug. See docs/hooks.md.\n */\nexport const trackSlugHistoryHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => {\n const oldSlug = originalDoc?.slug\n const newSlug = data.slug\n if (\n typeof oldSlug === 'string' &&\n typeof newSlug === 'string' &&\n oldSlug !== newSlug &&\n oldSlug.length > 0\n ) {\n const history: Array<{ slug: string }> = Array.isArray(data.slugHistory)\n ? data.slugHistory\n : Array.isArray(originalDoc?.slugHistory)\n ? originalDoc.slugHistory\n : []\n // Avoid duplicates; don't record the new slug itself.\n if (!history.some((h) => h?.slug === oldSlug)) {\n data.slugHistory = [...history, { slug: oldSlug }]\n }\n }\n return data\n}\n"],"names":["trackSlugHistoryHook","data","originalDoc","oldSlug","slug","newSlug","length","history","Array","isArray","slugHistory","some","h"],"mappings":"AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMA,uBAAmD,CAAC,EAAEC,IAAI,EAAEC,WAAW,EAAE;IACpF,MAAMC,UAAUD,aAAaE;IAC7B,MAAMC,UAAUJ,KAAKG,IAAI;IACzB,IACE,OAAOD,YAAY,YACnB,OAAOE,YAAY,YACnBF,YAAYE,WACZF,QAAQG,MAAM,GAAG,GACjB;QACA,MAAMC,UAAmCC,MAAMC,OAAO,CAACR,KAAKS,WAAW,IACnET,KAAKS,WAAW,GAChBF,MAAMC,OAAO,CAACP,aAAaQ,eACzBR,YAAYQ,WAAW,GACvB,EAAE;QACR,sDAAsD;QACtD,IAAI,CAACH,QAAQI,IAAI,CAAC,CAACC,IAAMA,GAAGR,SAASD,UAAU;YAC7CF,KAAKS,WAAW,GAAG;mBAAIH;gBAAS;oBAAEH,MAAMD;gBAAQ;aAAE;QACpD;IACF;IACA,OAAOF;AACT,EAAC"}
+16
View File
@@ -0,0 +1,16 @@
import type { FieldHook } from 'payload';
/**
* Field hook for a System Page role relationship in SiteSettings: ensures a page
* isn't assigned to two roles at once (e.g. the same page as both homepage and
* privacyPolicy), which would make routing ambiguous.
*
* Attach to each role field's beforeValidate. `siblingFields` are the OTHER role
* field names to check against.
*
* hooks: { beforeValidate: [buildValidateUniqueRole({
* siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'],
* })] }
*/
export declare function buildValidateUniqueRole(args: {
siblingFields: string[];
}): FieldHook;
+31
View File
@@ -0,0 +1,31 @@
import { APIError } from 'payload';
/**
* Field hook for a System Page role relationship in SiteSettings: ensures a page
* isn't assigned to two roles at once (e.g. the same page as both homepage and
* privacyPolicy), which would make routing ambiguous.
*
* Attach to each role field's beforeValidate. `siblingFields` are the OTHER role
* field names to check against.
*
* hooks: { beforeValidate: [buildValidateUniqueRole({
* siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'],
* })] }
*/ export function buildValidateUniqueRole(args) {
return ({ field, siblingData, value })=>{
if (value == null) {
return value;
}
const thisId = typeof value === 'object' ? value.id : value;
for (const sibling of args.siblingFields){
const other = siblingData?.[sibling];
const otherId = other && typeof other === 'object' ? other.id : other;
if (otherId != null && String(otherId) === String(thisId)) {
const name = typeof field === 'object' && 'name' in field ? field.name : 'ta rola';
throw new APIError(`Ta sama strona jest przypisana do "${name}" i "${sibling}". ` + `Każda rola systemowa musi wskazywać inną stronę.`, 400);
}
}
return value;
};
}
//# sourceMappingURL=validateUniqueRole.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/hooks/validateUniqueRole.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\nimport { APIError } from 'payload'\n\n/**\n * Field hook for a System Page role relationship in SiteSettings: ensures a page\n * isn't assigned to two roles at once (e.g. the same page as both homepage and\n * privacyPolicy), which would make routing ambiguous.\n *\n * Attach to each role field's beforeValidate. `siblingFields` are the OTHER role\n * field names to check against.\n *\n * hooks: { beforeValidate: [buildValidateUniqueRole({\n * siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'],\n * })] }\n */\nexport function buildValidateUniqueRole(args: { siblingFields: string[] }): FieldHook {\n return ({ field, siblingData, value }) => {\n if (value == null) {return value}\n const thisId = typeof value === 'object' ? (value as { id?: unknown }).id : value\n for (const sibling of args.siblingFields) {\n const other = (siblingData as Record<string, unknown>)?.[sibling]\n const otherId = other && typeof other === 'object' ? (other as { id?: unknown }).id : other\n if (otherId != null && String(otherId) === String(thisId)) {\n const name = typeof field === 'object' && 'name' in field ? field.name : 'ta rola'\n throw new APIError(\n `Ta sama strona jest przypisana do \"${name}\" i \"${sibling}\". ` +\n `Każda rola systemowa musi wskazywać inną stronę.`,\n 400,\n )\n }\n }\n return value\n }\n}\n"],"names":["APIError","buildValidateUniqueRole","args","field","siblingData","value","thisId","id","sibling","siblingFields","other","otherId","String","name"],"mappings":"AAEA,SAASA,QAAQ,QAAQ,UAAS;AAElC;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,OAAO,CAAC,EAAEC,KAAK,EAAEC,WAAW,EAAEC,KAAK,EAAE;QACnC,IAAIA,SAAS,MAAM;YAAC,OAAOA;QAAK;QAChC,MAAMC,SAAS,OAAOD,UAAU,WAAW,AAACA,MAA2BE,EAAE,GAAGF;QAC5E,KAAK,MAAMG,WAAWN,KAAKO,aAAa,CAAE;YACxC,MAAMC,QAASN,aAAyC,CAACI,QAAQ;YACjE,MAAMG,UAAUD,SAAS,OAAOA,UAAU,WAAW,AAACA,MAA2BH,EAAE,GAAGG;YACtF,IAAIC,WAAW,QAAQC,OAAOD,aAAaC,OAAON,SAAS;gBACzD,MAAMO,OAAO,OAAOV,UAAU,YAAY,UAAUA,QAAQA,MAAMU,IAAI,GAAG;gBACzE,MAAM,IAAIb,SACR,CAAC,mCAAmC,EAAEa,KAAK,KAAK,EAAEL,QAAQ,GAAG,CAAC,GAC5D,CAAC,gDAAgD,CAAC,EACpD;YAEJ;QACF;QACA,OAAOH;IACT;AACF"}
+22 -22
View File
@@ -1,21 +1,21 @@
import type { I18nConfig } from './types.js'; import type { I18nConfig } from '../i18n/index.js';
/** /**
* Minimal request shape the middleware reads. Kept structural so the plugin * Minimal request shape the middleware reads. Kept structural so the plugin
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it. * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
*/ */
type MiddlewareRequest = { type MiddlewareRequest = {
nextUrl: {
pathname: string;
search: string;
clone: () => URL;
};
cookies: { cookies: {
get: (name: string) => { get: (name: string) => {
value: string; value: string;
} | undefined; } | undefined;
}; };
headers: { headers: {
get: (name: string) => null | string; get: (name: string) => string | null;
};
nextUrl: {
clone: () => URL;
pathname: string;
search: string;
}; };
url: string; url: string;
}; };
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
* `next` means let the request pass through untouched. * `next` means let the request pass through untouched.
*/ */
export type LocaleMiddlewareResult = { export type LocaleMiddlewareResult = {
cookie?: {
name: string;
value: string;
};
location: string;
type: 'redirect';
} | {
cookie?: {
name: string;
value: string;
};
type: 'next'; type: 'next';
cookie?: {
name: string;
value: string;
};
} | {
type: 'redirect';
location: string;
cookie?: {
name: string;
value: string;
};
}; };
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
config: I18nConfig; config: I18nConfig;
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string;
/** /**
* Consent category that gates *persisting* the locale cookie. The locale is * Consent category that gates *persisting* the locale cookie. The locale is
* always detected (routing works regardless), but the choice is only written * always detected (routing works regardless), but the choice is only written
@@ -47,11 +49,9 @@ type CreateLocaleMiddlewareArgs = {
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly * 'functional'. Pass 'necessary' to always persist (treat locale as strictly
* necessary), which restores the pre-consent behaviour. * necessary), which restores the pre-consent behaviour.
*/ */
consentCategory?: 'functional' | 'necessary'; consentCategory?: 'necessary' | 'functional';
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */ /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
consentCookieName?: string; consentCookieName?: string;
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string;
}; };
/** /**
* Builds locale-routing logic for Next.js middleware. * Builds locale-routing logic for Next.js middleware.
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
* return res * return res
* } * }
*/ */
export declare function createLocaleMiddleware({ config, consentCategory, consentCookieName, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult; export declare function createLocaleMiddleware({ config, cookieName, consentCategory, consentCookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
/** /**
* Default Next.js middleware matcher: run on everything except API routes, the * Default Next.js middleware matcher: run on everything except API routes, the
* admin panel, Next internals, and files with an extension (static assets). * admin panel, Next internals, and files with an extension (static assets).
+15 -7
View File
@@ -1,5 +1,5 @@
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js';
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'; import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
/** /**
* First path segment of a URL pathname, or '' for root. * First path segment of a URL pathname, or '' for root.
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''. * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
@@ -32,18 +32,26 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) { */ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE }) {
// Whether the locale cookie may be written: 'necessary' is always granted; // Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented. // 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request) { function mayPersistLocale(request) {
if (consentCategory === 'necessary') { if (consentCategory === 'necessary') return true;
return true;
}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value); const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
return consent?.[consentCategory] === true; return consent?.[consentCategory] === true;
} }
return function localeMiddleware(request) { return function localeMiddleware(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Single-locale sites have no /pl, /en prefix and no negotiation — one
// language, no redirect. The middleware becomes a pass-through: paths stay
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
// single-locale usually don't even mount this middleware, but guarding here
// makes it safe if they do.)
if (config.locales.length === 1) {
return {
type: 'next'
};
}
// Already locale-prefixed (e.g. the visitor switched language by // Already locale-prefixed (e.g. the visitor switched language by
// navigating to /en). Routing is fine — but if the URL's locale differs // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we // from the stored cookie, the visitor is *choosing* a language, and we
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
} }
// Resolve the locale to use // Resolve the locale to use
const locale = negotiateLocale({ const locale = negotiateLocale({
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
acceptLanguage: request.headers.get('accept-language'), acceptLanguage: request.headers.get('accept-language'),
config, config
cookieLocale: request.cookies.get(cookieName)?.value ?? null
}); });
// Redirect to the locale-prefixed path, preserving the rest // Redirect to the locale-prefixed path, preserving the rest
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
File diff suppressed because one or more lines are too long
+19 -13
View File
@@ -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 {};
+23 -15
View File
@@ -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;
} }
@@ -28,6 +28,12 @@ import { isValidLocale } from './helpers.js';
if (!slug) { if (!slug) {
return undefined; return undefined;
} }
// Single-locale sites have no /pl, /en prefix — the language segment is
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
// one configured locale means one language, so no prefix is needed. The
// project's folder structure matches (app/[[...slug]] without [locale]).
const singleLocale = config.locales.length === 1;
const localeSegment = singleLocale ? '' : `/${locale}`;
if (prefix) { if (prefix) {
const segment = prefix[locale]; const segment = prefix[locale];
// No archive slug in this locale means the entry is unreachable there — // No archive slug in this locale means the entry is unreachable there —
@@ -36,12 +42,18 @@ import { isValidLocale } from './helpers.js';
if (!segment) { if (!segment) {
return undefined; return undefined;
} }
return `/${locale}/${segment}/${slug}`; return `${localeSegment}/${segment}/${slug}`;
} }
if (slug === homeSlug) { // Resolve the home slug for THIS locale. With a per-locale map, each language
return `/${locale}`; // has its own home slug (pl: 'strona-glowna', de: 'startseite'), so the check
// below correctly collapses each language's home to its root — instead of only
// the current-language slug matching and the others staying un-collapsed.
const targetHomeSlug = typeof homeSlug === 'object' ? homeSlug[locale] : homeSlug;
if (slug === targetHomeSlug) {
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
return localeSegment || '/';
} }
return `/${locale}/${slug}`; return `${localeSegment}/${slug}`;
} }
/** /**
* Resolves the equivalent path for the same document in a different locale — * Resolves the equivalent path for the same document in a different locale —
@@ -50,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}`;
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
View File
@@ -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"}
+46
View File
@@ -0,0 +1,46 @@
export type BuildCspArgs = {
/** Google Analytics / GTM — adds googletagmanager + google-analytics. */
analytics?: boolean;
/** Extra sources per directive, merged with the built-ins. */
extra?: Partial<Record<CspDirective, string[]>>;
/** Google Maps embeds — adds maps.google.com / *.gstatic.com. */
googleMaps?: boolean;
/** 'enforce' → Content-Security-Policy; 'report-only' → …-Report-Only header. */
mode?: 'enforce' | 'report-only';
/** Media/R2 public URL (from R2_PUBLIC_URL) — added to img-src. */
r2Url?: string;
/** Cloudflare Turnstile — adds challenges.cloudflare.com to script/frame/connect. */
turnstile?: boolean;
/** YouTube embeds — adds youtube to frame-src. */
youtube?: boolean;
};
type CspDirective = 'base-uri' | 'connect-src' | 'default-src' | 'font-src' | 'form-action' | 'frame-ancestors' | 'frame-src' | 'img-src' | 'media-src' | 'object-src' | 'script-src' | 'style-src' | 'worker-src';
/**
* Builds a Content-Security-Policy string with the hard OWASP/Lighthouse-required
* directives baked in, and opt-in sources for common third parties. Solves the
* real risk of hand-writing raw CSP per project and forgetting `base-uri 'self'`
* or `object-src 'none'`.
*
* CSP still lives in the project (it lists the project's own domains), but this
* helper standardizes the skeleton so every project's CSP has the same hardened
* base — you only flip flags for what the project actually loads.
*
* Returns { key, value } ready for buildSecurityHeaders `additional`:
*
* import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'
* const csp = buildCsp({
* mode: 'report-only', // start here; switch to 'enforce' when clean
* r2Url: process.env.R2_PUBLIC_URL,
* turnstile: true, analytics: true,
* })
* const headers = buildSecurityHeaders({ hsts: prod, additional: [csp] })
*
* Deploy CSP carefully: start with mode:'report-only', check the console for
* violations across the whole site (forms/Turnstile, gallery/R2, embeds), add
* missing sources via `extra`, THEN switch to 'enforce'. See docs/security.md.
*/
export declare function buildCsp(args?: BuildCspArgs): {
key: string;
value: string;
};
export {};
+109
View File
@@ -0,0 +1,109 @@
/**
* Builds a Content-Security-Policy string with the hard OWASP/Lighthouse-required
* directives baked in, and opt-in sources for common third parties. Solves the
* real risk of hand-writing raw CSP per project and forgetting `base-uri 'self'`
* or `object-src 'none'`.
*
* CSP still lives in the project (it lists the project's own domains), but this
* helper standardizes the skeleton so every project's CSP has the same hardened
* base — you only flip flags for what the project actually loads.
*
* Returns { key, value } ready for buildSecurityHeaders `additional`:
*
* import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'
* const csp = buildCsp({
* mode: 'report-only', // start here; switch to 'enforce' when clean
* r2Url: process.env.R2_PUBLIC_URL,
* turnstile: true, analytics: true,
* })
* const headers = buildSecurityHeaders({ hsts: prod, additional: [csp] })
*
* Deploy CSP carefully: start with mode:'report-only', check the console for
* violations across the whole site (forms/Turnstile, gallery/R2, embeds), add
* missing sources via `extra`, THEN switch to 'enforce'. See docs/security.md.
*/ export function buildCsp(args = {}) {
const { analytics, extra = {}, googleMaps, mode = 'enforce', r2Url, turnstile, youtube } = args;
const src = {
'default-src': [
"'self'"
],
// 'unsafe-inline' is hard to avoid with Next/analytics; 'unsafe-eval' is NOT
// added by default (weakens CSP) — add via extra only if a library needs it.
'connect-src': [
"'self'"
],
'font-src': [
"'self'",
'https://fonts.gstatic.com',
'data:'
],
'form-action': [
"'self'"
],
'frame-src': [],
'img-src': [
"'self'",
'data:',
'blob:'
],
'media-src': [],
'script-src': [
"'self'",
"'unsafe-inline'"
],
'style-src': [
"'self'",
"'unsafe-inline'",
'https://fonts.googleapis.com'
],
'worker-src': [],
// HARD defaults (OWASP/Lighthouse) — always on, no reason to omit:
'base-uri': [
"'self'"
],
'frame-ancestors': [
"'none'"
],
'object-src': [
"'none'"
]
};
if (r2Url) {
src['img-src'].push(r2Url);
}
if (turnstile) {
src['script-src'].push('https://challenges.cloudflare.com');
src['frame-src'].push('https://challenges.cloudflare.com');
src['connect-src'].push('https://challenges.cloudflare.com');
}
if (analytics) {
src['script-src'].push('https://www.googletagmanager.com');
src['connect-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com');
src['img-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com');
}
if (youtube) {
src['frame-src'].push('https://www.youtube.com', 'https://www.youtube-nocookie.com');
}
if (googleMaps) {
src['frame-src'].push('https://www.google.com', 'https://maps.google.com');
src['script-src'].push('https://maps.googleapis.com');
src['img-src'].push('https://maps.gstatic.com', 'https://*.googleapis.com');
}
// Merge caller extras.
for (const [dir, values] of Object.entries(extra)){
if (values && values.length) {
src[dir] = [
...src[dir] ?? [],
...values
];
}
}
const value = Object.entries(src).filter(([, values])=>values.length > 0).map(([dir, values])=>`${dir} ${values.join(' ')}`).join('; ');
const key = mode === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
return {
key,
value
};
}
//# sourceMappingURL=buildCsp.js.map
File diff suppressed because one or more lines are too long
+8
View File
@@ -13,6 +13,14 @@ export type BuildSecurityHeadersArgs = {
* domains (scripts, images, fonts, analytics). Keep CSP in your project. * domains (scripts, images, fonts, analytics). Keep CSP in your project.
*/ */
additional?: SecurityHeader[]; additional?: SecurityHeader[];
/**
* Cross-Origin-Opener-Policy. Default 'same-origin' — isolates the browsing
* context so a malicious page can't hold a window.opener reference (protects
* against XS-Leaks / Spectre-class attacks). Project-independent, so it's a
* default. Use 'same-origin-allow-popups' if you open OAuth/payment popups
* that need window.opener; false to omit.
*/
coop?: 'same-origin' | 'same-origin-allow-popups' | false;
/** /**
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN' * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in * allows same-origin framing. Note: CSP frame-ancestors supersedes this in
+8 -1
View File
@@ -26,7 +26,7 @@
* }, * },
* } * }
*/ export function buildSecurityHeaders(args = {}) { */ export function buildSecurityHeaders(args = {}) {
const { additional = [], frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args; const { additional = [], coop = 'same-origin', frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args;
const headers = []; const headers = [];
if (hsts) { if (hsts) {
const parts = [ const parts = [
@@ -66,6 +66,13 @@
value: permissionsPolicy value: permissionsPolicy
}); });
} }
// COOP — isolates the browsing context (XS-Leaks / Spectre protection).
if (coop) {
headers.push({
key: 'Cross-Origin-Opener-Policy',
value: coop
});
}
// Merge additional: same-key entries override the defaults above. // Merge additional: same-key entries override the defaults above.
for (const extra of additional){ for (const extra of additional){
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase()); const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,2 +1,4 @@
export { buildCsp } from './buildCsp.js';
export type { BuildCspArgs } from './buildCsp.js';
export { buildSecurityHeaders } from './buildSecurityHeaders.js'; export { buildSecurityHeaders } from './buildSecurityHeaders.js';
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'; export type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js';
+1
View File
@@ -1,3 +1,4 @@
export { buildCsp } from './buildCsp.js';
export { buildSecurityHeaders } from './buildSecurityHeaders.js'; export { buildSecurityHeaders } from './buildSecurityHeaders.js';
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'\n"],"names":["buildSecurityHeaders"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"} {"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildCsp } from './buildCsp.js'\nexport type { BuildCspArgs } from './buildCsp.js'\nexport { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js'\n"],"names":["buildCsp","buildSecurityHeaders"],"mappings":"AAAA,SAASA,QAAQ,QAAQ,gBAAe;AAExC,SAASC,oBAAoB,QAAQ,4BAA2B"}
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+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
+16 -2
View File
@@ -19,6 +19,7 @@ export type PageMetadata = {
locale?: string; locale?: string;
title: string; title: string;
}; };
/** robots directives — set to noindex/follow for legal/thin/search pages. */
robots?: { robots?: {
follow: boolean; follow: boolean;
index: boolean; index: boolean;
@@ -30,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?: 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. */
@@ -39,6 +40,13 @@ type BuildMetadataArgs = {
meta?: null | SeoMeta; meta?: null | SeoMeta;
/** Page title or site name first. Defaults to 'page-first'. */ /** Page title or site name first. Defaults to 'page-first'. */
order?: TitleOrder; order?: TitleOrder;
/**
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
* page-title source when meta.title is empty — the browser tab and search
* result should show the page name, not go blank, when an editor didn't fill
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
*/
pageTitle?: null | string;
/** /**
* Localized segment the document lives under (an archive page's slugs). * Localized segment the document lives under (an archive page's slugs).
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
@@ -55,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. */
@@ -69,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, 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 {};
+16 -11
View File
@@ -9,17 +9,20 @@ 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, prefix, query, separator, siteName, slugs }) { */ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteDescription, siteName, slugs }) {
// titleOverride wins outright: an editor who filled it in wants that exact // Title source priority: titleOverride (exact, wins outright) > meta.title
// string in the tab, not a composition. // (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')
// composed with the site name, instead of just the site name or a blank.
const override = meta?.titleOverride?.trim(); const override = meta?.titleOverride?.trim();
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
const title = override || composeTitle({ const title = override || composeTitle({
order, order,
pageTitle: meta?.title, pageTitle: resolvedPageTitle,
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({
@@ -60,12 +63,6 @@ import { buildHreflangAlternates } from './hreflang.js';
languages languages
} }
}, },
...meta?.noindex ? {
robots: {
follow: true,
index: false
}
} : {},
openGraph: { openGraph: {
title, title,
...description && { ...description && {
@@ -75,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
images images
}, },
locale locale
},
// noindex → tell search engines to exclude the page but still follow links
// (authority flows through). For legal/thin/search-result pages.
...meta?.noindex ? {
robots: {
follow: true,
index: false
} }
} : {}
}; };
} }
File diff suppressed because one or more lines are too long
+14 -14
View File
@@ -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 {};
+70 -33
View File
@@ -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,17 +100,23 @@ 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'; // Home slug as a per-locale MAP so every language's homepage collapses to its
// root in the sitemap (/pl, /de, /en) — not just the default locale. Priority:
// caller-provided homeSlug > full map from the homepage relationship > single
// default-locale slug > 'home'. slugMapAllLocales already builds the map (it's
// used for archive prefixes below); reusing it here fixes /de/startseite
// appearing in the sitemap instead of /de.
const resolvedHomeSlug = homeSlug ?? slugMapAllLocales(settings.homepage, locales) ?? 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
// its archive-page slugs as the localized prefix. // its archive-page slugs as the localized prefix.
const collections = [ const collections = [
@@ -121,24 +138,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 +177,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;
} }
File diff suppressed because one or more lines are too long
+33
View File
@@ -0,0 +1,33 @@
import type { SitemapEntry } from './buildSitemapEntries.js';
/**
* Serializes sitemap entries to an XML STRING with an XSL stylesheet reference,
* so /sitemap.xml renders as a readable table in the browser (not raw XML) while
* staying a valid sitemap for crawlers.
*
* Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts
* (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get
* the styled table, serve a custom route that returns this string instead:
*
* // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit'
* import { sitemap } from '@/lib/content' // your entries source
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const entries = await sitemap()
* const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' })
* return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* })
* }
*
* Put sitemap.xsl in the project's /public (copy from the plugin's assets, or
* serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers
* ignore it and read the XML. Include hreflang alternates as <xhtml:link>.
*
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one
* (the styled route OR the Next MetadataRoute). Two sitemaps at different paths
* confuse crawlers.
*/
export declare function buildSitemapXml(entries: SitemapEntry[], opts?: {
stylesheetUrl?: string;
}): string;
+57
View File
@@ -0,0 +1,57 @@
/**
* Serializes sitemap entries to an XML STRING with an XSL stylesheet reference,
* so /sitemap.xml renders as a readable table in the browser (not raw XML) while
* staying a valid sitemap for crawlers.
*
* Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts
* (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get
* the styled table, serve a custom route that returns this string instead:
*
* // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit'
* import { sitemap } from '@/lib/content' // your entries source
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const entries = await sitemap()
* const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' })
* return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* })
* }
*
* Put sitemap.xsl in the project's /public (copy from the plugin's assets, or
* serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers
* ignore it and read the XML. Include hreflang alternates as <xhtml:link>.
*
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one
* (the styled route OR the Next MetadataRoute). Two sitemaps at different paths
* confuse crawlers.
*/ export function buildSitemapXml(entries, opts = {}) {
const { stylesheetUrl } = opts;
const esc = (s)=>s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const urls = entries.map((e)=>{
const parts = [
` <loc>${esc(e.url)}</loc>`
];
if (e.lastModified) {
const iso = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
parts.push(` <lastmod>${esc(iso)}</lastmod>`);
}
if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`);
if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`);
// hreflang alternates
const alternates = e.alternates?.languages;
if (alternates) {
for (const [lang, href] of Object.entries(alternates)){
if (typeof href === 'string') {
parts.push(` <xhtml:link rel="alternate" hreflang="${esc(lang)}" href="${esc(href)}"/>`);
}
}
}
return ` <url>\n${parts.join('\n')}\n </url>`;
}).join('\n');
const stylesheet = stylesheetUrl ? `<?xml-stylesheet type="text/xsl" href="${esc(stylesheetUrl)}"?>\n` : '';
return `<?xml version="1.0" encoding="UTF-8"?>\n` + stylesheet + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls + `\n</urlset>`;
}
//# sourceMappingURL=buildSitemapXml.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/seo/buildSitemapXml.ts"],"sourcesContent":["import type { SitemapEntry } from './buildSitemapEntries.js'\n\n/**\n * Serializes sitemap entries to an XML STRING with an XSL stylesheet reference,\n * so /sitemap.xml renders as a readable table in the browser (not raw XML) while\n * staying a valid sitemap for crawlers.\n *\n * Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts\n * (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get\n * the styled table, serve a custom route that returns this string instead:\n *\n * // app/sitemap.xml/route.ts\n * import { buildSitemapXml } from '@intecion/ipal-kit'\n * import { sitemap } from '@/lib/content' // your entries source\n * export const dynamic = 'force-dynamic'\n * export async function GET() {\n * const entries = await sitemap()\n * const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' })\n * return new Response(xml, {\n * headers: { 'Content-Type': 'application/xml; charset=utf-8' },\n * })\n * }\n *\n * Put sitemap.xsl in the project's /public (copy from the plugin's assets, or\n * serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers\n * ignore it and read the XML. Include hreflang alternates as <xhtml:link>.\n *\n * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one\n * (the styled route OR the Next MetadataRoute). Two sitemaps at different paths\n * confuse crawlers.\n */\nexport function buildSitemapXml(\n entries: SitemapEntry[],\n opts: { stylesheetUrl?: string } = {},\n): string {\n const { stylesheetUrl } = opts\n\n const esc = (s: string): string =>\n s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n\n const urls = entries\n .map((e) => {\n const parts = [` <loc>${esc(e.url)}</loc>`]\n if (e.lastModified) {\n const iso =\n e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified)\n parts.push(` <lastmod>${esc(iso)}</lastmod>`)\n }\n if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`)\n if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`)\n // hreflang alternates\n const alternates = e.alternates?.languages\n if (alternates) {\n for (const [lang, href] of Object.entries(alternates)) {\n if (typeof href === 'string') {\n parts.push(\n ` <xhtml:link rel=\"alternate\" hreflang=\"${esc(lang)}\" href=\"${esc(href)}\"/>`,\n )\n }\n }\n }\n return ` <url>\\n${parts.join('\\n')}\\n </url>`\n })\n .join('\\n')\n\n const stylesheet = stylesheetUrl\n ? `<?xml-stylesheet type=\"text/xsl\" href=\"${esc(stylesheetUrl)}\"?>\\n`\n : ''\n\n return (\n `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n` +\n stylesheet +\n `<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ` +\n `xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\\n` +\n urls +\n `\\n</urlset>`\n )\n}\n"],"names":["buildSitemapXml","entries","opts","stylesheetUrl","esc","s","replace","urls","map","e","parts","url","lastModified","iso","Date","toISOString","String","push","changeFrequency","priority","alternates","languages","lang","href","Object","join","stylesheet"],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASA,gBACdC,OAAuB,EACvBC,OAAmC,CAAC,CAAC;IAErC,MAAM,EAAEC,aAAa,EAAE,GAAGD;IAE1B,MAAME,MAAM,CAACC,IACXA,EACGC,OAAO,CAAC,MAAM,SACdA,OAAO,CAAC,MAAM,QACdA,OAAO,CAAC,MAAM,QACdA,OAAO,CAAC,MAAM,UACdA,OAAO,CAAC,MAAM;IAEnB,MAAMC,OAAON,QACVO,GAAG,CAAC,CAACC;QACJ,MAAMC,QAAQ;YAAC,CAAC,SAAS,EAAEN,IAAIK,EAAEE,GAAG,EAAE,MAAM,CAAC;SAAC;QAC9C,IAAIF,EAAEG,YAAY,EAAE;YAClB,MAAMC,MACJJ,EAAEG,YAAY,YAAYE,OAAOL,EAAEG,YAAY,CAACG,WAAW,KAAKC,OAAOP,EAAEG,YAAY;YACvFF,MAAMO,IAAI,CAAC,CAAC,aAAa,EAAEb,IAAIS,KAAK,UAAU,CAAC;QACjD;QACA,IAAIJ,EAAES,eAAe,EAAER,MAAMO,IAAI,CAAC,CAAC,gBAAgB,EAAER,EAAES,eAAe,CAAC,aAAa,CAAC;QACrF,IAAI,OAAOT,EAAEU,QAAQ,KAAK,UAAUT,MAAMO,IAAI,CAAC,CAAC,cAAc,EAAER,EAAEU,QAAQ,CAAC,WAAW,CAAC;QACvF,sBAAsB;QACtB,MAAMC,aAAaX,EAAEW,UAAU,EAAEC;QACjC,IAAID,YAAY;YACd,KAAK,MAAM,CAACE,MAAMC,KAAK,IAAIC,OAAOvB,OAAO,CAACmB,YAAa;gBACrD,IAAI,OAAOG,SAAS,UAAU;oBAC5Bb,MAAMO,IAAI,CACR,CAAC,0CAA0C,EAAEb,IAAIkB,MAAM,QAAQ,EAAElB,IAAImB,MAAM,GAAG,CAAC;gBAEnF;YACF;QACF;QACA,OAAO,CAAC,SAAS,EAAEb,MAAMe,IAAI,CAAC,MAAM,UAAU,CAAC;IACjD,GACCA,IAAI,CAAC;IAER,MAAMC,aAAavB,gBACf,CAAC,uCAAuC,EAAEC,IAAID,eAAe,KAAK,CAAC,GACnE;IAEJ,OACE,CAAC,wCAAwC,CAAC,GAC1CuB,aACA,CAAC,4DAA4D,CAAC,GAC9D,CAAC,6CAA6C,CAAC,GAC/CnB,OACA,CAAC,WAAW,CAAC;AAEjB"}
+6 -6
View File
@@ -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 {};
+9 -1
View File
@@ -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 || '';
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+1
View File
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
...base, ...base,
imageUrl: resolveOgImage(doc), imageUrl: resolveOgImage(doc),
meta: doc.meta, meta: doc.meta,
pageTitle: doc.title,
prefix, prefix,
query, query,
slugs slugs
File diff suppressed because one or more lines are too long
+7 -15
View File
@@ -1,42 +1,34 @@
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'; import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
type BuildHreflangArgs = { type BuildHreflangArgs = {
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs;
config: I18nConfig;
/** 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;
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>;
/** /**
* 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
* are omitted — an entry with no archive in that language has no URL there. * are omitted — an entry with no archive in that language has no URL there.
*/ */
prefix?: LocalizedSlugs; prefix?: LocalizedSlugs;
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs;
}; };
/** /**
* Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`. * Next.js Metadata `alternates.languages`.
* *
* Bridges SEO and i18n: for each configured locale that the document has a * Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath), * slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin. * optionally prefixed with an absolute origin.
* *
* Also emits `x-default` pointing at the default locale — the version Google
* serves when the user's language/region matches no hreflang, and the fallback
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
* Accept-Language).
*
* @example * @example
* buildHreflangAlternates({ * buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' }, * slugs: { pl: 'o-nas', en: 'about' },
* config, * config,
* baseUrl: 'https://example.com', * baseUrl: 'https://example.com',
* }) * })
* // → { * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
* // pl: 'https://example.com/pl/o-nas',
* // en: 'https://example.com/en/about',
* // 'x-default': 'https://example.com/pl/o-nas',
* // }
*/ */
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>; export declare function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix, }: BuildHreflangArgs): Record<string, string>;
export {}; export {};
+15 -16
View File
@@ -1,37 +1,36 @@
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'; import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
/** /**
* Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`. * Next.js Metadata `alternates.languages`.
* *
* Bridges SEO and i18n: for each configured locale that the document has a * Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath), * slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin. * optionally prefixed with an absolute origin.
* *
* Also emits `x-default` pointing at the default locale — the version Google
* serves when the user's language/region matches no hreflang, and the fallback
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
* Accept-Language).
*
* @example * @example
* buildHreflangAlternates({ * buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' }, * slugs: { pl: 'o-nas', en: 'about' },
* config, * config,
* baseUrl: 'https://example.com', * baseUrl: 'https://example.com',
* }) * })
* // → { * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
* // pl: 'https://example.com/pl/o-nas', */ export function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug = 'home', prefix }) {
* // en: 'https://example.com/en/about',
* // 'x-default': 'https://example.com/pl/o-nas',
* // }
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
const origin = baseUrl?.replace(/\/$/, '') ?? ''; const origin = baseUrl?.replace(/\/$/, '') ?? '';
const alternates = {}; const alternates = {};
// Single-locale sites have no language alternatives — hreflang describes
// relationships BETWEEN language versions, and there's only one. Emitting
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
// its canonical, but no alternate-language links.
if (config.locales.length === 1) {
return alternates;
}
for (const locale of getLocaleCodes(config)){ for (const locale of getLocaleCodes(config)){
const path = buildLocalizedPath({ const path = buildLocalizedPath({
slugs,
locale,
config, config,
homeSlug, homeSlug,
locale, prefix
prefix,
slugs
}); });
if (path) { if (path) {
alternates[locale] = `${origin}${path}`; alternates[locale] = `${origin}${path}`;
@@ -42,7 +41,7 @@ import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/in
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
// at the default locale (the primary market) so search shows that version by // at the default locale (the primary market) so search shows that version by
// default instead of guessing. Only set when the default locale has a URL. // default instead of guessing. Only set when the default locale has a URL.
const defaultLocalePath = alternates[getDefaultLocale(config)]; const defaultLocalePath = alternates[config.defaultLocale];
if (defaultLocalePath) { if (defaultLocalePath) {
alternates['x-default'] = defaultLocalePath; alternates['x-default'] = defaultLocalePath;
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\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 /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\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 * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\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[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;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"}
+3
View File
@@ -1,8 +1,10 @@
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';
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';
@@ -12,6 +14,7 @@ export type { RobotsRules } from './buildRobots.js';
export { buildServiceJsonLd } from './buildServiceJsonLd.js'; export { buildServiceJsonLd } from './buildServiceJsonLd.js';
export { buildSitemapEntries } from './buildSitemapEntries.js'; export { buildSitemapEntries } from './buildSitemapEntries.js';
export type { SitemapEntry } from './buildSitemapEntries.js'; export type { SitemapEntry } from './buildSitemapEntries.js';
export { buildSitemapXml } from './buildSitemapXml.js';
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'; export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'; export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js'; export { composeTitle } from './composeTitle.js';
+3
View File
@@ -1,13 +1,16 @@
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';
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';
export { buildRobots } from './buildRobots.js'; export { buildRobots } from './buildRobots.js';
export { buildServiceJsonLd } from './buildServiceJsonLd.js'; export { buildServiceJsonLd } from './buildServiceJsonLd.js';
export { buildSitemapEntries } from './buildSitemapEntries.js'; export { buildSitemapEntries } from './buildSitemapEntries.js';
export { buildSitemapXml } from './buildSitemapXml.js';
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'; export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'; export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js'; export { composeTitle } from './composeTitle.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 { 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 { 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 { buildSitemapXml } from './buildSitemapXml.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","buildSitemapXml","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,eAAe,QAAQ,uBAAsB;AACtD,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"}
+9 -2
View File
@@ -1,11 +1,18 @@
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. */ /**
homeSlug?: string; * 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>;
order?: TitleOrder; 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;
/** Global fallback meta description. */
siteDescription?: null | string;
siteName: null | string; siteName: null | string;
}; };
/** /**
+45 -1
View File
@@ -14,10 +14,54 @@
locale: locale 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
// 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({
id: homepage.id,
collection: 'pages',
depth: 0,
locale: 'all'
});
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 {
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.
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined, homeSlug,
order: settings.titleOrder ?? undefined, order: settings.titleOrder ?? undefined,
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
}; };
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
/**
* Provides the Turnstile site key once for the whole app, like ConsentProvider
* for cookies. The project reads the key server-side and passes it here in the
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
* key wiring.
*
* // layout.tsx (server) → read key, pass to provider
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
* const siteKey = await getTurnstileSiteKey() // your server helper
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
*
* When siteKey is null (Turnstile not configured), widgets render nothing and
* token stays null — forms should treat "no Turnstile" as allowed in dev.
*/
export declare function TurnstileProvider({ siteKey, children, }: {
siteKey: string | null;
children: React.ReactNode;
}): import("react/jsx-runtime").JSX.Element;
/**
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
* No per-form siteKey plumbing — the provider supplies it.
*
* const { token, TurnstileWidget, reset } = useTurnstile()
* // in JSX: <TurnstileWidget />
* // at submit: submitForm({ ..., turnstileToken: token })
* // after submit: reset() // clear for the next submission
*/
export declare function useTurnstile(): {
token: string | null;
TurnstileWidget: (props?: {
theme?: 'light' | 'dark' | 'auto';
}) => React.ReactNode;
reset: () => void;
/** True when Turnstile is configured (site key present). */
enabled: boolean;
};
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, useCallback, useContext, useState } from 'react';
import { Turnstile } from './Turnstile.js';
const TurnstileContext = /*#__PURE__*/ createContext(null);
/**
* Provides the Turnstile site key once for the whole app, like ConsentProvider
* for cookies. The project reads the key server-side and passes it here in the
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
* key wiring.
*
* // layout.tsx (server) → read key, pass to provider
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
* const siteKey = await getTurnstileSiteKey() // your server helper
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
*
* When siteKey is null (Turnstile not configured), widgets render nothing and
* token stays null — forms should treat "no Turnstile" as allowed in dev.
*/ export function TurnstileProvider({ siteKey, children }) {
const [token, setToken] = useState(null);
return /*#__PURE__*/ _jsx(TurnstileContext.Provider, {
value: {
siteKey,
token,
setToken
},
children: children
});
}
/**
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
* No per-form siteKey plumbing — the provider supplies it.
*
* const { token, TurnstileWidget, reset } = useTurnstile()
* // in JSX: <TurnstileWidget />
* // at submit: submitForm({ ..., turnstileToken: token })
* // after submit: reset() // clear for the next submission
*/ export function useTurnstile() {
const ctx = useContext(TurnstileContext);
if (!ctx) {
throw new Error('useTurnstile must be used within <TurnstileProvider>');
}
const { siteKey, token, setToken } = ctx;
const reset = useCallback(()=>setToken(null), [
setToken
]);
const TurnstileWidget = useCallback((props)=>{
if (!siteKey) return null;
return /*#__PURE__*/ _jsx(Turnstile, {
siteKey: siteKey,
onToken: setToken,
theme: props?.theme
});
}, [
siteKey,
setToken
]);
return {
token,
TurnstileWidget,
reset,
enabled: Boolean(siteKey)
};
}
//# sourceMappingURL=TurnstileProvider.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/TurnstileProvider.tsx"],"sourcesContent":["'use client'\n\nimport { createContext, useCallback, useContext, useState } from 'react'\nimport { Turnstile } from './Turnstile.js'\n\ntype TurnstileContextValue = {\n /** Public site key from the provider (read server-side, passed once). */\n siteKey: string | null\n /** Current token (null until solved / after expiry). */\n token: string | null\n setToken: (t: string | null) => void\n}\n\nconst TurnstileContext = createContext<TurnstileContextValue | null>(null)\n\n/**\n * Provides the Turnstile site key once for the whole app, like ConsentProvider\n * for cookies. The project reads the key server-side and passes it here in the\n * layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form\n * key wiring.\n *\n * // layout.tsx (server) → read key, pass to provider\n * import { TurnstileProvider } from '@intecion/ipal-kit/client'\n * const siteKey = await getTurnstileSiteKey() // your server helper\n * <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>\n *\n * When siteKey is null (Turnstile not configured), widgets render nothing and\n * token stays null — forms should treat \"no Turnstile\" as allowed in dev.\n */\nexport function TurnstileProvider({\n siteKey,\n children,\n}: {\n siteKey: string | null\n children: React.ReactNode\n}) {\n const [token, setToken] = useState<string | null>(null)\n return (\n <TurnstileContext.Provider value={{ siteKey, token, setToken }}>\n {children}\n </TurnstileContext.Provider>\n )\n}\n\n/**\n * Hook giving a form the Turnstile token + a widget bound to the provider's key.\n * No per-form siteKey plumbing — the provider supplies it.\n *\n * const { token, TurnstileWidget, reset } = useTurnstile()\n * // in JSX: <TurnstileWidget />\n * // at submit: submitForm({ ..., turnstileToken: token })\n * // after submit: reset() // clear for the next submission\n */\nexport function useTurnstile(): {\n token: string | null\n TurnstileWidget: (props?: { theme?: 'light' | 'dark' | 'auto' }) => React.ReactNode\n reset: () => void\n /** True when Turnstile is configured (site key present). */\n enabled: boolean\n} {\n const ctx = useContext(TurnstileContext)\n if (!ctx) {\n throw new Error('useTurnstile must be used within <TurnstileProvider>')\n }\n const { siteKey, token, setToken } = ctx\n\n const reset = useCallback(() => setToken(null), [setToken])\n\n const TurnstileWidget = useCallback(\n (props?: { theme?: 'light' | 'dark' | 'auto' }) => {\n if (!siteKey) return null\n return <Turnstile siteKey={siteKey} onToken={setToken} theme={props?.theme} />\n },\n [siteKey, setToken],\n )\n\n return { token, TurnstileWidget, reset, enabled: Boolean(siteKey) }\n}\n"],"names":["createContext","useCallback","useContext","useState","Turnstile","TurnstileContext","TurnstileProvider","siteKey","children","token","setToken","Provider","value","useTurnstile","ctx","Error","reset","TurnstileWidget","props","onToken","theme","enabled","Boolean"],"mappings":"AAAA;;AAEA,SAASA,aAAa,EAAEC,WAAW,EAAEC,UAAU,EAAEC,QAAQ,QAAQ,QAAO;AACxE,SAASC,SAAS,QAAQ,iBAAgB;AAU1C,MAAMC,iCAAmBL,cAA4C;AAErE;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASM,kBAAkB,EAChCC,OAAO,EACPC,QAAQ,EAIT;IACC,MAAM,CAACC,OAAOC,SAAS,GAAGP,SAAwB;IAClD,qBACE,KAACE,iBAAiBM,QAAQ;QAACC,OAAO;YAAEL;YAASE;YAAOC;QAAS;kBAC1DF;;AAGP;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASK;IAOd,MAAMC,MAAMZ,WAAWG;IACvB,IAAI,CAACS,KAAK;QACR,MAAM,IAAIC,MAAM;IAClB;IACA,MAAM,EAAER,OAAO,EAAEE,KAAK,EAAEC,QAAQ,EAAE,GAAGI;IAErC,MAAME,QAAQf,YAAY,IAAMS,SAAS,OAAO;QAACA;KAAS;IAE1D,MAAMO,kBAAkBhB,YACtB,CAACiB;QACC,IAAI,CAACX,SAAS,OAAO;QACrB,qBAAO,KAACH;YAAUG,SAASA;YAASY,SAAST;YAAUU,OAAOF,OAAOE;;IACvE,GACA;QAACb;QAASG;KAAS;IAGrB,OAAO;QAAED;QAAOQ;QAAiBD;QAAOK,SAASC,QAAQf;IAAS;AACpE"}
+1
View File
@@ -1,2 +1,3 @@
export { Turnstile } from './Turnstile.js'; export { Turnstile } from './Turnstile.js';
export type { TurnstileProps } from './Turnstile.js'; export type { TurnstileProps } from './Turnstile.js';
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
+1
View File
@@ -2,5 +2,6 @@
// Client-only exports — the Turnstile widget. Kept separate from index.ts so // Client-only exports — the Turnstile widget. Kept separate from index.ts so
// the server-only verify never leaks into a browser bundle. // the server-only verify never leaks into a browser bundle.
export { Turnstile } from './Turnstile.js'; export { Turnstile } from './Turnstile.js';
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
//# sourceMappingURL=client.js.map //# sourceMappingURL=client.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\n"],"names":["Turnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB"} {"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\nexport { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n"],"names":["Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB;AAE1C,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB"}
+1
View File
@@ -1 +1,2 @@
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
export { verifyTurnstile } from './verify.js'; export { verifyTurnstile } from './verify.js';
+1
View File
@@ -1,3 +1,4 @@
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
// Server-only exports. verify.ts imports 'server-only', so this must never be // Server-only exports. verify.ts imports 'server-only', so this must never be
// imported from a client component — use ./client for the widget instead. // imported from a client component — use ./client for the widget instead.
export { verifyTurnstile } from './verify.js'; export { verifyTurnstile } from './verify.js';
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["verifyTurnstile"],"mappings":"AAAA,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASA,eAAe,QAAQ,cAAa"} {"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["TurnstileProvider","useTurnstile","verifyTurnstile"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB;AACxE,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASC,eAAe,QAAQ,cAAa"}
+8 -1
View File
@@ -103,11 +103,17 @@ export default buildConfig({
| Moduł | Opis | Dok | | Moduł | Opis | Dok |
|---|---|---| |---|---|---|
| i18n | Lokalizacja, negocjacja locale, ścieżki URL | [i18n.md](./i18n.md) | | i18n | Lokalizacja, negocjacja locale, ścieżki URL, strona jednojęzyczna | [i18n.md](./i18n.md) |
| hooks | Hooki: revalidate ISR, slug history 301, ochrona stron systemowych | [hooks.md](./hooks.md) |
| kolekcje-katalog | Jakie kolekcje budować, kiedy, jak wpiąć (minimum nie maksimum) | [kolekcje-katalog.md](./kolekcje-katalog.md) |
| fundamenty-projektu | Struktura katalogów, nazewnictwo, konwencje | [fundamenty-projektu.md](./fundamenty-projektu.md) |
| deployment | Zmienne .env, ISR/SSG, force-dynamic, Coolify/Docker | [deployment.md](./deployment.md) |
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) | | pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) | | access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) | | payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) | | seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
| wymagania-prawne | **Polityki, regulaminy, baner cookies, RODO (compliance)** | [wymagania-prawne.md](./wymagania-prawne.md) |
| standardy-kodu | **Dobre praktyki senior: typy, architektura, antywzorce** | [standardy-kodu.md](./standardy-kodu.md) |
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) | | architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) | | blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) | | consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
@@ -117,6 +123,7 @@ export default buildConfig({
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) | | analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) | | slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) | | notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
| storage | Media na Cloudflare R2 (offload z .env) | [storage.md](./storage.md) |
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) | | security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) | | content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
+24
View File
@@ -10,6 +10,30 @@ registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący. (pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
## ⚠️ NIE pisz własnego renderera bloków (switch)
**Renderer bloków to `RenderBlocks` z pluginu — NIGDY własny `switch`/`if`.**
Częsty błąd: projekt pisze własny `BlockRenderer` z `switch (block.blockType)`
i 20 case'ami. To łamie A0 — plugin ma silnik, projekt dostarcza tylko MAPĘ
komponentów.
```tsx
// ŹLE — własny switch w projekcie (gadatliwy, bez enhanceProps, rośnie liniowo)
switch (block.blockType) {
case 'hero': return <Hero {...block} />
case 'faq': return <FAQ {...block} />
// ...20 case'ów
}
// DOBRZE — mapa + RenderBlocks (silnik z pluginu)
const registry = { hero: Hero, faq: FAQ, /* ... */ }
<RenderBlocks blocks={page.layout} components={registry} />
```
Dlaczego RenderBlocks, nie switch: enhanceProps (anchory nav, itp.), guardy,
obsługa zagnieżdżeń, spójność między projektami. Switch tego nie ma i rośnie
z każdym blokiem. Mapa jest płaska i deklaratywna.
## Front — RenderBlocks ## Front — RenderBlocks
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy): Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
+39
View File
@@ -129,6 +129,45 @@ const nextConfig: NextConfig = {
Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`. Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`.
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots). Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
## 3a2. ISR — cache stron (metadata w head + szybkość)
Dla stron contentowych (page.tsx) użyj ISR: `export const revalidate = 3600`.
Cache'uje całą stronę z gotowym `<head>` → metadata zawsze w head (nie body,
brak race condition streamingu), TTFB ~20ms, brak 503 (cold start).
```ts
// app/(frontend)/[locale]/[[...slug]]/page.tsx
export const revalidate = 3600 // 1h; albo krócej, albo on-demand
```
UWAGA: ISR i `force-dynamic` się WYKLUCZAJĄ. Strony → ISR (revalidate).
sitemap/robots → force-dynamic (bo generują przy żądaniu). Nie mieszaj na jednej
trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand
revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md.
## 3a3. SSG a dostęp do bazy przy buildzie (WAŻNE dla SEO)
`generateStaticParams` (z lib/content) prerenderuje strony jako SSG — head
synchroniczny, SEO 100/100. ALE żeby prerenderować, **build musi mieć dostęp do
bazy** (generateStaticParams czyta strony z bazy w czasie buildu).
- **Build MA dostęp do bazy** (baza w tej samej sieci Docker, dostępna w build
stage) → strony prerenderowane jako SSG (`●`), head synchroniczny → SEO OK ✓
- **Build NIE MA dostępu** (izolowany build stage) → generateStaticParams zwraca
`[]` (plugin łapie błąd, build nie pada), ale strony renderują się on-demand
(dynamicznie) → head może streamować do body → problem SEO wraca ✗
Plugin zabezpiecza build przed CRASHEM (try/catch → `[]`), ale to NIE zastępuje
dostępu do bazy. **Dla pełnego SSG/SEO zapewnij, że build kontenerowy widzi bazę.**
W Coolify/Docker: baza (Mongo/Postgres) powinna być dostępna podczas `pnpm build`,
nie tylko w runtime. Jeśli build jest w izolowanej sieci — rozważ:
- uruchom bazę w tej samej sieci Docker co build stage, albo
- build z DATABASE_URI wskazującym na dostępną bazę (nie wewnętrzny host niedostępny w buildzie).
Weryfikacja: po buildzie `pnpm build` pokazuje trasy jako `●` (SSG), nie `ƒ`
(Dynamic). Jeśli `ƒ` mimo generateStaticParams → build nie miał dostępu do bazy.
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE) ## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` — Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
+16
View File
@@ -359,7 +359,11 @@ export default async function LocaleLayout({ children, params }) {
return ( return (
<html lang={locale}> <html lang={locale}>
{/* BEZ jawnego <head>! Sztywny <head> wypycha metadata do <body>
(canonical/title poza head → crawlery ich nie widzą). Next zarządza
<head> sam; MediaPreconnect w body, React 19 hoistuje link do head. */}
<body> <body>
<MediaPreconnect /> {/* preconnect CDN, jeśli R2 */}
<ConsentProvider texts={texts}> <ConsentProvider texts={texts}>
<main>{children}</main> <main>{children}</main>
<CookieBanner /> <CookieBanner />
@@ -392,6 +396,11 @@ const pageMetadata = createPageMetadata({
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
}) })
// ISR — cache strony z gotowym <head>. Eliminuje race condition streamingu
// metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503.
// Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu).
export const revalidate = 3600
export async function generateMetadata({ params }): Promise<Metadata> { export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params const { locale, slug } = await params
return pageMetadata({ payload: await getCachedPayload(), locale, slug }) return pageMetadata({ payload: await getCachedPayload(), locale, slug })
@@ -408,6 +417,8 @@ export default async function Page({ params, searchParams }) {
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug) - brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale - slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
- **ISR (`revalidate`)** → metadata zawsze w `<head>` (nie body), szybki TTFB.
KRYTYCZNE dla SEO — patrz seo.md (metadata w head).
- `depth: 2` → relacje w blokach (form) się populują - `depth: 2` → relacje w blokach (form) się populują
## 11. Metadata / SEO (szczegóły) ## 11. Metadata / SEO (szczegóły)
@@ -486,10 +497,15 @@ przypisanie strony-archiwum w System Pages.
```ts ```ts
// app/sitemap.ts // app/sitemap.ts
export { sitemap as default } from '@/lib/content' export { sitemap as default } from '@/lib/content'
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
// app/robots.ts // app/robots.ts
export { robots as default } from '@/lib/content' export { robots as default } from '@/lib/content'
``` ```
`force-dynamic` w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/
Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą,
której kontener budujący nie widzi → build pada. Szczegóły: [deployment.md](./deployment.md).
--- ---
## Kiedy coś nie działa ## Kiedy coś nie działa
+150
View File
@@ -0,0 +1,150 @@
# Hooki pluginu — automatyzacja tworzenia stron
Plugin dostarcza hooki, które zdejmują z projektów powtarzalną robotę. Wpinasz je
w kolekcje; działają automatycznie. Wszystkie gotowe do użycia (import z pluginu).
Powiązane: [pages.md](./pages.md), [seo.md](./seo.md), [wymagania-prawne.md](./wymagania-prawne.md).
---
## buildRevalidateHook — ISR odświeżany po zapisie (NAJWAŻNIEJSZY)
Bez tego ISR ma haczyk: redaktor zapisuje stronę i CZEKA na revalidate (do
godziny). Z tym — zapisuje i OD RAZU widzi zmianę. To warunek, żeby ISR był
używalny dla CMS.
```ts
// kolekcja Pages — z pliku projektu, który MOŻE importować next/cache
import { revalidatePath } from 'next/cache'
import { buildRevalidateHook } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
const { afterChange, afterDelete } = buildRevalidateHook({
revalidatePath, // wstrzykiwany — plugin NIE importuje next/cache
config: i18nConfig,
})
export const Pages: CollectionConfig = {
slug: 'pages',
hooks: { afterChange: [afterChange], afterDelete: [afterDelete] },
// ...
}
```
**Dlaczego revalidatePath wstrzykiwany:** plugin nie importuje `next/cache` (to
by wywaliło Payload przy generate:importmap / czystym Node). Projekt podaje.
Obsługuje: wszystkie języki, root (home), zmianę slug (rewaliduje stary I nowy
path — stary URL nie serwuje starej treści), delete.
---
## setPublishedAtHook — auto-data publikacji
Ustawia `publishedAt` na teraz przy pierwszej publikacji (jeśli puste). Redaktor
nie wpisuje daty ręcznie; data jest dokładna dla Article JSON-LD i sitemap.
```ts
import { setPublishedAtHook } from '@intecion/ipal-kit'
// kolekcja z draftami (blog, artykuły):
hooks: { beforeChange: [setPublishedAtHook] }
```
Ustawia tylko przy przejściu na published; nie nadpisuje istniejącej daty
(redaktor może backdatować ręcznie).
---
## buildPreventDeleteSystemPage — ochrona stron systemowych
Blokuje usunięcie strony przypisanej do roli (homepage, privacyPolicy,
cookiePolicy, termsOfService). Redaktor nie usunie przypadkiem polityki
prywatności albo strony głównej → nie rozbije routingu i linków compliance.
```ts
import { buildPreventDeleteSystemPage } from '@intecion/ipal-kit'
hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }
```
Żeby usunąć — najpierw odłącz rolę w Site Settings (świadoma decyzja).
---
## buildValidateUniqueRole — jedna strona = jedna rola
Zapobiega przypisaniu tej samej strony do dwóch ról systemowych (np. homepage I
privacyPolicy naraz → niejednoznaczny routing).
```ts
import { buildValidateUniqueRole } from '@intecion/ipal-kit'
// na polu roli w SiteSettings:
{
name: 'privacyPolicy',
type: 'relationship',
relationTo: 'pages',
hooks: { beforeValidate: [buildValidateUniqueRole({
siblingFields: ['homepage', 'cookiePolicy', 'termsOfService'],
})] },
}
```
---
## trackSlugHistoryHook — auto-redirect 301 przy zmianie slug
Gdy slug się zmienia, zapisuje STARY slug do pola `slugHistory`. Projekt czyta to
i robi 301 ze starego URL na nowy → zmiana adresu nie daje 404 (realna strata SEO
z audytu).
```ts
import { trackSlugHistoryHook } from '@intecion/ipal-kit'
export const Pages: CollectionConfig = {
fields: [
// ...
{ name: 'slugHistory', type: 'array', admin: { readOnly: true },
fields: [{ name: 'slug', type: 'text' }] },
],
hooks: { beforeChange: [trackSlugHistoryHook] },
}
```
Projekt w resolveRoute / sprawdzeniu redirectów: jeśli żądany slug jest w
slugHistory jakiejś strony → 301 na jej aktualny slug. Przykład:
```ts
// w page.tsx, gdy resolveRoute nie znajdzie strony po slug:
const byHistory = await payload.find({
collection: 'pages',
where: { 'slugHistory.slug': { equals: requestedSlug } },
limit: 1,
})
if (byHistory.docs[0]) {
redirect(`/${locale}/${byHistory.docs[0].slug}`) // 301 na aktualny
}
```
---
## KOLEJNOŚĆ hooków (ważne)
W jednej kolekcji hooki tej samej fazy uruchamiają się po kolei. Typowa Media:
```ts
hooks: {
beforeOperation: [normalizeFilenameHook], // czyste nazwy
afterChange: [afterChange], // revalidate
afterDelete: [afterDelete],
}
```
Typowa Pages:
```ts
hooks: {
beforeChange: [setPublishedAtHook, trackSlugHistoryHook],
beforeDelete: [buildPreventDeleteSystemPage(...)],
afterChange: [afterChange], // revalidate
afterDelete: [afterDelete],
}
```
Które hooki wpiąć zależy od kolekcji — nie każda potrzebuje wszystkich (blog:
setPublishedAt; wszystkie z URL: revalidate + slugHistory; Pages: + preventDelete).
+94
View File
@@ -155,3 +155,97 @@ Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą)
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`. > **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną > Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty. > negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
## Strona jednojęzyczna (bez prefiksu /pl)
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie
`/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb
jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
się same:
- **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/`
- **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych)
- **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji)
- **canonical** → `https://klient.pl/o-nas` (bez prefiksu)
### Config — jeden locale
```ts
// i18n.config.ts
export const i18nConfig = {
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
defaultLocale: 'pl',
}
```
### Struktura katalogów — BEZ [locale]
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`:
```
# JEDNOJĘZYCZNY (bez [locale])
app/(frontend)/
layout.tsx # locale stałe z config, nie z params
not-found.tsx
[[...slug]]/page.tsx # /o-nas, /kontakt
# WIELOJĘZYCZNY (z [locale]) — dla porównania
app/(frontend)/[locale]/
layout.tsx # locale z params
[[...slug]]/page.tsx # /pl/o-nas, /en/about
```
### Layout jednojęzyczny — locale z config
```tsx
// app/(frontend)/layout.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export default async function Layout({ children }: { children: React.ReactNode }) {
const locale = i18nConfig.defaultLocale // stałe, nie z params
const settings = await getSettings(locale)
// ...reszta jak zwykle, ale locale jest stałe
return <html lang={locale}>...</html>
}
```
### Strony jednojęzyczne — locale z config
```tsx
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export async function generateMetadata({ params }) {
const { slug } = await params // TYLKO slug, nie locale
const locale = i18nConfig.defaultLocale // stałe
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
}
export default async function Page({ params }) {
const { slug } = await params
const locale = i18nConfig.defaultLocale // stałe
const route = await resolveRoute(locale, slug ?? [], pageNum)
// ...
}
```
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config.
### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz
go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez
przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
### Przejście jedno- → wielojęzyczny (later)
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
- dodaj locale do config
- przenieś strukturę do `[locale]/`
- layout/strony czytają locale z params
- wróci prefiks `/pl`, `/en` + hreflang
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
`/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.
+60
View File
@@ -53,3 +53,63 @@ 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; zwraca `{slug}[]` (jednojęzyczny) albo `{locale, slug}[]`
(wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`) oraz
zlokalizowane (string albo mapa per język).
**Strony noindex SĄ renderowane** (nie pomijane jak w sitemap). Strona prawna
(polityka, cookies) z noindex nadal musi się wyświetlić — użytkownik wchodzi z
stopki, crawler czyta jej `<meta robots=noindex>`. noindex kontroluje
INDEKSOWANIE, nie istnienie strony. Pominięcie wymusiłoby dynamiczne renderowanie
(ten sam problem streamingu, którego unikamy).
Helper NIE filtruje `_status` w zapytaniu (`where`) — kolekcje bez włączonych
draftów nie mają tego pola i zapytanie by rzuciło błąd. Drafty odfiltrowane w
pamięci (bezpieczne dla każdej kolekcji).
## 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.
+136
View File
@@ -62,3 +62,139 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options `additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` — na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
plugin go nie generuje, bo zależy od projektu. plugin go nie generuje, bo zależy od projektu.
### Dlaczego CSP zostaje w projekcie (nie plugin)
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
projekt dostarcza CSP dopasowany do siebie.
### buildCsp — generator CSP (zalecane zamiast ręcznego)
Zamiast pisać surowy CSP w każdym projekcie (ryzyko pominięcia base-uri,
object-src), użyj `buildCsp` — ma twarde reguły OWASP/Lighthouse wbudowane, a Ty
włączasz tylko flagi tego, co projekt ładuje:
```ts
// next.config.ts
import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'
const csp = buildCsp({
mode: 'report-only', // zacznij tu; 'enforce' gdy konsola czysta
r2Url: process.env.R2_PUBLIC_URL, // media R2 → img-src
turnstile: true, // challenges.cloudflare.com → script/frame/connect
analytics: true, // GTM + GA
youtube: true, // youtube → frame-src
googleMaps: true, // mapy Google
// extra: { 'script-src': ['https://inny-skrypt.pl'] }, // dodatkowe źródła
})
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production',
additional: [csp],
})
```
**Twarde reguły wbudowane** (zawsze, nie da się zapomnieć): `base-uri 'self'`,
`object-src 'none'`, `frame-ancestors 'none'`. To te, które Lighthouse/OWASP
wymagają, a łatwo je pominąć pisząc CSP ręcznie.
`buildCsp` NIE dodaje `'unsafe-eval'` (osłabia CSP) — dodaj przez `extra` tylko
jeśli biblioteka tego wymaga. `mode: 'report-only'` daje nagłówek
`…-Report-Only`; `'enforce'` daje `Content-Security-Policy`.
CSP dalej „w projekcie" (Ty wybierasz flagi wg tego, co ładujesz), ale skeleton
jest z pluginu — każdy projekt ma ten sam zahardowany fundament.
### Budowa CSP — ręcznie (jeśli potrzebujesz pełnej kontroli)
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
dopasuj do tego, co projekt faktycznie ładuje:
```ts
// next.config.ts
const r2Url = process.env.R2_PUBLIC_URL || ''
const csp = [
"default-src 'self'",
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
// style: self + inline (Tailwind) + Google Fonts
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// obrazy: self + media R2 (z env!) + data:
`img-src 'self' data: ${r2Url}`.trim(),
"font-src 'self' https://fonts.gstatic.com data:",
"connect-src 'self' https://www.google-analytics.com",
// ramki: Turnstile (widget captcha)
"frame-src https://challenges.cloudflare.com",
"form-action 'self'",
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
].join('; ')
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production',
additional: [{ key: 'Content-Security-Policy', value: csp }],
})
```
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
zostanie zablokowane.
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
enforcing CSP na ślepo. Metoda bezpieczna:
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
ale NIE blokuje — strona działa normalnie.
```ts
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
```
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
dyrektywy CSP.
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
(enforcing). Teraz CSP chroni, nie psując.
### Weryfikacja nagłówków na produkcji
```bash
# sprawdź, które nagłówki faktycznie wychodzą:
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
```
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
> albo upewnij się, że CF przepuszcza nagłówki z origin.
## COOP (Cross-Origin-Opener-Policy) — domyślnie włączony
buildSecurityHeaders wysyła domyślnie `Cross-Origin-Opener-Policy: same-origin` —
izoluje kontekst przeglądarki (ochrona przed XS-Leaks / Spectre, wyciekiem
window.opener). Uniwersalny nagłówek, więc z automatu.
- Domyślnie `same-origin` (najbezpieczniejsze)
- `coop: 'same-origin-allow-popups'` — jeśli otwierasz popupy OAuth/płatności
wymagające window.opener
- `coop: false` — wyłącz (rzadko potrzebne)
## Trusted Types — NIE wdrażać (na teraz)
NIE wymuszaj `require-trusted-types-for 'script'`. Powód:
- Audyt Lighthouse to „Bez oceny" (informacyjny/eksperymentalny w Chromium)
- Wymuszenie bez kompleksowego silnika polityk w Next/React powoduje `TypeError`
przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.
+242 -25
View File
@@ -53,6 +53,31 @@ Per strona (tab SEO):
Domyślnie: `Tytuł | Nazwa witryny`. Domyślnie: `Tytuł | Nazwa witryny`.
### Skąd bierze się „Tytuł" (priorytet źródła)
Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:
1. **titleOverride** — jeśli wypełniony, jest całym tytułem (bez składania).
2. **meta.title** — tytuł SEO wpisany w tab SEO.
3. **page.title** — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.
Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:
- **buildAutoFillMetaHook** (przy ZAPISIE) — wypełnia puste `meta.title` z pola
dokumentu (`title`). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
- **pageTitle w buildMetadata** (przy RENDEROWANIU) — jeśli meta.title mimo to
puste (np. auto-fill niewpięty), używa `page.title`. Druga linia obrony.
Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie
pusty tytuł ani sam siteName.
> **Uwaga — „Strona Główna” w tytule:** jeśli strona główna ma nazwę dokumentu
> „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie
> „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz **titleOverride**
> dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na
> coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest
> sama treść nazwy, nie brak tytułu.
## Front — createPageMetadata ## Front — createPageMetadata
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
@@ -457,54 +482,103 @@ typu, itp.):
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast - jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0) ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
## KRYTYCZNE: metadata w <head> dla Google (htmlLimitedBots) ## KRYTYCZNE: metadata w <head> dla Google
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie **Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`, renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
lądują w body w surowym HTML. Crawlery, które nie wykonują JS (Screaming Frog, lądują w body w surowym HTML. Crawlery bez JS (Screaming Frog, część botów) widzą
część botów), widzą je poza head → ignorują → utrata SEO. je poza head → ignorują → utrata SEO.
Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka **To wyścig czasowy (race condition):** gdy baza odpowie szybko, metadata zdąży
(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić do head; gdy wolniej (albo crawler odpytuje wiele stron naraz, obciążając bazę),
metadata do head dla crawlerów. Next zamyka `</head>` i dokleja metadata w `<body>`. Dlatego pojedynczy `curl`
może pokazać head OK, a test 10 zapytań — 5/10 w body. **Testuj wielokrotnie.**
### Rozwiązanie — htmlLimitedBots w next.config ### Rozwiązanie GŁÓWNE — ISR (revalidate) w stronach
Najskuteczniejsze: **cache całej strony (ISR)**. Strona generowana raz z gotowym
`<head>`, kolejne żądania serwują cache — zero zapytań do bazy przy renderowaniu,
więc race condition ZNIKA (metadata zawsze w head). Bonus: TTFB spada z ~500ms do
~20ms, znikają sporadyczne 503 (cold start).
```ts
// app/(frontend)/[locale]/[[...slug]]/page.tsx
export const revalidate = 3600 // cache 1h, regeneracja w tle
```
> **UWAGA — ISR a treść z panelu:** strona cache'owana `revalidate` sekund NIE
> pokaże zmian redaktora od razu (czeka do rewalidacji). Dla treści zmienianej
> rzadko OK. Jeśli redaktor ma widzieć zmiany natychmiast — użyj **on-demand
> revalidation**: hook `afterChange` w kolekcji → `revalidatePath(path)` (patrz
> HOOKS.md). Albo krótszy `revalidate` (np. 300 = 5 min). NIE łącz ISR z
> `force-dynamic` — wykluczają się.
### Rozwiązanie DRUGIE — usuń jawny <head> z layoutu
Sztywny `<head>` w layoucie App Router wymusza przedwczesne zamknięcie head —
zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj `<head>`:
```tsx
// ŹLE — jawny <head> zamyka head za wcześnie
<html lang={locale}>
<head><MediaPreconnect /></head>
<body>{children}</body>
</html>
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
<html lang={locale}>
<body>
<MediaPreconnect />
{children}
</body>
</html>
```
React 19 sam przenosi `<link rel="preconnect">` do head. Jawny `<head>` jest
zbędny i szkodliwy (wymusza wczesne zamknięcie).
### Rozwiązanie TRZECIE — htmlLimitedBots (uzupełnienie)
Dodatkowo można wymusić blocking metadata dla crawlerów (przydatne, gdy strona
z jakiegoś powodu nie może być ISR):
```ts ```ts
// next.config.ts // next.config.ts
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
// dla crawlerów SEO — zamiast streamingu do <body>.
htmlLimitedBots: htmlLimitedBots:
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i, /Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
// ...reszta
} }
``` ```
`htmlLimitedBots` mówi Next: dla tych User-Agentów wyłącz streaming, wstaw `htmlLimitedBots` wyłącza streaming dla tych User-Agentów (metadata w head). ALE
metadata do `<head>` w surowym HTML (blocking). Użytkownicy dalej dostają to słabsze niż ISR — bo strona dalej renderuje dynamicznie (zapytanie do bazy →
streaming (szybkie ładowanie); crawlery dostają poprawny head. wolniej, ryzyko race). **ISR eliminuje przyczynę, htmlLimitedBots łagodzi objaw.**
Najlepiej: ISR + brak jawnego head. htmlLimitedBots jako dodatkowa warstwa.
### Objawy (że masz ten problem) ### Objawy (że masz ten problem)
- Screaming Frog: „canonical/hreflang/title outside <head>" - Screaming Frog: „canonical/hreflang/title outside <head>" (na wielu podstronach)
- Search Console: „brak canonical", favicon nie pokazuje się (glob) - Search Console: „brak canonical", favicon glob
- W surowym HTML canonical/title są PO `</head>`, na końcu body, ze skryptem - Surowy HTML: canonical/title PO `</head>`, na końcu body, ze skryptem appendChild
`document.querySelectorAll('body link[rel=icon]')...appendChild` - Test 10 zapytań: część w head, część w body (race condition)
### Weryfikacja ### Weryfikacja — TESTUJ WIELOKROTNIE (nie pojedynczo)
Pojedynczy `curl` może trafić w „szczęśliwy" timing. Testuj 10 razy:
```bash ```bash
# jako Googlebot — metadata MUSI być w <head> # 10 zapytań jako Googlebot — ile ma canonical w <head>
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical for i in $(seq 1 10); do
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS) curl -s -A "Googlebot" https://twojadomena.pl/pl/strona | \
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona python3 -c "import sys; h=sys.stdin.read(); e=h.find('</head>'); c=h.find('rel=\"canonical\"'); print('head' if 0<c<e else 'BODY')"
done
# Cel: 10x 'head'. Jeśli część 'BODY' → race condition, dodaj ISR.
``` ```
Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical Testuj też PODSTRONY (nie tylko główną) — race częściej dotyka podstron.
(„User-declared canonical: None"), hreflang i title. Jedna linia w config DevTools (F12) NIE nadaje się do testu — hoistuje tagi do head automatycznie,
naprawia wszystko naraz. pokazując fałszywie poprawny head. Używaj `curl` / Ctrl+U (surowe źródło).
## SEO wielojęzyczne — hreflang, x-default, redirect roota ## SEO wielojęzyczne — hreflang, x-default, redirect roota
@@ -711,3 +785,146 @@ 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.
## 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).
## Sitemap jako czytelna tabela (XSL stylesheet)
Domyślny `/sitemap.xml` to surowy XML — Google go czyta, ale człowiek widzi
„ścianę tagów". Można ostylować przez XSL (przeglądarka renderuje tabelę),
zachowując poprawność dla crawlerów.
### Ograniczenie Next
Standardowy `app/sitemap.ts` (Next MetadataRoute) **nie pozwala** wstrzyknąć
`<?xml-stylesheet?>`. Żeby mieć styl, serwuj sitemap własnym route przez
`buildSitemapXml` (string XML z odwołaniem do XSL):
```ts
// app/sitemap.xml/route.ts (zamiast app/sitemap.ts)
import { buildSitemapXml } from '@intecion/ipal-kit'
import { sitemap } from '@/lib/content'
export const dynamic = 'force-dynamic'
export async function GET() {
const entries = await sitemap()
const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' })
return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
})
}
```
### Plik XSL w /public
Skopiuj `sitemap.xsl` (z pluginu: `node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.xsl`)
do `public/sitemap.xsl` w projekcie. Zawiera responsywną tabelę (numer, URL,
data, języki) z dark mode. `stylesheetUrl: '/sitemap.xsl'` wskazuje na niego.
### WAŻNE — jeden sitemap, nie dwa
Jeśli używasz route `app/sitemap.xml/route.ts` (styled), **USUŃ** `app/sitemap.ts`
(MetadataRoute). Dwa sitemapy pod różnymi ścieżkami mylą crawlery. Wybierz jeden:
- **styled** (`sitemap.xml/route.ts` + buildSitemapXml + XSL) — ładna tabela
- **prosty** (`sitemap.ts` + reeksport z lib/content) — bez stylu, mniej kodu
Styl to kosmetyka (Google czyta oba tak samo) — rób, jeśli klient/audyt tego chce.
+21
View File
@@ -133,6 +133,27 @@ pnpm dev
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił # wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
``` ```
## Root subdomeny media zwraca 404 (to normalne)
`media.klient.pl/plik.jpg` → R2 zwraca plik. Ale `media.klient.pl/` (sam root,
bez pliku) → **404**, bo R2 nie ma obiektu pod rootem. To NORMALNE zachowanie
R2, nie błąd.
Audyty SEO (Screaming Frog) czasem zgłaszają to 404 — bo crawler widzi URL-e
plików (`media.../logo.svg`) i próbuje roota. Ale:
- **NIE linkuj do samego roota** `media.klient.pl/` — tylko do plików. Kod nie
powinien nigdzie mieć `media.klient.pl/` bez nazwy pliku.
- Root media 404 **nie szkodzi SEO** głównej domeny (Google indeksuje klient.pl,
nie media.klient.pl). Nikt nie trafia na root media.
**Plugin tego nie naprawi** — subdomena media to serwis R2/Cloudflare, nie
aplikacja Next. Żądania do media.klient.pl nie docierają do Twojego kodu.
Jeśli chcesz „czysto" w Search Console (opcjonalne): Cloudflare → Rules →
Redirect Rules → gdy hostname = `media.klient.pl` i path = `/` → 301 na
`klient.pl`. Jednorazowo w panelu CF. Ale to kosmetyka — root media 404 jest
nieszkodliwe.
## Dev na lokalnym I na R2 (seedowanie podczas developmentu) ## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**: Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
+69
View File
@@ -59,3 +59,72 @@ trafi do bundla przeglądarki.
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to > W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
> `submitForm` (patrz [forms.md](./forms.md)). > `submitForm` (patrz [forms.md](./forms.md)).
## Uproszczone wpięcie — TurnstileProvider + useTurnstile (zalecane)
Jak CookieBanner: siteKey raz w layoutcie, formularze biorą z kontekstu. Koniec
przekazywania siteKey do każdego formularza.
### 1. Provider w layoutcie (raz, siteKey z serwera)
```tsx
// app/(frontend)/[locale]/layout.tsx (server)
import { TurnstileProvider } from '@intecion/ipal-kit/client'
import { getTurnstileSiteKey } from '@/lib/payload' // Twój helper server-side
export default async function Layout({ children }) {
const siteKey = await getTurnstileSiteKey() // z panelu (SiteIntegrations)
return (
<html>
<body>
<TurnstileProvider siteKey={siteKey}>
{children}
</TurnstileProvider>
</body>
</html>
)
}
```
### 2. Formularz — useTurnstile (zero plumbingu siteKey)
```tsx
'use client'
import { useTurnstile } from '@intecion/ipal-kit/client'
function ContactForm() {
const { token, TurnstileWidget, reset, enabled } = useTurnstile()
async function handleSubmit(data) {
const result = await submitForm({ ...data, turnstileToken: token })
if (result.ok) reset() // wyczyść token na następne wysłanie
}
return (
<form onSubmit={...}>
{/* pola formularza */}
<TurnstileWidget /> {/* widget tam, gdzie ma być */}
<button type="submit">Wyślij</button>
</form>
)
}
```
`token` → do submitForm. `TurnstileWidget` → wstaw gdzie ma być. `reset()` → po
wysłaniu. `enabled` → false gdy brak klucza (dev bez Turnstile).
### Dlaczego Turnstile NIE jest w pełni "wstaw i zapomnij" jak CookieBanner
CookieBanner jest samodzielny (renderuje się, zarządza zgodą, zero interakcji).
Turnstile z natury jest CZĘŚCIĄ formularza — zwraca token, który formularz musi
wysłać przy submit i zweryfikować server-side. Nie da się go „wstawić
gdziekolwiek" — musi być w formularzu, przy jego logice wysyłki.
Provider+hook to maksimum uproszczenia: siteKey raz (jak CookieBanner), a w
formularzu tylko `<TurnstileWidget/>` + `token`. Reszta (weryfikacja) dzieje się
w submitForm automatycznie.
### Stary sposób (nadal działa)
`<Turnstile siteKey={...} onToken={...} />` bezpośrednio — jeśli potrzebujesz
pełnej kontroli albo masz nietypowy przypadek. Provider to warstwa wygody nad tym.

Some files were not shown because too many files have changed in this diff Show More