Compare commits
41
Commits
671090b1b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca2b20c3d | ||
|
|
105c454d73 | ||
|
|
cf6feefebc | ||
|
|
1857c8f771 | ||
|
|
542ad4ab9c | ||
|
|
9bf8599116 | ||
|
|
170c9a53f1 | ||
|
|
e0d5095099 | ||
|
|
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 |
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"}
|
||||||
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 { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||||
|
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd, } from './modules/seo/index.js';
|
||||||
|
export { buildArticleJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildSitemapXml } from './modules/seo/index.js';
|
||||||
|
export { buildLlmsTxt } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
export { buildR2Storage } from './modules/storage/index.js';
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|||||||
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 { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
export { buildCsp } from './modules/security/index.js';
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
|
||||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||||
|
// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.
|
||||||
|
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildArticleJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildSitemapXml } from './modules/seo/index.js';
|
||||||
|
export { buildLlmsTxt } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
|
||||||
export { buildR2Storage } from './modules/storage/index.js';
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|
||||||
|
|||||||
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"}
|
||||||
+84
-64
@@ -131,79 +131,99 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
* Handles automatically:
|
* Handles automatically:
|
||||||
* - pages collection + content collections (with their archive prefix)
|
* - pages collection + content collections (with their archive prefix)
|
||||||
* - excludes the homepage (maps to { slug: [] } — the root)
|
* - excludes the homepage (maps to { slug: [] } — the root)
|
||||||
* - excludes drafts, 404/500/system slugs, and meta.noindex docs
|
* - 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 }[]
|
* - single-locale → { slug }[]; multi-locale → { locale, slug }[]
|
||||||
*
|
*
|
||||||
* Wire it in the project:
|
* Wire it in the project:
|
||||||
* // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])
|
* // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])
|
||||||
* export { generateStaticParams } from '@/lib/content'
|
* export { generateStaticParams } from '@/lib/content'
|
||||||
*/ const generateStaticParams = async ()=>{
|
*/ const generateStaticParams = async ()=>{
|
||||||
const payload = await getCachedPayload();
|
try {
|
||||||
const locales = i18n ? i18n.locales.map((l)=>l.code) : [
|
const payload = await getCachedPayload();
|
||||||
undefined
|
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 singleLocale = !i18n || i18n.locales.length === 1;
|
||||||
const settings = await payload.findGlobal({
|
// Home slug per locale, to exclude the homepage (it's the root, slug []).
|
||||||
slug: settingsSlug,
|
const settings = await payload.findGlobal({
|
||||||
depth: 1,
|
slug: settingsSlug,
|
||||||
locale: 'all'
|
depth: 1,
|
||||||
}).catch(()=>null);
|
locale: 'all'
|
||||||
const homeId = settings?.homepage?.id;
|
}).catch(()=>null);
|
||||||
const EXCLUDED = new Set([
|
const homeId = settings?.homepage?.id;
|
||||||
'404',
|
const EXCLUDED = new Set([
|
||||||
'500',
|
'404',
|
||||||
'error',
|
'500',
|
||||||
'not-found'
|
'error',
|
||||||
]);
|
'not-found'
|
||||||
const params = [];
|
]);
|
||||||
for (const locale of locales){
|
const params = [];
|
||||||
const result = await payload.find({
|
for (const locale of locales){
|
||||||
collection: pagesSlug,
|
// NO where:{_status} filter — collections without drafts enabled don't
|
||||||
depth: 0,
|
// register the _status field, and querying it throws
|
||||||
limit: 1000,
|
// "path cannot be queried: _status". We filter drafts in memory below,
|
||||||
locale: locale ?? 'all',
|
// which is safe for every collection (with or without drafts).
|
||||||
where: {
|
const result = await payload.find({
|
||||||
_status: {
|
collection: pagesSlug,
|
||||||
not_equals: 'draft'
|
depth: 0,
|
||||||
}
|
limit: 1000,
|
||||||
}
|
locale: locale ?? 'all'
|
||||||
});
|
|
||||||
for (const raw of result.docs){
|
|
||||||
if (raw._status && raw._status !== 'published') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (raw.meta?.noindex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (homeId && raw.id === homeId) {
|
|
||||||
// Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.
|
|
||||||
const empty = singleLocale ? {
|
|
||||||
slug: []
|
|
||||||
} : {
|
|
||||||
slug: [],
|
|
||||||
locale: locale
|
|
||||||
};
|
|
||||||
if (!params.some((p)=>JSON.stringify(p) === JSON.stringify(empty))) {
|
|
||||||
params.push(empty);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
|
|
||||||
if (!slug || EXCLUDED.has(slug)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Multi-level slugs ('atrakcje/telefon') → array segments.
|
|
||||||
const segments = slug.split('/').filter(Boolean);
|
|
||||||
params.push(singleLocale ? {
|
|
||||||
slug: segments
|
|
||||||
} : {
|
|
||||||
slug: segments,
|
|
||||||
locale: locale
|
|
||||||
});
|
});
|
||||||
|
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 params;
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
generateStaticParams,
|
generateStaticParams,
|
||||||
|
|||||||
+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"}
|
||||||
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
+154
@@ -0,0 +1,154 @@
|
|||||||
|
/*
|
||||||
|
* 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: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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;
|
||||||
|
order: -2;
|
||||||
|
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;
|
||||||
|
order: -1;
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
order: 0;
|
||||||
|
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: "Updated: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
changefreq::before {
|
||||||
|
content: "Frequency: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
priority::before {
|
||||||
|
content: "Priority: ";
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternate language pills */
|
||||||
|
link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
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;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
link::before {
|
||||||
|
content: attr(hreflang);
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+6
-6
@@ -37,16 +37,16 @@ import { buildLocalizedPath } from '../i18n/index.js';
|
|||||||
});
|
});
|
||||||
const name = settings.siteName?.trim() || 'Website';
|
const name = settings.siteName?.trim() || 'Website';
|
||||||
const description = settings.siteDescription?.trim();
|
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({
|
const result = await payload.find({
|
||||||
collection: pagesSlug,
|
collection: pagesSlug,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
locale: loc,
|
locale: loc
|
||||||
where: {
|
|
||||||
_status: {
|
|
||||||
not_equals: 'draft'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const lines = [
|
const lines = [
|
||||||
`# ${name}`,
|
`# ${name}`,
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+16
-1
@@ -17,12 +17,27 @@ export type PageMetadata = {
|
|||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
/** Site/brand name for the OG card. */
|
||||||
|
siteName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 'website' | 'article' etc. Defaults to 'website'. */
|
||||||
|
type?: string;
|
||||||
|
/** Canonical URL of this page. */
|
||||||
|
url?: string;
|
||||||
};
|
};
|
||||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
/**
|
||||||
|
* robots directives. Indexed pages get generous snippet/preview limits by
|
||||||
|
* default (Google shows richer results); noindex pages get index:false.
|
||||||
|
*/
|
||||||
robots?: {
|
robots?: {
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
index: boolean;
|
index: boolean;
|
||||||
|
/** 'none' | 'standard' | 'large' — image preview size in results. */
|
||||||
|
'max-image-preview'?: 'large' | 'none' | 'standard';
|
||||||
|
/** Max text snippet length; -1 = no limit. Next maps to max-snippet. */
|
||||||
|
'max-snippet'?: number;
|
||||||
|
/** Max video preview seconds; -1 = no limit. */
|
||||||
|
'max-video-preview'?: number;
|
||||||
};
|
};
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+20
-4
@@ -71,16 +71,32 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
...images && {
|
...images && {
|
||||||
images
|
images
|
||||||
},
|
},
|
||||||
locale
|
type: 'website',
|
||||||
|
locale,
|
||||||
|
...siteName ? {
|
||||||
|
siteName
|
||||||
|
} : {},
|
||||||
|
...canonical ? {
|
||||||
|
url: canonical
|
||||||
|
} : {}
|
||||||
},
|
},
|
||||||
// noindex → tell search engines to exclude the page but still follow links
|
// robots: noindex pages are excluded (follow keeps link authority). Indexed
|
||||||
// (authority flows through). For legal/thin/search-result pages.
|
// pages get generous snippet/preview limits so Google can show rich results
|
||||||
|
// (long snippets, large image previews, full video previews).
|
||||||
...meta?.noindex ? {
|
...meta?.noindex ? {
|
||||||
robots: {
|
robots: {
|
||||||
follow: true,
|
follow: true,
|
||||||
index: false
|
index: false
|
||||||
}
|
}
|
||||||
} : {}
|
} : {
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: true,
|
||||||
|
'max-image-preview': 'large',
|
||||||
|
'max-snippet': -1,
|
||||||
|
'max-video-preview': -1
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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
+1
@@ -14,6 +14,7 @@ export type { RobotsRules } from './buildRobots.js';
|
|||||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
export type { SitemapEntry } from './buildSitemapEntries.js';
|
export type { SitemapEntry } from './buildSitemapEntries.js';
|
||||||
|
export { buildSitemapXml } from './buildSitemapXml.js';
|
||||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||||
export { composeTitle } from './composeTitle.js';
|
export { composeTitle } from './composeTitle.js';
|
||||||
|
|||||||
Vendored
+1
@@ -10,6 +10,7 @@ export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
|||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
|
export { buildSitemapXml } from './buildSitemapXml.js';
|
||||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||||
export { composeTitle } from './composeTitle.js';
|
export { composeTitle } from './composeTitle.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildArticleJsonLd } from './buildArticleJsonLd.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLlmsTxt } from './buildLlmsTxt.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildArticleJsonLd","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLlmsTxt","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildArticleJsonLd } from './buildArticleJsonLd.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLlmsTxt } from './buildLlmsTxt.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSitemapXml } from './buildSitemapXml.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildArticleJsonLd","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLlmsTxt","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSitemapXml","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,eAAe,QAAQ,uBAAsB;AACtD,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Provides the Turnstile site key once for the whole app, like ConsentProvider
|
||||||
|
* for cookies. The project reads the key server-side and passes it here in the
|
||||||
|
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
|
||||||
|
* key wiring.
|
||||||
|
*
|
||||||
|
* // layout.tsx (server) → read key, pass to provider
|
||||||
|
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
|
||||||
|
* const siteKey = await getTurnstileSiteKey() // your server helper
|
||||||
|
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
|
||||||
|
*
|
||||||
|
* When siteKey is null (Turnstile not configured), widgets render nothing and
|
||||||
|
* token stays null — forms should treat "no Turnstile" as allowed in dev.
|
||||||
|
*/
|
||||||
|
export declare function TurnstileProvider({ siteKey, children, }: {
|
||||||
|
siteKey: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): import("react/jsx-runtime").JSX.Element;
|
||||||
|
/**
|
||||||
|
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
|
||||||
|
* No per-form siteKey plumbing — the provider supplies it.
|
||||||
|
*
|
||||||
|
* const { token, TurnstileWidget, reset } = useTurnstile()
|
||||||
|
* // in JSX: <TurnstileWidget />
|
||||||
|
* // at submit: submitForm({ ..., turnstileToken: token })
|
||||||
|
* // after submit: reset() // clear for the next submission
|
||||||
|
*/
|
||||||
|
export declare function useTurnstile(): {
|
||||||
|
token: string | null;
|
||||||
|
TurnstileWidget: (props?: {
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
}) => React.ReactNode;
|
||||||
|
reset: () => void;
|
||||||
|
/** True when Turnstile is configured (site key present). */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { createContext, useCallback, useContext, useState } from 'react';
|
||||||
|
import { Turnstile } from './Turnstile.js';
|
||||||
|
const TurnstileContext = /*#__PURE__*/ createContext(null);
|
||||||
|
/**
|
||||||
|
* Provides the Turnstile site key once for the whole app, like ConsentProvider
|
||||||
|
* for cookies. The project reads the key server-side and passes it here in the
|
||||||
|
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
|
||||||
|
* key wiring.
|
||||||
|
*
|
||||||
|
* // layout.tsx (server) → read key, pass to provider
|
||||||
|
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
|
||||||
|
* const siteKey = await getTurnstileSiteKey() // your server helper
|
||||||
|
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
|
||||||
|
*
|
||||||
|
* When siteKey is null (Turnstile not configured), widgets render nothing and
|
||||||
|
* token stays null — forms should treat "no Turnstile" as allowed in dev.
|
||||||
|
*/ export function TurnstileProvider({ siteKey, children }) {
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
return /*#__PURE__*/ _jsx(TurnstileContext.Provider, {
|
||||||
|
value: {
|
||||||
|
siteKey,
|
||||||
|
token,
|
||||||
|
setToken
|
||||||
|
},
|
||||||
|
children: children
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
|
||||||
|
* No per-form siteKey plumbing — the provider supplies it.
|
||||||
|
*
|
||||||
|
* const { token, TurnstileWidget, reset } = useTurnstile()
|
||||||
|
* // in JSX: <TurnstileWidget />
|
||||||
|
* // at submit: submitForm({ ..., turnstileToken: token })
|
||||||
|
* // after submit: reset() // clear for the next submission
|
||||||
|
*/ export function useTurnstile() {
|
||||||
|
const ctx = useContext(TurnstileContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTurnstile must be used within <TurnstileProvider>');
|
||||||
|
}
|
||||||
|
const { siteKey, token, setToken } = ctx;
|
||||||
|
const reset = useCallback(()=>setToken(null), [
|
||||||
|
setToken
|
||||||
|
]);
|
||||||
|
const TurnstileWidget = useCallback((props)=>{
|
||||||
|
if (!siteKey) return null;
|
||||||
|
return /*#__PURE__*/ _jsx(Turnstile, {
|
||||||
|
siteKey: siteKey,
|
||||||
|
onToken: setToken,
|
||||||
|
theme: props?.theme
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
siteKey,
|
||||||
|
setToken
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
TurnstileWidget,
|
||||||
|
reset,
|
||||||
|
enabled: Boolean(siteKey)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=TurnstileProvider.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/turnstile/TurnstileProvider.tsx"],"sourcesContent":["'use client'\n\nimport { createContext, useCallback, useContext, useState } from 'react'\nimport { Turnstile } from './Turnstile.js'\n\ntype TurnstileContextValue = {\n /** Public site key from the provider (read server-side, passed once). */\n siteKey: string | null\n /** Current token (null until solved / after expiry). */\n token: string | null\n setToken: (t: string | null) => void\n}\n\nconst TurnstileContext = createContext<TurnstileContextValue | null>(null)\n\n/**\n * Provides the Turnstile site key once for the whole app, like ConsentProvider\n * for cookies. The project reads the key server-side and passes it here in the\n * layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form\n * key wiring.\n *\n * // layout.tsx (server) → read key, pass to provider\n * import { TurnstileProvider } from '@intecion/ipal-kit/client'\n * const siteKey = await getTurnstileSiteKey() // your server helper\n * <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>\n *\n * When siteKey is null (Turnstile not configured), widgets render nothing and\n * token stays null — forms should treat \"no Turnstile\" as allowed in dev.\n */\nexport function TurnstileProvider({\n siteKey,\n children,\n}: {\n siteKey: string | null\n children: React.ReactNode\n}) {\n const [token, setToken] = useState<string | null>(null)\n return (\n <TurnstileContext.Provider value={{ siteKey, token, setToken }}>\n {children}\n </TurnstileContext.Provider>\n )\n}\n\n/**\n * Hook giving a form the Turnstile token + a widget bound to the provider's key.\n * No per-form siteKey plumbing — the provider supplies it.\n *\n * const { token, TurnstileWidget, reset } = useTurnstile()\n * // in JSX: <TurnstileWidget />\n * // at submit: submitForm({ ..., turnstileToken: token })\n * // after submit: reset() // clear for the next submission\n */\nexport function useTurnstile(): {\n token: string | null\n TurnstileWidget: (props?: { theme?: 'light' | 'dark' | 'auto' }) => React.ReactNode\n reset: () => void\n /** True when Turnstile is configured (site key present). */\n enabled: boolean\n} {\n const ctx = useContext(TurnstileContext)\n if (!ctx) {\n throw new Error('useTurnstile must be used within <TurnstileProvider>')\n }\n const { siteKey, token, setToken } = ctx\n\n const reset = useCallback(() => setToken(null), [setToken])\n\n const TurnstileWidget = useCallback(\n (props?: { theme?: 'light' | 'dark' | 'auto' }) => {\n if (!siteKey) return null\n return <Turnstile siteKey={siteKey} onToken={setToken} theme={props?.theme} />\n },\n [siteKey, setToken],\n )\n\n return { token, TurnstileWidget, reset, enabled: Boolean(siteKey) }\n}\n"],"names":["createContext","useCallback","useContext","useState","Turnstile","TurnstileContext","TurnstileProvider","siteKey","children","token","setToken","Provider","value","useTurnstile","ctx","Error","reset","TurnstileWidget","props","onToken","theme","enabled","Boolean"],"mappings":"AAAA;;AAEA,SAASA,aAAa,EAAEC,WAAW,EAAEC,UAAU,EAAEC,QAAQ,QAAQ,QAAO;AACxE,SAASC,SAAS,QAAQ,iBAAgB;AAU1C,MAAMC,iCAAmBL,cAA4C;AAErE;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASM,kBAAkB,EAChCC,OAAO,EACPC,QAAQ,EAIT;IACC,MAAM,CAACC,OAAOC,SAAS,GAAGP,SAAwB;IAClD,qBACE,KAACE,iBAAiBM,QAAQ;QAACC,OAAO;YAAEL;YAASE;YAAOC;QAAS;kBAC1DF;;AAGP;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASK;IAOd,MAAMC,MAAMZ,WAAWG;IACvB,IAAI,CAACS,KAAK;QACR,MAAM,IAAIC,MAAM;IAClB;IACA,MAAM,EAAER,OAAO,EAAEE,KAAK,EAAEC,QAAQ,EAAE,GAAGI;IAErC,MAAME,QAAQf,YAAY,IAAMS,SAAS,OAAO;QAACA;KAAS;IAE1D,MAAMO,kBAAkBhB,YACtB,CAACiB;QACC,IAAI,CAACX,SAAS,OAAO;QACrB,qBAAO,KAACH;YAAUG,SAASA;YAASY,SAAST;YAAUU,OAAOF,OAAOE;;IACvE,GACA;QAACb;QAASG;KAAS;IAGrB,OAAO;QAAED;QAAOQ;QAAiBD;QAAOK,SAASC,QAAQf;IAAS;AACpE"}
|
||||||
Vendored
+1
@@ -1,2 +1,3 @@
|
|||||||
export { Turnstile } from './Turnstile.js';
|
export { Turnstile } from './Turnstile.js';
|
||||||
export type { TurnstileProps } from './Turnstile.js';
|
export type { TurnstileProps } from './Turnstile.js';
|
||||||
|
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
|
||||||
|
|||||||
Vendored
+1
@@ -2,5 +2,6 @@
|
|||||||
// Client-only exports — the Turnstile widget. Kept separate from index.ts so
|
// Client-only exports — the Turnstile widget. Kept separate from index.ts so
|
||||||
// the server-only verify never leaks into a browser bundle.
|
// the server-only verify never leaks into a browser bundle.
|
||||||
export { Turnstile } from './Turnstile.js';
|
export { Turnstile } from './Turnstile.js';
|
||||||
|
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
|
||||||
|
|
||||||
//# sourceMappingURL=client.js.map
|
//# sourceMappingURL=client.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\n"],"names":["Turnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB"}
|
{"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\nexport { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n"],"names":["Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB;AAE1C,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB"}
|
||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
|
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
|
||||||
export { verifyTurnstile } from './verify.js';
|
export { verifyTurnstile } from './verify.js';
|
||||||
|
|||||||
Vendored
+1
@@ -1,3 +1,4 @@
|
|||||||
|
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
|
||||||
// Server-only exports. verify.ts imports 'server-only', so this must never be
|
// Server-only exports. verify.ts imports 'server-only', so this must never be
|
||||||
// imported from a client component — use ./client for the widget instead.
|
// imported from a client component — use ./client for the widget instead.
|
||||||
export { verifyTurnstile } from './verify.js';
|
export { verifyTurnstile } from './verify.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["verifyTurnstile"],"mappings":"AAAA,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASA,eAAe,QAAQ,cAAa"}
|
{"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["TurnstileProvider","useTurnstile","verifyTurnstile"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB;AACxE,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASC,eAAe,QAAQ,cAAa"}
|
||||||
Vendored
+44
-4
@@ -42,6 +42,50 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
let config = {
|
let config = {
|
||||||
...incomingConfig
|
...incomingConfig
|
||||||
};
|
};
|
||||||
|
// --- custom admin route (e.g. '/its' instead of '/admin') ---
|
||||||
|
// Sets config.routes.admin; the project must move its app/(payload)/<route>/
|
||||||
|
// folder to match (plugin can't create files in the project's app/).
|
||||||
|
if (options.adminRoute) {
|
||||||
|
config.routes = {
|
||||||
|
...config.routes ?? {},
|
||||||
|
admin: options.adminRoute
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// --- enforced 2FA (TOTP) via @clocklimited/payload-2fa ---
|
||||||
|
// Enforced by default (forceSetup) unless twoFactor is explicitly false. The
|
||||||
|
// plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle
|
||||||
|
// it, so projects that opt out (twoFactor: false) needn't install it, and the
|
||||||
|
// import never runs under generate:importmap when 2FA is off. Wrapping access
|
||||||
|
// control (not just admin UI) means TOTP gates data access — no API bypass.
|
||||||
|
if (options.twoFactor !== false) {
|
||||||
|
const tf = options.twoFactor;
|
||||||
|
if (!tf?.issuer) {
|
||||||
|
throw new Error('[ipal] twoFactor.issuer is required (name shown in the authenticator ' + 'app). Pass twoFactor: { issuer: "Your Site" }, or twoFactor: false to ' + 'opt out (discouraged).');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Dynamic specifier via a variable so TS doesn't try to resolve this
|
||||||
|
// optional peer dependency at build time (it isn't in the plugin's own
|
||||||
|
// node_modules). Avoids TS2307 without @ts-expect-error; the module
|
||||||
|
// exists at runtime in projects that installed it.
|
||||||
|
// @ts-ignore
|
||||||
|
const mod = await import('@clocklimited/payload-2fa');
|
||||||
|
// The package exports `payloadTotp`; older/other builds may use
|
||||||
|
// `totpPlugin`. Accept either so a rename doesn't break us.
|
||||||
|
const totp = mod.payloadTotp ?? mod.totpPlugin;
|
||||||
|
if (typeof totp !== 'function') {
|
||||||
|
throw new Error('expected export payloadTotp (or totpPlugin) to be a function — ' + 'check the installed @clocklimited/payload-2fa version');
|
||||||
|
}
|
||||||
|
config = await totp({
|
||||||
|
collection: tf.collectionSlug ?? 'users',
|
||||||
|
forceSetup: true,
|
||||||
|
totp: {
|
||||||
|
issuer: tf.issuer
|
||||||
|
}
|
||||||
|
})(config);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' + 'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' + `opt out (discouraged). Original error: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// --- i18n ---
|
// --- i18n ---
|
||||||
config.localization = buildLocalizationConfig(options.i18n);
|
config.localization = buildLocalizationConfig(options.i18n);
|
||||||
// --- access: inject roles into the client's auth collection ---
|
// --- access: inject roles into the client's auth collection ---
|
||||||
@@ -88,10 +132,6 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
buildCookieSettings(),
|
buildCookieSettings(),
|
||||||
buildNotifications()
|
buildNotifications()
|
||||||
];
|
];
|
||||||
// --- endpoints ---
|
|
||||||
// Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe
|
|
||||||
// message through the currently selected transport, so the panel's "send
|
|
||||||
// test" button can confirm delivery without leaving the admin UI.
|
|
||||||
config.endpoints = [
|
config.endpoints = [
|
||||||
...config.endpoints ?? [],
|
...config.endpoints ?? [],
|
||||||
testEmailEndpoint
|
testEmailEndpoint
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+27
@@ -15,6 +15,17 @@ export type IpalOptions = {
|
|||||||
* (admin > editor > user) into the client's auth collection.
|
* (admin > editor > user) into the client's auth collection.
|
||||||
*/
|
*/
|
||||||
access?: AccessOption;
|
access?: AccessOption;
|
||||||
|
/**
|
||||||
|
* Custom admin panel route, e.g. '/its' instead of the default '/admin'.
|
||||||
|
* The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —
|
||||||
|
* the project must ALSO move its panel folder to match:
|
||||||
|
* app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin
|
||||||
|
* can't create files in the project's app/. See docs/security.md.
|
||||||
|
*
|
||||||
|
* This is obscurity, not security: it hides the panel from dumb bots scanning
|
||||||
|
* /admin, but real protection is strong auth + 2FA + rate limiting.
|
||||||
|
*/
|
||||||
|
adminRoute?: string;
|
||||||
/**
|
/**
|
||||||
* Collections whose entries live under an archive page — blog posts, case
|
* Collections whose entries live under an archive page — blog posts, case
|
||||||
* studies, anything with a listing. Adds an "archive page" assignment per
|
* studies, anything with a listing. Adds an "archive page" assignment per
|
||||||
@@ -45,4 +56,20 @@ export type IpalOptions = {
|
|||||||
seo?: SeoOption;
|
seo?: SeoOption;
|
||||||
/** Additional fields injected into SiteSettings global */
|
/** Additional fields injected into SiteSettings global */
|
||||||
siteSettingsFields?: Field[];
|
siteSettingsFields?: Field[];
|
||||||
|
/**
|
||||||
|
* Two-factor authentication (TOTP), ENFORCED for every user. Wires
|
||||||
|
* @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every
|
||||||
|
* user must configure an authenticator app after login; TOTP is checked before
|
||||||
|
* data access (not just the admin UI). Requires the peer dep installed and an
|
||||||
|
* issuer name (shown in the authenticator app).
|
||||||
|
*
|
||||||
|
* Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)
|
||||||
|
* — default is enforced. See docs/security.md.
|
||||||
|
*/
|
||||||
|
twoFactor?: {
|
||||||
|
/** Auth collection slug. Defaults to 'users'. */
|
||||||
|
collectionSlug?: string;
|
||||||
|
/** Name shown in the authenticator app (e.g. company/site name). */
|
||||||
|
issuer: string;
|
||||||
|
} | false;
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA4CC"}
|
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Custom admin panel route, e.g. '/its' instead of the default '/admin'.\n * The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —\n * the project must ALSO move its panel folder to match:\n * app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin\n * can't create files in the project's app/. See docs/security.md.\n *\n * This is obscurity, not security: it hides the panel from dumb bots scanning\n * /admin, but real protection is strong auth + 2FA + rate limiting.\n */\n adminRoute?: string\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n\n /**\n * Two-factor authentication (TOTP), ENFORCED for every user. Wires\n * @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every\n * user must configure an authenticator app after login; TOTP is checked before\n * data access (not just the admin UI). Requires the peer dep installed and an\n * issuer name (shown in the authenticator app).\n *\n * Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)\n * — default is enforced. See docs/security.md.\n */\n twoFactor?:\n | {\n /** Auth collection slug. Defaults to 'users'. */\n collectionSlug?: string\n /** Name shown in the authenticator app (e.g. company/site name). */\n issuer: string\n }\n | false\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA2EC"}
|
||||||
+8
-1
@@ -103,11 +103,17 @@ export default buildConfig({
|
|||||||
|
|
||||||
| Moduł | Opis | Dok |
|
| Moduł | Opis | Dok |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| i18n | Lokalizacja, negocjacja locale, ścieżki URL | [i18n.md](./i18n.md) |
|
| i18n | Lokalizacja, negocjacja locale, ścieżki URL, strona jednojęzyczna | [i18n.md](./i18n.md) |
|
||||||
|
| hooks | Hooki: revalidate ISR, slug history 301, ochrona stron systemowych | [hooks.md](./hooks.md) |
|
||||||
|
| kolekcje-katalog | Jakie kolekcje budować, kiedy, jak wpiąć (minimum nie maksimum) | [kolekcje-katalog.md](./kolekcje-katalog.md) |
|
||||||
|
| fundamenty-projektu | Struktura katalogów, nazewnictwo, konwencje | [fundamenty-projektu.md](./fundamenty-projektu.md) |
|
||||||
|
| deployment | Zmienne .env, ISR/SSG, force-dynamic, Coolify/Docker | [deployment.md](./deployment.md) |
|
||||||
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
|
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
|
||||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||||
|
| wymagania-prawne | **Polityki, regulaminy, baner cookies, RODO (compliance)** | [wymagania-prawne.md](./wymagania-prawne.md) |
|
||||||
|
| standardy-kodu | **Dobre praktyki senior: typy, architektura, antywzorce** | [standardy-kodu.md](./standardy-kodu.md) |
|
||||||
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
|
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
|
||||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||||
@@ -117,6 +123,7 @@ export default buildConfig({
|
|||||||
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) |
|
||||||
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
|
| notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) |
|
||||||
|
| storage | Media na Cloudflare R2 (offload z .env) | [storage.md](./storage.md) |
|
||||||
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
|
| security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) |
|
||||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Dostępność — widget a11y (WCAG)
|
||||||
|
|
||||||
|
Widget dostępności: pływający przycisk otwierający panel z opcjami dla osób z
|
||||||
|
niepełnosprawnościami (rozmiar tekstu, kontrast, skala szarości, podkreślone
|
||||||
|
linki, czytelna czcionka, wyłączenie animacji, duży kursor).
|
||||||
|
|
||||||
|
> **OPCJONALNY — nie dodawaj domyślnie.** Widget a11y jest wymagany prawnie
|
||||||
|
> TYLKO dla niektórych stron (podmioty publiczne, część e-commerce/usług objętych
|
||||||
|
> European Accessibility Act). Dla większości stron komercyjnych to OPCJA, nie
|
||||||
|
> obowiązek. Dodawaj GDY klient/projekt tego wymaga — nie na każdej stronie z
|
||||||
|
> automatu. W razie wątpliwości: zapytaj, czy strona podlega wymogom dostępności.
|
||||||
|
|
||||||
|
Wzorzec jak CookieBanner: provider + widget, wpinasz raz. Preferencje w cookie
|
||||||
|
(bez flash), stosowane jako atrybuty `data-a11y-*` na `<html>`.
|
||||||
|
|
||||||
|
> **Granica plugin/projekt:** plugin dostarcza MECHANIZM (widget, stan, cookie,
|
||||||
|
> atrybuty na html). Projekt dostarcza CSS reagujący na atrybuty — bo style
|
||||||
|
> zależą od designu projektu (kolory, czcionki, Tailwind). Plugin NIE narzuca
|
||||||
|
> stylów, żeby nie kolidować. Gotowy CSS do skopiowania niżej.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Wpięcie — Provider + Widget
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/[locale]/layout.tsx
|
||||||
|
import { AccessibilityProvider, AccessibilityWidget } from '@intecion/ipal-kit/client'
|
||||||
|
|
||||||
|
<AccessibilityProvider>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<AccessibilityWidget
|
||||||
|
classNames={{
|
||||||
|
button: 'a11y-button',
|
||||||
|
panel: 'a11y-panel',
|
||||||
|
row: 'a11y-row',
|
||||||
|
label: 'a11y-label',
|
||||||
|
control: 'a11y-control',
|
||||||
|
active: 'a11y-active',
|
||||||
|
resetButton: 'a11y-reset',
|
||||||
|
closeButton: 'a11y-close',
|
||||||
|
}}
|
||||||
|
texts={{ title: 'Dostępność', reset: 'Resetuj' }} // opcjonalne, PL domyślnie
|
||||||
|
/>
|
||||||
|
</body>
|
||||||
|
</AccessibilityProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
Widget jest bez stylów (jak CookieBanner) — classNames dopasowujesz do designu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CSS reagujący na atrybuty (OBOWIĄZKOWE — projekt)
|
||||||
|
|
||||||
|
Widget ustawia atrybuty na `<html>`. Bez tego CSS nic się nie dzieje. Skopiuj do
|
||||||
|
globalnego CSS projektu (dostosuj do designu):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Rozmiar tekstu */
|
||||||
|
html[data-a11y-text="1"] { font-size: 112.5%; }
|
||||||
|
html[data-a11y-text="2"] { font-size: 125%; }
|
||||||
|
html[data-a11y-text="3"] { font-size: 150%; }
|
||||||
|
|
||||||
|
/* Odstęp między liniami */
|
||||||
|
html[data-a11y-line="1"] * { line-height: 1.8 !important; }
|
||||||
|
html[data-a11y-line="2"] * { line-height: 2.2 !important; }
|
||||||
|
|
||||||
|
/* Kontrast wysoki */
|
||||||
|
html[data-a11y-contrast="high"] {
|
||||||
|
filter: contrast(1.4);
|
||||||
|
}
|
||||||
|
/* Kontrast odwrócony */
|
||||||
|
html[data-a11y-contrast="inverted"] {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
html[data-a11y-contrast="inverted"] img,
|
||||||
|
html[data-a11y-contrast="inverted"] video {
|
||||||
|
filter: invert(1) hue-rotate(180deg); /* przywróć media */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skala szarości */
|
||||||
|
html[data-a11y-grayscale="on"] { filter: grayscale(1); }
|
||||||
|
/* Uwaga: filter na html nie kumuluje się — jeśli łączysz kontrast+szarość,
|
||||||
|
zastosuj na body albo połącz w jednej regule. */
|
||||||
|
|
||||||
|
/* Podkreślone linki */
|
||||||
|
html[data-a11y-underline="on"] a { text-decoration: underline !important; }
|
||||||
|
|
||||||
|
/* Czytelna czcionka (podmień na swoją dyslexia-friendly / prostą) */
|
||||||
|
html[data-a11y-font="readable"] * {
|
||||||
|
font-family: Verdana, Tahoma, sans-serif !important;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wyłączenie animacji */
|
||||||
|
html[data-a11y-motion="reduce"] *,
|
||||||
|
html[data-a11y-motion="reduce"] *::before,
|
||||||
|
html[data-a11y-motion="reduce"] *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duży kursor */
|
||||||
|
html[data-a11y-cursor="big"] * {
|
||||||
|
cursor: url('/cursors/big.svg') 4 4, auto !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dostosuj wartości do projektu (kolory kontrastu, czcionka, kursor). To Twój CSS —
|
||||||
|
plugin tylko ustawia atrybuty.
|
||||||
|
|
||||||
|
> **Kontrast + filter:** wiele opcji używa `filter` na `<html>`. CSS `filter` na
|
||||||
|
> jednym elemencie NIE kumuluje wielu wartości z różnych reguł — ostatnia wygrywa.
|
||||||
|
> Jeśli chcesz łączyć (np. szarość + kontrast), zastosuj filtry na `body` z
|
||||||
|
> pełną wartością, albo zbuduj reguły kombinowane. Dla pojedynczych opcji działa
|
||||||
|
> bez problemu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bez flash (SSR) — opcjonalne
|
||||||
|
|
||||||
|
Domyślnie widget stosuje atrybuty po hydratacji (krótki flash przy ładowaniu,
|
||||||
|
jeśli użytkownik miał ustawienia). Żeby tego uniknąć, odczytaj cookie server-side
|
||||||
|
i ustaw atrybuty na `<html>` w SSR:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// layout.tsx (server) — odczytaj cookie i ustaw atrybuty od razu
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { A11Y_COOKIE, parseA11y, a11yAttributes } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const raw = (await cookies()).get(A11Y_COOKIE)?.value
|
||||||
|
const attrs = a11yAttributes(parseA11y(raw))
|
||||||
|
const htmlAttrs = Object.fromEntries(
|
||||||
|
Object.entries(attrs).filter(([, v]) => v !== null),
|
||||||
|
)
|
||||||
|
|
||||||
|
return <html lang={locale} {...htmlAttrs}>...</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider i tak re-aplikuje na kliencie i synchronizuje. To tylko eliminuje flash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Osobny przycisk otwierający (opcjonalnie)
|
||||||
|
|
||||||
|
Widget ma wbudowany pływający przycisk. Jeśli chcesz otwierać panel z innego
|
||||||
|
miejsca (np. stopka „Dostępność"), użyj hooka:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
import { useAccessibility } from '@intecion/ipal-kit/client'
|
||||||
|
// stan otwarcia trzymaj sam, albo rozbuduj widget — hook daje state/set/reset
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Compliance — kiedy dostępność jest wymagana
|
||||||
|
|
||||||
|
Dostępność (WCAG) jest wymagana prawnie TYLKO dla części stron:
|
||||||
|
- **Podmioty publiczne** (urzędy, szkoły, instytucje) — ustawa o dostępności cyfrowej
|
||||||
|
- **Duże e-commerce / usługi** objęte European Accessibility Act (2019/882, od 2025)
|
||||||
|
- Strony, gdzie klient sam tego wymaga (polityka firmy, przetarg)
|
||||||
|
|
||||||
|
Dla **większości stron komercyjnych** (wizytówka, mała firma, katalog) widget a11y
|
||||||
|
to **opcja, nie obowiązek** — dodawaj gdy klient wymaga, nie z automatu.
|
||||||
|
|
||||||
|
Gdy dodajesz: widget sam w sobie NIE czyni strony w pełni dostępną — to pomoc dla
|
||||||
|
użytkownika. Pełna dostępność to też semantyczny HTML, alt teksty, nawigacja
|
||||||
|
klawiaturą, kontrast bazowy. Widget uzupełnia, nie zastępuje. Nie sprzedawaj
|
||||||
|
klientowi „mamy widget = jesteśmy zgodni z WCAG". Patrz wymagania-prawne.md.
|
||||||
@@ -145,6 +145,29 @@ sitemap/robots → force-dynamic (bo generują przy żądaniu). Nie mieszaj na j
|
|||||||
trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand
|
trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand
|
||||||
revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md.
|
revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md.
|
||||||
|
|
||||||
|
## 3a3. SSG a dostęp do bazy przy buildzie (WAŻNE dla SEO)
|
||||||
|
|
||||||
|
`generateStaticParams` (z lib/content) prerenderuje strony jako SSG — head
|
||||||
|
synchroniczny, SEO 100/100. ALE żeby prerenderować, **build musi mieć dostęp do
|
||||||
|
bazy** (generateStaticParams czyta strony z bazy w czasie buildu).
|
||||||
|
|
||||||
|
- **Build MA dostęp do bazy** (baza w tej samej sieci Docker, dostępna w build
|
||||||
|
stage) → strony prerenderowane jako SSG (`●`), head synchroniczny → SEO OK ✓
|
||||||
|
- **Build NIE MA dostępu** (izolowany build stage) → generateStaticParams zwraca
|
||||||
|
`[]` (plugin łapie błąd, build nie pada), ale strony renderują się on-demand
|
||||||
|
(dynamicznie) → head może streamować do body → problem SEO wraca ✗
|
||||||
|
|
||||||
|
Plugin zabezpiecza build przed CRASHEM (try/catch → `[]`), ale to NIE zastępuje
|
||||||
|
dostępu do bazy. **Dla pełnego SSG/SEO zapewnij, że build kontenerowy widzi bazę.**
|
||||||
|
|
||||||
|
W Coolify/Docker: baza (Mongo/Postgres) powinna być dostępna podczas `pnpm build`,
|
||||||
|
nie tylko w runtime. Jeśli build jest w izolowanej sieci — rozważ:
|
||||||
|
- uruchom bazę w tej samej sieci Docker co build stage, albo
|
||||||
|
- build z DATABASE_URI wskazującym na dostępną bazę (nie wewnętrzny host niedostępny w buildzie).
|
||||||
|
|
||||||
|
Weryfikacja: po buildzie `pnpm build` pokazuje trasy jako `●` (SSG), nie `ƒ`
|
||||||
|
(Dynamic). Jeśli `ƒ` mimo generateStaticParams → build nie miał dostępu do bazy.
|
||||||
|
|
||||||
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
||||||
|
|
||||||
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# Hooki pluginu — automatyzacja tworzenia stron
|
||||||
|
|
||||||
|
Plugin dostarcza hooki, które zdejmują z projektów powtarzalną robotę. Wpinasz je
|
||||||
|
w kolekcje; działają automatycznie. Wszystkie gotowe do użycia (import z pluginu).
|
||||||
|
|
||||||
|
Powiązane: [pages.md](./pages.md), [seo.md](./seo.md), [wymagania-prawne.md](./wymagania-prawne.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## buildRevalidateHook — ISR odświeżany po zapisie (NAJWAŻNIEJSZY)
|
||||||
|
|
||||||
|
Bez tego ISR ma haczyk: redaktor zapisuje stronę i CZEKA na revalidate (do
|
||||||
|
godziny). Z tym — zapisuje i OD RAZU widzi zmianę. To warunek, żeby ISR był
|
||||||
|
używalny dla CMS.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// kolekcja Pages — z pliku projektu, który MOŻE importować next/cache
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { buildRevalidateHook } from '@intecion/ipal-kit'
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
const { afterChange, afterDelete } = buildRevalidateHook({
|
||||||
|
revalidatePath, // wstrzykiwany — plugin NIE importuje next/cache
|
||||||
|
config: i18nConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pages: CollectionConfig = {
|
||||||
|
slug: 'pages',
|
||||||
|
hooks: { afterChange: [afterChange], afterDelete: [afterDelete] },
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dlaczego revalidatePath wstrzykiwany:** plugin nie importuje `next/cache` (to
|
||||||
|
by wywaliło Payload przy generate:importmap / czystym Node). Projekt podaje.
|
||||||
|
|
||||||
|
Obsługuje: wszystkie języki, root (home), zmianę slug (rewaliduje stary I nowy
|
||||||
|
path — stary URL nie serwuje starej treści), delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## setPublishedAtHook — auto-data publikacji
|
||||||
|
|
||||||
|
Ustawia `publishedAt` na teraz przy pierwszej publikacji (jeśli puste). Redaktor
|
||||||
|
nie wpisuje daty ręcznie; data jest dokładna dla Article JSON-LD i sitemap.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { setPublishedAtHook } from '@intecion/ipal-kit'
|
||||||
|
// kolekcja z draftami (blog, artykuły):
|
||||||
|
hooks: { beforeChange: [setPublishedAtHook] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ustawia tylko przy przejściu na published; nie nadpisuje istniejącej daty
|
||||||
|
(redaktor może backdatować ręcznie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## buildPreventDeleteSystemPage — ochrona stron systemowych
|
||||||
|
|
||||||
|
Blokuje usunięcie strony przypisanej do roli (homepage, privacyPolicy,
|
||||||
|
cookiePolicy, termsOfService). Redaktor nie usunie przypadkiem polityki
|
||||||
|
prywatności albo strony głównej → nie rozbije routingu i linków compliance.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { buildPreventDeleteSystemPage } from '@intecion/ipal-kit'
|
||||||
|
hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Żeby usunąć — najpierw odłącz rolę w Site Settings (świadoma decyzja).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## buildValidateUniqueRole — jedna strona = jedna rola
|
||||||
|
|
||||||
|
Zapobiega przypisaniu tej samej strony do dwóch ról systemowych (np. homepage I
|
||||||
|
privacyPolicy naraz → niejednoznaczny routing).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { buildValidateUniqueRole } from '@intecion/ipal-kit'
|
||||||
|
// na polu roli w SiteSettings:
|
||||||
|
{
|
||||||
|
name: 'privacyPolicy',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'pages',
|
||||||
|
hooks: { beforeValidate: [buildValidateUniqueRole({
|
||||||
|
siblingFields: ['homepage', 'cookiePolicy', 'termsOfService'],
|
||||||
|
})] },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## trackSlugHistoryHook — auto-redirect 301 przy zmianie slug
|
||||||
|
|
||||||
|
Gdy slug się zmienia, zapisuje STARY slug do pola `slugHistory`. Projekt czyta to
|
||||||
|
i robi 301 ze starego URL na nowy → zmiana adresu nie daje 404 (realna strata SEO
|
||||||
|
z audytu).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { trackSlugHistoryHook } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
export const Pages: CollectionConfig = {
|
||||||
|
fields: [
|
||||||
|
// ...
|
||||||
|
{ name: 'slugHistory', type: 'array', admin: { readOnly: true },
|
||||||
|
fields: [{ name: 'slug', type: 'text' }] },
|
||||||
|
],
|
||||||
|
hooks: { beforeChange: [trackSlugHistoryHook] },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Projekt w resolveRoute / sprawdzeniu redirectów: jeśli żądany slug jest w
|
||||||
|
slugHistory jakiejś strony → 301 na jej aktualny slug. Przykład:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// w page.tsx, gdy resolveRoute nie znajdzie strony po slug:
|
||||||
|
const byHistory = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { 'slugHistory.slug': { equals: requestedSlug } },
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
if (byHistory.docs[0]) {
|
||||||
|
redirect(`/${locale}/${byHistory.docs[0].slug}`) // 301 na aktualny
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KOLEJNOŚĆ hooków (ważne)
|
||||||
|
|
||||||
|
W jednej kolekcji hooki tej samej fazy uruchamiają się po kolei. Typowa Media:
|
||||||
|
```ts
|
||||||
|
hooks: {
|
||||||
|
beforeOperation: [normalizeFilenameHook], // czyste nazwy
|
||||||
|
afterChange: [afterChange], // revalidate
|
||||||
|
afterDelete: [afterDelete],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Typowa Pages:
|
||||||
|
```ts
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [setPublishedAtHook, trackSlugHistoryHook],
|
||||||
|
beforeDelete: [buildPreventDeleteSystemPage(...)],
|
||||||
|
afterChange: [afterChange], // revalidate
|
||||||
|
afterDelete: [afterDelete],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Które hooki wpiąć zależy od kolekcji — nie każda potrzebuje wszystkich (blog:
|
||||||
|
setPublishedAt; wszystkie z URL: revalidate + slugHistory; Pages: + preventDelete).
|
||||||
+13
-2
@@ -80,8 +80,19 @@ export { generateStaticParams } from '@/lib/content'
|
|||||||
```
|
```
|
||||||
|
|
||||||
Helper automatycznie: pobiera pages + kolekcje treści, wyklucza homepage (→ root),
|
Helper automatycznie: pobiera pages + kolekcje treści, wyklucza homepage (→ root),
|
||||||
drafty, 404/500, noindex; zwraca `{slug}[]` (jednojęzyczny) albo
|
drafty, 404/500; zwraca `{slug}[]` (jednojęzyczny) albo `{locale, slug}[]`
|
||||||
`{locale, slug}[]` (wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`).
|
(wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`) oraz
|
||||||
|
zlokalizowane (string albo mapa per język).
|
||||||
|
|
||||||
|
**Strony noindex SĄ renderowane** (nie pomijane jak w sitemap). Strona prawna
|
||||||
|
(polityka, cookies) z noindex nadal musi się wyświetlić — użytkownik wchodzi z
|
||||||
|
stopki, crawler czyta jej `<meta robots=noindex>`. noindex kontroluje
|
||||||
|
INDEKSOWANIE, nie istnienie strony. Pominięcie wymusiłoby dynamiczne renderowanie
|
||||||
|
(ten sam problem streamingu, którego unikamy).
|
||||||
|
|
||||||
|
Helper NIE filtruje `_status` w zapytaniu (`where`) — kolekcje bez włączonych
|
||||||
|
draftów nie mają tego pola i zapytanie by rzuciło błąd. Drafty odfiltrowane w
|
||||||
|
pamięci (bezpieczne dla każdej kolekcji).
|
||||||
|
|
||||||
## PUŁAPKA: await searchParams deoptymalizuje ISR
|
## PUŁAPKA: await searchParams deoptymalizuje ISR
|
||||||
|
|
||||||
|
|||||||
+164
-1
@@ -71,7 +71,44 @@ analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
|||||||
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||||
projekt dostarcza CSP dopasowany do siebie.
|
projekt dostarcza CSP dopasowany do siebie.
|
||||||
|
|
||||||
### Budowa CSP — domeny z env, nie hardkod
|
### buildCsp — generator CSP (zalecane zamiast ręcznego)
|
||||||
|
|
||||||
|
Zamiast pisać surowy CSP w każdym projekcie (ryzyko pominięcia base-uri,
|
||||||
|
object-src), użyj `buildCsp` — ma twarde reguły OWASP/Lighthouse wbudowane, a Ty
|
||||||
|
włączasz tylko flagi tego, co projekt ładuje:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const csp = buildCsp({
|
||||||
|
mode: 'report-only', // zacznij tu; 'enforce' gdy konsola czysta
|
||||||
|
r2Url: process.env.R2_PUBLIC_URL, // media R2 → img-src
|
||||||
|
turnstile: true, // challenges.cloudflare.com → script/frame/connect
|
||||||
|
analytics: true, // GTM + GA
|
||||||
|
youtube: true, // youtube → frame-src
|
||||||
|
googleMaps: true, // mapy Google
|
||||||
|
// extra: { 'script-src': ['https://inny-skrypt.pl'] }, // dodatkowe źródła
|
||||||
|
})
|
||||||
|
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production',
|
||||||
|
additional: [csp],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Twarde reguły wbudowane** (zawsze, nie da się zapomnieć): `base-uri 'self'`,
|
||||||
|
`object-src 'none'`, `frame-ancestors 'none'`. To te, które Lighthouse/OWASP
|
||||||
|
wymagają, a łatwo je pominąć pisząc CSP ręcznie.
|
||||||
|
|
||||||
|
`buildCsp` NIE dodaje `'unsafe-eval'` (osłabia CSP) — dodaj przez `extra` tylko
|
||||||
|
jeśli biblioteka tego wymaga. `mode: 'report-only'` daje nagłówek
|
||||||
|
`…-Report-Only`; `'enforce'` daje `Content-Security-Policy`.
|
||||||
|
|
||||||
|
CSP dalej „w projekcie" (Ty wybierasz flagi wg tego, co ładujesz), ale skeleton
|
||||||
|
jest z pluginu — każdy projekt ma ten sam zahardowany fundament.
|
||||||
|
|
||||||
|
### Budowa CSP — ręcznie (jeśli potrzebujesz pełnej kontroli)
|
||||||
|
|
||||||
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||||
dopasuj do tego, co projekt faktycznie ładuje:
|
dopasuj do tego, co projekt faktycznie ładuje:
|
||||||
@@ -139,3 +176,129 @@ wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
|||||||
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||||
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||||
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||||
|
|
||||||
|
|
||||||
|
## COOP (Cross-Origin-Opener-Policy) — domyślnie włączony
|
||||||
|
|
||||||
|
buildSecurityHeaders wysyła domyślnie `Cross-Origin-Opener-Policy: same-origin` —
|
||||||
|
izoluje kontekst przeglądarki (ochrona przed XS-Leaks / Spectre, wyciekiem
|
||||||
|
window.opener). Uniwersalny nagłówek, więc z automatu.
|
||||||
|
|
||||||
|
- Domyślnie `same-origin` (najbezpieczniejsze)
|
||||||
|
- `coop: 'same-origin-allow-popups'` — jeśli otwierasz popupy OAuth/płatności
|
||||||
|
wymagające window.opener
|
||||||
|
- `coop: false` — wyłącz (rzadko potrzebne)
|
||||||
|
|
||||||
|
## Trusted Types — NIE wdrażać (na teraz)
|
||||||
|
|
||||||
|
NIE wymuszaj `require-trusted-types-for 'script'`. Powód:
|
||||||
|
- Audyt Lighthouse to „Bez oceny" (informacyjny/eksperymentalny w Chromium)
|
||||||
|
- Wymuszenie bez kompleksowego silnika polityk w Next/React powoduje `TypeError`
|
||||||
|
przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
|
||||||
|
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
|
||||||
|
|
||||||
|
Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.
|
||||||
|
|
||||||
|
## Zmiana ścieżki panelu admina (/admin → /its)
|
||||||
|
|
||||||
|
Ukrycie panelu przed botami skanującymi znane ścieżki (`/admin`, `/wp-admin`).
|
||||||
|
Plugin ustawia ścieżkę przez opcję `adminRoute`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// payload.config.ts
|
||||||
|
ipalKit({
|
||||||
|
i18n: i18nConfig,
|
||||||
|
adminRoute: '/its', // panel pod /its zamiast /admin
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### WYMAGANE — przenieś folder panelu w projekcie
|
||||||
|
|
||||||
|
Plugin ustawia `config.routes.admin`, ale NIE tworzy plików w `app/` projektu.
|
||||||
|
Musisz przenieść folder panelu, żeby ścieżka zadziałała:
|
||||||
|
|
||||||
|
```
|
||||||
|
# PRZED:
|
||||||
|
app/(payload)/admin/[[...segments]]/page.tsx
|
||||||
|
app/(payload)/admin/[[...segments]]/not-found.tsx
|
||||||
|
|
||||||
|
# PO (nazwa folderu = adminRoute bez ukośnika):
|
||||||
|
app/(payload)/its/[[...segments]]/page.tsx
|
||||||
|
app/(payload)/its/[[...segments]]/not-found.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Bez przeniesienia folderu: `config.routes.admin = '/its'`, ale `/its` daje 404
|
||||||
|
(brak pliku), a `/admin` też nie działa (config zmieniony). Oba muszą się zgadzać.
|
||||||
|
|
||||||
|
### To OBSCURITY, nie SECURITY
|
||||||
|
|
||||||
|
Zmiana ścieżki utrudnia automatyczne skany, ale NIE jest zabezpieczeniem.
|
||||||
|
Prawdziwa ochrona panelu:
|
||||||
|
- **2FA** dla każdego użytkownika (planowane — wymuszenie przez plugin)
|
||||||
|
- Silne hasła
|
||||||
|
- Rate limiting na logowaniu
|
||||||
|
- IP allowlist (jeśli panel tylko dla zespołu)
|
||||||
|
- buildSecurityHeaders (nagłówki)
|
||||||
|
|
||||||
|
Zmiana `/admin → /its` to warstwa (odsiewa głupie boty), nie zamek. Traktuj jako
|
||||||
|
dodatek do prawdziwych zabezpieczeń, nie zamiast nich.
|
||||||
|
|
||||||
|
## 2FA (TOTP) — WYMUSZONE dla każdego użytkownika
|
||||||
|
|
||||||
|
Plugin wymusza dwuskładnikowe uwierzytelnianie (TOTP) dla WSZYSTKICH użytkowników
|
||||||
|
panelu — bez możliwości wyłączenia per użytkownik. Każdy projekt ma to z automatu.
|
||||||
|
Używa sprawdzonego `@clocklimited/payload-2fa` (wrapuje access control — TOTP
|
||||||
|
sprawdzane przed dostępem do DANYCH, nie tylko UI panelu).
|
||||||
|
|
||||||
|
### Zależność
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @clocklimited/[email protected]
|
||||||
|
```
|
||||||
|
Uwaga: pakiet nie ma jeszcze stabilnego 3.0.0 — użyj konkretnej wersji beta
|
||||||
|
(albo `^3.0.0-0`, żeby dopuścić prereleasy). Sam `^3.0.0` da błąd
|
||||||
|
ERR_PNPM_NO_MATCHING_VERSION.
|
||||||
|
To PEER dependency — ipal-kit importuje ją dynamicznie tylko gdy 2FA włączone
|
||||||
|
(domyślnie). Bez niej i z włączonym 2FA plugin rzuci jasny błąd.
|
||||||
|
|
||||||
|
### Konfiguracja (payload.config.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ipalKit({
|
||||||
|
i18n: i18nConfig,
|
||||||
|
twoFactor: {
|
||||||
|
issuer: 'Nazwa Firmy', // pokazywane w aplikacji authenticator (Google Auth itp.)
|
||||||
|
// collectionSlug: 'users', // domyślnie 'users'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin ustawia `forceSetup: true` — każdy użytkownik MUSI skonfigurować TOTP po
|
||||||
|
zalogowaniu (przekierowanie na setup). Nie ma opcji „włącz/wyłącz" dla użytkownika.
|
||||||
|
|
||||||
|
### Wyłączenie (ODRADZANE)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
twoFactor: false // TYLKO gdy projekt naprawdę nie może użyć 2FA (rzadkie)
|
||||||
|
```
|
||||||
|
Domyślnie 2FA jest WYMUSZONE. `false` to świadoma rezygnacja — unikaj.
|
||||||
|
|
||||||
|
### Jak działa dla użytkownika
|
||||||
|
|
||||||
|
1. Loguje się (email + hasło)
|
||||||
|
2. Przy pierwszym logowaniu: przekierowanie na Setup TOTP (QR + sekret)
|
||||||
|
3. Skanuje QR aplikacją (Google Authenticator, Authy, 1Password, Microsoft Auth)
|
||||||
|
4. Wpisuje kod → 2FA aktywne
|
||||||
|
5. Kolejne logowania: email + hasło + kod TOTP
|
||||||
|
|
||||||
|
### Reset 2FA (admin)
|
||||||
|
|
||||||
|
Admin może zresetować 2FA innego użytkownika (gdy zgubi telefon) — przez
|
||||||
|
`adminManageAccess` w konfiguracji @clocklimited. Patrz jego dokumentacja.
|
||||||
|
|
||||||
|
### Dlaczego @clocklimited, nie inne
|
||||||
|
|
||||||
|
Wybrany, bo wrapuje ACCESS CONTROL (TOTP przed dostępem do danych) + forceSetup
|
||||||
|
(wymuszenie dla wszystkich). Inne pluginy 2FA dla Payload gatują tylko nawigację
|
||||||
|
/admin — user z hasłem może omijać przez REST/GraphQL/Bearer. @clocklimited chroni
|
||||||
|
dostęp do danych, nie tylko UI.
|
||||||
+121
@@ -843,6 +843,31 @@ const jsonLd = buildArticleJsonLd({
|
|||||||
|
|
||||||
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
|
||||||
|
|
||||||
|
## Robots — rich results (max-snippet, image/video preview)
|
||||||
|
|
||||||
|
Strony indeksowane dostają automatycznie dyrektywy pozwalające Google na bogate
|
||||||
|
wyniki (buildMetadata generuje):
|
||||||
|
- `max-snippet: -1` — pełnej długości snippet (bez limitu)
|
||||||
|
- `max-image-preview: large` — duży podgląd obrazu w wynikach
|
||||||
|
- `max-video-preview: -1` — pełny podgląd wideo
|
||||||
|
|
||||||
|
Strony noindex (polityki) dostają `index: false, follow: true` — bez powyższych.
|
||||||
|
Zero konfiguracji — działa automatycznie dla każdej strony przez createPageMetadata.
|
||||||
|
|
||||||
|
## Open Graph — pełne pola (type, siteName, url, description)
|
||||||
|
|
||||||
|
buildMetadata generuje kompletny OG dla social share (Facebook, LinkedIn, Slack):
|
||||||
|
- `og:title` — tytuł strony
|
||||||
|
- `og:description` — meta.description → fallback siteDescription (dziedziczy)
|
||||||
|
- `og:image` — defaultShareImage / obraz strony
|
||||||
|
- `og:type` — 'website'
|
||||||
|
- `og:site_name` — siteName z panelu
|
||||||
|
- `og:url` — canonical strony
|
||||||
|
- `og:locale` — język strony
|
||||||
|
|
||||||
|
Wszystko z panelu, automatycznie. Opis OG dziedziczy z meta.description (albo
|
||||||
|
globalnego siteDescription gdy pusty) — patrz niżej.
|
||||||
|
|
||||||
## Fallback meta description (siteDescription)
|
## Fallback meta description (siteDescription)
|
||||||
|
|
||||||
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
|
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
|
||||||
@@ -885,3 +910,99 @@ Generative Engine Optimization) i audytach „Agentic Browsing”.
|
|||||||
|
|
||||||
Wymaga wypełnionego siteDescription i sensownych meta.description stron
|
Wymaga wypełnionego siteDescription i sensownych meta.description stron
|
||||||
(inaczej llms.txt będzie ubogi).
|
(inaczej llms.txt będzie ubogi).
|
||||||
|
|
||||||
|
## Sitemap czytelny w przeglądarce (czysty XML)
|
||||||
|
|
||||||
|
Domyślny `/sitemap.xml` (Next MetadataRoute) działa dla Google, ale w przeglądarce
|
||||||
|
bywa nieczytelny. Możesz serwować go jako **czysty, sformatowany XML** przez
|
||||||
|
`buildSitemapXml` — przeglądarka pokaże wbudowane drzewo XML (wcięcia, zwijanie,
|
||||||
|
kolorowanie składni), bez żadnej transformacji.
|
||||||
|
|
||||||
|
> **NIE używaj XSLT.** Przeglądarki (Chrome i in.) WYCOFUJĄ XSLT — arkusz
|
||||||
|
> `<?xml-stylesheet?>` pokazuje ostrzeżenie i wkrótce przestanie działać.
|
||||||
|
> Rozwiązanie: serwuj czysty XML z poprawnym Content-Type; przeglądarka
|
||||||
|
> renderuje swój natywny widok drzewa XML sama.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/sitemap.xml/route.ts (zamiast app/sitemap.ts)
|
||||||
|
import { buildSitemapXml } from '@intecion/ipal-kit'
|
||||||
|
import { sitemap } from '@/lib/content'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const entries = await sitemap()
|
||||||
|
const xml = buildSitemapXml(entries)
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`buildSitemapXml` zwraca wcięty XML. Dwa tryby wyświetlania:
|
||||||
|
|
||||||
|
**Bez `cssUrl`** → przeglądarka pokazuje natywny widok drzewa XML (wcięcia, zwijanie).
|
||||||
|
|
||||||
|
**Z `cssUrl`** → stylujesz XML własnym CSS (kafelki, etykiety). To W3C standard
|
||||||
|
„Associating Style Sheets with XML" — `type="text/css"`, NIE wycofywane (w
|
||||||
|
przeciwieństwie do XSLT/`text/xsl`). Zero ostrzeżenia, ładny wygląd, bezpieczne
|
||||||
|
dla Google (crawlery ignorują dyrektywę).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Starter CSS (public/sitemap.css)
|
||||||
|
|
||||||
|
Plugin dostarcza gotowy plik — skopiuj do projektu:
|
||||||
|
```bash
|
||||||
|
cp node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.css public/sitemap.css
|
||||||
|
```
|
||||||
|
Albo skopiuj poniższy starter i dostosuj do designu. Selektory to
|
||||||
|
bezpośrednio nazwy tagów XML:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* public/sitemap.css */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
url { /* każdy adres jako kafelek */
|
||||||
|
display: block;
|
||||||
|
background: #111827; border: 1px solid #1e293b; border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem; margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
loc { /* adres URL */
|
||||||
|
display: block; font-size: 0.95rem; font-weight: 600;
|
||||||
|
color: #f97316; margin-bottom: 0.5rem; word-break: break-all;
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
link { /* tagi hreflang */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kluczowe: `display: block` na `<url>`/`<loc>` zamienia „ścianę tekstu" w kafelki.
|
||||||
|
Etykiety („Ostatnia modyfikacja:") przez `::before`. Dostosuj kolory do projektu.
|
||||||
|
|
||||||
|
**Ograniczenie:** `<loc>` to tag XML, nie `<a href>` — CSS nie zrobi z niego
|
||||||
|
klikalnego linku (niektóre przeglądarki autodetektują URL). Zysk to czytelność
|
||||||
|
i organizacja, nie klikalność. Dla sitemap (głównie dla robotów) to akceptowalne.
|
||||||
|
|
||||||
|
### WAŻNE — jeden sitemap, nie dwa
|
||||||
|
|
||||||
|
Jeśli używasz `app/sitemap.xml/route.ts`, USUŃ `app/sitemap.ts` (MetadataRoute).
|
||||||
|
Dwa sitemapy pod różnymi ścieżkami mylą crawlery. Wybierz jeden:
|
||||||
|
- **route.ts + buildSitemapXml** — czytelny XML w przeglądarce
|
||||||
|
- **sitemap.ts** (reeksport z lib/content) — mniej kodu, mniej czytelny w przeglądarce
|
||||||
|
|
||||||
|
Oba tak samo dobre dla Google — to kwestia czytelności dla człowieka, nie SEO.
|
||||||
@@ -59,3 +59,72 @@ trafi do bundla przeglądarki.
|
|||||||
|
|
||||||
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
|
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
|
||||||
> `submitForm` (patrz [forms.md](./forms.md)).
|
> `submitForm` (patrz [forms.md](./forms.md)).
|
||||||
|
|
||||||
|
## Uproszczone wpięcie — TurnstileProvider + useTurnstile (zalecane)
|
||||||
|
|
||||||
|
Jak CookieBanner: siteKey raz w layoutcie, formularze biorą z kontekstu. Koniec
|
||||||
|
przekazywania siteKey do każdego formularza.
|
||||||
|
|
||||||
|
### 1. Provider w layoutcie (raz, siteKey z serwera)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/[locale]/layout.tsx (server)
|
||||||
|
import { TurnstileProvider } from '@intecion/ipal-kit/client'
|
||||||
|
import { getTurnstileSiteKey } from '@/lib/payload' // Twój helper server-side
|
||||||
|
|
||||||
|
export default async function Layout({ children }) {
|
||||||
|
const siteKey = await getTurnstileSiteKey() // z panelu (SiteIntegrations)
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<TurnstileProvider siteKey={siteKey}>
|
||||||
|
{children}
|
||||||
|
</TurnstileProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Formularz — useTurnstile (zero plumbingu siteKey)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client'
|
||||||
|
import { useTurnstile } from '@intecion/ipal-kit/client'
|
||||||
|
|
||||||
|
function ContactForm() {
|
||||||
|
const { token, TurnstileWidget, reset, enabled } = useTurnstile()
|
||||||
|
|
||||||
|
async function handleSubmit(data) {
|
||||||
|
const result = await submitForm({ ...data, turnstileToken: token })
|
||||||
|
if (result.ok) reset() // wyczyść token na następne wysłanie
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={...}>
|
||||||
|
{/* pola formularza */}
|
||||||
|
<TurnstileWidget /> {/* widget tam, gdzie ma być */}
|
||||||
|
<button type="submit">Wyślij</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`token` → do submitForm. `TurnstileWidget` → wstaw gdzie ma być. `reset()` → po
|
||||||
|
wysłaniu. `enabled` → false gdy brak klucza (dev bez Turnstile).
|
||||||
|
|
||||||
|
### Dlaczego Turnstile NIE jest w pełni "wstaw i zapomnij" jak CookieBanner
|
||||||
|
|
||||||
|
CookieBanner jest samodzielny (renderuje się, zarządza zgodą, zero interakcji).
|
||||||
|
Turnstile z natury jest CZĘŚCIĄ formularza — zwraca token, który formularz musi
|
||||||
|
wysłać przy submit i zweryfikować server-side. Nie da się go „wstawić
|
||||||
|
gdziekolwiek" — musi być w formularzu, przy jego logice wysyłki.
|
||||||
|
|
||||||
|
Provider+hook to maksimum uproszczenia: siteKey raz (jak CookieBanner), a w
|
||||||
|
formularzu tylko `<TurnstileWidget/>` + `token`. Reszta (weryfikacja) dzieje się
|
||||||
|
w submitForm automatycznie.
|
||||||
|
|
||||||
|
### Stary sposób (nadal działa)
|
||||||
|
|
||||||
|
`<Turnstile siteKey={...} onToken={...} />` bezpośrednio — jeśli potrzebujesz
|
||||||
|
pełnej kontroli albo masz nietypowy przypadek. Provider to warstwa wygody nad tym.
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.2.8",
|
"version": "1.5.6",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@clocklimited/payload-2fa": "^3.0.0",
|
||||||
"@payloadcms/next": "^3.88.0",
|
"@payloadcms/next": "^3.88.0",
|
||||||
"@payloadcms/plugin-form-builder": "^3.88.0",
|
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||||
"@payloadcms/plugin-seo": "^3.88.0",
|
"@payloadcms/plugin-seo": "^3.88.0",
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,13 @@
|
|||||||
'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 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
|
||||||
@@ -17,8 +24,15 @@ export {
|
|||||||
useConsentContext,
|
useConsentContext,
|
||||||
} from '../modules/consent/client.js'
|
} from '../modules/consent/client.js'
|
||||||
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
export type { CookieBannerClassNames } 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 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'
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GlobalConfig } from 'payload'
|
import type { GlobalConfig } from 'payload'
|
||||||
|
|
||||||
import { notificationsFields } from './fields.js'
|
import { notificationsFields } from './fields.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,10 +10,10 @@ import { notificationsFields } from './fields.js'
|
|||||||
export function buildNotifications(): GlobalConfig {
|
export function buildNotifications(): GlobalConfig {
|
||||||
return {
|
return {
|
||||||
slug: 'notifications',
|
slug: 'notifications',
|
||||||
label: 'Notifications',
|
|
||||||
access: {
|
access: {
|
||||||
read: () => true, // texts are public-facing (shown to end users)
|
read: () => true, // texts are public-facing (shown to end users)
|
||||||
},
|
},
|
||||||
fields: notificationsFields,
|
fields: notificationsFields,
|
||||||
|
label: 'Notifications',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-25
@@ -13,6 +13,14 @@ export {
|
|||||||
requireRoleField,
|
requireRoleField,
|
||||||
ROLE_HIERARCHY,
|
ROLE_HIERARCHY,
|
||||||
} from './modules/access/index.js'
|
} 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 {
|
export {
|
||||||
@@ -60,6 +68,13 @@ export type {
|
|||||||
FormsOption,
|
FormsOption,
|
||||||
} from './modules/forms/types.js'
|
} 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 {
|
export {
|
||||||
buildLocalizedPath,
|
buildLocalizedPath,
|
||||||
@@ -75,18 +90,13 @@ export {
|
|||||||
} from './modules/i18n/index.js'
|
} 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'
|
||||||
// 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 {
|
export {
|
||||||
getNotificationTexts,
|
getNotificationTexts,
|
||||||
NOTIFICATION_FALLBACK,
|
NOTIFICATION_FALLBACK,
|
||||||
resolveFormMessage,
|
resolveFormMessage,
|
||||||
} from './modules/notifications/index.js'
|
} from './modules/notifications/index.js'
|
||||||
export type {
|
export type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js'
|
||||||
FormNotificationTexts,
|
|
||||||
NotificationsData,
|
|
||||||
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'
|
||||||
@@ -98,24 +108,10 @@ export {
|
|||||||
SITE_SETTINGS_SLUG,
|
SITE_SETTINGS_SLUG,
|
||||||
} from './modules/payload/index.js'
|
} 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 {
|
|
||||||
buildArticleJsonLd,
|
|
||||||
buildFaqJsonLd,
|
|
||||||
buildIconsMetadata,
|
|
||||||
buildLlmsTxt,
|
|
||||||
buildLocalBusinessJsonLd,
|
|
||||||
buildOrganizationJsonLd,
|
|
||||||
buildServiceJsonLd,
|
|
||||||
validateFaviconField,
|
|
||||||
} from './modules/seo/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 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'
|
||||||
@@ -127,9 +123,26 @@ export {
|
|||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
injectAutoFillMeta,
|
injectAutoFillMeta,
|
||||||
} from './modules/seo/index.js'
|
} 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'
|
||||||
export type { IpalOptions } from './types.js'
|
export type { IpalOptions } from './types.js'
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, use, useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
A11Y_COOKIE,
|
||||||
|
A11Y_COOKIE_MAX_AGE,
|
||||||
|
A11Y_DEFAULT,
|
||||||
|
a11yAttributes,
|
||||||
|
type A11yState,
|
||||||
|
parseA11y,
|
||||||
|
serializeA11y,
|
||||||
|
} from './state.js'
|
||||||
|
|
||||||
|
type A11yContextValue = {
|
||||||
|
reset: () => void
|
||||||
|
set: <K extends keyof A11yState>(key: K, value: A11yState[K]) => void
|
||||||
|
state: A11yState
|
||||||
|
}
|
||||||
|
|
||||||
|
const A11yContext = createContext<A11yContextValue | null>(null)
|
||||||
|
|
||||||
|
function readCookie(name: string): string | undefined {
|
||||||
|
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 }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<A11yState>(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: A11yState) => {
|
||||||
|
document.cookie = `${A11Y_COOKIE}=${encodeURIComponent(
|
||||||
|
serializeA11y(next),
|
||||||
|
)}; path=/; max-age=${A11Y_COOKIE_MAX_AGE}; samesite=lax`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const set = useCallback(
|
||||||
|
<K extends keyof A11yState>(key: K, value: A11yState[K]) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = { ...prev, [key]: value }
|
||||||
|
persist(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState(A11Y_DEFAULT)
|
||||||
|
persist(A11Y_DEFAULT)
|
||||||
|
}, [persist])
|
||||||
|
|
||||||
|
return <A11yContext value={{ reset, set, state }}>{children}</A11yContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access accessibility preferences + setters. Use inside AccessibilityProvider. */
|
||||||
|
export function useAccessibility(): A11yContextValue {
|
||||||
|
const ctx = use(A11yContext)
|
||||||
|
if (!ctx) {throw new Error('useAccessibility must be used within <AccessibilityProvider>')}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { A11yState } from './state.js'
|
||||||
|
|
||||||
|
import { useAccessibility } from './AccessibilityProvider.js'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEXTS: Required<A11yTexts> = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function AccessibilityWidget({
|
||||||
|
classNames,
|
||||||
|
texts,
|
||||||
|
}: {
|
||||||
|
classNames?: A11yClassNames
|
||||||
|
texts?: A11yTexts
|
||||||
|
}) {
|
||||||
|
const { reset, set, state } = useAccessibility()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const t = { ...DEFAULT_TEXTS, ...texts }
|
||||||
|
const cn = classNames ?? {}
|
||||||
|
|
||||||
|
const toggle = <K extends keyof A11yState>(key: K) => set(key, !state[key] as A11yState[K])
|
||||||
|
|
||||||
|
const isActive = (on: boolean) => (on ? (cn.active ?? '') : '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t.open}
|
||||||
|
className={cn.button}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{/* Project can style/replace via CSS; simple glyph fallback. */}
|
||||||
|
<span aria-hidden="true">♿</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div aria-label={t.title} className={cn.panel} role="dialog">
|
||||||
|
<div className={cn.row}>
|
||||||
|
<span className={cn.label}>{t.textSize}</span>
|
||||||
|
<div className={cn.control}>
|
||||||
|
{[0, 1, 2, 3].map((n) => (
|
||||||
|
<button
|
||||||
|
className={isActive(state.textSize === n)}
|
||||||
|
key={n}
|
||||||
|
onClick={() => set('textSize', n as A11yState['textSize'])}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
A{n > 0 ? '+'.repeat(n) : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn.row}>
|
||||||
|
<span className={cn.label}>{t.lineHeight}</span>
|
||||||
|
<div className={cn.control}>
|
||||||
|
{[0, 1, 2].map((n) => (
|
||||||
|
<button
|
||||||
|
className={isActive(state.lineHeight === n)}
|
||||||
|
key={n}
|
||||||
|
onClick={() => set('lineHeight', n as A11yState['lineHeight'])}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{n === 0 ? '—' : '≡'.repeat(n)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn.row}>
|
||||||
|
<span className={cn.label}>{t.contrast}</span>
|
||||||
|
<div className={cn.control}>
|
||||||
|
<button
|
||||||
|
className={isActive(state.contrast === 'high')}
|
||||||
|
onClick={() => set('contrast', state.contrast === 'high' ? 'default' : 'high')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.contrastHigh}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={isActive(state.contrast === 'inverted')}
|
||||||
|
onClick={() =>
|
||||||
|
set('contrast', state.contrast === 'inverted' ? 'default' : 'inverted')
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.contrastInverted}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['grayscale', t.grayscale],
|
||||||
|
['underlineLinks', t.underlineLinks],
|
||||||
|
['readableFont', t.readableFont],
|
||||||
|
['reduceMotion', t.reduceMotion],
|
||||||
|
['bigCursor', t.bigCursor],
|
||||||
|
] as Array<[keyof A11yState, string]>
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<div className={cn.row} key={key}>
|
||||||
|
<span className={cn.label}>{label}</span>
|
||||||
|
<button
|
||||||
|
aria-pressed={Boolean(state[key])}
|
||||||
|
className={`${cn.control ?? ''} ${isActive(Boolean(state[key]))}`}
|
||||||
|
onClick={() => toggle(key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{state[key] ? 'ON' : 'OFF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button className={cn.resetButton} onClick={reset} type="button">
|
||||||
|
{t.reset}
|
||||||
|
</button>
|
||||||
|
<button className={cn.closeButton} onClick={() => setOpen(false)} type="button">
|
||||||
|
{t.close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
'use client'
|
||||||
|
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'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 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 const A11Y_DEFAULT: A11yState = {
|
||||||
|
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: A11yState): string {
|
||||||
|
return JSON.stringify(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse from the cookie; falls back to defaults on any bad value. */
|
||||||
|
export function parseA11y(raw: null | string | undefined): A11yState {
|
||||||
|
if (!raw) {return A11Y_DEFAULT}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<A11yState>
|
||||||
|
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: A11yState): Record<string, null | string> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,7 +181,10 @@ export function createContentHelpers({
|
|||||||
* Handles automatically:
|
* Handles automatically:
|
||||||
* - pages collection + content collections (with their archive prefix)
|
* - pages collection + content collections (with their archive prefix)
|
||||||
* - excludes the homepage (maps to { slug: [] } — the root)
|
* - excludes the homepage (maps to { slug: [] } — the root)
|
||||||
* - excludes drafts, 404/500/system slugs, and meta.noindex docs
|
* - 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 }[]
|
* - single-locale → { slug }[]; multi-locale → { locale, slug }[]
|
||||||
*
|
*
|
||||||
* Wire it in the project:
|
* Wire it in the project:
|
||||||
@@ -191,55 +194,91 @@ export function createContentHelpers({
|
|||||||
const generateStaticParams = async (): Promise<
|
const generateStaticParams = async (): Promise<
|
||||||
Array<{ locale: string; slug: string[] } | { slug: string[] }>
|
Array<{ locale: string; slug: string[] } | { slug: string[] }>
|
||||||
> => {
|
> => {
|
||||||
const payload = await getCachedPayload()
|
try {
|
||||||
const locales = i18n ? i18n.locales.map((l) => l.code) : [undefined]
|
const payload = await getCachedPayload()
|
||||||
const singleLocale = !i18n || i18n.locales.length === 1
|
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 []).
|
// Home slug per locale, to exclude the homepage (it's the root, slug []).
|
||||||
const settings = (await payload
|
const settings = (await payload
|
||||||
.findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })
|
.findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })
|
||||||
.catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null
|
.catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null
|
||||||
const homeId = settings?.homepage?.id
|
const homeId = settings?.homepage?.id
|
||||||
|
|
||||||
const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])
|
const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])
|
||||||
const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []
|
const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []
|
||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
const result = await payload.find({
|
// NO where:{_status} filter — collections without drafts enabled don't
|
||||||
collection: pagesSlug as never,
|
// register the _status field, and querying it throws
|
||||||
depth: 0,
|
// "path cannot be queried: _status". We filter drafts in memory below,
|
||||||
limit: 1000,
|
// which is safe for every collection (with or without drafts).
|
||||||
locale: (locale ?? 'all') as never,
|
const result = await payload.find({
|
||||||
where: { _status: { not_equals: 'draft' } } as never,
|
collection: pagesSlug as never,
|
||||||
})
|
depth: 0,
|
||||||
|
limit: 1000,
|
||||||
|
locale: (locale ?? 'all') as never,
|
||||||
|
})
|
||||||
|
|
||||||
for (const raw of result.docs as Array<{
|
for (const raw of result.docs as Array<{
|
||||||
_status?: string
|
_status?: string
|
||||||
id: number | string
|
id: number | string
|
||||||
meta?: { noindex?: boolean } | null
|
meta?: { noindex?: boolean } | null
|
||||||
slug?: unknown
|
slug?: unknown
|
||||||
}>) {
|
}>) {
|
||||||
if (raw._status && raw._status !== 'published') {continue}
|
// Draft filter in memory (safe whether or not the collection has drafts).
|
||||||
if (raw.meta?.noindex) {continue}
|
if (raw._status && raw._status !== 'published') {continue}
|
||||||
if (homeId && raw.id === homeId) {
|
|
||||||
// Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.
|
// NOTE: unlike the sitemap, we do NOT skip meta.noindex here. A noindex
|
||||||
const empty = singleLocale ? { slug: [] } : { slug: [], locale: locale as string }
|
// page (privacy, cookies, terms) still needs to render — users reach it
|
||||||
if (!params.some((p) => JSON.stringify(p) === JSON.stringify(empty))) {params.push(empty)}
|
// from the footer and crawlers read its <meta robots=noindex>. Pre-render
|
||||||
continue
|
// 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 as string }
|
||||||
|
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 as Record<string, unknown>)[locale ?? ''] as string | undefined) ??
|
||||||
|
(Object.values(rawSlug as Record<string, unknown>)[0] as string | undefined))
|
||||||
|
: 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 as string },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
|
|
||||||
if (!slug || EXCLUDED.has(slug)) {continue}
|
|
||||||
|
|
||||||
// Multi-level slugs ('atrakcje/telefon') → array segments.
|
|
||||||
const segments = slug.split('/').filter(Boolean)
|
|
||||||
params.push(
|
|
||||||
singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
return 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 {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||||
|
|
||||||
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
|
||||||
|
import { buildLocalizedPath, getLocaleCodes } 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocWithSlug = { slug?: unknown }
|
||||||
|
|
||||||
|
/** Resolve a doc's path in one locale (root for home). Null if no slug there. */
|
||||||
|
function pathForLocale(
|
||||||
|
doc: DocWithSlug,
|
||||||
|
locale: string,
|
||||||
|
config: I18nConfig,
|
||||||
|
homeSlug?: Record<string, string> | string,
|
||||||
|
): null | string {
|
||||||
|
const slugField = doc.slug
|
||||||
|
const slug =
|
||||||
|
typeof slugField === 'string'
|
||||||
|
? slugField
|
||||||
|
: slugField && typeof slugField === 'object'
|
||||||
|
? ((slugField as Record<string, unknown>)[locale] as string | undefined)
|
||||||
|
: 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,
|
||||||
|
}: BuildRevalidateHookArgs): {
|
||||||
|
afterChange: CollectionAfterChangeHook
|
||||||
|
afterDelete: CollectionAfterDeleteHook
|
||||||
|
} {
|
||||||
|
const locales = getLocaleCodes(config)
|
||||||
|
|
||||||
|
const afterChange: CollectionAfterChangeHook = ({ doc, previousDoc }) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const locale of locales) {
|
||||||
|
// New path.
|
||||||
|
const newPath = pathForLocale(doc as DocWithSlug, 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 as DocWithSlug, locale, config, homeSlug)
|
||||||
|
if (oldPath && oldPath !== newPath && !seen.has(oldPath)) {
|
||||||
|
revalidatePath(oldPath)
|
||||||
|
seen.add(oldPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterDelete: CollectionAfterDeleteHook = ({ doc }) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const locale of locales) {
|
||||||
|
const path = pathForLocale(doc as DocWithSlug, locale, config, homeSlug)
|
||||||
|
if (path && !seen.has(path)) {
|
||||||
|
revalidatePath(path)
|
||||||
|
seen.add(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
return { afterChange, afterDelete }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 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'
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CollectionBeforeDeleteHook } from 'payload'
|
||||||
|
|
||||||
|
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: { roleFields?: string[]; settingsSlug?: string } = {},
|
||||||
|
): CollectionBeforeDeleteHook {
|
||||||
|
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 as never, depth: 0 })
|
||||||
|
.catch(() => null)) as null | Record<string, unknown>
|
||||||
|
if (!settings) {return}
|
||||||
|
|
||||||
|
for (const field of roleFields) {
|
||||||
|
const assigned = settings[field]
|
||||||
|
const assignedId =
|
||||||
|
assigned && typeof assigned === 'object' ? (assigned as { id?: unknown }).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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 const setPublishedAtHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
|
||||||
|
const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published'
|
||||||
|
if (becomingPublished && !data.publishedAt) {
|
||||||
|
data.publishedAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user