Compare commits
51
Commits
v1.1.2
..
08a8a10478
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a8a10478 | ||
|
|
7bbaf14d14 | ||
|
|
b7d0b01122 | ||
|
|
4627266577 | ||
|
|
471ec4b12a | ||
|
|
060a61fd41 | ||
|
|
7cef95225a | ||
|
|
0ae62226bc | ||
|
|
610ab6fdf5 | ||
|
|
81275c0395 | ||
|
|
4a46651839 | ||
|
|
e7f06548fb | ||
|
|
e2a703fc72 | ||
|
|
de75d8374d | ||
|
|
b807fbe69a | ||
|
|
827cd9bcbc | ||
|
|
fa2b607979 | ||
|
|
4fe690e1e2 | ||
|
|
3553daa086 | ||
|
|
a4c5bcfbdf | ||
|
|
026c2696b6 | ||
|
|
6cb4168f44 | ||
|
|
b82ef82f50 | ||
|
|
9a18434f4a | ||
|
|
f126eacf8c | ||
|
|
848b12ce5d | ||
|
|
3d350bd9e5 | ||
|
|
e03dd8c5a6 | ||
|
|
9101a5b48e | ||
|
|
1186f4f620 | ||
|
|
988fcaf855 | ||
|
|
b27031f7c4 | ||
|
|
7ee60e7313 | ||
|
|
671090b1b1 | ||
|
|
0f03bb7446 | ||
|
|
55a1e5bf8f | ||
|
|
33ae035adc | ||
|
|
87d575f335 | ||
|
|
e4329f76b1 | ||
|
|
fa4cf20022 | ||
|
|
2ecfc2e12d | ||
|
|
db2ac80a1e | ||
|
|
99490bfc4d | ||
|
|
fce17654f8 | ||
|
|
419207ac12 | ||
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f |
Vendored
+5
-2
@@ -1,5 +1,7 @@
|
|||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||||
|
export { AccessibilityProvider, AccessibilityWidget, useAccessibility, } from '../modules/accessibility/client.js';
|
||||||
|
export type { A11yClassNames, A11yState, A11yTexts } from '../modules/accessibility/client.js';
|
||||||
export { Analytics } from '../modules/analytics/client.js';
|
export { Analytics } from '../modules/analytics/client.js';
|
||||||
/**
|
/**
|
||||||
* Entry point: ipal-kit/client
|
* Entry point: ipal-kit/client
|
||||||
@@ -10,7 +12,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 { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs, switchLocalePath, } from '../modules/i18n/index.js';
|
||||||
|
export type { I18nConfig, LocalizedSlugs } from '../modules/i18n/index.js';
|
||||||
export { Turnstile } from '../modules/turnstile/client.js';
|
export { Turnstile } from '../modules/turnstile/client.js';
|
||||||
|
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
|
||||||
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
||||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
|
||||||
export type { FormNotificationTexts } from '../modules/notifications/types.js';
|
|
||||||
|
|||||||
Vendored
+5
-1
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||||
|
export { AccessibilityProvider, AccessibilityWidget, useAccessibility } from '../modules/accessibility/client.js';
|
||||||
export { Analytics } from '../modules/analytics/client.js';
|
export { Analytics } from '../modules/analytics/client.js';
|
||||||
/**
|
/**
|
||||||
* Entry point: ipal-kit/client
|
* Entry point: ipal-kit/client
|
||||||
@@ -9,7 +10,10 @@ 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';
|
||||||
|
// Pure i18n path helpers — no server/RSC deps, safe to import in client
|
||||||
|
// components (e.g. a LanguageSwitcher that computes locale URLs on the client).
|
||||||
|
export { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs, switchLocalePath } from '../modules/i18n/index.js';
|
||||||
export { Turnstile } from '../modules/turnstile/client.js';
|
export { Turnstile } from '../modules/turnstile/client.js';
|
||||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
|
||||||
|
|
||||||
//# sourceMappingURL=client.js.map
|
//# sourceMappingURL=client.js.map
|
||||||
Vendored
+1
-1
@@ -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 {\n AccessibilityProvider,\n AccessibilityWidget,\n useAccessibility,\n} from '../modules/accessibility/client.js'\nexport type { A11yClassNames, A11yState, A11yTexts } from '../modules/accessibility/client.js'\n\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'\n// Pure i18n path helpers — no server/RSC deps, safe to import in client\n// components (e.g. a LanguageSwitcher that computes locale URLs on the client).\nexport {\n buildLocalizedPath,\n getLocaleCodes,\n getLocalizedSlugs,\n switchLocalePath,\n} from '../modules/i18n/index.js'\nexport type { I18nConfig, LocalizedSlugs } from '../modules/i18n/index.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","AccessibilityProvider","AccessibilityWidget","useAccessibility","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","buildLocalizedPath","getLocaleCodes","getLocalizedSlugs","switchLocalePath","Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SACEC,qBAAqB,EACrBC,mBAAmB,EACnBC,gBAAgB,QACX,qCAAoC;AAG3C,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,wEAAwE;AACxE,gFAAgF;AAChF,SACEC,kBAAkB,EAClBC,cAAc,EACdC,iBAAiB,EACjBC,gBAAgB,QACX,2BAA0B;AAEjC,SAASC,SAAS,QAAQ,iCAAgC;AAC1D,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,iCAAgC"}
|
||||||
Vendored
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||||
Vendored
+12
-3
@@ -1,5 +1,7 @@
|
|||||||
export type { AccessOption, Role } from './modules/access/index.js';
|
export type { AccessOption, Role } from './modules/access/index.js';
|
||||||
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY, } from './modules/access/index.js';
|
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY, } from './modules/access/index.js';
|
||||||
|
export { A11Y_COOKIE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y, } from './modules/accessibility/index.js';
|
||||||
|
export type { A11yState } from './modules/accessibility/index.js';
|
||||||
export type { AnalyticsConfig } from './modules/analytics/index.js';
|
export type { AnalyticsConfig } from './modules/analytics/index.js';
|
||||||
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
||||||
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent, } from './modules/consent/index.js';
|
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent, } from './modules/consent/index.js';
|
||||||
@@ -16,25 +18,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 { buildIconsMetadata, buildOrganizationJsonLd, 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';
|
||||||
|
|||||||
Vendored
+10
-6
@@ -1,4 +1,5 @@
|
|||||||
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/index.js';
|
export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/index.js';
|
||||||
|
export { A11Y_COOKIE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './modules/accessibility/index.js';
|
||||||
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
||||||
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
||||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
||||||
@@ -10,22 +11,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 { buildIconsMetadata, buildOrganizationJsonLd, 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';
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
import { type A11yState } from './state.js';
|
||||||
|
type A11yContextValue = {
|
||||||
|
reset: () => void;
|
||||||
|
set: <K extends keyof A11yState>(key: K, value: A11yState[K]) => void;
|
||||||
|
state: A11yState;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Provides accessibility preferences, persists them in a cookie, and applies them
|
||||||
|
* as data-attributes on <html> so the project's CSS can react. Like
|
||||||
|
* ConsentProvider for cookies — wrap the app once; the widget/button consume it.
|
||||||
|
*
|
||||||
|
* The plugin ships NO styles: it only sets attributes (data-a11y-*). The project
|
||||||
|
* writes CSS for those it supports (see docs/accessibility.md). This keeps the
|
||||||
|
* design in the project's hands.
|
||||||
|
*
|
||||||
|
* // layout.tsx
|
||||||
|
* import { AccessibilityProvider } from '@intecion/ipal-kit/client'
|
||||||
|
* <AccessibilityProvider>{children}</AccessibilityProvider>
|
||||||
|
*
|
||||||
|
* To avoid a flash, the project can read the a11y-prefs cookie server-side and
|
||||||
|
* set the attributes on <html> during SSR (see docs). This provider re-applies
|
||||||
|
* on the client and keeps them in sync.
|
||||||
|
*/
|
||||||
|
export declare function AccessibilityProvider({ children }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): import("react/jsx-runtime").JSX.Element;
|
||||||
|
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */
|
||||||
|
export declare function useAccessibility(): A11yContextValue;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { createContext, use, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { A11Y_COOKIE, A11Y_COOKIE_MAX_AGE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './state.js';
|
||||||
|
const A11yContext = /*#__PURE__*/ createContext(null);
|
||||||
|
function readCookie(name) {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||||
|
return match ? decodeURIComponent(match[1]) : undefined;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Provides accessibility preferences, persists them in a cookie, and applies them
|
||||||
|
* as data-attributes on <html> so the project's CSS can react. Like
|
||||||
|
* ConsentProvider for cookies — wrap the app once; the widget/button consume it.
|
||||||
|
*
|
||||||
|
* The plugin ships NO styles: it only sets attributes (data-a11y-*). The project
|
||||||
|
* writes CSS for those it supports (see docs/accessibility.md). This keeps the
|
||||||
|
* design in the project's hands.
|
||||||
|
*
|
||||||
|
* // layout.tsx
|
||||||
|
* import { AccessibilityProvider } from '@intecion/ipal-kit/client'
|
||||||
|
* <AccessibilityProvider>{children}</AccessibilityProvider>
|
||||||
|
*
|
||||||
|
* To avoid a flash, the project can read the a11y-prefs cookie server-side and
|
||||||
|
* set the attributes on <html> during SSR (see docs). This provider re-applies
|
||||||
|
* on the client and keeps them in sync.
|
||||||
|
*/ export function AccessibilityProvider({ children }) {
|
||||||
|
const [state, setState] = useState(A11Y_DEFAULT);
|
||||||
|
// Hydrate from cookie on mount.
|
||||||
|
useEffect(()=>{
|
||||||
|
setState(parseA11y(readCookie(A11Y_COOKIE)));
|
||||||
|
}, []);
|
||||||
|
// Apply attributes to <html> whenever state changes.
|
||||||
|
useEffect(()=>{
|
||||||
|
const el = document.documentElement;
|
||||||
|
const attrs = a11yAttributes(state);
|
||||||
|
for (const [attr, value] of Object.entries(attrs)){
|
||||||
|
if (value === null) {
|
||||||
|
el.removeAttribute(attr);
|
||||||
|
} else {
|
||||||
|
el.setAttribute(attr, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
state
|
||||||
|
]);
|
||||||
|
const persist = useCallback((next)=>{
|
||||||
|
document.cookie = `${A11Y_COOKIE}=${encodeURIComponent(serializeA11y(next))}; path=/; max-age=${A11Y_COOKIE_MAX_AGE}; samesite=lax`;
|
||||||
|
}, []);
|
||||||
|
const set = useCallback((key, value)=>{
|
||||||
|
setState((prev)=>{
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
};
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
persist
|
||||||
|
]);
|
||||||
|
const reset = useCallback(()=>{
|
||||||
|
setState(A11Y_DEFAULT);
|
||||||
|
persist(A11Y_DEFAULT);
|
||||||
|
}, [
|
||||||
|
persist
|
||||||
|
]);
|
||||||
|
return /*#__PURE__*/ _jsx(A11yContext, {
|
||||||
|
value: {
|
||||||
|
reset,
|
||||||
|
set,
|
||||||
|
state
|
||||||
|
},
|
||||||
|
children: children
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */ export function useAccessibility() {
|
||||||
|
const ctx = use(A11yContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAccessibility must be used within <AccessibilityProvider>');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=AccessibilityProvider.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
|||||||
|
export type A11yTexts = {
|
||||||
|
bigCursor?: string;
|
||||||
|
close?: string;
|
||||||
|
contrast?: string;
|
||||||
|
contrastHigh?: string;
|
||||||
|
contrastInverted?: string;
|
||||||
|
grayscale?: string;
|
||||||
|
lineHeight?: string;
|
||||||
|
open?: string;
|
||||||
|
readableFont?: string;
|
||||||
|
reduceMotion?: string;
|
||||||
|
reset?: string;
|
||||||
|
textSize?: string;
|
||||||
|
title?: string;
|
||||||
|
underlineLinks?: string;
|
||||||
|
};
|
||||||
|
export type A11yClassNames = {
|
||||||
|
active?: string;
|
||||||
|
button?: string;
|
||||||
|
closeButton?: string;
|
||||||
|
control?: string;
|
||||||
|
label?: string;
|
||||||
|
panel?: string;
|
||||||
|
resetButton?: string;
|
||||||
|
row?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Accessibility toolbar: a floating button that opens a panel of options (text
|
||||||
|
* size, line height, contrast, grayscale, underline links, readable font, reduce
|
||||||
|
* motion, big cursor). Choices persist in a cookie and apply as data-attributes
|
||||||
|
* on <html> (the project's CSS styles them).
|
||||||
|
*
|
||||||
|
* Unstyled by default — pass classNames to match the project's design (like
|
||||||
|
* CookieBanner). Wrap the app in <AccessibilityProvider> first.
|
||||||
|
*
|
||||||
|
* <AccessibilityWidget classNames={{ button: 'a11y-btn', panel: 'a11y-panel' }} />
|
||||||
|
*/
|
||||||
|
export declare function AccessibilityWidget({ classNames, texts, }: {
|
||||||
|
classNames?: A11yClassNames;
|
||||||
|
texts?: A11yTexts;
|
||||||
|
}): import("react/jsx-runtime").JSX.Element;
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAccessibility } from './AccessibilityProvider.js';
|
||||||
|
const DEFAULT_TEXTS = {
|
||||||
|
bigCursor: 'Duży kursor',
|
||||||
|
close: 'Zamknij',
|
||||||
|
contrast: 'Kontrast',
|
||||||
|
contrastHigh: 'Wysoki',
|
||||||
|
contrastInverted: 'Odwrócony',
|
||||||
|
grayscale: 'Skala szarości',
|
||||||
|
lineHeight: 'Odstęp między liniami',
|
||||||
|
open: 'Otwórz panel dostępności',
|
||||||
|
readableFont: 'Czytelna czcionka',
|
||||||
|
reduceMotion: 'Wyłącz animacje',
|
||||||
|
reset: 'Resetuj',
|
||||||
|
textSize: 'Rozmiar tekstu',
|
||||||
|
title: 'Dostępność',
|
||||||
|
underlineLinks: 'Podkreśl linki'
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Accessibility toolbar: a floating button that opens a panel of options (text
|
||||||
|
* size, line height, contrast, grayscale, underline links, readable font, reduce
|
||||||
|
* motion, big cursor). Choices persist in a cookie and apply as data-attributes
|
||||||
|
* on <html> (the project's CSS styles them).
|
||||||
|
*
|
||||||
|
* Unstyled by default — pass classNames to match the project's design (like
|
||||||
|
* CookieBanner). Wrap the app in <AccessibilityProvider> first.
|
||||||
|
*
|
||||||
|
* <AccessibilityWidget classNames={{ button: 'a11y-btn', panel: 'a11y-panel' }} />
|
||||||
|
*/ export function AccessibilityWidget({ classNames, texts }) {
|
||||||
|
const { reset, set, state } = useAccessibility();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const t = {
|
||||||
|
...DEFAULT_TEXTS,
|
||||||
|
...texts
|
||||||
|
};
|
||||||
|
const cn = classNames ?? {};
|
||||||
|
const toggle = (key)=>set(key, !state[key]);
|
||||||
|
const isActive = (on)=>on ? cn.active ?? '' : '';
|
||||||
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
"aria-expanded": open,
|
||||||
|
"aria-label": t.open,
|
||||||
|
className: cn.button,
|
||||||
|
onClick: ()=>setOpen((o)=>!o),
|
||||||
|
type: "button",
|
||||||
|
children: /*#__PURE__*/ _jsx("span", {
|
||||||
|
"aria-hidden": "true",
|
||||||
|
children: "♿"
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
open && /*#__PURE__*/ _jsxs("div", {
|
||||||
|
"aria-label": t.title,
|
||||||
|
className: cn.panel,
|
||||||
|
role: "dialog",
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: cn.row,
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("span", {
|
||||||
|
className: cn.label,
|
||||||
|
children: t.textSize
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("div", {
|
||||||
|
className: cn.control,
|
||||||
|
children: [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
].map((n)=>/*#__PURE__*/ _jsxs("button", {
|
||||||
|
className: isActive(state.textSize === n),
|
||||||
|
onClick: ()=>set('textSize', n),
|
||||||
|
type: "button",
|
||||||
|
children: [
|
||||||
|
"A",
|
||||||
|
n > 0 ? '+'.repeat(n) : ''
|
||||||
|
]
|
||||||
|
}, n))
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: cn.row,
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("span", {
|
||||||
|
className: cn.label,
|
||||||
|
children: t.lineHeight
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("div", {
|
||||||
|
className: cn.control,
|
||||||
|
children: [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
].map((n)=>/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: isActive(state.lineHeight === n),
|
||||||
|
onClick: ()=>set('lineHeight', n),
|
||||||
|
type: "button",
|
||||||
|
children: n === 0 ? '—' : '≡'.repeat(n)
|
||||||
|
}, n))
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: cn.row,
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("span", {
|
||||||
|
className: cn.label,
|
||||||
|
children: t.contrast
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: cn.control,
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: isActive(state.contrast === 'high'),
|
||||||
|
onClick: ()=>set('contrast', state.contrast === 'high' ? 'default' : 'high'),
|
||||||
|
type: "button",
|
||||||
|
children: t.contrastHigh
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: isActive(state.contrast === 'inverted'),
|
||||||
|
onClick: ()=>set('contrast', state.contrast === 'inverted' ? 'default' : 'inverted'),
|
||||||
|
type: "button",
|
||||||
|
children: t.contrastInverted
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'grayscale',
|
||||||
|
t.grayscale
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'underlineLinks',
|
||||||
|
t.underlineLinks
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'readableFont',
|
||||||
|
t.readableFont
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'reduceMotion',
|
||||||
|
t.reduceMotion
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'bigCursor',
|
||||||
|
t.bigCursor
|
||||||
|
]
|
||||||
|
].map(([key, label])=>/*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: cn.row,
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("span", {
|
||||||
|
className: cn.label,
|
||||||
|
children: label
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
"aria-pressed": Boolean(state[key]),
|
||||||
|
className: `${cn.control ?? ''} ${isActive(Boolean(state[key]))}`,
|
||||||
|
onClick: ()=>toggle(key),
|
||||||
|
type: "button",
|
||||||
|
children: state[key] ? 'ON' : 'OFF'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}, key)),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: cn.resetButton,
|
||||||
|
onClick: reset,
|
||||||
|
type: "button",
|
||||||
|
children: t.reset
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: cn.closeButton,
|
||||||
|
onClick: ()=>setOpen(false),
|
||||||
|
type: "button",
|
||||||
|
children: t.close
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=AccessibilityWidget.js.map
|
||||||
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
|
||||||
|
export { AccessibilityWidget } from './AccessibilityWidget.js';
|
||||||
|
export type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js';
|
||||||
|
export type { A11yState } from './state.js';
|
||||||
|
export { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js';
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
|
||||||
|
export { AccessibilityWidget } from './AccessibilityWidget.js';
|
||||||
|
export { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=client.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/accessibility/client.ts"],"sourcesContent":["'use client'\nexport { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'\nexport { AccessibilityWidget } from './AccessibilityWidget.js'\nexport type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'\nexport type { A11yState } from './state.js'\nexport { A11Y_COOKIE, a11yAttributes, parseA11y } from './state.js'\n"],"names":["AccessibilityProvider","useAccessibility","AccessibilityWidget","A11Y_COOKIE","a11yAttributes","parseA11y"],"mappings":"AAAA;AACA,SAASA,qBAAqB,EAAEC,gBAAgB,QAAQ,6BAA4B;AACpF,SAASC,mBAAmB,QAAQ,2BAA0B;AAG9D,SAASC,WAAW,EAAEC,cAAc,EAAEC,SAAS,QAAQ,aAAY"}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
|
||||||
|
export { AccessibilityWidget } from './AccessibilityWidget.js';
|
||||||
|
export type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js';
|
||||||
|
export { A11Y_COOKIE, A11Y_COOKIE_MAX_AGE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y, } from './state.js';
|
||||||
|
export type { A11yState } from './state.js';
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js';
|
||||||
|
export { AccessibilityWidget } from './AccessibilityWidget.js';
|
||||||
|
export { A11Y_COOKIE, A11Y_COOKIE_MAX_AGE, A11Y_DEFAULT, a11yAttributes, parseA11y, serializeA11y } from './state.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/accessibility/index.ts"],"sourcesContent":["export { AccessibilityProvider, useAccessibility } from './AccessibilityProvider.js'\nexport { AccessibilityWidget } from './AccessibilityWidget.js'\nexport type { A11yClassNames, A11yTexts } from './AccessibilityWidget.js'\nexport {\n A11Y_COOKIE,\n A11Y_COOKIE_MAX_AGE,\n A11Y_DEFAULT,\n a11yAttributes,\n parseA11y,\n serializeA11y,\n} from './state.js'\nexport type { A11yState } from './state.js'\n"],"names":["AccessibilityProvider","useAccessibility","AccessibilityWidget","A11Y_COOKIE","A11Y_COOKIE_MAX_AGE","A11Y_DEFAULT","a11yAttributes","parseA11y","serializeA11y"],"mappings":"AAAA,SAASA,qBAAqB,EAAEC,gBAAgB,QAAQ,6BAA4B;AACpF,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SACEC,WAAW,EACXC,mBAAmB,EACnBC,YAAY,EACZC,cAAc,EACdC,SAAS,EACTC,aAAa,QACR,aAAY"}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Accessibility preferences state. Each option maps to a data-attribute on
|
||||||
|
* <html> (e.g. data-a11y-contrast="high"); the PROJECT's CSS reacts to those
|
||||||
|
* attributes. The plugin sets the attributes and persists the choice — it does
|
||||||
|
* NOT ship styles, so it never fights the project's design.
|
||||||
|
*/
|
||||||
|
export type A11yState = {
|
||||||
|
/** Larger cursor. */
|
||||||
|
bigCursor: boolean;
|
||||||
|
/** 'default' | 'high' (high contrast) | 'inverted' (dark-on-light flip). */
|
||||||
|
contrast: 'default' | 'high' | 'inverted';
|
||||||
|
/** Grayscale filter on the whole page. */
|
||||||
|
grayscale: boolean;
|
||||||
|
/** Line spacing: 0 = default, 1..2 = looser. */
|
||||||
|
lineHeight: 0 | 1 | 2;
|
||||||
|
/** Readable font (project maps this to a dyslexia-friendly / simple font). */
|
||||||
|
readableFont: boolean;
|
||||||
|
/** Stop animations / transitions (prefers-reduced-motion equivalent). */
|
||||||
|
reduceMotion: boolean;
|
||||||
|
/** Text size step: 0 = default, 1..3 = larger. */
|
||||||
|
textSize: 0 | 1 | 2 | 3;
|
||||||
|
/** Underline all links (WCAG: don't rely on color alone). */
|
||||||
|
underlineLinks: boolean;
|
||||||
|
};
|
||||||
|
export declare const A11Y_DEFAULT: A11yState;
|
||||||
|
export declare const A11Y_COOKIE = "a11y-prefs";
|
||||||
|
export declare const A11Y_COOKIE_MAX_AGE: number;
|
||||||
|
/** Serialize for the cookie (compact). */
|
||||||
|
export declare function serializeA11y(state: A11yState): string;
|
||||||
|
/** Parse from the cookie; falls back to defaults on any bad value. */
|
||||||
|
export declare function parseA11y(raw: null | string | undefined): A11yState;
|
||||||
|
/**
|
||||||
|
* Maps state → data-attributes to set on <html>. Returns { attr: value|null };
|
||||||
|
* null means remove the attribute (option is at default). The project's CSS
|
||||||
|
* targets these, e.g. `[data-a11y-contrast="high"] { … }`.
|
||||||
|
*/
|
||||||
|
export declare function a11yAttributes(state: A11yState): Record<string, null | string>;
|
||||||
Vendored
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Accessibility preferences state. Each option maps to a data-attribute on
|
||||||
|
* <html> (e.g. data-a11y-contrast="high"); the PROJECT's CSS reacts to those
|
||||||
|
* attributes. The plugin sets the attributes and persists the choice — it does
|
||||||
|
* NOT ship styles, so it never fights the project's design.
|
||||||
|
*/ export const A11Y_DEFAULT = {
|
||||||
|
bigCursor: false,
|
||||||
|
contrast: 'default',
|
||||||
|
grayscale: false,
|
||||||
|
lineHeight: 0,
|
||||||
|
readableFont: false,
|
||||||
|
reduceMotion: false,
|
||||||
|
textSize: 0,
|
||||||
|
underlineLinks: false
|
||||||
|
};
|
||||||
|
export const A11Y_COOKIE = 'a11y-prefs';
|
||||||
|
export const A11Y_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||||
|
;
|
||||||
|
/** Serialize for the cookie (compact). */ export function serializeA11y(state) {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
/** Parse from the cookie; falls back to defaults on any bad value. */ export function parseA11y(raw) {
|
||||||
|
if (!raw) {
|
||||||
|
return A11Y_DEFAULT;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
...A11Y_DEFAULT,
|
||||||
|
...parsed
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return A11Y_DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Maps state → data-attributes to set on <html>. Returns { attr: value|null };
|
||||||
|
* null means remove the attribute (option is at default). The project's CSS
|
||||||
|
* targets these, e.g. `[data-a11y-contrast="high"] { … }`.
|
||||||
|
*/ export function a11yAttributes(state) {
|
||||||
|
return {
|
||||||
|
'data-a11y-contrast': state.contrast !== 'default' ? state.contrast : null,
|
||||||
|
'data-a11y-cursor': state.bigCursor ? 'big' : null,
|
||||||
|
'data-a11y-font': state.readableFont ? 'readable' : null,
|
||||||
|
'data-a11y-grayscale': state.grayscale ? 'on' : null,
|
||||||
|
'data-a11y-line': state.lineHeight > 0 ? String(state.lineHeight) : null,
|
||||||
|
'data-a11y-motion': state.reduceMotion ? 'reduce' : null,
|
||||||
|
'data-a11y-text': state.textSize > 0 ? String(state.textSize) : null,
|
||||||
|
'data-a11y-underline': state.underlineLinks ? 'on' : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=state.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/accessibility/state.ts"],"sourcesContent":["/**\n * Accessibility preferences state. Each option maps to a data-attribute on\n * <html> (e.g. data-a11y-contrast=\"high\"); the PROJECT's CSS reacts to those\n * attributes. The plugin sets the attributes and persists the choice — it does\n * NOT ship styles, so it never fights the project's design.\n */\nexport type A11yState = {\n /** Larger cursor. */\n bigCursor: boolean\n /** 'default' | 'high' (high contrast) | 'inverted' (dark-on-light flip). */\n contrast: 'default' | 'high' | 'inverted'\n /** Grayscale filter on the whole page. */\n grayscale: boolean\n /** Line spacing: 0 = default, 1..2 = looser. */\n lineHeight: 0 | 1 | 2\n /** Readable font (project maps this to a dyslexia-friendly / simple font). */\n readableFont: boolean\n /** Stop animations / transitions (prefers-reduced-motion equivalent). */\n reduceMotion: boolean\n /** Text size step: 0 = default, 1..3 = larger. */\n textSize: 0 | 1 | 2 | 3\n /** Underline all links (WCAG: don't rely on color alone). */\n underlineLinks: boolean\n}\n\nexport const A11Y_DEFAULT: A11yState = {\n bigCursor: false,\n contrast: 'default',\n grayscale: false,\n lineHeight: 0,\n readableFont: false,\n reduceMotion: false,\n textSize: 0,\n underlineLinks: false,\n}\n\nexport const A11Y_COOKIE = 'a11y-prefs'\nexport const A11Y_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year\n\n/** Serialize for the cookie (compact). */\nexport function serializeA11y(state: A11yState): string {\n return JSON.stringify(state)\n}\n\n/** Parse from the cookie; falls back to defaults on any bad value. */\nexport function parseA11y(raw: null | string | undefined): A11yState {\n if (!raw) {return A11Y_DEFAULT}\n try {\n const parsed = JSON.parse(raw) as Partial<A11yState>\n return { ...A11Y_DEFAULT, ...parsed }\n } catch {\n return A11Y_DEFAULT\n }\n}\n\n/**\n * Maps state → data-attributes to set on <html>. Returns { attr: value|null };\n * null means remove the attribute (option is at default). The project's CSS\n * targets these, e.g. `[data-a11y-contrast=\"high\"] { … }`.\n */\nexport function a11yAttributes(state: A11yState): Record<string, null | string> {\n return {\n 'data-a11y-contrast': state.contrast !== 'default' ? state.contrast : null,\n 'data-a11y-cursor': state.bigCursor ? 'big' : null,\n 'data-a11y-font': state.readableFont ? 'readable' : null,\n 'data-a11y-grayscale': state.grayscale ? 'on' : null,\n 'data-a11y-line': state.lineHeight > 0 ? String(state.lineHeight) : null,\n 'data-a11y-motion': state.reduceMotion ? 'reduce' : null,\n 'data-a11y-text': state.textSize > 0 ? String(state.textSize) : null,\n 'data-a11y-underline': state.underlineLinks ? 'on' : null,\n }\n}\n"],"names":["A11Y_DEFAULT","bigCursor","contrast","grayscale","lineHeight","readableFont","reduceMotion","textSize","underlineLinks","A11Y_COOKIE","A11Y_COOKIE_MAX_AGE","serializeA11y","state","JSON","stringify","parseA11y","raw","parsed","parse","a11yAttributes","String"],"mappings":"AAAA;;;;;CAKC,GAoBD,OAAO,MAAMA,eAA0B;IACrCC,WAAW;IACXC,UAAU;IACVC,WAAW;IACXC,YAAY;IACZC,cAAc;IACdC,cAAc;IACdC,UAAU;IACVC,gBAAgB;AAClB,EAAC;AAED,OAAO,MAAMC,cAAc,aAAY;AACvC,OAAO,MAAMC,sBAAsB,KAAK,KAAK,KAAK,IAAI,SAAS;CAAV;AAErD,wCAAwC,GACxC,OAAO,SAASC,cAAcC,KAAgB;IAC5C,OAAOC,KAAKC,SAAS,CAACF;AACxB;AAEA,oEAAoE,GACpE,OAAO,SAASG,UAAUC,GAA8B;IACtD,IAAI,CAACA,KAAK;QAAC,OAAOhB;IAAY;IAC9B,IAAI;QACF,MAAMiB,SAASJ,KAAKK,KAAK,CAACF;QAC1B,OAAO;YAAE,GAAGhB,YAAY;YAAE,GAAGiB,MAAM;QAAC;IACtC,EAAE,OAAM;QACN,OAAOjB;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASmB,eAAeP,KAAgB;IAC7C,OAAO;QACL,sBAAsBA,MAAMV,QAAQ,KAAK,YAAYU,MAAMV,QAAQ,GAAG;QACtE,oBAAoBU,MAAMX,SAAS,GAAG,QAAQ;QAC9C,kBAAkBW,MAAMP,YAAY,GAAG,aAAa;QACpD,uBAAuBO,MAAMT,SAAS,GAAG,OAAO;QAChD,kBAAkBS,MAAMR,UAAU,GAAG,IAAIgB,OAAOR,MAAMR,UAAU,IAAI;QACpE,oBAAoBQ,MAAMN,YAAY,GAAG,WAAW;QACpD,kBAAkBM,MAAML,QAAQ,GAAG,IAAIa,OAAOR,MAAML,QAAQ,IAAI;QAChE,uBAAuBK,MAAMJ,cAAc,GAAG,OAAO;IACvD;AACF"}
|
||||||
@@ -53,6 +53,12 @@ type CreateContentHelpersArgs = {
|
|||||||
* not the second, and a page component composes them in two obvious lines.
|
* not the second, and a page component composes them in two obvious lines.
|
||||||
*/
|
*/
|
||||||
export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: 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[]>;
|
||||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||||
|
|||||||
+137
-8
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
* ```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' // generate at runtime, not build
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||||
|
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||||
|
* calls into Payload → the database. The build container usually has no access
|
||||||
|
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||||
|
* the build dies. Two defenses:
|
||||||
|
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||||
|
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||||
|
* 2. This handler also catches DB errors and returns [] so that even without
|
||||||
|
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||||
|
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||||
*/ const sitemap = cache(async ()=>{
|
*/ const sitemap = cache(async ()=>{
|
||||||
if (!i18n) {
|
if (!i18n) {
|
||||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
||||||
}
|
}
|
||||||
return buildSitemapEntries({
|
try {
|
||||||
baseUrl: origin,
|
return await buildSitemapEntries({
|
||||||
config: i18n,
|
baseUrl: origin,
|
||||||
content,
|
config: i18n,
|
||||||
pagesSlug,
|
content,
|
||||||
payload: await getCachedPayload(),
|
pagesSlug,
|
||||||
settingsSlug
|
payload: await getCachedPayload(),
|
||||||
});
|
settingsSlug
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// DB unreachable (typically a container build with no DB network) — return
|
||||||
|
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||||
|
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||||
|
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||||
@@ -97,7 +117,116 @@ import { buildRobots, buildSitemapEntries } 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,
|
getEntries,
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+35
@@ -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
@@ -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
Vendored
+7
@@ -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';
|
||||||
Vendored
+11
@@ -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
|
||||||
Vendored
+1
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
Vendored
+19
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+19
-13
@@ -8,14 +8,22 @@ import type { I18nConfig } from './types.js';
|
|||||||
*/
|
*/
|
||||||
export type LocalizedSlugs = Record<string, string>;
|
export type LocalizedSlugs = Record<string, string>;
|
||||||
type BuildPathArgs = {
|
type BuildPathArgs = {
|
||||||
config: I18nConfig;
|
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
||||||
/**
|
slugs: LocalizedSlugs;
|
||||||
* Slug that represents the site root (served at /{locale} with no trailing
|
|
||||||
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
|
||||||
*/
|
|
||||||
homeSlug?: string;
|
|
||||||
/** Target locale to build the path for */
|
/** Target locale to build the path for */
|
||||||
locale: string;
|
locale: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
/**
|
||||||
|
* Slug(s) representing the site root (served at /{locale} with no trailing
|
||||||
|
* segment). Defaults to 'home'. Matched against the slug in the target locale.
|
||||||
|
*
|
||||||
|
* Can be a single string (same home slug in every locale) OR a per-locale map
|
||||||
|
* (`{ pl: 'strona-glowna', de: 'startseite', en: 'home' }`). The map form is
|
||||||
|
* REQUIRED for multilingual homepages whose slug differs per language —
|
||||||
|
* otherwise the home page collapses to '/pl' but '/de/startseite' stays
|
||||||
|
* un-collapsed, breaking hreflang return tags (a real GSC error).
|
||||||
|
*/
|
||||||
|
homeSlug?: string | Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under, e.g.
|
* Localized segment the document lives under, e.g.
|
||||||
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
|
||||||
@@ -25,8 +33,6 @@ type BuildPathArgs = {
|
|||||||
* A document under a prefix is never the home page, so homeSlug is ignored.
|
* A document under a prefix is never the home page, so homeSlug is ignored.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs;
|
prefix?: LocalizedSlugs;
|
||||||
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
|
|
||||||
slugs: LocalizedSlugs;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds a locale-prefixed path for a document in a target locale.
|
* Builds a locale-prefixed path for a document in a target locale.
|
||||||
@@ -50,13 +56,13 @@ type BuildPathArgs = {
|
|||||||
* })
|
* })
|
||||||
* // → '/en/articles/my-post'
|
* // → '/en/articles/my-post'
|
||||||
*/
|
*/
|
||||||
export declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined;
|
export declare function buildLocalizedPath({ slugs, locale, config, homeSlug, prefix, }: BuildPathArgs): string | undefined;
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
config: I18nConfig;
|
|
||||||
homeSlug?: string;
|
|
||||||
prefix?: LocalizedSlugs;
|
|
||||||
slugs: LocalizedSlugs;
|
slugs: LocalizedSlugs;
|
||||||
targetLocale: string;
|
targetLocale: string;
|
||||||
|
config: I18nConfig;
|
||||||
|
homeSlug?: string | Record<string, string>;
|
||||||
|
prefix?: LocalizedSlugs;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Resolves the equivalent path for the same document in a different locale —
|
* Resolves the equivalent path for the same document in a different locale —
|
||||||
@@ -66,5 +72,5 @@ type SwitchLocaleArgs = {
|
|||||||
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
* falls back to the archive it belongs to (/en/articles) if there is one, and
|
||||||
* to the locale root otherwise — the closest place the visitor would want.
|
* to the locale root otherwise — the closest place the visitor would want.
|
||||||
*/
|
*/
|
||||||
export declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string;
|
export declare function switchLocalePath({ slugs, targetLocale, config, homeSlug, prefix, }: SwitchLocaleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+23
-15
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -16,7 +16,7 @@ type GetSystemPagePathArgs = {
|
|||||||
* page's slug in the target locale equals this, the path collapses to the
|
* page's slug in the target locale equals this, the path collapses to the
|
||||||
* locale root (/pl, /en).
|
* locale root (/pl, /en).
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/** Target locale to build the path for. */
|
/** Target locale to build the path for. */
|
||||||
locale: string;
|
locale: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record<string, unknown>\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string | Record<string, string>\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"}
|
||||||
Vendored
+46
@@ -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 {};
|
||||||
Vendored
+109
@@ -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
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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());
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-1
@@ -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';
|
||||||
|
|||||||
Vendored
+1
@@ -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
|
||||||
Vendored
+1
-1
@@ -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"}
|
||||||
Vendored
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Starter stylesheet for a sitemap served via buildSitemapXml({ cssUrl }).
|
||||||
|
* Uses type="text/css" on XML (W3C "Associating Style Sheets with XML") — NOT
|
||||||
|
* XSLT, so no browser deprecation warning. Selectors are the XML tag names.
|
||||||
|
*
|
||||||
|
* Copy to your project's /public/sitemap.css and adjust colors/spacing to taste.
|
||||||
|
* Crawlers ignore this; it only affects the human-readable browser view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
urlset {
|
||||||
|
display: block;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #090d16;
|
||||||
|
color: #f1f5f9;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each URL entry as a card. */
|
||||||
|
url {
|
||||||
|
display: block;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The URL itself. */
|
||||||
|
loc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f97316;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata line: lastmod / changefreq / priority, each with a label. */
|
||||||
|
lastmod,
|
||||||
|
changefreq,
|
||||||
|
priority {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastmod::before {
|
||||||
|
content: 'Ostatnia modyfikacja: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: 'Częstotliwość: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: 'Priorytet: ';
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hreflang alternates as small pills. */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0.4rem 0.35rem 0 0;
|
||||||
|
}
|
||||||
Vendored
+143
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
* Universal, minimalist & elegant stylesheet for XML Sitemap.
|
||||||
|
* Neutral palette with automatic dark and light mode support.
|
||||||
|
* W3C standard: type="text/css" on XML (zero browser deprecation warnings).
|
||||||
|
*
|
||||||
|
* Ships with @intecion/ipal-kit. Copy to /public/sitemap.css and adjust freely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--border-hover: #d1d5db;
|
||||||
|
--text-main: #111827;
|
||||||
|
--text-secondary: #4b5563;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--url-color: #1e293b;
|
||||||
|
--badge-bg: #f3f4f6;
|
||||||
|
--badge-border: #e5e7eb;
|
||||||
|
--badge-text: #4b5563;
|
||||||
|
--accent: #027bd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #090a0f;
|
||||||
|
--card: #12131a;
|
||||||
|
--border: #1e202e;
|
||||||
|
--border-hover: #2e3247;
|
||||||
|
--text-main: #f9fafb;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
--url-color: #f3f4f6;
|
||||||
|
--badge-bg: #1a1c26;
|
||||||
|
--badge-border: #282b3d;
|
||||||
|
--badge-text: #9ca3af;
|
||||||
|
--accent: #027bd0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlset {
|
||||||
|
display: block;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimalist header */
|
||||||
|
urlset::before {
|
||||||
|
content: "XML Sitemap";
|
||||||
|
display: block;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand note under the header — crafted by Intecion Group */
|
||||||
|
urlset::after {
|
||||||
|
content: "Intecion.com, Technology — engineered for modern digital experiences.";
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL card */
|
||||||
|
url {
|
||||||
|
display: block;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
url:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL address */
|
||||||
|
loc {
|
||||||
|
display: block;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--url-color);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata row */
|
||||||
|
lastmod,
|
||||||
|
changefreq,
|
||||||
|
priority {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: 1.25rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastmod::before {
|
||||||
|
content: "Zaktualizowano: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: "Częstotliwość: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: "Priorytet: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternate language pills */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: var(--badge-bg);
|
||||||
|
border: 1px solid var(--badge-border);
|
||||||
|
color: var(--badge-text);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: string | null;
|
||||||
|
} | null | undefined;
|
||||||
|
type ArticleJsonLdArgs = {
|
||||||
|
/** Article headline (the title). */
|
||||||
|
headline: string;
|
||||||
|
/** Absolute URL of the article. */
|
||||||
|
url: string;
|
||||||
|
description?: string;
|
||||||
|
/** Cover/hero image. */
|
||||||
|
image?: MediaLike;
|
||||||
|
/** ISO date the article was first published, e.g. '2026-01-15'. */
|
||||||
|
datePublished?: string;
|
||||||
|
/** ISO date of the last modification. Defaults to datePublished. */
|
||||||
|
dateModified?: string;
|
||||||
|
/** Author name (person or org). */
|
||||||
|
author?: string;
|
||||||
|
/** Publisher (usually the site/organization) name. */
|
||||||
|
publisherName?: string;
|
||||||
|
/** Publisher logo (for the Article rich result). */
|
||||||
|
publisherLogo?: MediaLike;
|
||||||
|
/** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */
|
||||||
|
type?: 'Article' | 'BlogPosting' | 'NewsArticle';
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||||
|
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||||
|
* with the plugin's content collections (blog/archives).
|
||||||
|
*
|
||||||
|
* Per-article (each post emits its own), data from the document + panel:
|
||||||
|
*
|
||||||
|
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildArticleJsonLd({
|
||||||
|
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
* image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
* author: post.author, publisherName: settings.siteName,
|
||||||
|
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||||
|
* and dates for the rich result; supply them from the post's real fields.
|
||||||
|
*/
|
||||||
|
export declare function buildArticleJsonLd({ headline, url, description, image, datePublished, dateModified, author, publisherName, publisherLogo, type, }: ArticleJsonLdArgs): {
|
||||||
|
publisher?: {
|
||||||
|
logo?: {
|
||||||
|
'@type': string;
|
||||||
|
url: string | undefined;
|
||||||
|
} | undefined;
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
author?: {
|
||||||
|
'@type': string;
|
||||||
|
name: string;
|
||||||
|
} | undefined;
|
||||||
|
dateModified?: string | undefined;
|
||||||
|
datePublished?: string | undefined;
|
||||||
|
image?: string | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
'@context': string;
|
||||||
|
'@type': "Article" | "BlogPosting" | "NewsArticle";
|
||||||
|
headline: string;
|
||||||
|
url: string;
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': string;
|
||||||
|
'@id': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.
|
||||||
|
* Enables article rich results in Google (headline, date, author, image). Pairs
|
||||||
|
* with the plugin's content collections (blog/archives).
|
||||||
|
*
|
||||||
|
* Per-article (each post emits its own), data from the document + panel:
|
||||||
|
*
|
||||||
|
* import { buildArticleJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildArticleJsonLd({
|
||||||
|
* headline: post.title, url: postUrl, description: post.excerpt,
|
||||||
|
* image: post.coverImage, datePublished: post.publishedAt,
|
||||||
|
* author: post.author, publisherName: settings.siteName,
|
||||||
|
* publisherLogo: settings.logo, type: 'BlogPosting',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* All data from the document/panel — nothing hardcoded. Google requires headline
|
||||||
|
* and dates for the rich result; supply them from the post's real fields.
|
||||||
|
*/ export function buildArticleJsonLd({ headline, url, description, image, datePublished, dateModified, author, publisherName, publisherLogo, type = 'Article' }) {
|
||||||
|
const imageUrl = image?.url;
|
||||||
|
const logoUrl = publisherLogo?.url;
|
||||||
|
const abs = (u)=>u ? u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}` : undefined;
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': type,
|
||||||
|
headline,
|
||||||
|
url,
|
||||||
|
mainEntityOfPage: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': url
|
||||||
|
},
|
||||||
|
...description ? {
|
||||||
|
description
|
||||||
|
} : {},
|
||||||
|
...imageUrl ? {
|
||||||
|
image: abs(imageUrl)
|
||||||
|
} : {},
|
||||||
|
...datePublished ? {
|
||||||
|
datePublished
|
||||||
|
} : {},
|
||||||
|
...dateModified || datePublished ? {
|
||||||
|
dateModified: dateModified ?? datePublished
|
||||||
|
} : {},
|
||||||
|
...author ? {
|
||||||
|
author: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: author
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...publisherName ? {
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: publisherName,
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: abs(logoUrl)
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildArticleJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildArticleJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: string | null } | null | undefined\n\ntype ArticleJsonLdArgs = {\n /** Article headline (the title). */\n headline: string\n /** Absolute URL of the article. */\n url: string\n description?: string\n /** Cover/hero image. */\n image?: MediaLike\n /** ISO date the article was first published, e.g. '2026-01-15'. */\n datePublished?: string\n /** ISO date of the last modification. Defaults to datePublished. */\n dateModified?: string\n /** Author name (person or org). */\n author?: string\n /** Publisher (usually the site/organization) name. */\n publisherName?: string\n /** Publisher logo (for the Article rich result). */\n publisherLogo?: MediaLike\n /** 'Article' (default), 'BlogPosting', or 'NewsArticle'. */\n type?: 'Article' | 'BlogPosting' | 'NewsArticle'\n}\n\n/**\n * Builds Article / BlogPosting JSON-LD (schema.org) for blog posts and articles.\n * Enables article rich results in Google (headline, date, author, image). Pairs\n * with the plugin's content collections (blog/archives).\n *\n * Per-article (each post emits its own), data from the document + panel:\n *\n * import { buildArticleJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildArticleJsonLd({\n * headline: post.title, url: postUrl, description: post.excerpt,\n * image: post.coverImage, datePublished: post.publishedAt,\n * author: post.author, publisherName: settings.siteName,\n * publisherLogo: settings.logo, type: 'BlogPosting',\n * })\n *\n * All data from the document/panel — nothing hardcoded. Google requires headline\n * and dates for the rich result; supply them from the post's real fields.\n */\nexport function buildArticleJsonLd({\n headline,\n url,\n description,\n image,\n datePublished,\n dateModified,\n author,\n publisherName,\n publisherLogo,\n type = 'Article',\n}: ArticleJsonLdArgs) {\n const imageUrl = image?.url\n const logoUrl = publisherLogo?.url\n\n const abs = (u: string | null | undefined): string | undefined =>\n u ? (u.startsWith('http') ? u : `${url.split('/').slice(0, 3).join('/')}${u}`) : undefined\n\n return {\n '@context': 'https://schema.org',\n '@type': type,\n headline,\n url,\n mainEntityOfPage: { '@type': 'WebPage', '@id': url },\n ...(description ? { description } : {}),\n ...(imageUrl ? { image: abs(imageUrl) } : {}),\n ...(datePublished ? { datePublished } : {}),\n ...(dateModified || datePublished ? { dateModified: dateModified ?? datePublished } : {}),\n ...(author ? { author: { '@type': 'Person', name: author } } : {}),\n ...(publisherName\n ? {\n publisher: {\n '@type': 'Organization',\n name: publisherName,\n ...(logoUrl ? { logo: { '@type': 'ImageObject', url: abs(logoUrl) } } : {}),\n },\n }\n : {}),\n }\n}\n"],"names":["buildArticleJsonLd","headline","url","description","image","datePublished","dateModified","author","publisherName","publisherLogo","type","imageUrl","logoUrl","abs","u","startsWith","split","slice","join","undefined","mainEntityOfPage","name","publisher","logo"],"mappings":"AAwBA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,mBAAmB,EACjCC,QAAQ,EACRC,GAAG,EACHC,WAAW,EACXC,KAAK,EACLC,aAAa,EACbC,YAAY,EACZC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,OAAO,SAAS,EACE;IAClB,MAAMC,WAAWP,OAAOF;IACxB,MAAMU,UAAUH,eAAeP;IAE/B,MAAMW,MAAM,CAACC,IACXA,IAAKA,EAAEC,UAAU,CAAC,UAAUD,IAAI,GAAGZ,IAAIc,KAAK,CAAC,KAAKC,KAAK,CAAC,GAAG,GAAGC,IAAI,CAAC,OAAOJ,GAAG,GAAIK;IAEnF,OAAO;QACL,YAAY;QACZ,SAAST;QACTT;QACAC;QACAkB,kBAAkB;YAAE,SAAS;YAAW,OAAOlB;QAAI;QACnD,GAAIC,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAIQ,WAAW;YAAEP,OAAOS,IAAIF;QAAU,IAAI,CAAC,CAAC;QAC5C,GAAIN,gBAAgB;YAAEA;QAAc,IAAI,CAAC,CAAC;QAC1C,GAAIC,gBAAgBD,gBAAgB;YAAEC,cAAcA,gBAAgBD;QAAc,IAAI,CAAC,CAAC;QACxF,GAAIE,SAAS;YAAEA,QAAQ;gBAAE,SAAS;gBAAUc,MAAMd;YAAO;QAAE,IAAI,CAAC,CAAC;QACjE,GAAIC,gBACA;YACEc,WAAW;gBACT,SAAS;gBACTD,MAAMb;gBACN,GAAII,UAAU;oBAAEW,MAAM;wBAAE,SAAS;wBAAerB,KAAKW,IAAID;oBAAS;gBAAE,IAAI,CAAC,CAAC;YAC5E;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||||
Vendored
+33
@@ -0,0 +1,33 @@
|
|||||||
|
type FaqItem = {
|
||||||
|
answer: string;
|
||||||
|
question: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||||
|
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||||
|
* with voice/AI answers. Strong for service landing pages.
|
||||||
|
*
|
||||||
|
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||||
|
* the panel) — the structured data must match visible content, or Google may
|
||||||
|
* flag it. Never invent Q&A that isn't on the page.
|
||||||
|
*
|
||||||
|
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildFaqJsonLd(
|
||||||
|
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns null for empty list.
|
||||||
|
*/
|
||||||
|
export declare function buildFaqJsonLd(items: FaqItem[]): {
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
mainEntity: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
export {};
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||||
|
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||||
|
* with voice/AI answers. Strong for service landing pages.
|
||||||
|
*
|
||||||
|
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||||
|
* the panel) — the structured data must match visible content, or Google may
|
||||||
|
* flag it. Never invent Q&A that isn't on the page.
|
||||||
|
*
|
||||||
|
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildFaqJsonLd(
|
||||||
|
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns null for empty list.
|
||||||
|
*/ export function buildFaqJsonLd(items) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: items.map((item)=>({
|
||||||
|
name: item.question,
|
||||||
|
'@type': 'Question',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: item.answer
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildFaqJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildFaqJsonLd.ts"],"sourcesContent":["type FaqItem = {\n answer: string\n question: string\n}\n\n/**\n * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as\n * expandable FAQ rich results under the page, taking more SERP space and helping\n * with voice/AI answers. Strong for service landing pages.\n *\n * Feed it the SAME questions/answers rendered on the page (from an FAQ block in\n * the panel) — the structured data must match visible content, or Google may\n * flag it. Never invent Q&A that isn't on the page.\n *\n * import { buildFaqJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildFaqJsonLd(\n * faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))\n * )\n *\n * Returns null for empty list.\n */\nexport function buildFaqJsonLd(items: FaqItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: items.map((item) => ({\n name: item.question,\n '@type': 'Question',\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n }\n}\n"],"names":["buildFaqJsonLd","items","length","mainEntity","map","item","name","question","acceptedAnswer","text","answer"],"mappings":"AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASA,eAAeC,KAAgB;IAC7C,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,YAAYF,MAAMG,GAAG,CAAC,CAACC,OAAU,CAAA;gBAC/BC,MAAMD,KAAKE,QAAQ;gBACnB,SAAS;gBACTC,gBAAgB;oBACd,SAAS;oBACTC,MAAMJ,KAAKK,MAAM;gBACnB;YACF,CAAA;IACF;AACF"}
|
||||||
Vendored
+45
@@ -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 {};
|
||||||
Vendored
+90
@@ -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();
|
||||||
|
// NO where:{_status} filter — collections without drafts enabled don't
|
||||||
|
// register the _status field, and querying it throws
|
||||||
|
// "path cannot be queried: _status" (a real bug report). Draft filtering
|
||||||
|
// happens in memory below, which is safe for every collection. Same as
|
||||||
|
// buildSitemapEntries and generateStaticParams.
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: pagesSlug,
|
||||||
|
depth: 0,
|
||||||
|
limit: 1000,
|
||||||
|
locale: loc
|
||||||
|
});
|
||||||
|
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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+71
@@ -0,0 +1,71 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: null | string;
|
||||||
|
} | null | undefined;
|
||||||
|
type Address = {
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
region?: string;
|
||||||
|
street?: string;
|
||||||
|
};
|
||||||
|
type LocalBusinessJsonLdArgs = {
|
||||||
|
address?: Address;
|
||||||
|
/** Geo coordinates for maps/local search. */
|
||||||
|
geo?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
image?: MediaLike;
|
||||||
|
logo?: MediaLike;
|
||||||
|
name: string;
|
||||||
|
/** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */
|
||||||
|
openingHours?: string[];
|
||||||
|
priceRange?: string;
|
||||||
|
sameAs?: string[];
|
||||||
|
/** Business phone, e.g. '+48 123 456 789'. */
|
||||||
|
telephone?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||||
|
* SEO. Helps Google show the business in local results / map pack with address,
|
||||||
|
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||||
|
*
|
||||||
|
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||||
|
* root layout (business is site-wide):
|
||||||
|
*
|
||||||
|
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildLocalBusinessJsonLd({
|
||||||
|
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||||
|
* address: company.address, openingHours: company.hours,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||||
|
* returned object — schema.org has many LocalBusiness subtypes.
|
||||||
|
*/
|
||||||
|
export declare function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url, }: LocalBusinessJsonLdArgs): {
|
||||||
|
sameAs?: string[] | undefined;
|
||||||
|
priceRange?: string | undefined;
|
||||||
|
openingHours?: string[] | undefined;
|
||||||
|
geo?: {
|
||||||
|
'@type': string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
} | undefined;
|
||||||
|
address?: {
|
||||||
|
addressCountry?: string | undefined;
|
||||||
|
addressRegion?: string | undefined;
|
||||||
|
postalCode?: string | undefined;
|
||||||
|
addressLocality?: string | undefined;
|
||||||
|
streetAddress?: string | undefined;
|
||||||
|
'@type': string;
|
||||||
|
} | undefined;
|
||||||
|
logo?: string | undefined;
|
||||||
|
image?: string | undefined;
|
||||||
|
telephone?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||||
|
* SEO. Helps Google show the business in local results / map pack with address,
|
||||||
|
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||||
|
*
|
||||||
|
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||||
|
* root layout (business is site-wide):
|
||||||
|
*
|
||||||
|
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildLocalBusinessJsonLd({
|
||||||
|
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||||
|
* address: company.address, openingHours: company.hours,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||||
|
* returned object — schema.org has many LocalBusiness subtypes.
|
||||||
|
*/ export function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url }) {
|
||||||
|
const logoUrl = logo?.url;
|
||||||
|
const imageUrl = image?.url ?? logoUrl;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
url,
|
||||||
|
...telephone ? {
|
||||||
|
telephone
|
||||||
|
} : {},
|
||||||
|
...imageUrl ? {
|
||||||
|
image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}`
|
||||||
|
} : {},
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||||
|
} : {},
|
||||||
|
...address ? {
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
...address.street ? {
|
||||||
|
streetAddress: address.street
|
||||||
|
} : {},
|
||||||
|
...address.city ? {
|
||||||
|
addressLocality: address.city
|
||||||
|
} : {},
|
||||||
|
...address.postalCode ? {
|
||||||
|
postalCode: address.postalCode
|
||||||
|
} : {},
|
||||||
|
...address.region ? {
|
||||||
|
addressRegion: address.region
|
||||||
|
} : {},
|
||||||
|
...address.country ? {
|
||||||
|
addressCountry: address.country
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...geo ? {
|
||||||
|
geo: {
|
||||||
|
'@type': 'GeoCoordinates',
|
||||||
|
latitude: geo.latitude,
|
||||||
|
longitude: geo.longitude
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...openingHours && openingHours.length > 0 ? {
|
||||||
|
openingHours
|
||||||
|
} : {},
|
||||||
|
...priceRange ? {
|
||||||
|
priceRange
|
||||||
|
} : {},
|
||||||
|
...sameAs && sameAs.length > 0 ? {
|
||||||
|
sameAs
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildLocalBusinessJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildLocalBusinessJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype Address = {\n city?: string\n country?: string // ISO code, e.g. 'PL'\n postalCode?: string\n region?: string\n street?: string\n}\n\ntype LocalBusinessJsonLdArgs = {\n address?: Address\n /** Geo coordinates for maps/local search. */\n geo?: { latitude: number; longitude: number }\n image?: MediaLike\n logo?: MediaLike\n name: string\n /** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */\n openingHours?: string[]\n priceRange?: string // e.g. '$$'\n sameAs?: string[]\n /** Business phone, e.g. '+48 123 456 789'. */\n telephone?: string\n url: string\n}\n\n/**\n * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL\n * SEO. Helps Google show the business in local results / map pack with address,\n * hours, phone. Strong signal for \"usługa + miasto\" queries.\n *\n * All data from the panel (company global) — nothing hardcoded. Emit once in the\n * root layout (business is site-wide):\n *\n * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildLocalBusinessJsonLd({\n * name: company.name, url: baseUrl, telephone: company.phone,\n * address: company.address, openingHours: company.hours,\n * })\n *\n * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the\n * returned object — schema.org has many LocalBusiness subtypes.\n */\nexport function buildLocalBusinessJsonLd({\n name,\n address,\n geo,\n image,\n logo,\n openingHours,\n priceRange,\n sameAs,\n telephone,\n url,\n}: LocalBusinessJsonLdArgs) {\n const logoUrl = logo?.url\n const imageUrl = image?.url ?? logoUrl\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'LocalBusiness',\n url,\n ...(telephone ? { telephone } : {}),\n ...(imageUrl ? { image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` } : {}),\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(address\n ? {\n address: {\n '@type': 'PostalAddress',\n ...(address.street ? { streetAddress: address.street } : {}),\n ...(address.city ? { addressLocality: address.city } : {}),\n ...(address.postalCode ? { postalCode: address.postalCode } : {}),\n ...(address.region ? { addressRegion: address.region } : {}),\n ...(address.country ? { addressCountry: address.country } : {}),\n },\n }\n : {}),\n ...(geo\n ? { geo: { '@type': 'GeoCoordinates', latitude: geo.latitude, longitude: geo.longitude } }\n : {}),\n ...(openingHours && openingHours.length > 0 ? { openingHours } : {}),\n ...(priceRange ? { priceRange } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildLocalBusinessJsonLd","name","address","geo","image","logo","openingHours","priceRange","sameAs","telephone","url","logoUrl","imageUrl","startsWith","street","streetAddress","city","addressLocality","postalCode","region","addressRegion","country","addressCountry","latitude","longitude","length"],"mappings":"AA0BA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,yBAAyB,EACvCC,IAAI,EACJC,OAAO,EACPC,GAAG,EACHC,KAAK,EACLC,IAAI,EACJC,YAAY,EACZC,UAAU,EACVC,MAAM,EACNC,SAAS,EACTC,GAAG,EACqB;IACxB,MAAMC,UAAUN,MAAMK;IACtB,MAAME,WAAWR,OAAOM,OAAOC;IAE/B,OAAO;QACLV;QACA,YAAY;QACZ,SAAS;QACTS;QACA,GAAID,YAAY;YAAEA;QAAU,IAAI,CAAC,CAAC;QAClC,GAAIG,WAAW;YAAER,OAAOQ,SAASC,UAAU,CAAC,UAAUD,WAAW,GAAGF,MAAME,UAAU;QAAC,IAAI,CAAC,CAAC;QAC3F,GAAID,UAAU;YAAEN,MAAMM,QAAQE,UAAU,CAAC,UAAUF,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIT,UACA;YACEA,SAAS;gBACP,SAAS;gBACT,GAAIA,QAAQY,MAAM,GAAG;oBAAEC,eAAeb,QAAQY,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIZ,QAAQc,IAAI,GAAG;oBAAEC,iBAAiBf,QAAQc,IAAI;gBAAC,IAAI,CAAC,CAAC;gBACzD,GAAId,QAAQgB,UAAU,GAAG;oBAAEA,YAAYhB,QAAQgB,UAAU;gBAAC,IAAI,CAAC,CAAC;gBAChE,GAAIhB,QAAQiB,MAAM,GAAG;oBAAEC,eAAelB,QAAQiB,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIjB,QAAQmB,OAAO,GAAG;oBAAEC,gBAAgBpB,QAAQmB,OAAO;gBAAC,IAAI,CAAC,CAAC;YAChE;QACF,IACA,CAAC,CAAC;QACN,GAAIlB,MACA;YAAEA,KAAK;gBAAE,SAAS;gBAAkBoB,UAAUpB,IAAIoB,QAAQ;gBAAEC,WAAWrB,IAAIqB,SAAS;YAAC;QAAE,IACvF,CAAC,CAAC;QACN,GAAIlB,gBAAgBA,aAAamB,MAAM,GAAG,IAAI;YAAEnB;QAAa,IAAI,CAAC,CAAC;QACnE,GAAIC,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIC,UAAUA,OAAOiB,MAAM,GAAG,IAAI;YAAEjB;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||||
Vendored
+20
-2
@@ -19,6 +19,11 @@ export type PageMetadata = {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
|
robots?: {
|
||||||
|
follow: boolean;
|
||||||
|
index: boolean;
|
||||||
|
};
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
type BuildMetadataArgs = {
|
type BuildMetadataArgs = {
|
||||||
@@ -26,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. */
|
||||||
@@ -35,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
|
||||||
@@ -51,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. */
|
||||||
@@ -65,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 {};
|
||||||
|
|||||||
Vendored
+17
-6
@@ -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({
|
||||||
@@ -69,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
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+39
@@ -0,0 +1,39 @@
|
|||||||
|
type ServiceJsonLdArgs = {
|
||||||
|
/** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */
|
||||||
|
areaServed?: string | string[];
|
||||||
|
description?: string;
|
||||||
|
/** Service name, e.g. 'Sprzątanie biur'. */
|
||||||
|
name: string;
|
||||||
|
/** Provider (business) name. */
|
||||||
|
providerName: string;
|
||||||
|
/** Service type / category. */
|
||||||
|
serviceType?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||||
|
* understand "what this page sells" — useful for service landing pages
|
||||||
|
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||||
|
*
|
||||||
|
* Per-page (each service page emits its own), data from the panel:
|
||||||
|
*
|
||||||
|
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildServiceJsonLd({
|
||||||
|
* name: page.serviceName, providerName: company.name,
|
||||||
|
* url: pageUrl, areaServed: 'Wrocław',
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export declare function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url, }: ServiceJsonLdArgs): {
|
||||||
|
serviceType?: string | undefined;
|
||||||
|
areaServed?: string | string[] | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
provider: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||||
|
* understand "what this page sells" — useful for service landing pages
|
||||||
|
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||||
|
*
|
||||||
|
* Per-page (each service page emits its own), data from the panel:
|
||||||
|
*
|
||||||
|
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildServiceJsonLd({
|
||||||
|
* name: page.serviceName, providerName: company.name,
|
||||||
|
* url: pageUrl, areaServed: 'Wrocław',
|
||||||
|
* })
|
||||||
|
*/ export function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
provider: {
|
||||||
|
name: providerName,
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
url
|
||||||
|
},
|
||||||
|
...description ? {
|
||||||
|
description
|
||||||
|
} : {},
|
||||||
|
...areaServed ? {
|
||||||
|
areaServed
|
||||||
|
} : {},
|
||||||
|
...serviceType ? {
|
||||||
|
serviceType
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildServiceJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildServiceJsonLd.ts"],"sourcesContent":["type ServiceJsonLdArgs = {\n /** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */\n areaServed?: string | string[]\n description?: string\n /** Service name, e.g. 'Sprzątanie biur'. */\n name: string\n /** Provider (business) name. */\n providerName: string\n /** Service type / category. */\n serviceType?: string\n url: string\n}\n\n/**\n * Builds Service JSON-LD (schema.org) for a service offering. Helps Google\n * understand \"what this page sells\" — useful for service landing pages\n * (\"usługa + miasto\"). Pairs well with LocalBusiness (the provider).\n *\n * Per-page (each service page emits its own), data from the panel:\n *\n * import { buildServiceJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildServiceJsonLd({\n * name: page.serviceName, providerName: company.name,\n * url: pageUrl, areaServed: 'Wrocław',\n * })\n */\nexport function buildServiceJsonLd({\n name,\n areaServed,\n description,\n providerName,\n serviceType,\n url,\n}: ServiceJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Service',\n provider: {\n name: providerName,\n '@type': 'LocalBusiness',\n url,\n },\n ...(description ? { description } : {}),\n ...(areaServed ? { areaServed } : {}),\n ...(serviceType ? { serviceType } : {}),\n }\n}\n"],"names":["buildServiceJsonLd","name","areaServed","description","providerName","serviceType","url","provider"],"mappings":"AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASA,mBAAmB,EACjCC,IAAI,EACJC,UAAU,EACVC,WAAW,EACXC,YAAY,EACZC,WAAW,EACXC,GAAG,EACe;IAClB,OAAO;QACLL;QACA,YAAY;QACZ,SAAS;QACTM,UAAU;YACRN,MAAMG;YACN,SAAS;YACTE;QACF;QACA,GAAIH,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAID,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIG,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;IACxC;AACF"}
|
||||||
+1
-1
@@ -29,7 +29,7 @@ type BuildSitemapArgs = {
|
|||||||
* 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;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
pagesSlug?: string;
|
pagesSlug?: string;
|
||||||
payload: BasePayload;
|
payload: BasePayload;
|
||||||
|
|||||||
+46
-7
@@ -1,13 +1,40 @@
|
|||||||
import { archiveFieldName } from '../content/index.js';
|
import { archiveFieldName } from '../content/index.js';
|
||||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js';
|
import { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs } from '../i18n/index.js';
|
||||||
import { buildHreflangAlternates } from './hreflang.js';
|
import { buildHreflangAlternates } from './hreflang.js';
|
||||||
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) {
|
/**
|
||||||
|
* 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
|
||||||
|
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||||
|
* is an audit finding. Matched against the slug in any locale.
|
||||||
|
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||||
|
'404',
|
||||||
|
'500',
|
||||||
|
'error',
|
||||||
|
'not-found'
|
||||||
|
]);
|
||||||
|
/** 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 (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug)){
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||||
if (doc._status && doc._status !== 'published') {
|
if (doc._status && doc._status !== 'published') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (doc.meta?.noindex) {
|
if (doc.meta?.noindex) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (hasExcludedSlug(doc.slug)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +110,13 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
depth: 1,
|
depth: 1,
|
||||||
locale: 'all'
|
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 = [
|
||||||
@@ -114,10 +147,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
if (!isIndexable(raw)) {
|
if (!isIndexable(raw)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
// Emit a separate <url> for EACH locale. Google's sitemap spec for
|
||||||
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
// localized sites requires one <url> per language version (each with its
|
||||||
if (entry) {
|
// own <loc> plus xhtml:link alternates), not a single default-locale <url>
|
||||||
entries.push(entry);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import type { SitemapEntry } from './buildSitemapEntries.js';
|
||||||
|
type BuildSitemapXmlOptions = {
|
||||||
|
/**
|
||||||
|
* URL of a CSS stylesheet to make the sitemap readable in the browser, e.g.
|
||||||
|
* '/sitemap.css'. Uses `type="text/css"` — the W3C "Associating Style Sheets
|
||||||
|
* with XML" mechanism, which is NOT deprecated (unlike XSLT / type="text/xsl",
|
||||||
|
* which Chrome/WebKit are removing). CSS on XML shows no warning, styles the
|
||||||
|
* raw tags directly (e.g. `url { display: block }` turns the wall of text into
|
||||||
|
* cards), and crawlers ignore the directive entirely.
|
||||||
|
*/
|
||||||
|
cssUrl?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
|
||||||
|
* and (with `cssUrl`) styled in the browser via plain CSS.
|
||||||
|
*
|
||||||
|
* Two viewing modes:
|
||||||
|
* - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
|
||||||
|
* - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
|
||||||
|
* CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
|
||||||
|
* removed from browsers and shows a deprecation warning. CSS is safe and
|
||||||
|
* W3C-standard.
|
||||||
|
*
|
||||||
|
* // app/sitemap.xml/route.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, { cssUrl: '/sitemap.css' })
|
||||||
|
* return new Response(xml, {
|
||||||
|
* headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Put sitemap.css in the project's /public and style the tags (see docs/seo.md
|
||||||
|
* for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
|
||||||
|
* clickable link (some browsers auto-detect URLs); the win is readability, not
|
||||||
|
* clickability.
|
||||||
|
*
|
||||||
|
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
|
||||||
|
* Two sitemaps confuse crawlers.
|
||||||
|
*/
|
||||||
|
export declare function buildSitemapXml(entries: SitemapEntry[], opts?: BuildSitemapXmlOptions): string;
|
||||||
|
export {};
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
|
||||||
|
* and (with `cssUrl`) styled in the browser via plain CSS.
|
||||||
|
*
|
||||||
|
* Two viewing modes:
|
||||||
|
* - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
|
||||||
|
* - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
|
||||||
|
* CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
|
||||||
|
* removed from browsers and shows a deprecation warning. CSS is safe and
|
||||||
|
* W3C-standard.
|
||||||
|
*
|
||||||
|
* // app/sitemap.xml/route.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, { cssUrl: '/sitemap.css' })
|
||||||
|
* return new Response(xml, {
|
||||||
|
* headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Put sitemap.css in the project's /public and style the tags (see docs/seo.md
|
||||||
|
* for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
|
||||||
|
* clickable link (some browsers auto-detect URLs); the win is readability, not
|
||||||
|
* clickability.
|
||||||
|
*
|
||||||
|
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
|
||||||
|
* Two sitemaps confuse crawlers.
|
||||||
|
*/ export function buildSitemapXml(entries, opts = {}) {
|
||||||
|
const { cssUrl } = opts;
|
||||||
|
const esc = (s)=>s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
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>`);
|
||||||
|
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 = cssUrl ? `<?xml-stylesheet type="text/css" href="${esc(cssUrl)}"?>\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
File diff suppressed because one or more lines are too long
Vendored
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
/** Which part comes first in a composed title. */
|
/** Which part comes first in a composed title. */
|
||||||
export type TitleOrder = 'page-first' | 'site-first';
|
export type TitleOrder = 'page-first' | 'site-first';
|
||||||
type ComposeTitleArgs = {
|
type ComposeTitleArgs = {
|
||||||
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
|
||||||
order?: TitleOrder;
|
|
||||||
/** Page-specific title, e.g. 'About Us'. */
|
/** Page-specific title, e.g. 'About Us'. */
|
||||||
pageTitle?: null | string;
|
pageTitle?: string | null;
|
||||||
|
/** Site name, e.g. 'Acme Inc'. */
|
||||||
|
siteName?: string | null;
|
||||||
/** Separator between page title and site name. Defaults to ' | '. */
|
/** Separator between page title and site name. Defaults to ' | '. */
|
||||||
separator?: string;
|
separator?: string;
|
||||||
/** Site name, e.g. 'Acme Inc'. */
|
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
|
||||||
siteName?: null | string;
|
order?: TitleOrder;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Composes a full document title from a page title and the site name.
|
* Composes a full document title from a page title and the site name.
|
||||||
@@ -21,5 +21,5 @@ type ComposeTitleArgs = {
|
|||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/
|
*/
|
||||||
export declare function composeTitle({ order, pageTitle, separator, siteName, }: ComposeTitleArgs): string;
|
export declare function composeTitle({ pageTitle, siteName, separator, order, }: ComposeTitleArgs): string;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+9
-1
@@ -8,10 +8,18 @@
|
|||||||
* - neither: ""
|
* - neither: ""
|
||||||
*
|
*
|
||||||
* Pure function — no dependency on Payload or request state.
|
* Pure function — no dependency on Payload or request state.
|
||||||
*/ export function composeTitle({ order = 'page-first', pageTitle, separator = ' | ', siteName }) {
|
*/ export function composeTitle({ pageTitle, siteName, separator = ' | ', order = 'page-first' }) {
|
||||||
const page = pageTitle?.trim();
|
const page = pageTitle?.trim();
|
||||||
const site = siteName?.trim();
|
const site = siteName?.trim();
|
||||||
if (page && site) {
|
if (page && site) {
|
||||||
|
// Dedup: if the page title already ends with the site name (an editor wrote
|
||||||
|
// "Budowa auta – RCC" into meta.title), don't append it again → avoid
|
||||||
|
// "Budowa auta – RCC – RCC". Match the site name at the end, with or without
|
||||||
|
// the separator before it.
|
||||||
|
const sep = separator.trim();
|
||||||
|
if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`;
|
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`;
|
||||||
}
|
}
|
||||||
return page || site || '';
|
return page || site || '';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: null | string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: null | string\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n order = 'page-first',\n pageTitle,\n separator = ' | ',\n siteName,\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","order","pageTitle","separator","siteName","page","trim","site"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,QAAQ,YAAY,EACpBC,SAAS,EACTC,YAAY,KAAK,EACjBC,QAAQ,EACS;IACjB,MAAMC,OAAOH,WAAWI;IACxB,MAAMC,OAAOH,UAAUE;IAEvB,IAAID,QAAQE,MAAM;QAChB,OAAON,UAAU,eAAe,GAAGM,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: string | null\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: string | null\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n pageTitle,\n siteName,\n separator = ' | ',\n order = 'page-first',\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n // Dedup: if the page title already ends with the site name (an editor wrote\n // \"Budowa auta – RCC\" into meta.title), don't append it again → avoid\n // \"Budowa auta – RCC – RCC\". Match the site name at the end, with or without\n // the separator before it.\n const sep = separator.trim()\n if (page === site || page.endsWith(`${sep} ${site}`) || page.endsWith(` ${site}`)) {\n return page\n }\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","pageTitle","siteName","separator","order","page","trim","site","sep","endsWith"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,SAAS,EACTC,QAAQ,EACRC,YAAY,KAAK,EACjBC,QAAQ,YAAY,EACH;IACjB,MAAMC,OAAOJ,WAAWK;IACxB,MAAMC,OAAOL,UAAUI;IAEvB,IAAID,QAAQE,MAAM;QAChB,4EAA4E;QAC5E,sEAAsE;QACtE,6EAA6E;QAC7E,2BAA2B;QAC3B,MAAMC,MAAML,UAAUG,IAAI;QAC1B,IAAID,SAASE,QAAQF,KAAKI,QAAQ,CAAC,GAAGD,IAAI,CAAC,EAAED,MAAM,KAAKF,KAAKI,QAAQ,CAAC,CAAC,CAAC,EAAEF,MAAM,GAAG;YACjF,OAAOF;QACT;QACA,OAAOD,UAAU,eAAe,GAAGG,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ type CreateMetadataGeneratorArgs = {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string;
|
homeSlug?: string | Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Resolves the document to build metadata for, given route params.
|
* Resolves the document to build metadata for, given route params.
|
||||||
* The client supplies this (they own the collections and routing); it should
|
* The client supplies this (they own the collections and routing); it should
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record<string, unknown>\n}\n\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string | Record<string, string>\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }) => Promise<MetadataDocument | null>\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise<null | string>\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record<string, string | string[]>\n payload: BasePayload\n }): Promise<PageMetadata> {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"}
|
||||||
+1
@@ -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
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+7
-15
@@ -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 {};
|
||||||
|
|||||||
Vendored
+15
-16
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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"}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user