diff --git a/dist/exports/client.d.js b/dist/exports/client.d.js new file mode 100644 index 0000000..5ee75d0 --- /dev/null +++ b/dist/exports/client.d.js @@ -0,0 +1,11 @@ +export { Analytics } from '../modules/analytics/client.js'; +/** + * Entry point: ipal-kit/client + * + * Client-side ('use client') exports — React hooks, providers, and UI + * components. Kept separate from the main entry so server bundles don't pull in + * client-only code. + */ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js'; +export { Turnstile } from '../modules/turnstile/client.js'; + +//# sourceMappingURL=client.d.js.map \ No newline at end of file diff --git a/dist/exports/client.d.js.map b/dist/exports/client.d.js.map new file mode 100644 index 0000000..ebf6360 --- /dev/null +++ b/dist/exports/client.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["client.d.ts"],"sourcesContent":["export { 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 { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext, } 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"],"names":["Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA,SAASA,SAAS,QAAQ,iCAAiC;AAC3D;;;;;;CAMC,GACD,SAASC,eAAe,EAAEC,YAAY,EAAEC,YAAY,EAAEC,UAAU,EAAEC,iBAAiB,QAAS,+BAA+B;AAE3H,SAASC,SAAS,QAAQ,iCAAiC"} \ No newline at end of file diff --git a/dist/exports/client.d.ts b/dist/exports/client.d.ts new file mode 100644 index 0000000..c33e6eb --- /dev/null +++ b/dist/exports/client.d.ts @@ -0,0 +1,12 @@ +export { Analytics } from '../modules/analytics/client.js'; +/** + * Entry point: ipal-kit/client + * + * Client-side ('use client') exports — React hooks, providers, and UI + * components. Kept separate from the main entry so server bundles don't pull in + * client-only code. + */ +export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext, } from '../modules/consent/client.js'; +export type { CookieBannerClassNames } from '../modules/consent/client.js'; +export { Turnstile } from '../modules/turnstile/client.js'; +export type { TurnstileProps } from '../modules/turnstile/client.js'; diff --git a/dist/exports/client.js b/dist/exports/client.js new file mode 100644 index 0000000..df1c2ee --- /dev/null +++ b/dist/exports/client.js @@ -0,0 +1,12 @@ +'use client'; +export { Analytics } from '../modules/analytics/client.js'; +/** + * Entry point: ipal-kit/client + * + * Client-side ('use client') exports — React hooks, providers, and UI + * components. Kept separate from the main entry so server bundles don't pull in + * client-only code. + */ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js'; +export { Turnstile } from '../modules/turnstile/client.js'; + +//# sourceMappingURL=client.js.map \ No newline at end of file diff --git a/dist/exports/client.js.map b/dist/exports/client.js.map new file mode 100644 index 0000000..66254f1 --- /dev/null +++ b/dist/exports/client.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\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"],"names":["Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"} \ No newline at end of file diff --git a/dist/exports/next-middleware.d.js b/dist/exports/next-middleware.d.js new file mode 100644 index 0000000..09d9c77 --- /dev/null +++ b/dist/exports/next-middleware.d.js @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/next/middleware + * + * Re-exports the locale middleware factory and default matcher for use in a + * client project's middleware.ts. Kept as a thin re-export so the Next-facing + * surface is separate from the main package export. + */ export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'; + +//# sourceMappingURL=next-middleware.d.js.map \ No newline at end of file diff --git a/dist/exports/next-middleware.d.js.map b/dist/exports/next-middleware.d.js.map new file mode 100644 index 0000000..201ef1c --- /dev/null +++ b/dist/exports/next-middleware.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["next-middleware.d.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/next/middleware\n *\n * Re-exports the locale middleware factory and default matcher for use in a\n * client project's middleware.ts. Kept as a thin re-export so the Next-facing\n * surface is separate from the main package export.\n */\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js';\nexport type { LocaleMiddlewareResult } from '../modules/i18n/index.js';\n"],"names":["createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,sBAAsB,EAAEC,0BAA0B,QAAQ,2BAA2B"} \ No newline at end of file diff --git a/dist/exports/next-middleware.d.ts b/dist/exports/next-middleware.d.ts new file mode 100644 index 0000000..4e5702f --- /dev/null +++ b/dist/exports/next-middleware.d.ts @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/next/middleware + * + * Re-exports the locale middleware factory and default matcher for use in a + * client project's middleware.ts. Kept as a thin re-export so the Next-facing + * surface is separate from the main package export. + */ +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'; +export type { LocaleMiddlewareResult } from '../modules/i18n/index.js'; diff --git a/dist/exports/next-middleware.js b/dist/exports/next-middleware.js new file mode 100644 index 0000000..3ebd34f --- /dev/null +++ b/dist/exports/next-middleware.js @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/next/middleware + * + * Re-exports the locale middleware factory and default matcher for use in a + * client project's middleware.ts. Kept as a thin re-export so the Next-facing + * surface is separate from the main package export. + */ export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'; + +//# sourceMappingURL=next-middleware.js.map \ No newline at end of file diff --git a/dist/exports/next-middleware.js.map b/dist/exports/next-middleware.js.map new file mode 100644 index 0000000..ad94411 --- /dev/null +++ b/dist/exports/next-middleware.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/exports/next-middleware.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/next/middleware\n *\n * Re-exports the locale middleware factory and default matcher for use in a\n * client project's middleware.ts. Kept as a thin re-export so the Next-facing\n * surface is separate from the main package export.\n */\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from '../modules/i18n/index.js'\n"],"names":["createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,sBAAsB,EAAEC,0BAA0B,QAAQ,2BAA0B"} \ No newline at end of file diff --git a/dist/exports/rsc.d.js b/dist/exports/rsc.d.js new file mode 100644 index 0000000..941f5c2 --- /dev/null +++ b/dist/exports/rsc.d.js @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/rsc + * + * Server-component exports. RenderBlocks is a React Server Component, so it + * lives here rather than in the main package entry to keep React out of the + * server-config bundle. + */ export { RenderBlocks } from '../modules/blocks/index.js'; + +//# sourceMappingURL=rsc.d.js.map \ No newline at end of file diff --git a/dist/exports/rsc.d.js.map b/dist/exports/rsc.d.js.map new file mode 100644 index 0000000..599e87f --- /dev/null +++ b/dist/exports/rsc.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["rsc.d.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js';\nexport type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA6B"} \ No newline at end of file diff --git a/dist/exports/rsc.d.ts b/dist/exports/rsc.d.ts new file mode 100644 index 0000000..6aa81d0 --- /dev/null +++ b/dist/exports/rsc.d.ts @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/rsc + * + * Server-component exports. RenderBlocks is a React Server Component, so it + * lives here rather than in the main package entry to keep React out of the + * server-config bundle. + */ +export { RenderBlocks } from '../modules/blocks/index.js'; +export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js'; diff --git a/dist/exports/rsc.js b/dist/exports/rsc.js new file mode 100644 index 0000000..fbe1661 --- /dev/null +++ b/dist/exports/rsc.js @@ -0,0 +1,9 @@ +/** + * Entry point: ipal-kit/rsc + * + * Server-component exports. RenderBlocks is a React Server Component, so it + * lives here rather than in the main package entry to keep React out of the + * server-config bundle. + */ export { RenderBlocks } from '../modules/blocks/index.js'; + +//# sourceMappingURL=rsc.js.map \ No newline at end of file diff --git a/dist/exports/rsc.js.map b/dist/exports/rsc.js.map new file mode 100644 index 0000000..6f4f24d --- /dev/null +++ b/dist/exports/rsc.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B"} \ No newline at end of file diff --git a/dist/exports/server.d.js b/dist/exports/server.d.js new file mode 100644 index 0000000..9abe2fa --- /dev/null +++ b/dist/exports/server.d.js @@ -0,0 +1,16 @@ +/** + * Entry point: ipal-kit/server + * + * Server-only runtime functions — these import 'server-only' (Turnstile secret, + * SMTP password, nodemailer). Kept OUT of the main entry so that loading the + * Payload config or running `payload generate:importmap` (both Node scripts, + * no bundler) never pulls in 'server-only', which throws outside a bundled + * server context. + * + * Import these only from Server Actions, route handlers, or server components — + * never from the Payload config or client code. + */ export { verifyTurnstile } from '../modules/turnstile/index.js'; +export { sendEmail } from '../modules/email/index.js'; +export { submitForm } from '../modules/forms/index.js'; + +//# sourceMappingURL=server.d.js.map \ No newline at end of file diff --git a/dist/exports/server.d.js.map b/dist/exports/server.d.js.map new file mode 100644 index 0000000..9f7548a --- /dev/null +++ b/dist/exports/server.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["server.d.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/server\n *\n * Server-only runtime functions — these import 'server-only' (Turnstile secret,\n * SMTP password, nodemailer). Kept OUT of the main entry so that loading the\n * Payload config or running `payload generate:importmap` (both Node scripts,\n * no bundler) never pulls in 'server-only', which throws outside a bundled\n * server context.\n *\n * Import these only from Server Actions, route handlers, or server components —\n * never from the Payload config or client code.\n */\nexport { verifyTurnstile } from '../modules/turnstile/index.js';\nexport { sendEmail } from '../modules/email/index.js';\nexport type { SendEmailArgs, SendEmailResult } from '../modules/email/index.js';\nexport { submitForm } from '../modules/forms/index.js';\nexport type { SubmitFormArgs, SubmitFormResult } from '../modules/forms/index.js';\n"],"names":["verifyTurnstile","sendEmail","submitForm"],"mappings":"AAAA;;;;;;;;;;;CAWC,GACD,SAASA,eAAe,QAAQ,gCAAgC;AAChE,SAASC,SAAS,QAAQ,4BAA4B;AAEtD,SAASC,UAAU,QAAQ,4BAA4B"} \ No newline at end of file diff --git a/dist/exports/server.d.ts b/dist/exports/server.d.ts new file mode 100644 index 0000000..2d85acf --- /dev/null +++ b/dist/exports/server.d.ts @@ -0,0 +1,17 @@ +/** + * Entry point: ipal-kit/server + * + * Server-only runtime functions — these import 'server-only' (Turnstile secret, + * SMTP password, nodemailer). Kept OUT of the main entry so that loading the + * Payload config or running `payload generate:importmap` (both Node scripts, + * no bundler) never pulls in 'server-only', which throws outside a bundled + * server context. + * + * Import these only from Server Actions, route handlers, or server components — + * never from the Payload config or client code. + */ +export { verifyTurnstile } from '../modules/turnstile/index.js'; +export { sendEmail } from '../modules/email/index.js'; +export type { SendEmailArgs, SendEmailResult } from '../modules/email/index.js'; +export { submitForm } from '../modules/forms/index.js'; +export type { SubmitFormArgs, SubmitFormResult } from '../modules/forms/index.js'; diff --git a/dist/exports/server.js b/dist/exports/server.js new file mode 100644 index 0000000..8b9d79d --- /dev/null +++ b/dist/exports/server.js @@ -0,0 +1,16 @@ +/** + * Entry point: ipal-kit/server + * + * Server-only runtime functions — these import 'server-only' (Turnstile secret, + * SMTP password, nodemailer). Kept OUT of the main entry so that loading the + * Payload config or running `payload generate:importmap` (both Node scripts, + * no bundler) never pulls in 'server-only', which throws outside a bundled + * server context. + * + * Import these only from Server Actions, route handlers, or server components — + * never from the Payload config or client code. + */ export { verifyTurnstile } from '../modules/turnstile/index.js'; +export { sendEmail } from '../modules/email/index.js'; +export { submitForm } from '../modules/forms/index.js'; + +//# sourceMappingURL=server.js.map \ No newline at end of file diff --git a/dist/exports/server.js.map b/dist/exports/server.js.map new file mode 100644 index 0000000..8e406f9 --- /dev/null +++ b/dist/exports/server.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../src/exports/server.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/server\n *\n * Server-only runtime functions — these import 'server-only' (Turnstile secret,\n * SMTP password, nodemailer). Kept OUT of the main entry so that loading the\n * Payload config or running `payload generate:importmap` (both Node scripts,\n * no bundler) never pulls in 'server-only', which throws outside a bundled\n * server context.\n *\n * Import these only from Server Actions, route handlers, or server components —\n * never from the Payload config or client code.\n */\nexport { verifyTurnstile } from '../modules/turnstile/index.js'\nexport { sendEmail } from '../modules/email/index.js'\nexport type { SendEmailArgs, SendEmailResult } from '../modules/email/index.js'\nexport { submitForm } from '../modules/forms/index.js'\nexport type { SubmitFormArgs, SubmitFormResult } from '../modules/forms/index.js'\n"],"names":["verifyTurnstile","sendEmail","submitForm"],"mappings":"AAAA;;;;;;;;;;;CAWC,GACD,SAASA,eAAe,QAAQ,gCAA+B;AAC/D,SAASC,SAAS,QAAQ,4BAA2B;AAErD,SAASC,UAAU,QAAQ,4BAA2B"} \ No newline at end of file diff --git a/dist/globals/CookieSettings/fields.d.js b/dist/globals/CookieSettings/fields.d.js new file mode 100644 index 0000000..d09e235 --- /dev/null +++ b/dist/globals/CookieSettings/fields.d.js @@ -0,0 +1,7 @@ +/** + * Fields for the CookieSettings global — GDPR consent banner copy, editable + * per locale. The privacy-policy link is NOT stored here: it comes from the + * pages module (system page role 'privacyPolicy'), keeping one source of truth. + */ export { }; + +//# sourceMappingURL=fields.d.js.map \ No newline at end of file diff --git a/dist/globals/CookieSettings/fields.d.js.map b/dist/globals/CookieSettings/fields.d.js.map new file mode 100644 index 0000000..fd60b80 --- /dev/null +++ b/dist/globals/CookieSettings/fields.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["fields.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * Fields for the CookieSettings global — GDPR consent banner copy, editable\n * per locale. The privacy-policy link is NOT stored here: it comes from the\n * pages module (system page role 'privacyPolicy'), keeping one source of truth.\n */\nexport declare const cookieSettingsFields: Field[];\n"],"names":[],"mappings":"AACA;;;;CAIC,GACD,WAAmD"} \ No newline at end of file diff --git a/dist/globals/CookieSettings/fields.d.ts b/dist/globals/CookieSettings/fields.d.ts new file mode 100644 index 0000000..98bfbd5 --- /dev/null +++ b/dist/globals/CookieSettings/fields.d.ts @@ -0,0 +1,7 @@ +import type { Field } from 'payload'; +/** + * Fields for the CookieSettings global — GDPR consent banner copy, editable + * per locale. The privacy-policy link is NOT stored here: it comes from the + * pages module (system page role 'privacyPolicy'), keeping one source of truth. + */ +export declare const cookieSettingsFields: Field[]; diff --git a/dist/globals/CookieSettings/fields.js b/dist/globals/CookieSettings/fields.js new file mode 100644 index 0000000..8f08ab6 --- /dev/null +++ b/dist/globals/CookieSettings/fields.js @@ -0,0 +1,87 @@ +import { CONSENT_CATEGORIES } from '../../modules/consent/index.js'; +/** + * Fields for the CookieSettings global — GDPR consent banner copy, editable + * per locale. The privacy-policy link is NOT stored here: it comes from the + * pages module (system page role 'privacyPolicy'), keeping one source of truth. + */ export const cookieSettingsFields = [ + { + name: 'message', + type: 'textarea', + admin: { + description: 'Main consent message shown in the banner.' + }, + localized: true + }, + { + name: 'settingsTitle', + type: 'text', + admin: { + description: 'Heading for the detailed settings panel.' + }, + localized: true + }, + { + name: 'buttons', + type: 'group', + admin: { + description: 'Button labels.' + }, + fields: [ + { + name: 'acceptAll', + type: 'text', + localized: true + }, + { + name: 'reject', + type: 'text', + localized: true + }, + { + name: 'settings', + type: 'text', + localized: true + }, + { + name: 'save', + type: 'text', + localized: true + }, + { + name: 'back', + type: 'text', + localized: true + } + ] + }, + { + name: 'categories', + type: 'array', + admin: { + description: 'Per-category titles and descriptions.' + }, + fields: [ + { + name: 'key', + type: 'select', + options: CONSENT_CATEGORIES.map((c)=>({ + label: c, + value: c + })), + required: true + }, + { + name: 'title', + type: 'text', + localized: true + }, + { + name: 'description', + type: 'textarea', + localized: true + } + ] + } +]; + +//# sourceMappingURL=fields.js.map \ No newline at end of file diff --git a/dist/globals/CookieSettings/fields.js.map b/dist/globals/CookieSettings/fields.js.map new file mode 100644 index 0000000..d8bbdcb --- /dev/null +++ b/dist/globals/CookieSettings/fields.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/CookieSettings/fields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { CONSENT_CATEGORIES } from '../../modules/consent/index.js'\n\n/**\n * Fields for the CookieSettings global — GDPR consent banner copy, editable\n * per locale. The privacy-policy link is NOT stored here: it comes from the\n * pages module (system page role 'privacyPolicy'), keeping one source of truth.\n */\nexport const cookieSettingsFields: Field[] = [\n {\n name: 'message',\n type: 'textarea',\n admin: {\n description: 'Main consent message shown in the banner.',\n },\n localized: true,\n },\n {\n name: 'settingsTitle',\n type: 'text',\n admin: {\n description: 'Heading for the detailed settings panel.',\n },\n localized: true,\n },\n {\n name: 'buttons',\n type: 'group',\n admin: {\n description: 'Button labels.',\n },\n fields: [\n { name: 'acceptAll', type: 'text', localized: true },\n { name: 'reject', type: 'text', localized: true },\n { name: 'settings', type: 'text', localized: true },\n { name: 'save', type: 'text', localized: true },\n { name: 'back', type: 'text', localized: true },\n ],\n },\n {\n name: 'categories',\n type: 'array',\n admin: {\n description: 'Per-category titles and descriptions.',\n },\n fields: [\n {\n name: 'key',\n type: 'select',\n options: CONSENT_CATEGORIES.map((c) => ({ label: c, value: c })),\n required: true,\n },\n { name: 'title', type: 'text', localized: true },\n { name: 'description', type: 'textarea', localized: true },\n ],\n },\n]\n"],"names":["CONSENT_CATEGORIES","cookieSettingsFields","name","type","admin","description","localized","fields","options","map","c","label","value","required"],"mappings":"AAEA,SAASA,kBAAkB,QAAQ,iCAAgC;AAEnE;;;;CAIC,GACD,OAAO,MAAMC,uBAAgC;IAC3C;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;IACb;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;IACb;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAE,QAAQ;YACN;gBAAEL,MAAM;gBAAaC,MAAM;gBAAQG,WAAW;YAAK;YACnD;gBAAEJ,MAAM;gBAAUC,MAAM;gBAAQG,WAAW;YAAK;YAChD;gBAAEJ,MAAM;gBAAYC,MAAM;gBAAQG,WAAW;YAAK;YAClD;gBAAEJ,MAAM;gBAAQC,MAAM;gBAAQG,WAAW;YAAK;YAC9C;gBAAEJ,MAAM;gBAAQC,MAAM;gBAAQG,WAAW;YAAK;SAC/C;IACH;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAE,QAAQ;YACN;gBACEL,MAAM;gBACNC,MAAM;gBACNK,SAASR,mBAAmBS,GAAG,CAAC,CAACC,IAAO,CAAA;wBAAEC,OAAOD;wBAAGE,OAAOF;oBAAE,CAAA;gBAC7DG,UAAU;YACZ;YACA;gBAAEX,MAAM;gBAASC,MAAM;gBAAQG,WAAW;YAAK;YAC/C;gBAAEJ,MAAM;gBAAeC,MAAM;gBAAYG,WAAW;YAAK;SAC1D;IACH;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/CookieSettings/index.d.js b/dist/globals/CookieSettings/index.d.js new file mode 100644 index 0000000..16e2ac6 --- /dev/null +++ b/dist/globals/CookieSettings/index.d.js @@ -0,0 +1,6 @@ +/** + * Builds the CookieSettings global — consent banner copy managed by editors. + * Public read (the banner needs it on the frontend for anonymous visitors). + */ export { }; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/globals/CookieSettings/index.d.js.map b/dist/globals/CookieSettings/index.d.js.map new file mode 100644 index 0000000..05454af --- /dev/null +++ b/dist/globals/CookieSettings/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload';\n/**\n * Builds the CookieSettings global — consent banner copy managed by editors.\n * Public read (the banner needs it on the frontend for anonymous visitors).\n */\nexport declare function buildCookieSettings(): GlobalConfig;\n"],"names":[],"mappings":"AACA;;;CAGC,GACD,WAA4D"} \ No newline at end of file diff --git a/dist/globals/CookieSettings/index.d.ts b/dist/globals/CookieSettings/index.d.ts new file mode 100644 index 0000000..3e21127 --- /dev/null +++ b/dist/globals/CookieSettings/index.d.ts @@ -0,0 +1,6 @@ +import type { GlobalConfig } from 'payload'; +/** + * Builds the CookieSettings global — consent banner copy managed by editors. + * Public read (the banner needs it on the frontend for anonymous visitors). + */ +export declare function buildCookieSettings(): GlobalConfig; diff --git a/dist/globals/CookieSettings/index.js b/dist/globals/CookieSettings/index.js new file mode 100644 index 0000000..eef7b2f --- /dev/null +++ b/dist/globals/CookieSettings/index.js @@ -0,0 +1,19 @@ +import { cookieSettingsFields } from './fields.js'; +/** + * Builds the CookieSettings global — consent banner copy managed by editors. + * Public read (the banner needs it on the frontend for anonymous visitors). + */ export function buildCookieSettings() { + return { + slug: 'cookie-settings', + access: { + read: ()=>true + }, + admin: { + group: 'Settings' + }, + fields: cookieSettingsFields, + label: 'Cookie Settings' + }; +} + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/globals/CookieSettings/index.js.map b/dist/globals/CookieSettings/index.js.map new file mode 100644 index 0000000..f2c58d5 --- /dev/null +++ b/dist/globals/CookieSettings/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/CookieSettings/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\n\nimport { cookieSettingsFields } from './fields.js'\n\n/**\n * Builds the CookieSettings global — consent banner copy managed by editors.\n * Public read (the banner needs it on the frontend for anonymous visitors).\n */\nexport function buildCookieSettings(): GlobalConfig {\n return {\n slug: 'cookie-settings',\n access: {\n read: () => true,\n },\n admin: {\n group: 'Settings',\n },\n fields: cookieSettingsFields,\n label: 'Cookie Settings',\n }\n}\n"],"names":["cookieSettingsFields","buildCookieSettings","slug","access","read","admin","group","fields","label"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,cAAa;AAElD;;;CAGC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,OAAO;YACLC,OAAO;QACT;QACAC,QAAQP;QACRQ,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/analytics.d.js b/dist/globals/SiteIntegrations/fields/analytics.d.js new file mode 100644 index 0000000..42dd76d --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/analytics.d.js @@ -0,0 +1,6 @@ +/** + * Analytics configuration — GA4 measurement ID and GTM container ID. + * IDs are public (exposed client-side), so no read restriction needed. + */ export { }; + +//# sourceMappingURL=analytics.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/analytics.d.js.map b/dist/globals/SiteIntegrations/fields/analytics.d.js.map new file mode 100644 index 0000000..56fc1d2 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/analytics.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["analytics.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * Analytics configuration — GA4 measurement ID and GTM container ID.\n * IDs are public (exposed client-side), so no read restriction needed.\n */\nexport declare const analyticsFields: Field[];\n"],"names":[],"mappings":"AACA;;;CAGC,GACD,WAA8C"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/analytics.d.ts b/dist/globals/SiteIntegrations/fields/analytics.d.ts new file mode 100644 index 0000000..518dfbd --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/analytics.d.ts @@ -0,0 +1,6 @@ +import type { Field } from 'payload'; +/** + * Analytics configuration — GA4 measurement ID and GTM container ID. + * IDs are public (exposed client-side), so no read restriction needed. + */ +export declare const analyticsFields: Field[]; diff --git a/dist/globals/SiteIntegrations/fields/analytics.js b/dist/globals/SiteIntegrations/fields/analytics.js new file mode 100644 index 0000000..31e3543 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/analytics.js @@ -0,0 +1,23 @@ +/** + * Analytics configuration — GA4 measurement ID and GTM container ID. + * IDs are public (exposed client-side), so no read restriction needed. + */ export const analyticsFields = [ + { + name: 'ga4MeasurementId', + type: 'text', + admin: { + description: 'Google Analytics 4 Measurement ID.', + placeholder: 'G-XXXXXXXXXX' + } + }, + { + name: 'gtmContainerId', + type: 'text', + admin: { + description: 'Google Tag Manager container ID.', + placeholder: 'GTM-XXXXXXX' + } + } +]; + +//# sourceMappingURL=analytics.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/analytics.js.map b/dist/globals/SiteIntegrations/fields/analytics.js.map new file mode 100644 index 0000000..34eed37 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/analytics.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/analytics.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Analytics configuration — GA4 measurement ID and GTM container ID.\n * IDs are public (exposed client-side), so no read restriction needed.\n */\nexport const analyticsFields: Field[] = [\n {\n name: 'ga4MeasurementId',\n type: 'text',\n admin: {\n description: 'Google Analytics 4 Measurement ID.',\n placeholder: 'G-XXXXXXXXXX',\n },\n },\n {\n name: 'gtmContainerId',\n type: 'text',\n admin: {\n description: 'Google Tag Manager container ID.',\n placeholder: 'GTM-XXXXXXX',\n },\n },\n]\n"],"names":["analyticsFields","name","type","admin","description","placeholder"],"mappings":"AAEA;;;CAGC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACbC,aAAa;QACf;IACF;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACbC,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.d.js b/dist/globals/SiteIntegrations/fields/smtp.d.js new file mode 100644 index 0000000..91a5a66 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/smtp.d.js @@ -0,0 +1,9 @@ +/** + * SMTP transport settings for outbound email. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so all fields — including the password — stay editable in the admin + * panel while remaining inaccessible to anonymous API requests. + */ export { }; + +//# sourceMappingURL=smtp.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.d.js.map b/dist/globals/SiteIntegrations/fields/smtp.d.js.map new file mode 100644 index 0000000..8086312 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/smtp.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["smtp.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport declare const smtpFields: Field[];\n"],"names":[],"mappings":"AACA;;;;;;CAMC,GACD,WAAyC"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.d.ts b/dist/globals/SiteIntegrations/fields/smtp.d.ts new file mode 100644 index 0000000..ed0419f --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/smtp.d.ts @@ -0,0 +1,9 @@ +import type { Field } from 'payload'; +/** + * SMTP transport settings for outbound email. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so all fields — including the password — stay editable in the admin + * panel while remaining inaccessible to anonymous API requests. + */ +export declare const smtpFields: Field[]; diff --git a/dist/globals/SiteIntegrations/fields/smtp.js b/dist/globals/SiteIntegrations/fields/smtp.js new file mode 100644 index 0000000..748dc0e --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/smtp.js @@ -0,0 +1,59 @@ +/** + * SMTP transport settings for outbound email. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so all fields — including the password — stay editable in the admin + * panel while remaining inaccessible to anonymous API requests. + */ export const smtpFields = [ + { + type: 'row', + fields: [ + { + name: 'smtpHost', + type: 'text', + admin: { + placeholder: 'smtp.example.com', + width: '70%' + } + }, + { + name: 'smtpPort', + type: 'number', + admin: { + width: '30%' + }, + defaultValue: 587 + } + ] + }, + { + name: 'smtpUser', + type: 'text', + admin: { + description: 'SMTP account username.' + } + }, + { + name: 'smtpPassword', + type: 'text', + admin: { + description: 'SMTP account password.' + } + }, + { + name: 'smtpFromAddress', + type: 'email', + admin: { + description: 'Default "from" address for outgoing mail.' + } + }, + { + name: 'smtpFromName', + type: 'text', + admin: { + description: 'Default "from" display name.' + } + } +]; + +//# sourceMappingURL=smtp.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/smtp.js.map b/dist/globals/SiteIntegrations/fields/smtp.js.map new file mode 100644 index 0000000..6ca3457 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/smtp.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/storage.d.js b/dist/globals/SiteIntegrations/fields/storage.d.js new file mode 100644 index 0000000..cc2d714 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/storage.d.js @@ -0,0 +1,10 @@ +/** + * Cloudflare R2 storage credentials. + * Reserved for future use — media offloading to R2. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so the access keys stay editable in the admin panel while remaining + * inaccessible to anonymous API requests. + */ export { }; + +//# sourceMappingURL=storage.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/storage.d.js.map b/dist/globals/SiteIntegrations/fields/storage.d.js.map new file mode 100644 index 0000000..fdd4b73 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/storage.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["storage.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport declare const storageFields: Field[];\n"],"names":[],"mappings":"AACA;;;;;;;CAOC,GACD,WAA4C"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/storage.d.ts b/dist/globals/SiteIntegrations/fields/storage.d.ts new file mode 100644 index 0000000..1b47103 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/storage.d.ts @@ -0,0 +1,10 @@ +import type { Field } from 'payload'; +/** + * Cloudflare R2 storage credentials. + * Reserved for future use — media offloading to R2. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so the access keys stay editable in the admin panel while remaining + * inaccessible to anonymous API requests. + */ +export declare const storageFields: Field[]; diff --git a/dist/globals/SiteIntegrations/fields/storage.js b/dist/globals/SiteIntegrations/fields/storage.js new file mode 100644 index 0000000..f700fe0 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/storage.js @@ -0,0 +1,39 @@ +/** + * Cloudflare R2 storage credentials. + * Reserved for future use — media offloading to R2. + * + * Protected at the global level (SiteIntegrations requires an authenticated + * user), so the access keys stay editable in the admin panel while remaining + * inaccessible to anonymous API requests. + */ export const storageFields = [ + { + name: 'r2Bucket', + type: 'text', + admin: { + description: 'R2 bucket name.' + } + }, + { + name: 'r2Endpoint', + type: 'text', + admin: { + description: 'R2 S3-compatible endpoint URL.' + } + }, + { + name: 'r2AccessKeyId', + type: 'text', + admin: { + description: 'R2 access key ID.' + } + }, + { + name: 'r2SecretAccessKey', + type: 'text', + admin: { + description: 'R2 secret access key.' + } + } +]; + +//# sourceMappingURL=storage.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/storage.js.map b/dist/globals/SiteIntegrations/fields/storage.js.map new file mode 100644 index 0000000..f84dc37 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/storage.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/storage.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare R2 storage credentials.\n * Reserved for future use — media offloading to R2.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so the access keys stay editable in the admin panel while remaining\n * inaccessible to anonymous API requests.\n */\nexport const storageFields: Field[] = [\n {\n name: 'r2Bucket',\n type: 'text',\n admin: {\n description: 'R2 bucket name.',\n },\n },\n {\n name: 'r2Endpoint',\n type: 'text',\n admin: {\n description: 'R2 S3-compatible endpoint URL.',\n },\n },\n {\n name: 'r2AccessKeyId',\n type: 'text',\n admin: {\n description: 'R2 access key ID.',\n },\n },\n {\n name: 'r2SecretAccessKey',\n type: 'text',\n admin: {\n description: 'R2 secret access key.',\n },\n },\n]\n"],"names":["storageFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/turnstile.d.js b/dist/globals/SiteIntegrations/fields/turnstile.d.js new file mode 100644 index 0000000..0bcd357 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/turnstile.d.js @@ -0,0 +1,10 @@ +/** + * Cloudflare Turnstile credentials. + * + * siteKey is public (rendered in the widget); secretKey is used for + * server-side verification. Both are protected at the global level + * (SiteIntegrations requires an authenticated user) rather than per-field, + * so they remain editable in the admin panel. + */ export { }; + +//# sourceMappingURL=turnstile.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/turnstile.d.js.map b/dist/globals/SiteIntegrations/fields/turnstile.d.js.map new file mode 100644 index 0000000..d07dd13 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/turnstile.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["turnstile.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport declare const turnstileFields: Field[];\n"],"names":[],"mappings":"AACA;;;;;;;CAOC,GACD,WAA8C"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/turnstile.d.ts b/dist/globals/SiteIntegrations/fields/turnstile.d.ts new file mode 100644 index 0000000..5963ee3 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/turnstile.d.ts @@ -0,0 +1,10 @@ +import type { Field } from 'payload'; +/** + * Cloudflare Turnstile credentials. + * + * siteKey is public (rendered in the widget); secretKey is used for + * server-side verification. Both are protected at the global level + * (SiteIntegrations requires an authenticated user) rather than per-field, + * so they remain editable in the admin panel. + */ +export declare const turnstileFields: Field[]; diff --git a/dist/globals/SiteIntegrations/fields/turnstile.js b/dist/globals/SiteIntegrations/fields/turnstile.js new file mode 100644 index 0000000..be87901 --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/turnstile.js @@ -0,0 +1,25 @@ +/** + * Cloudflare Turnstile credentials. + * + * siteKey is public (rendered in the widget); secretKey is used for + * server-side verification. Both are protected at the global level + * (SiteIntegrations requires an authenticated user) rather than per-field, + * so they remain editable in the admin panel. + */ export const turnstileFields = [ + { + name: 'turnstileSiteKey', + type: 'text', + admin: { + description: 'Public site key rendered in the Turnstile widget.' + } + }, + { + name: 'turnstileSecretKey', + type: 'text', + admin: { + description: 'Secret key used for server-side verification.' + } + } +]; + +//# sourceMappingURL=turnstile.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/fields/turnstile.js.map b/dist/globals/SiteIntegrations/fields/turnstile.js.map new file mode 100644 index 0000000..634b86a --- /dev/null +++ b/dist/globals/SiteIntegrations/fields/turnstile.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/turnstile.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Cloudflare Turnstile credentials.\n *\n * siteKey is public (rendered in the widget); secretKey is used for\n * server-side verification. Both are protected at the global level\n * (SiteIntegrations requires an authenticated user) rather than per-field,\n * so they remain editable in the admin panel.\n */\nexport const turnstileFields: Field[] = [\n {\n name: 'turnstileSiteKey',\n type: 'text',\n admin: {\n description: 'Public site key rendered in the Turnstile widget.',\n },\n },\n {\n name: 'turnstileSecretKey',\n type: 'text',\n admin: {\n description: 'Secret key used for server-side verification.',\n },\n },\n]\n"],"names":["turnstileFields","name","type","admin","description"],"mappings":"AAEA;;;;;;;CAOC,GACD,OAAO,MAAMA,kBAA2B;IACtC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/index.d.js b/dist/globals/SiteIntegrations/index.d.js new file mode 100644 index 0000000..c6f029b --- /dev/null +++ b/dist/globals/SiteIntegrations/index.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/index.d.js.map b/dist/globals/SiteIntegrations/index.d.js.map new file mode 100644 index 0000000..5e58a5f --- /dev/null +++ b/dist/globals/SiteIntegrations/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload';\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[];\n};\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n */\nexport declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig;\nexport {};\n"],"names":[],"mappings":"AAkBA,WAAU"} \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/index.d.ts b/dist/globals/SiteIntegrations/index.d.ts new file mode 100644 index 0000000..52e35d7 --- /dev/null +++ b/dist/globals/SiteIntegrations/index.d.ts @@ -0,0 +1,19 @@ +import type { Field, GlobalConfig } from 'payload'; +type BuildSiteIntegrationsArgs = { + /** Extra fields injected by the client project */ + additionalFields?: Field[]; +}; +/** + * Builds the SiteIntegrations global. + * + * Holds third-party service credentials. Access is enforced at the global + * level — the whole global requires an authenticated user — so secrets stay + * out of anonymous API responses while remaining editable in the admin panel + * and readable via the server-side Local API. (Field-level read:false was + * avoided because it also hides fields from the admin UI, making them + * impossible to enter.) + * + * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId). + */ +export declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig; +export {}; diff --git a/dist/globals/SiteIntegrations/index.js b/dist/globals/SiteIntegrations/index.js new file mode 100644 index 0000000..caabdad --- /dev/null +++ b/dist/globals/SiteIntegrations/index.js @@ -0,0 +1,62 @@ +import { isAdmin } from '../../modules/access/index.js'; +import { analyticsFields } from './fields/analytics.js'; +import { smtpFields } from './fields/smtp.js'; +import { storageFields } from './fields/storage.js'; +import { turnstileFields } from './fields/turnstile.js'; +/** + * Builds the SiteIntegrations global. + * + * Holds third-party service credentials. Access is enforced at the global + * level — the whole global requires an authenticated user — so secrets stay + * out of anonymous API responses while remaining editable in the admin panel + * and readable via the server-side Local API. (Field-level read:false was + * avoided because it also hides fields from the admin UI, making them + * impossible to enter.) + * + * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId). + */ export function buildSiteIntegrations({ additionalFields } = {}) { + return { + slug: 'site-integrations', + access: { + // Admin-only — secrets live here. Anonymous and non-admin users get + // nothing through the API; admins read/edit in the panel and via Local API. + read: ({ req: { user } })=>isAdmin(user), + update: ({ req: { user } })=>isAdmin(user) + }, + admin: { + group: 'Settings' + }, + fields: [ + { + type: 'tabs', + tabs: [ + { + fields: analyticsFields, + label: 'Analytics' + }, + { + fields: turnstileFields, + label: 'Turnstile' + }, + { + fields: smtpFields, + label: 'SMTP' + }, + { + fields: storageFields, + label: 'Storage' + }, + ...additionalFields?.length ? [ + { + fields: additionalFields, + label: 'Custom' + } + ] : [] + ] + } + ], + label: 'Site Integrations' + }; +} + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/globals/SiteIntegrations/index.js.map b/dist/globals/SiteIntegrations/index.js.map new file mode 100644 index 0000000..7aa28fa --- /dev/null +++ b/dist/globals/SiteIntegrations/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { storageFields } from './fields/storage.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n { fields: storageFields, label: 'Storage' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","storageFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,aAAa,QAAQ,sBAAqB;AACnD,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQd;wBAAiBiB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQb;wBAAYgB,OAAO;oBAAO;oBACpC;wBAAEH,QAAQZ;wBAAee,OAAO;oBAAU;uBACtCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/general.d.js b/dist/globals/SiteSettings/fields/general.d.js new file mode 100644 index 0000000..c6a08f4 --- /dev/null +++ b/dist/globals/SiteSettings/fields/general.d.js @@ -0,0 +1,6 @@ +/** + * General site identity fields. + * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo). + */ export { }; + +//# sourceMappingURL=general.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/general.d.js.map b/dist/globals/SiteSettings/fields/general.d.js.map new file mode 100644 index 0000000..1c2bda9 --- /dev/null +++ b/dist/globals/SiteSettings/fields/general.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["general.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport declare const generalFields: Field[];\n"],"names":[],"mappings":"AACA;;;CAGC,GACD,WAA4C"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/general.d.ts b/dist/globals/SiteSettings/fields/general.d.ts new file mode 100644 index 0000000..0a7c294 --- /dev/null +++ b/dist/globals/SiteSettings/fields/general.d.ts @@ -0,0 +1,6 @@ +import type { Field } from 'payload'; +/** + * General site identity fields. + * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo). + */ +export declare const generalFields: Field[]; diff --git a/dist/globals/SiteSettings/fields/general.js b/dist/globals/SiteSettings/fields/general.js new file mode 100644 index 0000000..50f4797 --- /dev/null +++ b/dist/globals/SiteSettings/fields/general.js @@ -0,0 +1,88 @@ +/** + * General site identity fields. + * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo). + */ export const generalFields = [ + { + name: 'siteName', + type: 'text', + admin: { + description: 'Used in page titles and Open Graph metadata.' + }, + localized: true, + required: true + }, + { + name: 'titleOrder', + type: 'select', + admin: { + description: 'Which comes first in browser tabs.' + }, + defaultValue: 'page-first', + options: [ + { + label: 'Page first — About Us | Acme', + value: 'page-first' + }, + { + label: 'Site first — Acme | About Us', + value: 'site-first' + } + ] + }, + { + name: 'titleSeparator', + type: 'select', + admin: { + description: 'Separates the page title from the site name in browser tabs.' + }, + defaultValue: '|', + options: [ + { + label: 'Pipe — Page | Site', + value: '|' + }, + { + label: 'Dash — Page – Site', + value: '–' + }, + { + label: 'Hyphen — Page - Site', + value: '-' + }, + { + label: 'Bullet — Page · Site', + value: '·' + }, + { + label: 'Slash — Page / Site', + value: '/' + } + ] + }, + { + name: 'logo', + type: 'upload', + admin: { + description: 'Primary site logo.' + }, + relationTo: 'media' + }, + { + name: 'defaultShareImage', + type: 'upload', + admin: { + description: 'Fallback Open Graph image when a page has none.' + }, + relationTo: 'media' + }, + { + name: 'favicon', + type: 'upload', + admin: { + description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.' + }, + relationTo: 'media' + } +]; + +//# sourceMappingURL=general.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/general.js.map b/dist/globals/SiteSettings/fields/general.js.map new file mode 100644 index 0000000..003be51 --- /dev/null +++ b/dist/globals/SiteSettings/fields/general.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',\n },\n relationTo: 'media',\n },\n]\n"],"names":["generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo"],"mappings":"AAEA;;;CAGC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/navigation.d.js b/dist/globals/SiteSettings/fields/navigation.d.js new file mode 100644 index 0000000..3b80b52 --- /dev/null +++ b/dist/globals/SiteSettings/fields/navigation.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=navigation.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/navigation.d.js.map b/dist/globals/SiteSettings/fields/navigation.d.js.map new file mode 100644 index 0000000..dbcdd87 --- /dev/null +++ b/dist/globals/SiteSettings/fields/navigation.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["navigation.d.ts"],"sourcesContent":["export {};\n"],"names":[],"mappings":"AAAA,WAAU"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/navigation.d.ts b/dist/globals/SiteSettings/fields/navigation.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/globals/SiteSettings/fields/navigation.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/globals/SiteSettings/fields/navigation.js b/dist/globals/SiteSettings/fields/navigation.js new file mode 100644 index 0000000..0dd88ba --- /dev/null +++ b/dist/globals/SiteSettings/fields/navigation.js @@ -0,0 +1,2 @@ + +//# sourceMappingURL=navigation.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/navigation.js.map b/dist/globals/SiteSettings/fields/navigation.js.map new file mode 100644 index 0000000..93c4e97 --- /dev/null +++ b/dist/globals/SiteSettings/fields/navigation.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/navigation.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/theme.d.js b/dist/globals/SiteSettings/fields/theme.d.js new file mode 100644 index 0000000..80085bc --- /dev/null +++ b/dist/globals/SiteSettings/fields/theme.d.js @@ -0,0 +1,9 @@ +/** + * Theme behavior configuration. + * + * These are decisions, not visuals — the plugin stores them, the client + * template reads them and decides whether to render a toggle and which + * mode to start in. Appearance itself stays in the template. + */ export { }; + +//# sourceMappingURL=theme.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/theme.d.js.map b/dist/globals/SiteSettings/fields/theme.d.js.map new file mode 100644 index 0000000..4c53a79 --- /dev/null +++ b/dist/globals/SiteSettings/fields/theme.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["theme.d.ts"],"sourcesContent":["import type { Field } from 'payload';\n/**\n * Theme behavior configuration.\n *\n * These are decisions, not visuals — the plugin stores them, the client\n * template reads them and decides whether to render a toggle and which\n * mode to start in. Appearance itself stays in the template.\n */\nexport declare const themeFields: Field[];\n"],"names":[],"mappings":"AACA;;;;;;CAMC,GACD,WAA0C"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/theme.d.ts b/dist/globals/SiteSettings/fields/theme.d.ts new file mode 100644 index 0000000..8f527ed --- /dev/null +++ b/dist/globals/SiteSettings/fields/theme.d.ts @@ -0,0 +1,9 @@ +import type { Field } from 'payload'; +/** + * Theme behavior configuration. + * + * These are decisions, not visuals — the plugin stores them, the client + * template reads them and decides whether to render a toggle and which + * mode to start in. Appearance itself stays in the template. + */ +export declare const themeFields: Field[]; diff --git a/dist/globals/SiteSettings/fields/theme.js b/dist/globals/SiteSettings/fields/theme.js new file mode 100644 index 0000000..f434b1e --- /dev/null +++ b/dist/globals/SiteSettings/fields/theme.js @@ -0,0 +1,37 @@ +/** + * Theme behavior configuration. + * + * These are decisions, not visuals — the plugin stores them, the client + * template reads them and decides whether to render a toggle and which + * mode to start in. Appearance itself stays in the template. + */ export const themeFields = [ + { + name: 'defaultTheme', + type: 'select', + admin: { + description: 'Theme applied on a visitor’s first visit.' + }, + defaultValue: 'light', + options: [ + { + label: 'Light', + value: 'light' + }, + { + label: 'Dark', + value: 'dark' + } + ], + required: true + }, + { + name: 'allowThemeToggle', + type: 'checkbox', + admin: { + description: 'Show a light/dark switch on the site.' + }, + defaultValue: true + } +]; + +//# sourceMappingURL=theme.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/fields/theme.js.map b/dist/globals/SiteSettings/fields/theme.js.map new file mode 100644 index 0000000..506a34b --- /dev/null +++ b/dist/globals/SiteSettings/fields/theme.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/theme.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * Theme behavior configuration.\n *\n * These are decisions, not visuals — the plugin stores them, the client\n * template reads them and decides whether to render a toggle and which\n * mode to start in. Appearance itself stays in the template.\n */\nexport const themeFields: Field[] = [\n {\n name: 'defaultTheme',\n type: 'select',\n admin: {\n description: 'Theme applied on a visitor’s first visit.',\n },\n defaultValue: 'light',\n options: [\n { label: 'Light', value: 'light' },\n { label: 'Dark', value: 'dark' },\n ],\n required: true,\n },\n {\n name: 'allowThemeToggle',\n type: 'checkbox',\n admin: {\n description: 'Show a light/dark switch on the site.',\n },\n defaultValue: true,\n },\n]\n"],"names":["themeFields","name","type","admin","description","defaultValue","options","label","value","required"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,cAAuB;IAClC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAASC,OAAO;YAAQ;YACjC;gBAAED,OAAO;gBAAQC,OAAO;YAAO;SAChC;QACDC,UAAU;IACZ;IACA;QACER,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,cAAc;IAChB;CACD,CAAA"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/index.d.js b/dist/globals/SiteSettings/index.d.js new file mode 100644 index 0000000..c6f029b --- /dev/null +++ b/dist/globals/SiteSettings/index.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/index.d.js.map b/dist/globals/SiteSettings/index.d.js.map new file mode 100644 index 0000000..459a743 --- /dev/null +++ b/dist/globals/SiteSettings/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload';\ntype BuildSiteSettingsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[];\n /**\n * Fields for the \"System Pages\" tab — page assignments by role (homepage,\n * privacy policy, collection archives). Assembled by the caller, which is the\n * only place that knows which modules are enabled. The tab is omitted when\n * this is empty.\n */\n systemPageFields?: Field[];\n};\n/**\n * Builds the SiteSettings global.\n *\n * Composes tabs out of field groups; it deliberately doesn't know where those\n * groups come from. Earlier it imported the pages and content modules directly,\n * which meant a global — a presentation-layer concern — had an opinion about\n * blogs. Now the plugin's composition root passes fields in, and adding a new\n * kind of page assignment needs no change here.\n *\n * Uses unnamed tabs — data stays flat (siteSettings.siteName, not\n * siteSettings.general.siteName). Client-provided fields land in their own\n * \"Custom\" tab so core data paths never shift.\n */\nexport declare function buildSiteSettings({ additionalFields, systemPageFields, }?: BuildSiteSettingsArgs): GlobalConfig;\nexport {};\n"],"names":[],"mappings":"AA0BA,WAAU"} \ No newline at end of file diff --git a/dist/globals/SiteSettings/index.d.ts b/dist/globals/SiteSettings/index.d.ts new file mode 100644 index 0000000..debf327 --- /dev/null +++ b/dist/globals/SiteSettings/index.d.ts @@ -0,0 +1,27 @@ +import type { Field, GlobalConfig } from 'payload'; +type BuildSiteSettingsArgs = { + /** Extra fields injected by the client project */ + additionalFields?: Field[]; + /** + * Fields for the "System Pages" tab — page assignments by role (homepage, + * privacy policy, collection archives). Assembled by the caller, which is the + * only place that knows which modules are enabled. The tab is omitted when + * this is empty. + */ + systemPageFields?: Field[]; +}; +/** + * Builds the SiteSettings global. + * + * Composes tabs out of field groups; it deliberately doesn't know where those + * groups come from. Earlier it imported the pages and content modules directly, + * which meant a global — a presentation-layer concern — had an opinion about + * blogs. Now the plugin's composition root passes fields in, and adding a new + * kind of page assignment needs no change here. + * + * Uses unnamed tabs — data stays flat (siteSettings.siteName, not + * siteSettings.general.siteName). Client-provided fields land in their own + * "Custom" tab so core data paths never shift. + */ +export declare function buildSiteSettings({ additionalFields, systemPageFields, }?: BuildSiteSettingsArgs): GlobalConfig; +export {}; diff --git a/dist/globals/SiteSettings/index.js b/dist/globals/SiteSettings/index.js new file mode 100644 index 0000000..613c0f2 --- /dev/null +++ b/dist/globals/SiteSettings/index.js @@ -0,0 +1,55 @@ +import { generalFields } from './fields/general.js'; +import { themeFields } from './fields/theme.js'; +/** + * Builds the SiteSettings global. + * + * Composes tabs out of field groups; it deliberately doesn't know where those + * groups come from. Earlier it imported the pages and content modules directly, + * which meant a global — a presentation-layer concern — had an opinion about + * blogs. Now the plugin's composition root passes fields in, and adding a new + * kind of page assignment needs no change here. + * + * Uses unnamed tabs — data stays flat (siteSettings.siteName, not + * siteSettings.general.siteName). Client-provided fields land in their own + * "Custom" tab so core data paths never shift. + */ export function buildSiteSettings({ additionalFields, systemPageFields } = {}) { + return { + slug: 'site-settings', + access: { + read: ()=>true + }, + admin: { + group: 'Settings' + }, + fields: [ + { + type: 'tabs', + tabs: [ + { + fields: generalFields, + label: 'General' + }, + { + fields: themeFields, + label: 'Theme' + }, + ...systemPageFields?.length ? [ + { + fields: systemPageFields, + label: 'System Pages' + } + ] : [], + ...additionalFields?.length ? [ + { + fields: additionalFields, + label: 'Custom' + } + ] : [] + ] + } + ], + label: 'Site Settings' + }; +} + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/globals/SiteSettings/index.js.map b/dist/globals/SiteSettings/index.js.map new file mode 100644 index 0000000..9e4d89f --- /dev/null +++ b/dist/globals/SiteSettings/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/globals/SiteSettings/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { generalFields } from './fields/general.js'\nimport { themeFields } from './fields/theme.js'\n\ntype BuildSiteSettingsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n /**\n * Fields for the \"System Pages\" tab — page assignments by role (homepage,\n * privacy policy, collection archives). Assembled by the caller, which is the\n * only place that knows which modules are enabled. The tab is omitted when\n * this is empty.\n */\n systemPageFields?: Field[]\n}\n\n/**\n * Builds the SiteSettings global.\n *\n * Composes tabs out of field groups; it deliberately doesn't know where those\n * groups come from. Earlier it imported the pages and content modules directly,\n * which meant a global — a presentation-layer concern — had an opinion about\n * blogs. Now the plugin's composition root passes fields in, and adding a new\n * kind of page assignment needs no change here.\n *\n * Uses unnamed tabs — data stays flat (siteSettings.siteName, not\n * siteSettings.general.siteName). Client-provided fields land in their own\n * \"Custom\" tab so core data paths never shift.\n */\nexport function buildSiteSettings({\n additionalFields,\n systemPageFields,\n}: BuildSiteSettingsArgs = {}): GlobalConfig {\n return {\n slug: 'site-settings',\n access: {\n read: () => true,\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n {\n fields: generalFields,\n label: 'General',\n },\n {\n fields: themeFields,\n label: 'Theme',\n },\n ...(systemPageFields?.length\n ? [{ fields: systemPageFields, label: 'System Pages' }]\n : []),\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Settings',\n }\n}\n"],"names":["generalFields","themeFields","buildSiteSettings","additionalFields","systemPageFields","slug","access","read","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,aAAa,QAAQ,sBAAqB;AACnD,SAASC,WAAW,QAAQ,oBAAmB;AAc/C;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,kBAAkB,EAChCC,gBAAgB,EAChBC,gBAAgB,EACM,GAAG,CAAC,CAAC;IAC3B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBACEF,QAAQV;wBACRa,OAAO;oBACT;oBACA;wBACEH,QAAQT;wBACRY,OAAO;oBACT;uBACIT,kBAAkBU,SAClB;wBAAC;4BAAEJ,QAAQN;4BAAkBS,OAAO;wBAAe;qBAAE,GACrD,EAAE;uBACFV,kBAAkBW,SAAS;wBAAC;4BAAEJ,QAAQP;4BAAkBU,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/index.d.js b/dist/index.d.js new file mode 100644 index 0000000..d859b15 --- /dev/null +++ b/dist/index.d.js @@ -0,0 +1,17 @@ +export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/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 { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js'; +export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'; +export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; +export { createContentHelpers } from './modules/frontend/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 { 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 { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'; +export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js'; +export { buildSlugField, toSlug } from './modules/slug/index.js'; +export { ipalKit } from './plugin.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/index.d.js.map b/dist/index.d.js.map new file mode 100644 index 0000000..1986b00 --- /dev/null +++ b/dist/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js';\nexport { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY, } from './modules/access/index.js';\nexport type { AnalyticsConfig } from './modules/analytics/index.js';\nexport { getAnalyticsConfig } from './modules/analytics/index.js';\nexport { 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';\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js';\nexport type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';\nexport { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js';\nexport type { ArchiveEntries } from './modules/content/index.js';\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';\nexport type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js';\nexport { createContentHelpers } from './modules/frontend/index.js';\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js';\nexport { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js';\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js';\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js';\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';\nexport type { GlobalQueryOptions } from './modules/payload/index.js';\nexport { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';\nexport { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';\nexport { buildSlugField, toSlug } from './modules/slug/index.js';\nexport { ipalKit } from './plugin.js';\nexport type { IpalOptions } from './types.js';\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","ipalKit"],"mappings":"AACA,SAASA,SAAS,EAAEC,cAAc,EAAEC,aAAa,EAAEC,kBAAkB,EAAEC,WAAW,EAAEC,aAAa,EAAEC,cAAc,EAAEC,OAAO,EAAEC,QAAQ,EAAEC,WAAW,EAAEC,gBAAgB,EAAEC,cAAc,QAAS,4BAA4B;AAExN,SAASC,kBAAkB,QAAQ,+BAA+B;AAClE,SAASC,kBAAkB,EAAEC,kBAAkB,EAAEC,cAAc,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAEC,YAAY,EAAEC,kBAAkB,EAAEC,gBAAgB,EAAEC,iBAAiB,EAAEC,aAAa,QAAS,6BAA6B;AAG/P,SAASC,gBAAgB,EAAEC,gBAAgB,EAAEC,cAAc,EAAEC,iBAAiB,EAAEC,cAAc,EAAEC,YAAY,QAAS,6BAA6B;AAElJ,SAASC,gBAAgB,QAAQ,sCAAsC;AAEvE,SAASC,gBAAgB,QAAQ,uCAAuC;AAExE,SAASC,oBAAoB,QAAQ,8BAA8B;AAEnE,SAASC,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,EAAEC,mBAAmB,EAAEC,iBAAiB,EAAEC,aAAa,EAAEC,kBAAkB,EAAEC,mBAAmB,EAAEC,eAAe,EAAEC,gBAAgB,QAAS,0BAA0B;AAEnO,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAA0B;AAE7F,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA2B;AAEpF,SAASC,SAAS,EAAEC,mBAAmB,EAAEC,eAAe,EAAEC,sBAAsB,EAAEC,kBAAkB,QAAS,6BAA6B;AAE1I,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAyB;AAE9F,SAASC,qBAAqB,EAAEC,WAAW,EAAEC,mBAAmB,EAAEC,uBAAuB,EAAEC,kBAAkB,EAAEC,kBAAkB,QAAS,yBAAyB;AACnK,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAA0B;AACjE,SAASC,OAAO,QAAQ,cAAc"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..26b729e --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,29 @@ +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 type { AnalyticsConfig } 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 type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'; +export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js'; +export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js'; +export type { ArchiveEntries } from './modules/content/index.js'; +export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'; +export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'; +export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; +export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js'; +export { createContentHelpers } from './modules/frontend/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 type { LocaleMiddlewareResult } from './modules/i18n/index.js'; +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; +export type { PagesOption, SystemPageRole } 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 { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js'; +export type { PageMetadata, SeoMeta, SeoOption } 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 { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js'; +export { buildSlugField, toSlug } from './modules/slug/index.js'; +export { ipalKit } from './plugin.js'; +export type { IpalOptions } from './types.js'; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..8ac3b44 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,20 @@ +export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, hasMinimumRole, isAdmin, isEditor, requireRole, requireRoleField, ROLE_HIERARCHY } from './modules/access/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 { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js'; +// Imported straight from the file, NOT from ./modules/email/index.js — that +// barrel re-exports sendEmail, which imports 'server-only' and would crash when +// Payload loads the config (or runs generate:importmap) as a plain Node script. +export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'; +export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; +export { createContentHelpers } from './modules/frontend/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 { 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 { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'; +export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js'; +export { buildSlugField, toSlug } from './modules/slug/index.js'; +export { ipalKit } from './plugin.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map new file mode 100644 index 0000000..b8b0389 --- /dev/null +++ b/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAE5F,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AAEnC,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file diff --git a/dist/modules/access/access.d.js b/dist/modules/access/access.d.js new file mode 100644 index 0000000..13cba76 --- /dev/null +++ b/dist/modules/access/access.d.js @@ -0,0 +1,3 @@ +/** Requires at least the given role, for field-level access. */ export { }; + +//# sourceMappingURL=access.d.js.map \ No newline at end of file diff --git a/dist/modules/access/access.d.js.map b/dist/modules/access/access.d.js.map new file mode 100644 index 0000000..b4f313c --- /dev/null +++ b/dist/modules/access/access.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["access.d.ts"],"sourcesContent":["import type { Access, FieldAccess } from 'payload';\nimport type { Role } from './types.js';\n/**\n * Collection-level access (returns boolean | Where).\n * Use in collection `access.read/create/update/delete`.\n */\nexport declare const adminOnly: Access;\nexport declare const adminOrEditor: Access;\nexport declare const authenticated: Access;\n/** Requires at least the given role. */\nexport declare const requireRole: (minimum: Role) => Access;\n/** Admins see all; others are constrained to their own document. */\nexport declare const adminOrSelf: Access;\n/**\n * Field-level access (returns boolean only — no Where support).\n * Use in field `access.read/update`.\n */\nexport declare const adminOnlyField: FieldAccess;\nexport declare const adminOrEditorField: FieldAccess;\n/** Requires at least the given role, for field-level access. */\nexport declare const requireRoleField: (minimum: Role) => FieldAccess;\n"],"names":[],"mappings":"AAmBA,8DAA8D,GAC9D,WAAsE"} \ No newline at end of file diff --git a/dist/modules/access/access.d.ts b/dist/modules/access/access.d.ts new file mode 100644 index 0000000..ec76b6a --- /dev/null +++ b/dist/modules/access/access.d.ts @@ -0,0 +1,21 @@ +import type { Access, FieldAccess } from 'payload'; +import type { Role } from './types.js'; +/** + * Collection-level access (returns boolean | Where). + * Use in collection `access.read/create/update/delete`. + */ +export declare const adminOnly: Access; +export declare const adminOrEditor: Access; +export declare const authenticated: Access; +/** Requires at least the given role. */ +export declare const requireRole: (minimum: Role) => Access; +/** Admins see all; others are constrained to their own document. */ +export declare const adminOrSelf: Access; +/** + * Field-level access (returns boolean only — no Where support). + * Use in field `access.read/update`. + */ +export declare const adminOnlyField: FieldAccess; +export declare const adminOrEditorField: FieldAccess; +/** Requires at least the given role, for field-level access. */ +export declare const requireRoleField: (minimum: Role) => FieldAccess; diff --git a/dist/modules/access/access.js b/dist/modules/access/access.js new file mode 100644 index 0000000..16e56d0 --- /dev/null +++ b/dist/modules/access/access.js @@ -0,0 +1,29 @@ +import { hasMinimumRole, isAdmin } from './predicates.js'; +/** + * Collection-level access (returns boolean | Where). + * Use in collection `access.read/create/update/delete`. + */ export const adminOnly = ({ req: { user } })=>isAdmin(user); +export const adminOrEditor = ({ req: { user } })=>hasMinimumRole(user, 'editor'); +export const authenticated = ({ req: { user } })=>Boolean(user); +/** Requires at least the given role. */ export const requireRole = (minimum)=>({ req: { user } })=>hasMinimumRole(user, minimum); +/** Admins see all; others are constrained to their own document. */ export const adminOrSelf = ({ req: { user } })=>{ + if (isAdmin(user)) { + return true; + } + if (!user) { + return false; + } + return { + id: { + equals: user.id + } + }; +}; +/** + * Field-level access (returns boolean only — no Where support). + * Use in field `access.read/update`. + */ export const adminOnlyField = ({ req: { user } })=>isAdmin(user); +export const adminOrEditorField = ({ req: { user } })=>hasMinimumRole(user, 'editor'); +/** Requires at least the given role, for field-level access. */ export const requireRoleField = (minimum)=>({ req: { user } })=>hasMinimumRole(user, minimum); + +//# sourceMappingURL=access.js.map \ No newline at end of file diff --git a/dist/modules/access/access.js.map b/dist/modules/access/access.js.map new file mode 100644 index 0000000..4fd68e0 --- /dev/null +++ b/dist/modules/access/access.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/access.ts"],"sourcesContent":["import type { Access, FieldAccess } from 'payload'\n\nimport type { Role } from './types.js'\n\nimport { hasMinimumRole, isAdmin } from './predicates.js'\n\n/**\n * Collection-level access (returns boolean | Where).\n * Use in collection `access.read/create/update/delete`.\n */\nexport const adminOnly: Access = ({ req: { user } }) => isAdmin(user)\n\nexport const adminOrEditor: Access = ({ req: { user } }) => hasMinimumRole(user, 'editor')\n\nexport const authenticated: Access = ({ req: { user } }) => Boolean(user)\n\n/** Requires at least the given role. */\nexport const requireRole =\n (minimum: Role): Access =>\n ({ req: { user } }) =>\n hasMinimumRole(user, minimum)\n\n/** Admins see all; others are constrained to their own document. */\nexport const adminOrSelf: Access = ({ req: { user } }) => {\n if (isAdmin(user)) {return true}\n if (!user) {return false}\n return { id: { equals: user.id } }\n}\n\n/**\n * Field-level access (returns boolean only — no Where support).\n * Use in field `access.read/update`.\n */\nexport const adminOnlyField: FieldAccess = ({ req: { user } }) => isAdmin(user)\n\nexport const adminOrEditorField: FieldAccess = ({ req: { user } }) => hasMinimumRole(user, 'editor')\n\n/** Requires at least the given role, for field-level access. */\nexport const requireRoleField =\n (minimum: Role): FieldAccess =>\n ({ req: { user } }) =>\n hasMinimumRole(user, minimum)\n"],"names":["hasMinimumRole","isAdmin","adminOnly","req","user","adminOrEditor","authenticated","Boolean","requireRole","minimum","adminOrSelf","id","equals","adminOnlyField","adminOrEditorField","requireRoleField"],"mappings":"AAIA,SAASA,cAAc,EAAEC,OAAO,QAAQ,kBAAiB;AAEzD;;;CAGC,GACD,OAAO,MAAMC,YAAoB,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKH,QAAQG,MAAK;AAErE,OAAO,MAAMC,gBAAwB,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKJ,eAAeI,MAAM,UAAS;AAE1F,OAAO,MAAME,gBAAwB,CAAC,EAAEH,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKG,QAAQH,MAAK;AAEzE,sCAAsC,GACtC,OAAO,MAAMI,cACX,CAACC,UACD,CAAC,EAAEN,KAAK,EAAEC,IAAI,EAAE,EAAE,GAChBJ,eAAeI,MAAMK,SAAQ;AAEjC,kEAAkE,GAClE,OAAO,MAAMC,cAAsB,CAAC,EAAEP,KAAK,EAAEC,IAAI,EAAE,EAAE;IACnD,IAAIH,QAAQG,OAAO;QAAC,OAAO;IAAI;IAC/B,IAAI,CAACA,MAAM;QAAC,OAAO;IAAK;IACxB,OAAO;QAAEO,IAAI;YAAEC,QAAQR,KAAKO,EAAE;QAAC;IAAE;AACnC,EAAC;AAED;;;CAGC,GACD,OAAO,MAAME,iBAA8B,CAAC,EAAEV,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKH,QAAQG,MAAK;AAE/E,OAAO,MAAMU,qBAAkC,CAAC,EAAEX,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKJ,eAAeI,MAAM,UAAS;AAEpG,8DAA8D,GAC9D,OAAO,MAAMW,mBACX,CAACN,UACD,CAAC,EAAEN,KAAK,EAAEC,IAAI,EAAE,EAAE,GAChBJ,eAAeI,MAAMK,SAAQ"} \ No newline at end of file diff --git a/dist/modules/access/index.d.js b/dist/modules/access/index.d.js new file mode 100644 index 0000000..a74c734 --- /dev/null +++ b/dist/modules/access/index.d.js @@ -0,0 +1,7 @@ +export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, requireRole, requireRoleField } from './access.js'; +export { injectRoles } from './injectRoles.js'; +export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'; +export { buildRolesField } from './rolesField.js'; +export { ROLE_HIERARCHY } from './types.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/access/index.d.js.map b/dist/modules/access/index.d.js.map new file mode 100644 index 0000000..d36fa92 --- /dev/null +++ b/dist/modules/access/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, requireRole, requireRoleField, } from './access.js';\nexport { injectRoles } from './injectRoles.js';\nexport { hasMinimumRole, isAdmin, isEditor } from './predicates.js';\nexport { buildRolesField } from './rolesField.js';\nexport type { AccessOption, Role } from './types.js';\nexport { ROLE_HIERARCHY } from './types.js';\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","requireRole","requireRoleField","injectRoles","hasMinimumRole","isAdmin","isEditor","buildRolesField","ROLE_HIERARCHY"],"mappings":"AAAA,SAASA,SAAS,EAAEC,cAAc,EAAEC,aAAa,EAAEC,kBAAkB,EAAEC,WAAW,EAAEC,aAAa,EAAEC,WAAW,EAAEC,gBAAgB,QAAS,cAAc;AACvJ,SAASC,WAAW,QAAQ,mBAAmB;AAC/C,SAASC,cAAc,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,kBAAkB;AACpE,SAASC,eAAe,QAAQ,kBAAkB;AAElD,SAASC,cAAc,QAAQ,aAAa"} \ No newline at end of file diff --git a/dist/modules/access/index.d.ts b/dist/modules/access/index.d.ts new file mode 100644 index 0000000..e6a8d16 --- /dev/null +++ b/dist/modules/access/index.d.ts @@ -0,0 +1,6 @@ +export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, requireRole, requireRoleField, } from './access.js'; +export { injectRoles } from './injectRoles.js'; +export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'; +export { buildRolesField } from './rolesField.js'; +export type { AccessOption, Role } from './types.js'; +export { ROLE_HIERARCHY } from './types.js'; diff --git a/dist/modules/access/index.js b/dist/modules/access/index.js new file mode 100644 index 0000000..f466bc5 --- /dev/null +++ b/dist/modules/access/index.js @@ -0,0 +1,7 @@ +export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSelf, authenticated, requireRole, requireRoleField } from './access.js'; +export { injectRoles } from './injectRoles.js'; +export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'; +export { buildRolesField } from './rolesField.js'; +export { ROLE_HIERARCHY } from './types.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/access/index.js.map b/dist/modules/access/index.js.map new file mode 100644 index 0000000..6a3b064 --- /dev/null +++ b/dist/modules/access/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/index.ts"],"sourcesContent":["export {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n requireRole,\n requireRoleField,\n} from './access.js'\nexport { injectRoles } from './injectRoles.js'\nexport { hasMinimumRole, isAdmin, isEditor } from './predicates.js'\nexport { buildRolesField } from './rolesField.js'\nexport type { AccessOption, Role } from './types.js'\nexport { ROLE_HIERARCHY } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","requireRole","requireRoleField","injectRoles","hasMinimumRole","isAdmin","isEditor","buildRolesField","ROLE_HIERARCHY"],"mappings":"AAAA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,WAAW,EACXC,gBAAgB,QACX,cAAa;AACpB,SAASC,WAAW,QAAQ,mBAAkB;AAC9C,SAASC,cAAc,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,kBAAiB;AACnE,SAASC,eAAe,QAAQ,kBAAiB;AAEjD,SAASC,cAAc,QAAQ,aAAY"} \ No newline at end of file diff --git a/dist/modules/access/injectRoles.d.js b/dist/modules/access/injectRoles.d.js new file mode 100644 index 0000000..98150da --- /dev/null +++ b/dist/modules/access/injectRoles.d.js @@ -0,0 +1,9 @@ +/** + * Injects the fixed `roles` field into the client's auth collection. + * + * The plugin owns the role definition; the client owns the collection. This + * finds the collection by slug and appends the field. We control the whole + * stack, so no conflict handling is needed — the field is simply added. + */ export { }; + +//# sourceMappingURL=injectRoles.d.js.map \ No newline at end of file diff --git a/dist/modules/access/injectRoles.d.js.map b/dist/modules/access/injectRoles.d.js.map new file mode 100644 index 0000000..b39baa0 --- /dev/null +++ b/dist/modules/access/injectRoles.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["injectRoles.d.ts"],"sourcesContent":["import type { Config } from 'payload';\nimport type { AccessOption } from './types.js';\n/**\n * Injects the fixed `roles` field into the client's auth collection.\n *\n * The plugin owns the role definition; the client owns the collection. This\n * finds the collection by slug and appends the field. We control the whole\n * stack, so no conflict handling is needed — the field is simply added.\n */\nexport declare function injectRoles(config: Config, access: AccessOption): Config;\n"],"names":[],"mappings":"AAEA;;;;;;CAMC,GACD,WAAkF"} \ No newline at end of file diff --git a/dist/modules/access/injectRoles.d.ts b/dist/modules/access/injectRoles.d.ts new file mode 100644 index 0000000..41d8a0d --- /dev/null +++ b/dist/modules/access/injectRoles.d.ts @@ -0,0 +1,10 @@ +import type { Config } from 'payload'; +import type { AccessOption } from './types.js'; +/** + * Injects the fixed `roles` field into the client's auth collection. + * + * The plugin owns the role definition; the client owns the collection. This + * finds the collection by slug and appends the field. We control the whole + * stack, so no conflict handling is needed — the field is simply added. + */ +export declare function injectRoles(config: Config, access: AccessOption): Config; diff --git a/dist/modules/access/injectRoles.js b/dist/modules/access/injectRoles.js new file mode 100644 index 0000000..e43897b --- /dev/null +++ b/dist/modules/access/injectRoles.js @@ -0,0 +1,27 @@ +import { buildRolesField } from './rolesField.js'; +/** + * Injects the fixed `roles` field into the client's auth collection. + * + * The plugin owns the role definition; the client owns the collection. This + * finds the collection by slug and appends the field. We control the whole + * stack, so no conflict handling is needed — the field is simply added. + */ export function injectRoles(config, access) { + const rolesField = buildRolesField(access.defaultRole); + return { + ...config, + collections: (config.collections ?? []).map((collection)=>{ + if (collection.slug !== access.authCollection) { + return collection; + } + return { + ...collection, + fields: [ + ...collection.fields, + rolesField + ] + }; + }) + }; +} + +//# sourceMappingURL=injectRoles.js.map \ No newline at end of file diff --git a/dist/modules/access/injectRoles.js.map b/dist/modules/access/injectRoles.js.map new file mode 100644 index 0000000..8e74bdf --- /dev/null +++ b/dist/modules/access/injectRoles.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/injectRoles.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { AccessOption } from './types.js'\n\nimport { buildRolesField } from './rolesField.js'\n\n/**\n * Injects the fixed `roles` field into the client's auth collection.\n *\n * The plugin owns the role definition; the client owns the collection. This\n * finds the collection by slug and appends the field. We control the whole\n * stack, so no conflict handling is needed — the field is simply added.\n */\nexport function injectRoles(config: Config, access: AccessOption): Config {\n const rolesField = buildRolesField(access.defaultRole)\n\n return {\n ...config,\n collections: (config.collections ?? []).map((collection) => {\n if (collection.slug !== access.authCollection) {\n return collection\n }\n return {\n ...collection,\n fields: [...collection.fields, rolesField],\n }\n }),\n }\n}\n"],"names":["buildRolesField","injectRoles","config","access","rolesField","defaultRole","collections","map","collection","slug","authCollection","fields"],"mappings":"AAIA,SAASA,eAAe,QAAQ,kBAAiB;AAEjD;;;;;;CAMC,GACD,OAAO,SAASC,YAAYC,MAAc,EAAEC,MAAoB;IAC9D,MAAMC,aAAaJ,gBAAgBG,OAAOE,WAAW;IAErD,OAAO;QACL,GAAGH,MAAM;QACTI,aAAa,AAACJ,CAAAA,OAAOI,WAAW,IAAI,EAAE,AAAD,EAAGC,GAAG,CAAC,CAACC;YAC3C,IAAIA,WAAWC,IAAI,KAAKN,OAAOO,cAAc,EAAE;gBAC7C,OAAOF;YACT;YACA,OAAO;gBACL,GAAGA,UAAU;gBACbG,QAAQ;uBAAIH,WAAWG,MAAM;oBAAEP;iBAAW;YAC5C;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/access/predicates.d.js b/dist/modules/access/predicates.d.js new file mode 100644 index 0000000..1349e70 --- /dev/null +++ b/dist/modules/access/predicates.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=predicates.d.js.map \ No newline at end of file diff --git a/dist/modules/access/predicates.d.js.map b/dist/modules/access/predicates.d.js.map new file mode 100644 index 0000000..f1d72b6 --- /dev/null +++ b/dist/modules/access/predicates.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["predicates.d.ts"],"sourcesContent":["import type { Role } from './types.js';\n/**\n * The plugin can't know the client's generated User type, and Payload types\n * `req.user` loosely (UntypedUser | null). Predicates therefore accept an\n * unknown-ish user and read `roles` defensively — no assumptions about shape\n * beyond an optional roles array.\n */\ntype MaybeUser = {\n roles?: null | Role[];\n} | null | Record | undefined;\n/**\n * True if the user holds at least the given role in the hierarchy.\n * admin satisfies 'editor' and 'user'; editor satisfies 'user'.\n */\nexport declare function hasMinimumRole(user: MaybeUser, minimum: Role): boolean;\n/** True if the user is an admin. */\nexport declare function isAdmin(user: MaybeUser): boolean;\n/** True if the user is an editor or higher (editor, admin). */\nexport declare function isEditor(user: MaybeUser): boolean;\nexport {};\n"],"names":[],"mappings":"AAmBA,WAAU"} \ No newline at end of file diff --git a/dist/modules/access/predicates.d.ts b/dist/modules/access/predicates.d.ts new file mode 100644 index 0000000..50d0f3b --- /dev/null +++ b/dist/modules/access/predicates.d.ts @@ -0,0 +1,20 @@ +import type { Role } from './types.js'; +/** + * The plugin can't know the client's generated User type, and Payload types + * `req.user` loosely (UntypedUser | null). Predicates therefore accept an + * unknown-ish user and read `roles` defensively — no assumptions about shape + * beyond an optional roles array. + */ +type MaybeUser = { + roles?: null | Role[]; +} | null | Record | undefined; +/** + * True if the user holds at least the given role in the hierarchy. + * admin satisfies 'editor' and 'user'; editor satisfies 'user'. + */ +export declare function hasMinimumRole(user: MaybeUser, minimum: Role): boolean; +/** True if the user is an admin. */ +export declare function isAdmin(user: MaybeUser): boolean; +/** True if the user is an editor or higher (editor, admin). */ +export declare function isEditor(user: MaybeUser): boolean; +export {}; diff --git a/dist/modules/access/predicates.js b/dist/modules/access/predicates.js new file mode 100644 index 0000000..b68a956 --- /dev/null +++ b/dist/modules/access/predicates.js @@ -0,0 +1,32 @@ +import { ROLE_HIERARCHY } from './types.js'; +/** Safely extracts the roles array from a loosely-typed user. */ function getRoles(user) { + if (!user || typeof user !== 'object') { + return []; + } + const roles = user.roles; + if (!Array.isArray(roles)) { + return []; + } + return roles.filter((role)=>ROLE_HIERARCHY.includes(role)); +} +/** Highest-privilege role index the user holds, or -1 if none. */ function highestRoleIndex(user) { + const roles = getRoles(user); + if (!roles.length) { + return -1; + } + return Math.max(...roles.map((role)=>ROLE_HIERARCHY.indexOf(role))); +} +/** + * True if the user holds at least the given role in the hierarchy. + * admin satisfies 'editor' and 'user'; editor satisfies 'user'. + */ export function hasMinimumRole(user, minimum) { + return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum); +} +/** True if the user is an admin. */ export function isAdmin(user) { + return hasMinimumRole(user, 'admin'); +} +/** True if the user is an editor or higher (editor, admin). */ export function isEditor(user) { + return hasMinimumRole(user, 'editor'); +} + +//# sourceMappingURL=predicates.js.map \ No newline at end of file diff --git a/dist/modules/access/predicates.js.map b/dist/modules/access/predicates.js.map new file mode 100644 index 0000000..cd6db80 --- /dev/null +++ b/dist/modules/access/predicates.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/predicates.ts"],"sourcesContent":["import type { Role } from './types.js'\n\nimport { ROLE_HIERARCHY } from './types.js'\n\n/**\n * The plugin can't know the client's generated User type, and Payload types\n * `req.user` loosely (UntypedUser | null). Predicates therefore accept an\n * unknown-ish user and read `roles` defensively — no assumptions about shape\n * beyond an optional roles array.\n */\ntype MaybeUser = { roles?: null | Role[] } | null | Record | undefined\n\n/** Safely extracts the roles array from a loosely-typed user. */\nfunction getRoles(user: MaybeUser): Role[] {\n if (!user || typeof user !== 'object') {return []}\n const roles = (user as { roles?: unknown }).roles\n if (!Array.isArray(roles)) {return []}\n return roles.filter((role): role is Role => ROLE_HIERARCHY.includes(role as Role))\n}\n\n/** Highest-privilege role index the user holds, or -1 if none. */\nfunction highestRoleIndex(user: MaybeUser): number {\n const roles = getRoles(user)\n if (!roles.length) {return -1}\n return Math.max(...roles.map((role) => ROLE_HIERARCHY.indexOf(role)))\n}\n\n/**\n * True if the user holds at least the given role in the hierarchy.\n * admin satisfies 'editor' and 'user'; editor satisfies 'user'.\n */\nexport function hasMinimumRole(user: MaybeUser, minimum: Role): boolean {\n return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum)\n}\n\n/** True if the user is an admin. */\nexport function isAdmin(user: MaybeUser): boolean {\n return hasMinimumRole(user, 'admin')\n}\n\n/** True if the user is an editor or higher (editor, admin). */\nexport function isEditor(user: MaybeUser): boolean {\n return hasMinimumRole(user, 'editor')\n}\n"],"names":["ROLE_HIERARCHY","getRoles","user","roles","Array","isArray","filter","role","includes","highestRoleIndex","length","Math","max","map","indexOf","hasMinimumRole","minimum","isAdmin","isEditor"],"mappings":"AAEA,SAASA,cAAc,QAAQ,aAAY;AAU3C,+DAA+D,GAC/D,SAASC,SAASC,IAAe;IAC/B,IAAI,CAACA,QAAQ,OAAOA,SAAS,UAAU;QAAC,OAAO,EAAE;IAAA;IACjD,MAAMC,QAAQ,AAACD,KAA6BC,KAAK;IACjD,IAAI,CAACC,MAAMC,OAAO,CAACF,QAAQ;QAAC,OAAO,EAAE;IAAA;IACrC,OAAOA,MAAMG,MAAM,CAAC,CAACC,OAAuBP,eAAeQ,QAAQ,CAACD;AACtE;AAEA,gEAAgE,GAChE,SAASE,iBAAiBP,IAAe;IACvC,MAAMC,QAAQF,SAASC;IACvB,IAAI,CAACC,MAAMO,MAAM,EAAE;QAAC,OAAO,CAAC;IAAC;IAC7B,OAAOC,KAAKC,GAAG,IAAIT,MAAMU,GAAG,CAAC,CAACN,OAASP,eAAec,OAAO,CAACP;AAChE;AAEA;;;CAGC,GACD,OAAO,SAASQ,eAAeb,IAAe,EAAEc,OAAa;IAC3D,OAAOP,iBAAiBP,SAASF,eAAec,OAAO,CAACE;AAC1D;AAEA,kCAAkC,GAClC,OAAO,SAASC,QAAQf,IAAe;IACrC,OAAOa,eAAeb,MAAM;AAC9B;AAEA,6DAA6D,GAC7D,OAAO,SAASgB,SAAShB,IAAe;IACtC,OAAOa,eAAeb,MAAM;AAC9B"} \ No newline at end of file diff --git a/dist/modules/access/rolesField.d.js b/dist/modules/access/rolesField.d.js new file mode 100644 index 0000000..9a55fa1 --- /dev/null +++ b/dist/modules/access/rolesField.d.js @@ -0,0 +1,8 @@ +/** + * Builds the fixed `roles` field the plugin injects into the auth collection. + * + * Saved to the JWT so role checks avoid a database lookup. Only admins can + * change roles, preventing privilege escalation by lower-privilege users. + */ export { }; + +//# sourceMappingURL=rolesField.d.js.map \ No newline at end of file diff --git a/dist/modules/access/rolesField.d.js.map b/dist/modules/access/rolesField.d.js.map new file mode 100644 index 0000000..8016ab2 --- /dev/null +++ b/dist/modules/access/rolesField.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["rolesField.d.ts"],"sourcesContent":["import type { Field } from 'payload';\nimport type { Role } from './types.js';\n/**\n * Builds the fixed `roles` field the plugin injects into the auth collection.\n *\n * Saved to the JWT so role checks avoid a database lookup. Only admins can\n * change roles, preventing privilege escalation by lower-privilege users.\n */\nexport declare function buildRolesField(defaultRole?: Role): Field;\n"],"names":[],"mappings":"AAEA;;;;;CAKC,GACD,WAAmE"} \ No newline at end of file diff --git a/dist/modules/access/rolesField.d.ts b/dist/modules/access/rolesField.d.ts new file mode 100644 index 0000000..7724f6d --- /dev/null +++ b/dist/modules/access/rolesField.d.ts @@ -0,0 +1,9 @@ +import type { Field } from 'payload'; +import type { Role } from './types.js'; +/** + * Builds the fixed `roles` field the plugin injects into the auth collection. + * + * Saved to the JWT so role checks avoid a database lookup. Only admins can + * change roles, preventing privilege escalation by lower-privilege users. + */ +export declare function buildRolesField(defaultRole?: Role): Field; diff --git a/dist/modules/access/rolesField.js b/dist/modules/access/rolesField.js new file mode 100644 index 0000000..73f474b --- /dev/null +++ b/dist/modules/access/rolesField.js @@ -0,0 +1,32 @@ +import { isAdmin } from './predicates.js'; +import { ROLE_HIERARCHY } from './types.js'; +/** + * Builds the fixed `roles` field the plugin injects into the auth collection. + * + * Saved to the JWT so role checks avoid a database lookup. Only admins can + * change roles, preventing privilege escalation by lower-privilege users. + */ export function buildRolesField(defaultRole = 'user') { + return { + name: 'roles', + type: 'select', + access: { + // Only admins may assign or change roles + update: ({ req: { user } })=>isAdmin(user) + }, + admin: { + description: 'Role hierarchy: admin > editor > user.' + }, + defaultValue: [ + defaultRole + ], + hasMany: true, + options: ROLE_HIERARCHY.map((role)=>({ + label: role.charAt(0).toUpperCase() + role.slice(1), + value: role + })), + required: true, + saveToJWT: true + }; +} + +//# sourceMappingURL=rolesField.js.map \ No newline at end of file diff --git a/dist/modules/access/rolesField.js.map b/dist/modules/access/rolesField.js.map new file mode 100644 index 0000000..ac8872c --- /dev/null +++ b/dist/modules/access/rolesField.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/rolesField.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { Role } from './types.js'\n\nimport { isAdmin } from './predicates.js'\nimport { ROLE_HIERARCHY } from './types.js'\n\n/**\n * Builds the fixed `roles` field the plugin injects into the auth collection.\n *\n * Saved to the JWT so role checks avoid a database lookup. Only admins can\n * change roles, preventing privilege escalation by lower-privilege users.\n */\nexport function buildRolesField(defaultRole: Role = 'user'): Field {\n return {\n name: 'roles',\n type: 'select',\n access: {\n // Only admins may assign or change roles\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n description: 'Role hierarchy: admin > editor > user.',\n },\n defaultValue: [defaultRole],\n hasMany: true,\n options: ROLE_HIERARCHY.map((role) => ({\n label: role.charAt(0).toUpperCase() + role.slice(1),\n value: role,\n })),\n required: true,\n saveToJWT: true,\n }\n}\n"],"names":["isAdmin","ROLE_HIERARCHY","buildRolesField","defaultRole","name","type","access","update","req","user","admin","description","defaultValue","hasMany","options","map","role","label","charAt","toUpperCase","slice","value","required","saveToJWT"],"mappings":"AAIA,SAASA,OAAO,QAAQ,kBAAiB;AACzC,SAASC,cAAc,QAAQ,aAAY;AAE3C;;;;;CAKC,GACD,OAAO,SAASC,gBAAgBC,cAAoB,MAAM;IACxD,OAAO;QACLC,MAAM;QACNC,MAAM;QACNC,QAAQ;YACN,yCAAyC;YACzCC,QAAQ,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKT,QAAQS;QACzC;QACAC,OAAO;YACLC,aAAa;QACf;QACAC,cAAc;YAACT;SAAY;QAC3BU,SAAS;QACTC,SAASb,eAAec,GAAG,CAAC,CAACC,OAAU,CAAA;gBACrCC,OAAOD,KAAKE,MAAM,CAAC,GAAGC,WAAW,KAAKH,KAAKI,KAAK,CAAC;gBACjDC,OAAOL;YACT,CAAA;QACAM,UAAU;QACVC,WAAW;IACb;AACF"} \ No newline at end of file diff --git a/dist/modules/access/types.d.js b/dist/modules/access/types.d.js new file mode 100644 index 0000000..e9eeb54 --- /dev/null +++ b/dist/modules/access/types.d.js @@ -0,0 +1,12 @@ +/** + * Role hierarchy, lowest to highest privilege. + * A higher role satisfies any requirement met by a lower one. + */ /** + * Access-control options. + * + * The plugin injects a fixed `roles` field into the client's auth collection + * — the collection itself belongs to the client (create-payload-app), the + * role definition belongs to the plugin. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/access/types.d.js.map b/dist/modules/access/types.d.js.map new file mode 100644 index 0000000..7d2c1ee --- /dev/null +++ b/dist/modules/access/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["/**\n * Role hierarchy, lowest to highest privilege.\n * A higher role satisfies any requirement met by a lower one.\n */\nexport declare const ROLE_HIERARCHY: readonly [\"user\", \"editor\", \"admin\"];\nexport type Role = (typeof ROLE_HIERARCHY)[number];\n/**\n * Access-control options.\n *\n * The plugin injects a fixed `roles` field into the client's auth collection\n * — the collection itself belongs to the client (create-payload-app), the\n * role definition belongs to the plugin.\n */\nexport type AccessOption = {\n /** Slug of the client's auth collection, e.g. 'users'. */\n authCollection: string;\n /** Role assigned to new users. Defaults to 'user'. */\n defaultRole?: Role;\n};\n"],"names":[],"mappings":"AAAA;;;CAGC,GAGD;;;;;;CAMC,GACD,WAKE"} \ No newline at end of file diff --git a/dist/modules/access/types.d.ts b/dist/modules/access/types.d.ts new file mode 100644 index 0000000..851d4cd --- /dev/null +++ b/dist/modules/access/types.d.ts @@ -0,0 +1,19 @@ +/** + * Role hierarchy, lowest to highest privilege. + * A higher role satisfies any requirement met by a lower one. + */ +export declare const ROLE_HIERARCHY: readonly ["user", "editor", "admin"]; +export type Role = (typeof ROLE_HIERARCHY)[number]; +/** + * Access-control options. + * + * The plugin injects a fixed `roles` field into the client's auth collection + * — the collection itself belongs to the client (create-payload-app), the + * role definition belongs to the plugin. + */ +export type AccessOption = { + /** Slug of the client's auth collection, e.g. 'users'. */ + authCollection: string; + /** Role assigned to new users. Defaults to 'user'. */ + defaultRole?: Role; +}; diff --git a/dist/modules/access/types.js b/dist/modules/access/types.js new file mode 100644 index 0000000..3afbaa8 --- /dev/null +++ b/dist/modules/access/types.js @@ -0,0 +1,10 @@ +/** + * Role hierarchy, lowest to highest privilege. + * A higher role satisfies any requirement met by a lower one. + */ export const ROLE_HIERARCHY = [ + 'user', + 'editor', + 'admin' +]; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/access/types.js.map b/dist/modules/access/types.js.map new file mode 100644 index 0000000..795ba05 --- /dev/null +++ b/dist/modules/access/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/access/types.ts"],"sourcesContent":["/**\n * Role hierarchy, lowest to highest privilege.\n * A higher role satisfies any requirement met by a lower one.\n */\nexport const ROLE_HIERARCHY = ['user', 'editor', 'admin'] as const\n\nexport type Role = (typeof ROLE_HIERARCHY)[number]\n\n/**\n * Access-control options.\n *\n * The plugin injects a fixed `roles` field into the client's auth collection\n * — the collection itself belongs to the client (create-payload-app), the\n * role definition belongs to the plugin.\n */\nexport type AccessOption = {\n /** Slug of the client's auth collection, e.g. 'users'. */\n authCollection: string\n /** Role assigned to new users. Defaults to 'user'. */\n defaultRole?: Role\n}\n"],"names":["ROLE_HIERARCHY"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,iBAAiB;IAAC;IAAQ;IAAU;CAAQ,CAAS"} \ No newline at end of file diff --git a/dist/modules/analytics/Analytics.d.js b/dist/modules/analytics/Analytics.d.js new file mode 100644 index 0000000..2244182 --- /dev/null +++ b/dist/modules/analytics/Analytics.d.js @@ -0,0 +1,14 @@ +/** + * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag. + * + * Consent Mode is initialised to the visitor's stored consent BEFORE the tag + * loads (via setDefaultConsent from the consent module), so tags respect the + * choice from the first hit; useConsent sends an update the moment the visitor + * decides. IDs are public and come from SiteIntegrations, passed in as props + * (the client project reads them server-side). + * + * Renders nothing — it only injects the scripts. Mount once, high in the tree + * (e.g. root layout), inside ConsentProvider. + */ export { }; + +//# sourceMappingURL=Analytics.d.js.map \ No newline at end of file diff --git a/dist/modules/analytics/Analytics.d.js.map b/dist/modules/analytics/Analytics.d.js.map new file mode 100644 index 0000000..705a67f --- /dev/null +++ b/dist/modules/analytics/Analytics.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["Analytics.d.ts"],"sourcesContent":["import type { AnalyticsConfig } from './types.js';\ndeclare global {\n interface Window {\n dataLayer?: unknown[];\n }\n}\n/**\n * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag.\n *\n * Consent Mode is initialised to the visitor's stored consent BEFORE the tag\n * loads (via setDefaultConsent from the consent module), so tags respect the\n * choice from the first hit; useConsent sends an update the moment the visitor\n * decides. IDs are public and come from SiteIntegrations, passed in as props\n * (the client project reads them server-side).\n *\n * Renders nothing — it only injects the scripts. Mount once, high in the tree\n * (e.g. root layout), inside ConsentProvider.\n */\nexport declare function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig): null;\n"],"names":[],"mappings":"AAMA;;;;;;;;;;;CAWC,GACD,WAA+F"} \ No newline at end of file diff --git a/dist/modules/analytics/Analytics.d.ts b/dist/modules/analytics/Analytics.d.ts new file mode 100644 index 0000000..4a2fd87 --- /dev/null +++ b/dist/modules/analytics/Analytics.d.ts @@ -0,0 +1,19 @@ +import type { AnalyticsConfig } from './types.js'; +declare global { + interface Window { + dataLayer?: unknown[]; + } +} +/** + * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag. + * + * Consent Mode is initialised to the visitor's stored consent BEFORE the tag + * loads (via setDefaultConsent from the consent module), so tags respect the + * choice from the first hit; useConsent sends an update the moment the visitor + * decides. IDs are public and come from SiteIntegrations, passed in as props + * (the client project reads them server-side). + * + * Renders nothing — it only injects the scripts. Mount once, high in the tree + * (e.g. root layout), inside ConsentProvider. + */ +export declare function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig): null; diff --git a/dist/modules/analytics/Analytics.js b/dist/modules/analytics/Analytics.js new file mode 100644 index 0000000..f3eef21 --- /dev/null +++ b/dist/modules/analytics/Analytics.js @@ -0,0 +1,63 @@ +'use client'; +import { useEffect } from 'react'; +import { gtag } from '../consent/googleConsent.js'; +import { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js'; +const GTM_SCRIPT = (id)=>`https://www.googletagmanager.com/gtm.js?id=${id}`; +const GA4_SCRIPT = (id)=>`https://www.googletagmanager.com/gtag/js?id=${id}`; +/** Reads the current consent state from cookie, or defaults if none. */ function readConsent() { + if (typeof document === 'undefined') { + return DEFAULT_CONSENT; + } + const raw = document.cookie.split('; ').find((c)=>c.startsWith(`${CONSENT_COOKIE}=`))?.split('=')[1]; + return parseConsent(raw) ?? DEFAULT_CONSENT; +} +function injectScript(src) { + if (document.querySelector(`script[src="${src}"]`)) { + return; + } + const s = document.createElement('script'); + s.src = src; + s.async = true; + document.head.appendChild(s); +} +/** + * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag. + * + * Consent Mode is initialised to the visitor's stored consent BEFORE the tag + * loads (via setDefaultConsent from the consent module), so tags respect the + * choice from the first hit; useConsent sends an update the moment the visitor + * decides. IDs are public and come from SiteIntegrations, passed in as props + * (the client project reads them server-side). + * + * Renders nothing — it only injects the scripts. Mount once, high in the tree + * (e.g. root layout), inside ConsentProvider. + */ export function Analytics({ ga4MeasurementId, gtmContainerId }) { + useEffect(()=>{ + if (!ga4MeasurementId && !gtmContainerId) { + return; + } + // 1. Consent Mode default = the visitor's stored choice, before tags load. + setDefaultConsent(readConsent()); + // 2. Load the tag. + if (gtmContainerId) { + window.dataLayer = window.dataLayer || []; + window.dataLayer.push({ + event: 'gtm.js', + 'gtm.start': Date.now() + }); + injectScript(GTM_SCRIPT(gtmContainerId)); + } else if (ga4MeasurementId) { + injectScript(GA4_SCRIPT(ga4MeasurementId)); + // Uses the shared gtag (pushes `arguments`, as Google's tags expect); + // real gtag.js wires itself up once the script loads. + gtag('js', new Date()); + gtag('config', ga4MeasurementId); + } + }, [ + ga4MeasurementId, + gtmContainerId + ]); + return null; +} + +//# sourceMappingURL=Analytics.js.map \ No newline at end of file diff --git a/dist/modules/analytics/Analytics.js.map b/dist/modules/analytics/Analytics.js.map new file mode 100644 index 0000000..7fa6bc1 --- /dev/null +++ b/dist/modules/analytics/Analytics.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/analytics/Analytics.tsx"],"sourcesContent":["'use client'\nimport { useEffect } from 'react'\n\nimport type { AnalyticsConfig } from './types.js'\n\nimport { gtag } from '../consent/googleConsent.js'\nimport { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js'\n\ndeclare global {\n interface Window {\n dataLayer?: unknown[]\n }\n}\n\nconst GTM_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtm.js?id=${id}`\nconst GA4_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtag/js?id=${id}`\n\n/** Reads the current consent state from cookie, or defaults if none. */\nfunction readConsent() {\n if (typeof document === 'undefined') {return DEFAULT_CONSENT}\n const raw = document.cookie\n .split('; ')\n .find((c) => c.startsWith(`${CONSENT_COOKIE}=`))\n ?.split('=')[1]\n return parseConsent(raw) ?? DEFAULT_CONSENT\n}\n\nfunction injectScript(src: string) {\n if (document.querySelector(`script[src=\"${src}\"]`)) {return}\n const s = document.createElement('script')\n s.src = src\n s.async = true\n document.head.appendChild(s)\n}\n\n/**\n * Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag.\n *\n * Consent Mode is initialised to the visitor's stored consent BEFORE the tag\n * loads (via setDefaultConsent from the consent module), so tags respect the\n * choice from the first hit; useConsent sends an update the moment the visitor\n * decides. IDs are public and come from SiteIntegrations, passed in as props\n * (the client project reads them server-side).\n *\n * Renders nothing — it only injects the scripts. Mount once, high in the tree\n * (e.g. root layout), inside ConsentProvider.\n */\nexport function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig) {\n useEffect(() => {\n if (!ga4MeasurementId && !gtmContainerId) {return}\n\n // 1. Consent Mode default = the visitor's stored choice, before tags load.\n setDefaultConsent(readConsent())\n\n // 2. Load the tag.\n if (gtmContainerId) {\n window.dataLayer = window.dataLayer || []\n window.dataLayer.push({ event: 'gtm.js', 'gtm.start': Date.now() })\n injectScript(GTM_SCRIPT(gtmContainerId))\n } else if (ga4MeasurementId) {\n injectScript(GA4_SCRIPT(ga4MeasurementId))\n // Uses the shared gtag (pushes `arguments`, as Google's tags expect);\n // real gtag.js wires itself up once the script loads.\n gtag('js', new Date())\n gtag('config', ga4MeasurementId)\n }\n }, [ga4MeasurementId, gtmContainerId])\n\n return null\n}\n"],"names":["useEffect","gtag","CONSENT_COOKIE","DEFAULT_CONSENT","parseConsent","setDefaultConsent","GTM_SCRIPT","id","GA4_SCRIPT","readConsent","document","raw","cookie","split","find","c","startsWith","injectScript","src","querySelector","s","createElement","async","head","appendChild","Analytics","ga4MeasurementId","gtmContainerId","window","dataLayer","push","event","Date","now"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,QAAO;AAIjC,SAASC,IAAI,QAAQ,8BAA6B;AAClD,SAASC,cAAc,EAAEC,eAAe,EAAEC,YAAY,EAAEC,iBAAiB,QAAS,sBAAqB;AAQvG,MAAMC,aAAa,CAACC,KAAe,CAAC,2CAA2C,EAAEA,IAAI;AACrF,MAAMC,aAAa,CAACD,KAAe,CAAC,4CAA4C,EAAEA,IAAI;AAEtF,sEAAsE,GACtE,SAASE;IACP,IAAI,OAAOC,aAAa,aAAa;QAAC,OAAOP;IAAe;IAC5D,MAAMQ,MAAMD,SAASE,MAAM,CACxBC,KAAK,CAAC,MACNC,IAAI,CAAC,CAACC,IAAMA,EAAEC,UAAU,CAAC,GAAGd,eAAe,CAAC,CAAC,IAC5CW,MAAM,IAAI,CAAC,EAAE;IACjB,OAAOT,aAAaO,QAAQR;AAC9B;AAEA,SAASc,aAAaC,GAAW;IAC/B,IAAIR,SAASS,aAAa,CAAC,CAAC,YAAY,EAAED,IAAI,EAAE,CAAC,GAAG;QAAC;IAAM;IAC3D,MAAME,IAAIV,SAASW,aAAa,CAAC;IACjCD,EAAEF,GAAG,GAAGA;IACRE,EAAEE,KAAK,GAAG;IACVZ,SAASa,IAAI,CAACC,WAAW,CAACJ;AAC5B;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASK,UAAU,EAAEC,gBAAgB,EAAEC,cAAc,EAAmB;IAC7E3B,UAAU;QACR,IAAI,CAAC0B,oBAAoB,CAACC,gBAAgB;YAAC;QAAM;QAEjD,2EAA2E;QAC3EtB,kBAAkBI;QAElB,mBAAmB;QACnB,IAAIkB,gBAAgB;YAClBC,OAAOC,SAAS,GAAGD,OAAOC,SAAS,IAAI,EAAE;YACzCD,OAAOC,SAAS,CAACC,IAAI,CAAC;gBAAEC,OAAO;gBAAU,aAAaC,KAAKC,GAAG;YAAG;YACjEhB,aAAaX,WAAWqB;QAC1B,OAAO,IAAID,kBAAkB;YAC3BT,aAAaT,WAAWkB;YACxB,sEAAsE;YACtE,sDAAsD;YACtDzB,KAAK,MAAM,IAAI+B;YACf/B,KAAK,UAAUyB;QACjB;IACF,GAAG;QAACA;QAAkBC;KAAe;IAErC,OAAO;AACT"} \ No newline at end of file diff --git a/dist/modules/analytics/client.d.js b/dist/modules/analytics/client.d.js new file mode 100644 index 0000000..d3ef406 --- /dev/null +++ b/dist/modules/analytics/client.d.js @@ -0,0 +1,3 @@ +export { Analytics } from './Analytics.js'; + +//# sourceMappingURL=client.d.js.map \ No newline at end of file diff --git a/dist/modules/analytics/client.d.js.map b/dist/modules/analytics/client.d.js.map new file mode 100644 index 0000000..8a5b4d6 --- /dev/null +++ b/dist/modules/analytics/client.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["client.d.ts"],"sourcesContent":["export { Analytics } from './Analytics.js';\n"],"names":["Analytics"],"mappings":"AAAA,SAASA,SAAS,QAAQ,iBAAiB"} \ No newline at end of file diff --git a/dist/modules/analytics/client.d.ts b/dist/modules/analytics/client.d.ts new file mode 100644 index 0000000..39e5126 --- /dev/null +++ b/dist/modules/analytics/client.d.ts @@ -0,0 +1 @@ +export { Analytics } from './Analytics.js'; diff --git a/dist/modules/analytics/client.js b/dist/modules/analytics/client.js new file mode 100644 index 0000000..8b711fa --- /dev/null +++ b/dist/modules/analytics/client.js @@ -0,0 +1,4 @@ +'use client'; +export { Analytics } from './Analytics.js'; + +//# sourceMappingURL=client.js.map \ No newline at end of file diff --git a/dist/modules/analytics/client.js.map b/dist/modules/analytics/client.js.map new file mode 100644 index 0000000..734ed82 --- /dev/null +++ b/dist/modules/analytics/client.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/analytics/client.ts"],"sourcesContent":["'use client'\nexport { Analytics } from './Analytics.js'\n"],"names":["Analytics"],"mappings":"AAAA;AACA,SAASA,SAAS,QAAQ,iBAAgB"} \ No newline at end of file diff --git a/dist/modules/analytics/getAnalyticsConfig.d.js b/dist/modules/analytics/getAnalyticsConfig.d.js new file mode 100644 index 0000000..9fbed95 --- /dev/null +++ b/dist/modules/analytics/getAnalyticsConfig.d.js @@ -0,0 +1,8 @@ +/** + * Reads the public analytics IDs from SiteIntegrations, server-side. + * + * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client + * component. Does NOT expose any secret from SiteIntegrations. + */ export { }; + +//# sourceMappingURL=getAnalyticsConfig.d.js.map \ No newline at end of file diff --git a/dist/modules/analytics/getAnalyticsConfig.d.js.map b/dist/modules/analytics/getAnalyticsConfig.d.js.map new file mode 100644 index 0000000..96be816 --- /dev/null +++ b/dist/modules/analytics/getAnalyticsConfig.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getAnalyticsConfig.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { AnalyticsConfig } from './types.js';\n/**\n * Reads the public analytics IDs from SiteIntegrations, server-side.\n *\n * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client\n * component. Does NOT expose any secret from SiteIntegrations.\n */\nexport declare function getAnalyticsConfig(payload: BasePayload): Promise;\n"],"names":[],"mappings":"AAEA;;;;;CAKC,GACD,WAA2F"} \ No newline at end of file diff --git a/dist/modules/analytics/getAnalyticsConfig.d.ts b/dist/modules/analytics/getAnalyticsConfig.d.ts new file mode 100644 index 0000000..16252a2 --- /dev/null +++ b/dist/modules/analytics/getAnalyticsConfig.d.ts @@ -0,0 +1,9 @@ +import type { BasePayload } from 'payload'; +import type { AnalyticsConfig } from './types.js'; +/** + * Reads the public analytics IDs from SiteIntegrations, server-side. + * + * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client + * component. Does NOT expose any secret from SiteIntegrations. + */ +export declare function getAnalyticsConfig(payload: BasePayload): Promise; diff --git a/dist/modules/analytics/getAnalyticsConfig.js b/dist/modules/analytics/getAnalyticsConfig.js new file mode 100644 index 0000000..2b6cfd0 --- /dev/null +++ b/dist/modules/analytics/getAnalyticsConfig.js @@ -0,0 +1,15 @@ +import { getSiteIntegrations } from '../payload/index.js'; +/** + * Reads the public analytics IDs from SiteIntegrations, server-side. + * + * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client + * component. Does NOT expose any secret from SiteIntegrations. + */ export async function getAnalyticsConfig(payload) { + const integrations = await getSiteIntegrations(payload); + return { + ga4MeasurementId: integrations.ga4MeasurementId ?? null, + gtmContainerId: integrations.gtmContainerId ?? null + }; +} + +//# sourceMappingURL=getAnalyticsConfig.js.map \ No newline at end of file diff --git a/dist/modules/analytics/getAnalyticsConfig.js.map b/dist/modules/analytics/getAnalyticsConfig.js.map new file mode 100644 index 0000000..493a328 --- /dev/null +++ b/dist/modules/analytics/getAnalyticsConfig.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/analytics/getAnalyticsConfig.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { AnalyticsConfig } from './types.js'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\n/**\n * Reads the public analytics IDs from SiteIntegrations, server-side.\n *\n * Returns only the two public IDs (GA4 / GTM) — safe to pass to the client\n * component. Does NOT expose any secret from SiteIntegrations.\n */\nexport async function getAnalyticsConfig(payload: BasePayload): Promise {\n const integrations = await getSiteIntegrations(payload)\n return {\n ga4MeasurementId: integrations.ga4MeasurementId ?? null,\n gtmContainerId: integrations.gtmContainerId ?? null,\n }\n}\n"],"names":["getSiteIntegrations","getAnalyticsConfig","payload","integrations","ga4MeasurementId","gtmContainerId"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,sBAAqB;AAEzD;;;;;CAKC,GACD,OAAO,eAAeC,mBAAmBC,OAAoB;IAC3D,MAAMC,eAAe,MAAMH,oBAAqCE;IAChE,OAAO;QACLE,kBAAkBD,aAAaC,gBAAgB,IAAI;QACnDC,gBAAgBF,aAAaE,cAAc,IAAI;IACjD;AACF"} \ No newline at end of file diff --git a/dist/modules/analytics/index.d.js b/dist/modules/analytics/index.d.js new file mode 100644 index 0000000..016dab0 --- /dev/null +++ b/dist/modules/analytics/index.d.js @@ -0,0 +1,3 @@ +export { getAnalyticsConfig } from './getAnalyticsConfig.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/analytics/index.d.js.map b/dist/modules/analytics/index.d.js.map new file mode 100644 index 0000000..fe5de2d --- /dev/null +++ b/dist/modules/analytics/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export type { AnalyticsConfig } from './types.js';\nexport { getAnalyticsConfig } from './getAnalyticsConfig.js';\n"],"names":["getAnalyticsConfig"],"mappings":"AACA,SAASA,kBAAkB,QAAQ,0BAA0B"} \ No newline at end of file diff --git a/dist/modules/analytics/index.d.ts b/dist/modules/analytics/index.d.ts new file mode 100644 index 0000000..776468d --- /dev/null +++ b/dist/modules/analytics/index.d.ts @@ -0,0 +1,2 @@ +export type { AnalyticsConfig } from './types.js'; +export { getAnalyticsConfig } from './getAnalyticsConfig.js'; diff --git a/dist/modules/analytics/index.js b/dist/modules/analytics/index.js new file mode 100644 index 0000000..11b6e49 --- /dev/null +++ b/dist/modules/analytics/index.js @@ -0,0 +1,5 @@ +// Server-safe: config type + the helper that reads IDs from SiteIntegrations. +// The component is client-side — exported via ./client. +export { getAnalyticsConfig } from './getAnalyticsConfig.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/analytics/index.js.map b/dist/modules/analytics/index.js.map new file mode 100644 index 0000000..8a3ea22 --- /dev/null +++ b/dist/modules/analytics/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/analytics/index.ts"],"sourcesContent":["// Server-safe: config type + the helper that reads IDs from SiteIntegrations.\n// The component is client-side — exported via ./client.\nexport type { AnalyticsConfig } from './types.js'\nexport { getAnalyticsConfig } from './getAnalyticsConfig.js'\n"],"names":["getAnalyticsConfig"],"mappings":"AAAA,8EAA8E;AAC9E,oEAAoE;AAEpE,SAASA,kBAAkB,QAAQ,0BAAyB"} \ No newline at end of file diff --git a/dist/modules/analytics/types.d.js b/dist/modules/analytics/types.d.js new file mode 100644 index 0000000..ba7eb3f --- /dev/null +++ b/dist/modules/analytics/types.d.js @@ -0,0 +1,5 @@ +/** + * Analytics IDs, read from SiteIntegrations (public — safe on the client). + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/analytics/types.d.js.map b/dist/modules/analytics/types.d.js.map new file mode 100644 index 0000000..76339cb --- /dev/null +++ b/dist/modules/analytics/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["/**\n * Analytics IDs, read from SiteIntegrations (public — safe on the client).\n */\nexport type AnalyticsConfig = {\n /** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */\n ga4MeasurementId?: null | string;\n /** GTM Container ID, e.g. 'GTM-XXXXXXX'. */\n gtmContainerId?: null | string;\n};\n"],"names":[],"mappings":"AAAA;;CAEC,GACD,WAKE"} \ No newline at end of file diff --git a/dist/modules/analytics/types.d.ts b/dist/modules/analytics/types.d.ts new file mode 100644 index 0000000..ac35fff --- /dev/null +++ b/dist/modules/analytics/types.d.ts @@ -0,0 +1,9 @@ +/** + * Analytics IDs, read from SiteIntegrations (public — safe on the client). + */ +export type AnalyticsConfig = { + /** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */ + ga4MeasurementId?: null | string; + /** GTM Container ID, e.g. 'GTM-XXXXXXX'. */ + gtmContainerId?: null | string; +}; diff --git a/dist/modules/analytics/types.js b/dist/modules/analytics/types.js new file mode 100644 index 0000000..93dfc2e --- /dev/null +++ b/dist/modules/analytics/types.js @@ -0,0 +1,5 @@ +/** + * Analytics IDs, read from SiteIntegrations (public — safe on the client). + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/analytics/types.js.map b/dist/modules/analytics/types.js.map new file mode 100644 index 0000000..95b37d4 --- /dev/null +++ b/dist/modules/analytics/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/analytics/types.ts"],"sourcesContent":["/**\n * Analytics IDs, read from SiteIntegrations (public — safe on the client).\n */\nexport type AnalyticsConfig = {\n /** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */\n ga4MeasurementId?: null | string\n /** GTM Container ID, e.g. 'GTM-XXXXXXX'. */\n gtmContainerId?: null | string\n}\n"],"names":[],"mappings":"AAAA;;CAEC,GACD,WAKC"} \ No newline at end of file diff --git a/dist/modules/blocks/RenderBlocks.d.js b/dist/modules/blocks/RenderBlocks.d.js new file mode 100644 index 0000000..3f1e411 --- /dev/null +++ b/dist/modules/blocks/RenderBlocks.d.js @@ -0,0 +1,21 @@ +/** + * Generic, server-side block renderer. + * + * Iterates a document's blocks and renders each via the client's component + * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is + * delegated to the client's optional `enhanceProps` — the plugin itself is + * block-agnostic. + * + * The client owns components and block schemas (they pass `components` and + * define blocks in their config); the plugin owns only the iteration and + * wiring. No wrapper markup is added — spacing/layout belong to the client's + * components. + * + * Nested blocks: a block that needs to render child blocks should render its + * own and import the registry itself, rather than receiving the + * component map as a prop. Passing the map as a prop breaks React Server + * Components — functions (client components) can't cross the server→client + * boundary via props. + */ export { }; + +//# sourceMappingURL=RenderBlocks.d.js.map \ No newline at end of file diff --git a/dist/modules/blocks/RenderBlocks.d.js.map b/dist/modules/blocks/RenderBlocks.d.js.map new file mode 100644 index 0000000..5d542e8 --- /dev/null +++ b/dist/modules/blocks/RenderBlocks.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["RenderBlocks.d.ts"],"sourcesContent":["import type { BlockComponentMap, BlockData, EnhanceProps } from './types.js';\nexport type RenderBlocksProps = {\n /** Block data array from a Payload document (e.g. page.layout). */\n blocks: BlockData[] | null | undefined;\n /** Client-provided map of blockType → component. */\n components: BlockComponentMap;\n /**\n * Optional client hook to inject block-specific props (nav anchors, etc.).\n * Keeps the engine generic — the plugin knows no concrete block types.\n */\n enhanceProps?: EnhanceProps;\n};\n/**\n * Generic, server-side block renderer.\n *\n * Iterates a document's blocks and renders each via the client's component\n * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is\n * delegated to the client's optional `enhanceProps` — the plugin itself is\n * block-agnostic.\n *\n * The client owns components and block schemas (they pass `components` and\n * define blocks in their config); the plugin owns only the iteration and\n * wiring. No wrapper markup is added — spacing/layout belong to the client's\n * components.\n *\n * Nested blocks: a block that needs to render child blocks should render its\n * own and import the registry itself, rather than receiving the\n * component map as a prop. Passing the map as a prop breaks React Server\n * Components — functions (client components) can't cross the server→client\n * boundary via props.\n */\nexport declare function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;;;;;;CAkBC,GACD,WAA8I"} \ No newline at end of file diff --git a/dist/modules/blocks/RenderBlocks.d.ts b/dist/modules/blocks/RenderBlocks.d.ts new file mode 100644 index 0000000..11eb2f8 --- /dev/null +++ b/dist/modules/blocks/RenderBlocks.d.ts @@ -0,0 +1,32 @@ +import type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'; +export type RenderBlocksProps = { + /** Block data array from a Payload document (e.g. page.layout). */ + blocks: BlockData[] | null | undefined; + /** Client-provided map of blockType → component. */ + components: BlockComponentMap; + /** + * Optional client hook to inject block-specific props (nav anchors, etc.). + * Keeps the engine generic — the plugin knows no concrete block types. + */ + enhanceProps?: EnhanceProps; +}; +/** + * Generic, server-side block renderer. + * + * Iterates a document's blocks and renders each via the client's component + * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is + * delegated to the client's optional `enhanceProps` — the plugin itself is + * block-agnostic. + * + * The client owns components and block schemas (they pass `components` and + * define blocks in their config); the plugin owns only the iteration and + * wiring. No wrapper markup is added — spacing/layout belong to the client's + * components. + * + * Nested blocks: a block that needs to render child blocks should render its + * own and import the registry itself, rather than receiving the + * component map as a prop. Passing the map as a prop breaks React Server + * Components — functions (client components) can't cross the server→client + * boundary via props. + */ +export declare function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps): import("react/jsx-runtime").JSX.Element | null; diff --git a/dist/modules/blocks/RenderBlocks.js b/dist/modules/blocks/RenderBlocks.js new file mode 100644 index 0000000..2cfb1ea --- /dev/null +++ b/dist/modules/blocks/RenderBlocks.js @@ -0,0 +1,44 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { Fragment } from 'react'; +/** + * Generic, server-side block renderer. + * + * Iterates a document's blocks and renders each via the client's component + * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is + * delegated to the client's optional `enhanceProps` — the plugin itself is + * block-agnostic. + * + * The client owns components and block schemas (they pass `components` and + * define blocks in their config); the plugin owns only the iteration and + * wiring. No wrapper markup is added — spacing/layout belong to the client's + * components. + * + * Nested blocks: a block that needs to render child blocks should render its + * own and import the registry itself, rather than receiving the + * component map as a prop. Passing the map as a prop breaks React Server + * Components — functions (client components) can't cross the server→client + * boundary via props. + */ export function RenderBlocks({ blocks, components, enhanceProps }) { + if (!blocks || !Array.isArray(blocks) || blocks.length === 0) { + return null; + } + return /*#__PURE__*/ _jsx(Fragment, { + children: blocks.map((block, index)=>{ + const Component = components[block.blockType]; + if (!Component) { + return null; + } + const extra = enhanceProps ? enhanceProps({ + allBlocks: blocks, + block, + index + }) : {}; + return /*#__PURE__*/ _jsx(Component, { + ...block, + ...extra + }, index); + }) + }); +} + +//# sourceMappingURL=RenderBlocks.js.map \ No newline at end of file diff --git a/dist/modules/blocks/RenderBlocks.js.map b/dist/modules/blocks/RenderBlocks.js.map new file mode 100644 index 0000000..a292dfe --- /dev/null +++ b/dist/modules/blocks/RenderBlocks.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/blocks/RenderBlocks.tsx"],"sourcesContent":["import { Fragment } from 'react'\n\nimport type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'\n\nexport type RenderBlocksProps = {\n /** Block data array from a Payload document (e.g. page.layout). */\n blocks: BlockData[] | null | undefined\n /** Client-provided map of blockType → component. */\n components: BlockComponentMap\n /**\n * Optional client hook to inject block-specific props (nav anchors, etc.).\n * Keeps the engine generic — the plugin knows no concrete block types.\n */\n enhanceProps?: EnhanceProps\n}\n\n/**\n * Generic, server-side block renderer.\n *\n * Iterates a document's blocks and renders each via the client's component\n * map. Unknown blocks are skipped (null), never thrown. Block-specific logic is\n * delegated to the client's optional `enhanceProps` — the plugin itself is\n * block-agnostic.\n *\n * The client owns components and block schemas (they pass `components` and\n * define blocks in their config); the plugin owns only the iteration and\n * wiring. No wrapper markup is added — spacing/layout belong to the client's\n * components.\n *\n * Nested blocks: a block that needs to render child blocks should render its\n * own and import the registry itself, rather than receiving the\n * component map as a prop. Passing the map as a prop breaks React Server\n * Components — functions (client components) can't cross the server→client\n * boundary via props.\n */\nexport function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps) {\n if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {\n return null\n }\n\n return (\n \n {blocks.map((block, index) => {\n const Component = components[block.blockType]\n if (!Component) {return null}\n\n const extra = enhanceProps ? enhanceProps({ allBlocks: blocks, block, index }) : {}\n\n return \n })}\n \n )\n}\n"],"names":["Fragment","RenderBlocks","blocks","components","enhanceProps","Array","isArray","length","map","block","index","Component","blockType","extra","allBlocks"],"mappings":";AAAA,SAASA,QAAQ,QAAQ,QAAO;AAgBhC;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,aAAa,EAAEC,MAAM,EAAEC,UAAU,EAAEC,YAAY,EAAqB;IAClF,IAAI,CAACF,UAAU,CAACG,MAAMC,OAAO,CAACJ,WAAWA,OAAOK,MAAM,KAAK,GAAG;QAC5D,OAAO;IACT;IAEA,qBACE,KAACP;kBACEE,OAAOM,GAAG,CAAC,CAACC,OAAOC;YAClB,MAAMC,YAAYR,UAAU,CAACM,MAAMG,SAAS,CAAC;YAC7C,IAAI,CAACD,WAAW;gBAAC,OAAO;YAAI;YAE5B,MAAME,QAAQT,eAAeA,aAAa;gBAAEU,WAAWZ;gBAAQO;gBAAOC;YAAM,KAAK,CAAC;YAElF,qBAAO,KAACC;gBAAuB,GAAGF,KAAK;gBAAG,GAAGI,KAAK;eAA3BH;QACzB;;AAGN"} \ No newline at end of file diff --git a/dist/modules/blocks/index.d.js b/dist/modules/blocks/index.d.js new file mode 100644 index 0000000..f9d06ce --- /dev/null +++ b/dist/modules/blocks/index.d.js @@ -0,0 +1,3 @@ +export { RenderBlocks } from './RenderBlocks.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/blocks/index.d.js.map b/dist/modules/blocks/index.d.js.map new file mode 100644 index 0000000..5a0d7d0 --- /dev/null +++ b/dist/modules/blocks/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { RenderBlocks } from './RenderBlocks.js';\nexport type { RenderBlocksProps } from './RenderBlocks.js';\nexport type { BlockComponentMap, BlockData, EnhanceProps } from './types.js';\n"],"names":["RenderBlocks"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAoB"} \ No newline at end of file diff --git a/dist/modules/blocks/index.d.ts b/dist/modules/blocks/index.d.ts new file mode 100644 index 0000000..a1d1fa5 --- /dev/null +++ b/dist/modules/blocks/index.d.ts @@ -0,0 +1,3 @@ +export { RenderBlocks } from './RenderBlocks.js'; +export type { RenderBlocksProps } from './RenderBlocks.js'; +export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'; diff --git a/dist/modules/blocks/index.js b/dist/modules/blocks/index.js new file mode 100644 index 0000000..4563f88 --- /dev/null +++ b/dist/modules/blocks/index.js @@ -0,0 +1,3 @@ +export { RenderBlocks } from './RenderBlocks.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/blocks/index.js.map b/dist/modules/blocks/index.js.map new file mode 100644 index 0000000..b502360 --- /dev/null +++ b/dist/modules/blocks/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/blocks/index.ts"],"sourcesContent":["export { RenderBlocks } from './RenderBlocks.js'\nexport type { RenderBlocksProps } from './RenderBlocks.js'\nexport type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB"} \ No newline at end of file diff --git a/dist/modules/blocks/types.d.js b/dist/modules/blocks/types.d.js new file mode 100644 index 0000000..d98e6b8 --- /dev/null +++ b/dist/modules/blocks/types.d.js @@ -0,0 +1,9 @@ +/** + * Optional per-block prop enhancer supplied by the client. + * + * Lets the client inject block-specific logic (e.g. collect anchors for a nav + * block) without the plugin knowing any concrete block types. Returns extra + * props merged into the rendered block. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/blocks/types.d.js.map b/dist/modules/blocks/types.d.js.map new file mode 100644 index 0000000..a84c798 --- /dev/null +++ b/dist/modules/blocks/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["import type { ComponentType } from 'react';\n/**\n * A single block's data as stored by Payload — always has a blockType, plus\n * arbitrary block-specific fields. The plugin stays generic over the shape.\n */\nexport type BlockData = {\n [key: string]: unknown;\n blockType: string;\n};\n/**\n * Maps a blockType to the client's component for it.\n * The client owns the components; the plugin only receives this map.\n */\nexport type BlockComponentMap = Record>;\n/**\n * Optional per-block prop enhancer supplied by the client.\n *\n * Lets the client inject block-specific logic (e.g. collect anchors for a nav\n * block) without the plugin knowing any concrete block types. Returns extra\n * props merged into the rendered block.\n */\nexport type EnhanceProps = (args: {\n allBlocks: BlockData[];\n block: BlockData;\n index: number;\n}) => Record;\n"],"names":[],"mappings":"AAcA;;;;;;CAMC,GACD,WAI8B"} \ No newline at end of file diff --git a/dist/modules/blocks/types.d.ts b/dist/modules/blocks/types.d.ts new file mode 100644 index 0000000..22e8c8b --- /dev/null +++ b/dist/modules/blocks/types.d.ts @@ -0,0 +1,26 @@ +import type { ComponentType } from 'react'; +/** + * A single block's data as stored by Payload — always has a blockType, plus + * arbitrary block-specific fields. The plugin stays generic over the shape. + */ +export type BlockData = { + [key: string]: unknown; + blockType: string; +}; +/** + * Maps a blockType to the client's component for it. + * The client owns the components; the plugin only receives this map. + */ +export type BlockComponentMap = Record>; +/** + * Optional per-block prop enhancer supplied by the client. + * + * Lets the client inject block-specific logic (e.g. collect anchors for a nav + * block) without the plugin knowing any concrete block types. Returns extra + * props merged into the rendered block. + */ +export type EnhanceProps = (args: { + allBlocks: BlockData[]; + block: BlockData; + index: number; +}) => Record; diff --git a/dist/modules/blocks/types.js b/dist/modules/blocks/types.js new file mode 100644 index 0000000..f7a3e54 --- /dev/null +++ b/dist/modules/blocks/types.js @@ -0,0 +1,9 @@ +/** + * Optional per-block prop enhancer supplied by the client. + * + * Lets the client inject block-specific logic (e.g. collect anchors for a nav + * block) without the plugin knowing any concrete block types. Returns extra + * props merged into the rendered block. + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/blocks/types.js.map b/dist/modules/blocks/types.js.map new file mode 100644 index 0000000..9650f08 --- /dev/null +++ b/dist/modules/blocks/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/blocks/types.ts"],"sourcesContent":["import type { ComponentType } from 'react'\n\n/**\n * A single block's data as stored by Payload — always has a blockType, plus\n * arbitrary block-specific fields. The plugin stays generic over the shape.\n */\nexport type BlockData = {\n [key: string]: unknown\n blockType: string\n}\n\n/**\n * Maps a blockType to the client's component for it.\n * The client owns the components; the plugin only receives this map.\n */\nexport type BlockComponentMap = Record>\n\n/**\n * Optional per-block prop enhancer supplied by the client.\n *\n * Lets the client inject block-specific logic (e.g. collect anchors for a nav\n * block) without the plugin knowing any concrete block types. Returns extra\n * props merged into the rendered block.\n */\nexport type EnhanceProps = (args: {\n allBlocks: BlockData[]\n block: BlockData\n index: number\n}) => Record\n"],"names":[],"mappings":"AAiBA;;;;;;CAMC,GACD,WAI6B"} \ No newline at end of file diff --git a/dist/modules/consent/ConsentContext.d.js b/dist/modules/consent/ConsentContext.d.js new file mode 100644 index 0000000..2d8f9c0 --- /dev/null +++ b/dist/modules/consent/ConsentContext.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=ConsentContext.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/ConsentContext.d.js.map b/dist/modules/consent/ConsentContext.d.js.map new file mode 100644 index 0000000..272b3d3 --- /dev/null +++ b/dist/modules/consent/ConsentContext.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["ConsentContext.d.ts"],"sourcesContent":["import { type ReactNode } from 'react';\nimport type { ConsentTexts } from './texts.js';\nimport { useConsent } from './useConsent.js';\ntype ConsentContextValue = {\n texts: ConsentTexts;\n} & ReturnType;\nexport declare function ConsentProvider({ children, texts }: {\n children: ReactNode;\n texts: ConsentTexts;\n}): import(\"react/jsx-runtime\").JSX.Element;\nexport declare function useConsentContext(): ConsentContextValue;\nexport {};\n"],"names":[],"mappings":"AAWA,WAAU"} \ No newline at end of file diff --git a/dist/modules/consent/ConsentContext.d.ts b/dist/modules/consent/ConsentContext.d.ts new file mode 100644 index 0000000..27b3fb6 --- /dev/null +++ b/dist/modules/consent/ConsentContext.d.ts @@ -0,0 +1,12 @@ +import { type ReactNode } from 'react'; +import type { ConsentTexts } from './texts.js'; +import { useConsent } from './useConsent.js'; +type ConsentContextValue = { + texts: ConsentTexts; +} & ReturnType; +export declare function ConsentProvider({ children, texts }: { + children: ReactNode; + texts: ConsentTexts; +}): import("react/jsx-runtime").JSX.Element; +export declare function useConsentContext(): ConsentContextValue; +export {}; diff --git a/dist/modules/consent/ConsentContext.js b/dist/modules/consent/ConsentContext.js new file mode 100644 index 0000000..77a88bc --- /dev/null +++ b/dist/modules/consent/ConsentContext.js @@ -0,0 +1,24 @@ +'use client'; +import { jsx as _jsx } from "react/jsx-runtime"; +import { createContext, use } from 'react'; +import { useConsent } from './useConsent.js'; +const ConsentContext = /*#__PURE__*/ createContext(null); +export function ConsentProvider({ children, texts }) { + const consent = useConsent(); + return /*#__PURE__*/ _jsx(ConsentContext, { + value: { + ...consent, + texts + }, + children: children + }); +} +export function useConsentContext() { + const ctx = use(ConsentContext); + if (!ctx) { + throw new Error('useConsentContext must be used within ConsentProvider'); + } + return ctx; +} + +//# sourceMappingURL=ConsentContext.js.map \ No newline at end of file diff --git a/dist/modules/consent/ConsentContext.js.map b/dist/modules/consent/ConsentContext.js.map new file mode 100644 index 0000000..a5c7f2e --- /dev/null +++ b/dist/modules/consent/ConsentContext.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/ConsentContext.tsx"],"sourcesContent":["'use client'\nimport { createContext, type ReactNode, use } from 'react'\n\nimport type { ConsentTexts } from './texts.js'\n\nimport { useConsent } from './useConsent.js'\n\ntype ConsentContextValue = { texts: ConsentTexts } & ReturnType\n\nconst ConsentContext = createContext(null)\n\nexport function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) {\n const consent = useConsent()\n return {children}\n}\n\nexport function useConsentContext(): ConsentContextValue {\n const ctx = use(ConsentContext)\n if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')}\n return ctx\n}\n"],"names":["createContext","use","useConsent","ConsentContext","ConsentProvider","children","texts","consent","value","useConsentContext","ctx","Error"],"mappings":"AAAA;;AACA,SAASA,aAAa,EAAkBC,GAAG,QAAQ,QAAO;AAI1D,SAASC,UAAU,QAAQ,kBAAiB;AAI5C,MAAMC,+BAAiBH,cAA0C;AAEjE,OAAO,SAASI,gBAAgB,EAAEC,QAAQ,EAAEC,KAAK,EAAgD;IAC/F,MAAMC,UAAUL;IAChB,qBAAO,KAACC;QAAeK,OAAO;YAAE,GAAGD,OAAO;YAAED;QAAM;kBAAID;;AACxD;AAEA,OAAO,SAASI;IACd,MAAMC,MAAMT,IAAIE;IAChB,IAAI,CAACO,KAAK;QAAC,MAAM,IAAIC,MAAM;IAAwD;IACnF,OAAOD;AACT"} \ No newline at end of file diff --git a/dist/modules/consent/CookieBanner.d.js b/dist/modules/consent/CookieBanner.d.js new file mode 100644 index 0000000..46e14b0 --- /dev/null +++ b/dist/modules/consent/CookieBanner.d.js @@ -0,0 +1,6 @@ +/** + * Optional class overrides — for when tokens aren't enough, e.g. turning the + * bottom bar into a centred modal. Replaces the slot's classes outright. + */ export { }; + +//# sourceMappingURL=CookieBanner.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/CookieBanner.d.js.map b/dist/modules/consent/CookieBanner.d.js.map new file mode 100644 index 0000000..3bc1a8a --- /dev/null +++ b/dist/modules/consent/CookieBanner.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["CookieBanner.d.ts"],"sourcesContent":["/**\n * Optional class overrides — for when tokens aren't enough, e.g. turning the\n * bottom bar into a centred modal. Replaces the slot's classes outright.\n */\nexport type CookieBannerClassNames = {\n primaryButton?: string;\n root?: string;\n secondaryButton?: string;\n};\nexport declare function CookieBanner({ classNames }?: {\n classNames?: CookieBannerClassNames;\n}): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAAA;;;CAGC,GAMD,WAEmD"} \ No newline at end of file diff --git a/dist/modules/consent/CookieBanner.d.ts b/dist/modules/consent/CookieBanner.d.ts new file mode 100644 index 0000000..f9fb179 --- /dev/null +++ b/dist/modules/consent/CookieBanner.d.ts @@ -0,0 +1,12 @@ +/** + * Optional class overrides — for when tokens aren't enough, e.g. turning the + * bottom bar into a centred modal. Replaces the slot's classes outright. + */ +export type CookieBannerClassNames = { + primaryButton?: string; + root?: string; + secondaryButton?: string; +}; +export declare function CookieBanner({ classNames }?: { + classNames?: CookieBannerClassNames; +}): import("react/jsx-runtime").JSX.Element | null; diff --git a/dist/modules/consent/CookieBanner.js b/dist/modules/consent/CookieBanner.js new file mode 100644 index 0000000..aae002e --- /dev/null +++ b/dist/modules/consent/CookieBanner.js @@ -0,0 +1,179 @@ +'use client'; +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { Cookie } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { CONSENT_CATEGORIES } from './categories.js'; +import { useConsentContext } from './ConsentContext.js'; +/** + * Colours and radius come from CSS custom properties with built-in fallbacks, + * so the banner looks right with no setup, and re-skinning per client means + * declaring a few variables — no imports, no props, no specificity fights: + * + * ```css + * :root { + * --ipal-primary: #16a34a; + * --ipal-radius: 1rem; + * } + * ``` + * + * Available: --ipal-surface, --ipal-border, --ipal-text, --ipal-text-strong, + * --ipal-text-muted, --ipal-primary, --ipal-primary-hover, --ipal-primary-text, + * --ipal-hover, --ipal-radius. Each has a `-dark` counterpart used under + * Tailwind's `dark:` variant (e.g. --ipal-surface-dark). + * + * Layout stays in Tailwind: spacing and flow aren't things a brand changes, and + * exposing them as tokens would mean re-inventing CSS one variable at a time. + */ const surface = 'bg-[var(--ipal-surface,#fff)] dark:bg-[var(--ipal-surface-dark,#171717)]'; +const border = 'border-[var(--ipal-border,#e5e5e5)] dark:border-[var(--ipal-border-dark,#262626)]'; +const radius = 'rounded-[var(--ipal-radius,0.375rem)]'; +const accent = 'text-[var(--ipal-primary,#2563eb)]'; +const defaults = { + primaryButton: `${radius} px-3 py-1.5 text-sm font-medium bg-[var(--ipal-primary,#2563eb)] text-[var(--ipal-primary-text,#fff)] hover:bg-[var(--ipal-primary-hover,#1d4ed8)]`, + root: `fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg ${surface} ${border}`, + secondaryButton: `${radius} px-3 py-1.5 text-sm font-medium text-[var(--ipal-text,#404040)] hover:bg-[var(--ipal-hover,#f5f5f5)] dark:text-[var(--ipal-text-dark,#e5e5e5)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]` +}; +const bodyText = 'text-[var(--ipal-text,#404040)] dark:text-[var(--ipal-text-dark,#d4d4d4)]'; +const strongText = 'text-[var(--ipal-text-strong,#171717)] dark:text-[var(--ipal-text-strong-dark,#f5f5f5)]'; +const mutedText = 'text-[var(--ipal-text-muted,#737373)] dark:text-[var(--ipal-text-muted-dark,#a3a3a3)]'; +export function CookieBanner({ classNames } = {}) { + const { acceptAll, decided, rejectAll, savePreferences, state, texts } = useConsentContext(); + const [showSettings, setShowSettings] = useState(false); + const [choices, setChoices] = useState(state); + useEffect(()=>{ + setChoices(state); + }, [ + state + ]); + if (decided) { + return null; + } + const toggle = (cat)=>{ + if (cat === 'necessary') { + return; + } + setChoices((prev)=>({ + ...prev, + [cat]: !prev[cat] + })); + }; + const rootClass = classNames?.root ?? defaults.root; + const primaryClass = classNames?.primaryButton ?? defaults.primaryButton; + const secondaryClass = classNames?.secondaryButton ?? defaults.secondaryButton; + return /*#__PURE__*/ _jsx("div", { + "aria-label": "Cookie consent", + className: rootClass, + "data-ipal": "banner", + role: "dialog", + children: /*#__PURE__*/ _jsx("div", { + className: "mx-auto max-w-4xl", + children: !showSettings ? /*#__PURE__*/ _jsxs("div", { + className: "flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", + children: [ + /*#__PURE__*/ _jsxs("div", { + className: "flex items-start gap-3", + children: [ + /*#__PURE__*/ _jsx(Cookie, { + "aria-hidden": "true", + className: `h-6 w-6 shrink-0 ${accent}` + }), + /*#__PURE__*/ _jsxs("p", { + className: `text-justify text-sm ${bodyText}`, + children: [ + texts.message, + texts.privacyLink && /*#__PURE__*/ _jsxs(_Fragment, { + children: [ + ' ', + /*#__PURE__*/ _jsx("a", { + className: `${accent} underline hover:no-underline`, + href: texts.privacyLink.href, + children: texts.privacyLink.label + }) + ] + }) + ] + }) + ] + }), + /*#__PURE__*/ _jsxs("div", { + className: "flex shrink-0 gap-2", + children: [ + /*#__PURE__*/ _jsx("button", { + className: secondaryClass, + onClick: ()=>setShowSettings(true), + children: texts.buttons.settings + }), + /*#__PURE__*/ _jsx("button", { + className: secondaryClass, + onClick: rejectAll, + children: texts.buttons.reject + }), + /*#__PURE__*/ _jsx("button", { + className: primaryClass, + onClick: acceptAll, + children: texts.buttons.acceptAll + }) + ] + }) + ] + }) : /*#__PURE__*/ _jsxs("div", { + className: "flex flex-col gap-4", + children: [ + /*#__PURE__*/ _jsxs("h2", { + className: `flex items-center gap-2 text-base font-semibold ${strongText}`, + children: [ + /*#__PURE__*/ _jsx(Cookie, { + "aria-hidden": "true", + className: `h-5 w-5 ${accent}` + }), + texts.settingsTitle + ] + }), + /*#__PURE__*/ _jsx("ul", { + className: "flex flex-col gap-3", + children: CONSENT_CATEGORIES.map((cat)=>/*#__PURE__*/ _jsxs("li", { + className: "flex items-start justify-between gap-4", + children: [ + /*#__PURE__*/ _jsxs("div", { + children: [ + /*#__PURE__*/ _jsx("p", { + className: `text-sm font-medium ${strongText}`, + children: texts.categories[cat].title + }), + /*#__PURE__*/ _jsx("p", { + className: `text-xs ${mutedText}`, + children: texts.categories[cat].description + }) + ] + }), + /*#__PURE__*/ _jsx("input", { + checked: cat === 'necessary' ? true : choices[cat], + className: "mt-1 h-4 w-4 shrink-0 accent-[var(--ipal-primary,#2563eb)]", + disabled: cat === 'necessary', + onChange: ()=>toggle(cat), + type: "checkbox" + }) + ] + }, cat)) + }), + /*#__PURE__*/ _jsxs("div", { + className: "flex justify-end gap-2", + children: [ + /*#__PURE__*/ _jsx("button", { + className: secondaryClass, + onClick: ()=>setShowSettings(false), + children: texts.buttons.back + }), + /*#__PURE__*/ _jsx("button", { + className: primaryClass, + onClick: ()=>savePreferences(choices), + children: texts.buttons.save + }) + ] + }) + ] + }) + }) + }); +} + +//# sourceMappingURL=CookieBanner.js.map \ No newline at end of file diff --git a/dist/modules/consent/CookieBanner.js.map b/dist/modules/consent/CookieBanner.js.map new file mode 100644 index 0000000..f268c1a --- /dev/null +++ b/dist/modules/consent/CookieBanner.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/CookieBanner.tsx"],"sourcesContent":["'use client'\nimport { Cookie } from 'lucide-react'\nimport { useEffect, useState } from 'react'\n\nimport { CONSENT_CATEGORIES, type ConsentCategory } from './categories.js'\nimport { useConsentContext } from './ConsentContext.js'\n\n/**\n * Optional class overrides — for when tokens aren't enough, e.g. turning the\n * bottom bar into a centred modal. Replaces the slot's classes outright.\n */\nexport type CookieBannerClassNames = {\n primaryButton?: string\n root?: string\n secondaryButton?: string\n}\n\n/**\n * Colours and radius come from CSS custom properties with built-in fallbacks,\n * so the banner looks right with no setup, and re-skinning per client means\n * declaring a few variables — no imports, no props, no specificity fights:\n *\n * ```css\n * :root {\n * --ipal-primary: #16a34a;\n * --ipal-radius: 1rem;\n * }\n * ```\n *\n * Available: --ipal-surface, --ipal-border, --ipal-text, --ipal-text-strong,\n * --ipal-text-muted, --ipal-primary, --ipal-primary-hover, --ipal-primary-text,\n * --ipal-hover, --ipal-radius. Each has a `-dark` counterpart used under\n * Tailwind's `dark:` variant (e.g. --ipal-surface-dark).\n *\n * Layout stays in Tailwind: spacing and flow aren't things a brand changes, and\n * exposing them as tokens would mean re-inventing CSS one variable at a time.\n */\nconst surface = 'bg-[var(--ipal-surface,#fff)] dark:bg-[var(--ipal-surface-dark,#171717)]'\nconst border = 'border-[var(--ipal-border,#e5e5e5)] dark:border-[var(--ipal-border-dark,#262626)]'\nconst radius = 'rounded-[var(--ipal-radius,0.375rem)]'\nconst accent = 'text-[var(--ipal-primary,#2563eb)]'\n\nconst defaults = {\n primaryButton: `${radius} px-3 py-1.5 text-sm font-medium bg-[var(--ipal-primary,#2563eb)] text-[var(--ipal-primary-text,#fff)] hover:bg-[var(--ipal-primary-hover,#1d4ed8)]`,\n root: `fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg ${surface} ${border}`,\n secondaryButton: `${radius} px-3 py-1.5 text-sm font-medium text-[var(--ipal-text,#404040)] hover:bg-[var(--ipal-hover,#f5f5f5)] dark:text-[var(--ipal-text-dark,#e5e5e5)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]`,\n}\n\nconst bodyText = 'text-[var(--ipal-text,#404040)] dark:text-[var(--ipal-text-dark,#d4d4d4)]'\nconst strongText =\n 'text-[var(--ipal-text-strong,#171717)] dark:text-[var(--ipal-text-strong-dark,#f5f5f5)]'\nconst mutedText =\n 'text-[var(--ipal-text-muted,#737373)] dark:text-[var(--ipal-text-muted-dark,#a3a3a3)]'\n\nexport function CookieBanner({ classNames }: { classNames?: CookieBannerClassNames } = {}) {\n const { acceptAll, decided, rejectAll, savePreferences, state, texts } = useConsentContext()\n const [showSettings, setShowSettings] = useState(false)\n const [choices, setChoices] = useState>(state)\n\n useEffect(() => {\n setChoices(state)\n }, [state])\n\n if (decided) {return null}\n\n const toggle = (cat: ConsentCategory) => {\n if (cat === 'necessary') {return}\n setChoices((prev) => ({ ...prev, [cat]: !prev[cat] }))\n }\n\n const rootClass = classNames?.root ?? defaults.root\n const primaryClass = classNames?.primaryButton ?? defaults.primaryButton\n const secondaryClass = classNames?.secondaryButton ?? defaults.secondaryButton\n\n return (\n
\n
\n {!showSettings ? (\n
\n
\n \n

\n {texts.message}\n {texts.privacyLink && (\n <>\n {' '}\n \n {texts.privacyLink.label}\n \n \n )}\n

\n
\n
\n \n \n \n
\n
\n ) : (\n
\n

\n \n {texts.settingsTitle}\n

\n
    \n {CONSENT_CATEGORIES.map((cat) => (\n
  • \n
    \n

    \n {texts.categories[cat].title}\n

    \n

    {texts.categories[cat].description}

    \n
    \n toggle(cat)}\n type=\"checkbox\"\n />\n
  • \n ))}\n
\n
\n \n \n
\n
\n )}\n
\n
\n )\n}\n"],"names":["Cookie","useEffect","useState","CONSENT_CATEGORIES","useConsentContext","surface","border","radius","accent","defaults","primaryButton","root","secondaryButton","bodyText","strongText","mutedText","CookieBanner","classNames","acceptAll","decided","rejectAll","savePreferences","state","texts","showSettings","setShowSettings","choices","setChoices","toggle","cat","prev","rootClass","primaryClass","secondaryClass","div","aria-label","className","data-ipal","role","aria-hidden","p","message","privacyLink","a","href","label","button","onClick","buttons","settings","reject","h2","settingsTitle","ul","map","li","categories","title","description","input","checked","disabled","onChange","type","back","save"],"mappings":"AAAA;;AACA,SAASA,MAAM,QAAQ,eAAc;AACrC,SAASC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAE3C,SAASC,kBAAkB,QAA8B,kBAAiB;AAC1E,SAASC,iBAAiB,QAAQ,sBAAqB;AAYvD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,MAAMC,UAAU;AAChB,MAAMC,SAAS;AACf,MAAMC,SAAS;AACf,MAAMC,SAAS;AAEf,MAAMC,WAAW;IACfC,eAAe,GAAGH,OAAO,mJAAmJ,CAAC;IAC7KI,MAAM,CAAC,qDAAqD,EAAEN,QAAQ,CAAC,EAAEC,QAAQ;IACjFM,iBAAiB,GAAGL,OAAO,8LAA8L,CAAC;AAC5N;AAEA,MAAMM,WAAW;AACjB,MAAMC,aACJ;AACF,MAAMC,YACJ;AAEF,OAAO,SAASC,aAAa,EAAEC,UAAU,EAA2C,GAAG,CAAC,CAAC;IACvF,MAAM,EAAEC,SAAS,EAAEC,OAAO,EAAEC,SAAS,EAAEC,eAAe,EAAEC,KAAK,EAAEC,KAAK,EAAE,GAAGnB;IACzE,MAAM,CAACoB,cAAcC,gBAAgB,GAAGvB,SAAS;IACjD,MAAM,CAACwB,SAASC,WAAW,GAAGzB,SAA2CoB;IAEzErB,UAAU;QACR0B,WAAWL;IACb,GAAG;QAACA;KAAM;IAEV,IAAIH,SAAS;QAAC,OAAO;IAAI;IAEzB,MAAMS,SAAS,CAACC;QACd,IAAIA,QAAQ,aAAa;YAAC;QAAM;QAChCF,WAAW,CAACG,OAAU,CAAA;gBAAE,GAAGA,IAAI;gBAAE,CAACD,IAAI,EAAE,CAACC,IAAI,CAACD,IAAI;YAAC,CAAA;IACrD;IAEA,MAAME,YAAYd,YAAYN,QAAQF,SAASE,IAAI;IACnD,MAAMqB,eAAef,YAAYP,iBAAiBD,SAASC,aAAa;IACxE,MAAMuB,iBAAiBhB,YAAYL,mBAAmBH,SAASG,eAAe;IAE9E,qBACE,KAACsB;QAAIC,cAAW;QAAiBC,WAAWL;QAAWM,aAAU;QAASC,MAAK;kBAC7E,cAAA,KAACJ;YAAIE,WAAU;sBACZ,CAACZ,6BACA,MAACU;gBAAIE,WAAU;;kCACb,MAACF;wBAAIE,WAAU;;0CACb,KAACpC;gCAAOuC,eAAY;gCAAOH,WAAW,CAAC,iBAAiB,EAAE5B,QAAQ;;0CAClE,MAACgC;gCAAEJ,WAAW,CAAC,qBAAqB,EAAEvB,UAAU;;oCAC7CU,MAAMkB,OAAO;oCACblB,MAAMmB,WAAW,kBAChB;;4CACG;0DACD,KAACC;gDACCP,WAAW,GAAG5B,OAAO,6BAA6B,CAAC;gDACnDoC,MAAMrB,MAAMmB,WAAW,CAACE,IAAI;0DAE3BrB,MAAMmB,WAAW,CAACG,KAAK;;;;;;;;kCAMlC,MAACX;wBAAIE,WAAU;;0CACb,KAACU;gCAAOV,WAAWH;gCAAgBc,SAAS,IAAMtB,gBAAgB;0CAC/DF,MAAMyB,OAAO,CAACC,QAAQ;;0CAEzB,KAACH;gCAAOV,WAAWH;gCAAgBc,SAAS3B;0CACzCG,MAAMyB,OAAO,CAACE,MAAM;;0CAEvB,KAACJ;gCAAOV,WAAWJ;gCAAce,SAAS7B;0CACvCK,MAAMyB,OAAO,CAAC9B,SAAS;;;;;+BAK9B,MAACgB;gBAAIE,WAAU;;kCACb,MAACe;wBAAGf,WAAW,CAAC,gDAAgD,EAAEtB,YAAY;;0CAC5E,KAACd;gCAAOuC,eAAY;gCAAOH,WAAW,CAAC,QAAQ,EAAE5B,QAAQ;;4BACxDe,MAAM6B,aAAa;;;kCAEtB,KAACC;wBAAGjB,WAAU;kCACXjC,mBAAmBmD,GAAG,CAAC,CAACzB,oBACvB,MAAC0B;gCAAGnB,WAAU;;kDACZ,MAACF;;0DACC,KAACM;gDAAEJ,WAAW,CAAC,oBAAoB,EAAEtB,YAAY;0DAC9CS,MAAMiC,UAAU,CAAC3B,IAAI,CAAC4B,KAAK;;0DAE9B,KAACjB;gDAAEJ,WAAW,CAAC,QAAQ,EAAErB,WAAW;0DAAGQ,MAAMiC,UAAU,CAAC3B,IAAI,CAAC6B,WAAW;;;;kDAE1E,KAACC;wCACCC,SAAS/B,QAAQ,cAAc,OAAOH,OAAO,CAACG,IAAI;wCAClDO,WAAU;wCACVyB,UAAUhC,QAAQ;wCAClBiC,UAAU,IAAMlC,OAAOC;wCACvBkC,MAAK;;;+BAZmDlC;;kCAiBhE,MAACK;wBAAIE,WAAU;;0CACb,KAACU;gCAAOV,WAAWH;gCAAgBc,SAAS,IAAMtB,gBAAgB;0CAC/DF,MAAMyB,OAAO,CAACgB,IAAI;;0CAErB,KAAClB;gCAAOV,WAAWJ;gCAAce,SAAS,IAAM1B,gBAAgBK;0CAC7DH,MAAMyB,OAAO,CAACiB,IAAI;;;;;;;;AAQnC"} \ No newline at end of file diff --git a/dist/modules/consent/CookieButton.d.js b/dist/modules/consent/CookieButton.d.js new file mode 100644 index 0000000..b627aec --- /dev/null +++ b/dist/modules/consent/CookieButton.d.js @@ -0,0 +1,12 @@ +/** + * Floating "cookie settings" button — lets visitors reopen the consent panel + * after deciding (GDPR: withdrawing consent must be as easy as giving it). + * Hidden while the banner is showing. + * + * Colours follow the same CSS custom properties as CookieBanner + * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's + * palette applies to both without touching either component. Pass className to + * replace the styling outright. + */ export { }; + +//# sourceMappingURL=CookieButton.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/CookieButton.d.js.map b/dist/modules/consent/CookieButton.d.js.map new file mode 100644 index 0000000..f493d3b --- /dev/null +++ b/dist/modules/consent/CookieButton.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["CookieButton.d.ts"],"sourcesContent":["/**\n * Floating \"cookie settings\" button — lets visitors reopen the consent panel\n * after deciding (GDPR: withdrawing consent must be as easy as giving it).\n * Hidden while the banner is showing.\n *\n * Colours follow the same CSS custom properties as CookieBanner\n * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's\n * palette applies to both without touching either component. Pass className to\n * replace the styling outright.\n */\nexport declare function CookieButton({ className }?: {\n className?: string;\n}): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAAA;;;;;;;;;CASC,GACD,WAEmD"} \ No newline at end of file diff --git a/dist/modules/consent/CookieButton.d.ts b/dist/modules/consent/CookieButton.d.ts new file mode 100644 index 0000000..c099fab --- /dev/null +++ b/dist/modules/consent/CookieButton.d.ts @@ -0,0 +1,13 @@ +/** + * Floating "cookie settings" button — lets visitors reopen the consent panel + * after deciding (GDPR: withdrawing consent must be as easy as giving it). + * Hidden while the banner is showing. + * + * Colours follow the same CSS custom properties as CookieBanner + * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's + * palette applies to both without touching either component. Pass className to + * replace the styling outright. + */ +export declare function CookieButton({ className }?: { + className?: string; +}): import("react/jsx-runtime").JSX.Element | null; diff --git a/dist/modules/consent/CookieButton.js b/dist/modules/consent/CookieButton.js new file mode 100644 index 0000000..9b8ec8e --- /dev/null +++ b/dist/modules/consent/CookieButton.js @@ -0,0 +1,32 @@ +'use client'; +import { jsx as _jsx } from "react/jsx-runtime"; +import { Cookie } from 'lucide-react'; +import { useConsentContext } from './ConsentContext.js'; +/** + * Floating "cookie settings" button — lets visitors reopen the consent panel + * after deciding (GDPR: withdrawing consent must be as easy as giving it). + * Hidden while the banner is showing. + * + * Colours follow the same CSS custom properties as CookieBanner + * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's + * palette applies to both without touching either component. Pass className to + * replace the styling outright. + */ export function CookieButton({ className } = {}) { + const { decided, reopen } = useConsentContext(); + if (!decided) { + return null; + } + const cls = className ?? 'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' + 'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' + 'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'; + return /*#__PURE__*/ _jsx("button", { + "aria-label": "Cookie settings", + className: cls, + "data-ipal": "cookie-button", + onClick: reopen, + children: /*#__PURE__*/ _jsx(Cookie, { + "aria-hidden": "true", + className: "h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]" + }) + }); +} + +//# sourceMappingURL=CookieButton.js.map \ No newline at end of file diff --git a/dist/modules/consent/CookieButton.js.map b/dist/modules/consent/CookieButton.js.map new file mode 100644 index 0000000..42d2f2e --- /dev/null +++ b/dist/modules/consent/CookieButton.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/CookieButton.tsx"],"sourcesContent":["'use client'\nimport { Cookie } from 'lucide-react'\n\nimport { useConsentContext } from './ConsentContext.js'\n\n/**\n * Floating \"cookie settings\" button — lets visitors reopen the consent panel\n * after deciding (GDPR: withdrawing consent must be as easy as giving it).\n * Hidden while the banner is showing.\n *\n * Colours follow the same CSS custom properties as CookieBanner\n * (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's\n * palette applies to both without touching either component. Pass className to\n * replace the styling outright.\n */\nexport function CookieButton({ className }: { className?: string } = {}) {\n const { decided, reopen } = useConsentContext()\n\n if (!decided) {return null}\n\n const cls =\n className ??\n 'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' +\n 'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' +\n 'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'\n\n return (\n \n )\n}\n"],"names":["Cookie","useConsentContext","CookieButton","className","decided","reopen","cls","button","aria-label","data-ipal","onClick","aria-hidden"],"mappings":"AAAA;;AACA,SAASA,MAAM,QAAQ,eAAc;AAErC,SAASC,iBAAiB,QAAQ,sBAAqB;AAEvD;;;;;;;;;CASC,GACD,OAAO,SAASC,aAAa,EAAEC,SAAS,EAA0B,GAAG,CAAC,CAAC;IACrE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAE,GAAGJ;IAE5B,IAAI,CAACG,SAAS;QAAC,OAAO;IAAI;IAE1B,MAAME,MACJH,aACA,2HACE,4GACA;IAEJ,qBACE,KAACI;QAAOC,cAAW;QAAkBL,WAAWG;QAAKG,aAAU;QAAgBC,SAASL;kBACtF,cAAA,KAACL;YACCW,eAAY;YACZR,WAAU;;;AAIlB"} \ No newline at end of file diff --git a/dist/modules/consent/categories.d.js b/dist/modules/consent/categories.d.js new file mode 100644 index 0000000..c59e3ef --- /dev/null +++ b/dist/modules/consent/categories.d.js @@ -0,0 +1,6 @@ +/** + * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be + * disabled. The rest default to false until the user opts in. + */ export { }; + +//# sourceMappingURL=categories.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/categories.d.js.map b/dist/modules/consent/categories.d.js.map new file mode 100644 index 0000000..b7bac6f --- /dev/null +++ b/dist/modules/consent/categories.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["categories.d.ts"],"sourcesContent":["/**\n * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be\n * disabled. The rest default to false until the user opts in.\n */\nexport declare const CONSENT_CATEGORIES: readonly [\"necessary\", \"functional\", \"analytics\", \"marketing\"];\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];\nexport type ConsentState = Record;\nexport declare const DEFAULT_CONSENT: ConsentState;\nexport declare const ACCEPT_ALL_CONSENT: ConsentState;\nexport declare const REJECT_ALL_CONSENT: ConsentState;\n"],"names":[],"mappings":"AAAA;;;CAGC,GAMD,WAAsD"} \ No newline at end of file diff --git a/dist/modules/consent/categories.d.ts b/dist/modules/consent/categories.d.ts new file mode 100644 index 0000000..aeefb63 --- /dev/null +++ b/dist/modules/consent/categories.d.ts @@ -0,0 +1,10 @@ +/** + * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be + * disabled. The rest default to false until the user opts in. + */ +export declare const CONSENT_CATEGORIES: readonly ["necessary", "functional", "analytics", "marketing"]; +export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]; +export type ConsentState = Record; +export declare const DEFAULT_CONSENT: ConsentState; +export declare const ACCEPT_ALL_CONSENT: ConsentState; +export declare const REJECT_ALL_CONSENT: ConsentState; diff --git a/dist/modules/consent/categories.js b/dist/modules/consent/categories.js new file mode 100644 index 0000000..5a7fc4d --- /dev/null +++ b/dist/modules/consent/categories.js @@ -0,0 +1,26 @@ +/** + * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be + * disabled. The rest default to false until the user opts in. + */ export const CONSENT_CATEGORIES = [ + 'necessary', + 'functional', + 'analytics', + 'marketing' +]; +export const DEFAULT_CONSENT = { + necessary: true, + functional: false, + analytics: false, + marketing: false +}; +export const ACCEPT_ALL_CONSENT = { + necessary: true, + functional: true, + analytics: true, + marketing: true +}; +export const REJECT_ALL_CONSENT = { + ...DEFAULT_CONSENT +}; + +//# sourceMappingURL=categories.js.map \ No newline at end of file diff --git a/dist/modules/consent/categories.js.map b/dist/modules/consent/categories.js.map new file mode 100644 index 0000000..20e7768 --- /dev/null +++ b/dist/modules/consent/categories.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/categories.ts"],"sourcesContent":["/**\n * Cookie consent categories (GDPR). 'necessary' is always granted and cannot be\n * disabled. The rest default to false until the user opts in.\n */\nexport const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'] as const\n\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\nexport type ConsentState = Record\n\nexport const DEFAULT_CONSENT: ConsentState = {\n necessary: true,\n functional: false,\n analytics: false,\n marketing: false,\n}\n\nexport const ACCEPT_ALL_CONSENT: ConsentState = {\n necessary: true,\n functional: true,\n analytics: true,\n marketing: true,\n}\n\nexport const REJECT_ALL_CONSENT: ConsentState = { ...DEFAULT_CONSENT }\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","necessary","functional","analytics","marketing","ACCEPT_ALL_CONSENT","REJECT_ALL_CONSENT"],"mappings":"AAAA;;;CAGC,GACD,OAAO,MAAMA,qBAAqB;IAAC;IAAa;IAAc;IAAa;CAAY,CAAS;AAMhG,OAAO,MAAMC,kBAAgC;IAC3CC,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAMC,qBAAmC;IAC9CJ,WAAW;IACXC,YAAY;IACZC,WAAW;IACXC,WAAW;AACb,EAAC;AAED,OAAO,MAAME,qBAAmC;IAAE,GAAGN,eAAe;AAAC,EAAC"} \ No newline at end of file diff --git a/dist/modules/consent/client.d.js b/dist/modules/consent/client.d.js new file mode 100644 index 0000000..4384a0d --- /dev/null +++ b/dist/modules/consent/client.d.js @@ -0,0 +1,6 @@ +export { ConsentProvider, useConsentContext } from './ConsentContext.js'; +export { CookieBanner } from './CookieBanner.js'; +export { CookieButton } from './CookieButton.js'; +export { useConsent } from './useConsent.js'; + +//# sourceMappingURL=client.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/client.d.js.map b/dist/modules/consent/client.d.js.map new file mode 100644 index 0000000..95c3d1d --- /dev/null +++ b/dist/modules/consent/client.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["client.d.ts"],"sourcesContent":["export { ConsentProvider, useConsentContext } from './ConsentContext.js';\nexport { CookieBanner } from './CookieBanner.js';\nexport type { CookieBannerClassNames } from './CookieBanner.js';\nexport { CookieButton } from './CookieButton.js';\nexport { useConsent } from './useConsent.js';\n"],"names":["ConsentProvider","useConsentContext","CookieBanner","CookieButton","useConsent"],"mappings":"AAAA,SAASA,eAAe,EAAEC,iBAAiB,QAAQ,sBAAsB;AACzE,SAASC,YAAY,QAAQ,oBAAoB;AAEjD,SAASC,YAAY,QAAQ,oBAAoB;AACjD,SAASC,UAAU,QAAQ,kBAAkB"} \ No newline at end of file diff --git a/dist/modules/consent/client.d.ts b/dist/modules/consent/client.d.ts new file mode 100644 index 0000000..a6f0d8b --- /dev/null +++ b/dist/modules/consent/client.d.ts @@ -0,0 +1,5 @@ +export { ConsentProvider, useConsentContext } from './ConsentContext.js'; +export { CookieBanner } from './CookieBanner.js'; +export type { CookieBannerClassNames } from './CookieBanner.js'; +export { CookieButton } from './CookieButton.js'; +export { useConsent } from './useConsent.js'; diff --git a/dist/modules/consent/client.js b/dist/modules/consent/client.js new file mode 100644 index 0000000..12946c3 --- /dev/null +++ b/dist/modules/consent/client.js @@ -0,0 +1,8 @@ +'use client'; +export { ConsentProvider, useConsentContext } from './ConsentContext.js'; +export { CookieBanner } from './CookieBanner.js'; +export { CookieButton } from './CookieButton.js'; +// Client-only consent exports — hook, provider, and UI components. +export { useConsent } from './useConsent.js'; + +//# sourceMappingURL=client.js.map \ No newline at end of file diff --git a/dist/modules/consent/client.js.map b/dist/modules/consent/client.js.map new file mode 100644 index 0000000..e28050d --- /dev/null +++ b/dist/modules/consent/client.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/client.ts"],"sourcesContent":["'use client'\nexport { ConsentProvider, useConsentContext } from './ConsentContext.js'\nexport { CookieBanner } from './CookieBanner.js'\nexport type { CookieBannerClassNames } from './CookieBanner.js'\nexport { CookieButton } from './CookieButton.js'\n// Client-only consent exports — hook, provider, and UI components.\nexport { useConsent } from './useConsent.js'\n"],"names":["ConsentProvider","useConsentContext","CookieBanner","CookieButton","useConsent"],"mappings":"AAAA;AACA,SAASA,eAAe,EAAEC,iBAAiB,QAAQ,sBAAqB;AACxE,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,mEAAmE;AACnE,SAASC,UAAU,QAAQ,kBAAiB"} \ No newline at end of file diff --git a/dist/modules/consent/getConsentTexts.d.js b/dist/modules/consent/getConsentTexts.d.js new file mode 100644 index 0000000..0ce0bca --- /dev/null +++ b/dist/modules/consent/getConsentTexts.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=getConsentTexts.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/getConsentTexts.d.js.map b/dist/modules/consent/getConsentTexts.d.js.map new file mode 100644 index 0000000..2fb6636 --- /dev/null +++ b/dist/modules/consent/getConsentTexts.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getConsentTexts.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { I18nConfig } from '../i18n/index.js';\nimport type { ConsentTexts } from './texts.js';\ntype GetConsentTextsArgs = {\n config: I18nConfig;\n locale?: string;\n payload: BasePayload;\n /**\n * Privacy-policy label + the system page (from SiteSettings.privacyPolicy,\n * read with locale:'all') to link to. Optional — omit for no link.\n */\n privacyPolicy?: {\n label: string;\n page: {\n slug?: null | Record;\n } | null;\n };\n};\n/**\n * Resolves consent banner texts from the CookieSettings global, falling back to\n * English defaults per field. The privacy-policy link is built from the pages\n * module (system page role), not stored in CookieSettings — one source of truth.\n */\nexport declare function getConsentTexts({ config, locale, payload, privacyPolicy, }: GetConsentTextsArgs): Promise;\nexport {};\n"],"names":[],"mappings":"AAwBA,WAAU"} \ No newline at end of file diff --git a/dist/modules/consent/getConsentTexts.d.ts b/dist/modules/consent/getConsentTexts.d.ts new file mode 100644 index 0000000..109b769 --- /dev/null +++ b/dist/modules/consent/getConsentTexts.d.ts @@ -0,0 +1,25 @@ +import type { BasePayload } from 'payload'; +import type { I18nConfig } from '../i18n/index.js'; +import type { ConsentTexts } from './texts.js'; +type GetConsentTextsArgs = { + config: I18nConfig; + locale?: string; + payload: BasePayload; + /** + * Privacy-policy label + the system page (from SiteSettings.privacyPolicy, + * read with locale:'all') to link to. Optional — omit for no link. + */ + privacyPolicy?: { + label: string; + page: { + slug?: null | Record; + } | null; + }; +}; +/** + * Resolves consent banner texts from the CookieSettings global, falling back to + * English defaults per field. The privacy-policy link is built from the pages + * module (system page role), not stored in CookieSettings — one source of truth. + */ +export declare function getConsentTexts({ config, locale, payload, privacyPolicy, }: GetConsentTextsArgs): Promise; +export {}; diff --git a/dist/modules/consent/getConsentTexts.js b/dist/modules/consent/getConsentTexts.js new file mode 100644 index 0000000..6b2a0be --- /dev/null +++ b/dist/modules/consent/getConsentTexts.js @@ -0,0 +1,79 @@ +import { getSystemPagePath } from '../pages/index.js'; +import { getGlobal } from '../payload/index.js'; +import { CONSENT_CATEGORIES } from './categories.js'; +/** English fallback used when the global has no value for a field. */ const FALLBACK = { + buttons: { + acceptAll: 'Accept all', + back: 'Back', + reject: 'Reject', + save: 'Save preferences', + settings: 'Settings' + }, + categories: { + analytics: { + description: 'Help us understand usage.', + title: 'Analytics' + }, + functional: { + description: 'Remember preferences.', + title: 'Functional' + }, + marketing: { + description: 'Used to show relevant ads.', + title: 'Marketing' + }, + necessary: { + description: 'Required for the site to work, including your language and theme preferences. Always on.', + title: 'Necessary' + } + }, + message: 'We use cookies to run the site, analyze traffic, and improve your experience. You can accept all, reject non-essential, or choose which to allow.', + privacyLink: null, + settingsTitle: 'Cookie settings' +}; +/** + * Resolves consent banner texts from the CookieSettings global, falling back to + * English defaults per field. The privacy-policy link is built from the pages + * module (system page role), not stored in CookieSettings — one source of truth. + */ export async function getConsentTexts({ config, locale, payload, privacyPolicy }) { + const g = await getGlobal(payload, 'cookie-settings', { + locale + }); + const categories = {}; + for (const cat of CONSENT_CATEGORIES){ + const entry = g.categories?.find((c)=>c.key === cat); + categories[cat] = { + description: entry?.description || FALLBACK.categories[cat].description, + title: entry?.title || FALLBACK.categories[cat].title + }; + } + let privacyLink = null; + if (privacyPolicy?.page && locale) { + const href = getSystemPagePath({ + config, + locale, + page: privacyPolicy.page + }); + if (href) { + privacyLink = { + href, + label: privacyPolicy.label + }; + } + } + return { + buttons: { + acceptAll: g.buttons?.acceptAll || FALLBACK.buttons.acceptAll, + back: g.buttons?.back || FALLBACK.buttons.back, + reject: g.buttons?.reject || FALLBACK.buttons.reject, + save: g.buttons?.save || FALLBACK.buttons.save, + settings: g.buttons?.settings || FALLBACK.buttons.settings + }, + categories, + message: g.message || FALLBACK.message, + privacyLink, + settingsTitle: g.settingsTitle || FALLBACK.settingsTitle + }; +} + +//# sourceMappingURL=getConsentTexts.js.map \ No newline at end of file diff --git a/dist/modules/consent/getConsentTexts.js.map b/dist/modules/consent/getConsentTexts.js.map new file mode 100644 index 0000000..af0898f --- /dev/null +++ b/dist/modules/consent/getConsentTexts.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/getConsentTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { ConsentTexts } from './texts.js'\n\nimport { getSystemPagePath } from '../pages/index.js'\nimport { getGlobal } from '../payload/index.js'\nimport { CONSENT_CATEGORIES } from './categories.js'\n\n/** English fallback used when the global has no value for a field. */\nconst FALLBACK: ConsentTexts = {\n buttons: {\n acceptAll: 'Accept all',\n back: 'Back',\n reject: 'Reject',\n save: 'Save preferences',\n settings: 'Settings',\n },\n categories: {\n analytics: { description: 'Help us understand usage.', title: 'Analytics' },\n functional: { description: 'Remember preferences.', title: 'Functional' },\n marketing: { description: 'Used to show relevant ads.', title: 'Marketing' },\n necessary: {\n description:\n 'Required for the site to work, including your language and theme preferences. Always on.',\n title: 'Necessary',\n },\n },\n message:\n 'We use cookies to run the site, analyze traffic, and improve your experience. You can accept all, reject non-essential, or choose which to allow.',\n privacyLink: null,\n settingsTitle: 'Cookie settings',\n}\n\ntype CookieSettingsData = {\n buttons?: null | Partial\n categories?: Array<{ description?: null | string; key: string; title?: null | string }> | null\n message?: null | string\n settingsTitle?: null | string\n}\n\ntype GetConsentTextsArgs = {\n config: I18nConfig\n locale?: string\n payload: BasePayload\n /**\n * Privacy-policy label + the system page (from SiteSettings.privacyPolicy,\n * read with locale:'all') to link to. Optional — omit for no link.\n */\n privacyPolicy?: {\n label: string\n page: { slug?: null | Record } | null\n }\n}\n\n/**\n * Resolves consent banner texts from the CookieSettings global, falling back to\n * English defaults per field. The privacy-policy link is built from the pages\n * module (system page role), not stored in CookieSettings — one source of truth.\n */\nexport async function getConsentTexts({\n config,\n locale,\n payload,\n privacyPolicy,\n}: GetConsentTextsArgs): Promise {\n const g = await getGlobal(payload, 'cookie-settings', { locale })\n\n const categories = {} as ConsentTexts['categories']\n for (const cat of CONSENT_CATEGORIES) {\n const entry = g.categories?.find((c) => c.key === cat)\n categories[cat] = {\n description: entry?.description || FALLBACK.categories[cat].description,\n title: entry?.title || FALLBACK.categories[cat].title,\n }\n }\n\n let privacyLink: ConsentTexts['privacyLink'] = null\n if (privacyPolicy?.page && locale) {\n const href = getSystemPagePath({ config, locale, page: privacyPolicy.page })\n if (href) {\n privacyLink = { href, label: privacyPolicy.label }\n }\n }\n\n return {\n buttons: {\n acceptAll: g.buttons?.acceptAll || FALLBACK.buttons.acceptAll,\n back: g.buttons?.back || FALLBACK.buttons.back,\n reject: g.buttons?.reject || FALLBACK.buttons.reject,\n save: g.buttons?.save || FALLBACK.buttons.save,\n settings: g.buttons?.settings || FALLBACK.buttons.settings,\n },\n categories,\n message: g.message || FALLBACK.message,\n privacyLink,\n settingsTitle: g.settingsTitle || FALLBACK.settingsTitle,\n }\n}\n"],"names":["getSystemPagePath","getGlobal","CONSENT_CATEGORIES","FALLBACK","buttons","acceptAll","back","reject","save","settings","categories","analytics","description","title","functional","marketing","necessary","message","privacyLink","settingsTitle","getConsentTexts","config","locale","payload","privacyPolicy","g","cat","entry","find","c","key","page","href","label"],"mappings":"AAKA,SAASA,iBAAiB,QAAQ,oBAAmB;AACrD,SAASC,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,kBAAkB,QAAQ,kBAAiB;AAEpD,oEAAoE,GACpE,MAAMC,WAAyB;IAC7BC,SAAS;QACPC,WAAW;QACXC,MAAM;QACNC,QAAQ;QACRC,MAAM;QACNC,UAAU;IACZ;IACAC,YAAY;QACVC,WAAW;YAAEC,aAAa;YAA6BC,OAAO;QAAY;QAC1EC,YAAY;YAAEF,aAAa;YAAyBC,OAAO;QAAa;QACxEE,WAAW;YAAEH,aAAa;YAA8BC,OAAO;QAAY;QAC3EG,WAAW;YACTJ,aACE;YACFC,OAAO;QACT;IACF;IACAI,SACE;IACFC,aAAa;IACbC,eAAe;AACjB;AAuBA;;;;CAIC,GACD,OAAO,eAAeC,gBAAgB,EACpCC,MAAM,EACNC,MAAM,EACNC,OAAO,EACPC,aAAa,EACO;IACpB,MAAMC,IAAI,MAAMxB,UAA8BsB,SAAS,mBAAmB;QAAED;IAAO;IAEnF,MAAMZ,aAAa,CAAC;IACpB,KAAK,MAAMgB,OAAOxB,mBAAoB;QACpC,MAAMyB,QAAQF,EAAEf,UAAU,EAAEkB,KAAK,CAACC,IAAMA,EAAEC,GAAG,KAAKJ;QAClDhB,UAAU,CAACgB,IAAI,GAAG;YAChBd,aAAae,OAAOf,eAAeT,SAASO,UAAU,CAACgB,IAAI,CAACd,WAAW;YACvEC,OAAOc,OAAOd,SAASV,SAASO,UAAU,CAACgB,IAAI,CAACb,KAAK;QACvD;IACF;IAEA,IAAIK,cAA2C;IAC/C,IAAIM,eAAeO,QAAQT,QAAQ;QACjC,MAAMU,OAAOhC,kBAAkB;YAAEqB;YAAQC;YAAQS,MAAMP,cAAcO,IAAI;QAAC;QAC1E,IAAIC,MAAM;YACRd,cAAc;gBAAEc;gBAAMC,OAAOT,cAAcS,KAAK;YAAC;QACnD;IACF;IAEA,OAAO;QACL7B,SAAS;YACPC,WAAWoB,EAAErB,OAAO,EAAEC,aAAaF,SAASC,OAAO,CAACC,SAAS;YAC7DC,MAAMmB,EAAErB,OAAO,EAAEE,QAAQH,SAASC,OAAO,CAACE,IAAI;YAC9CC,QAAQkB,EAAErB,OAAO,EAAEG,UAAUJ,SAASC,OAAO,CAACG,MAAM;YACpDC,MAAMiB,EAAErB,OAAO,EAAEI,QAAQL,SAASC,OAAO,CAACI,IAAI;YAC9CC,UAAUgB,EAAErB,OAAO,EAAEK,YAAYN,SAASC,OAAO,CAACK,QAAQ;QAC5D;QACAC;QACAO,SAASQ,EAAER,OAAO,IAAId,SAASc,OAAO;QACtCC;QACAC,eAAeM,EAAEN,aAAa,IAAIhB,SAASgB,aAAa;IAC1D;AACF"} \ No newline at end of file diff --git a/dist/modules/consent/googleConsent.d.js b/dist/modules/consent/googleConsent.d.js new file mode 100644 index 0000000..946971a --- /dev/null +++ b/dist/modules/consent/googleConsent.d.js @@ -0,0 +1,3 @@ +/** Updates consent after the user decides. */ export { }; + +//# sourceMappingURL=googleConsent.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/googleConsent.d.js.map b/dist/modules/consent/googleConsent.d.js.map new file mode 100644 index 0000000..913715c --- /dev/null +++ b/dist/modules/consent/googleConsent.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["googleConsent.d.ts"],"sourcesContent":["import type { ConsentState } from './categories.js';\ndeclare global {\n interface Window {\n dataLayer?: unknown[];\n }\n}\n/**\n * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.\n *\n * Pushing `arguments` rather than a plain array is not a stylistic detail.\n * Google's tags recognise a consent command by the Arguments object it arrives\n * in; a real array lands in the dataLayer as ordinary data and the command is\n * silently ignored — the tag loads, but consent never updates and analytics\n * stays denied. The rest parameter exists only to type the call sites.\n *\n * Exported for the analytics module (which needs `js`/`config` commands) —\n * intentionally not re-exported from the package's public API.\n */\nexport declare function gtag(..._args: unknown[]): void;\n/** Sets the default consent state (call before Google tags load). */\nexport declare function setDefaultConsent(state: ConsentState): void;\n/** Updates consent after the user decides. */\nexport declare function updateConsent(state: ConsentState): void;\n"],"names":[],"mappings":"AAqBA,4CAA4C,GAC5C,WAAiE"} \ No newline at end of file diff --git a/dist/modules/consent/googleConsent.d.ts b/dist/modules/consent/googleConsent.d.ts new file mode 100644 index 0000000..ccd3ea9 --- /dev/null +++ b/dist/modules/consent/googleConsent.d.ts @@ -0,0 +1,23 @@ +import type { ConsentState } from './categories.js'; +declare global { + interface Window { + dataLayer?: unknown[]; + } +} +/** + * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`. + * + * Pushing `arguments` rather than a plain array is not a stylistic detail. + * Google's tags recognise a consent command by the Arguments object it arrives + * in; a real array lands in the dataLayer as ordinary data and the command is + * silently ignored — the tag loads, but consent never updates and analytics + * stays denied. The rest parameter exists only to type the call sites. + * + * Exported for the analytics module (which needs `js`/`config` commands) — + * intentionally not re-exported from the package's public API. + */ +export declare function gtag(..._args: unknown[]): void; +/** Sets the default consent state (call before Google tags load). */ +export declare function setDefaultConsent(state: ConsentState): void; +/** Updates consent after the user decides. */ +export declare function updateConsent(state: ConsentState): void; diff --git a/dist/modules/consent/googleConsent.js b/dist/modules/consent/googleConsent.js new file mode 100644 index 0000000..f196814 --- /dev/null +++ b/dist/modules/consent/googleConsent.js @@ -0,0 +1,42 @@ +function toSignals(state) { + const g = (b)=>b ? 'granted' : 'denied'; + return { + ad_personalization: g(state.marketing), + ad_storage: g(state.marketing), + ad_user_data: g(state.marketing), + analytics_storage: g(state.analytics), + functionality_storage: g(state.functional), + personalization_storage: g(state.functional), + security_storage: 'granted' + }; +} +/** + * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`. + * + * Pushing `arguments` rather than a plain array is not a stylistic detail. + * Google's tags recognise a consent command by the Arguments object it arrives + * in; a real array lands in the dataLayer as ordinary data and the command is + * silently ignored — the tag loads, but consent never updates and analytics + * stays denied. The rest parameter exists only to type the call sites. + * + * Exported for the analytics module (which needs `js`/`config` commands) — + * intentionally not re-exported from the package's public API. + */ export function gtag(..._args) { + if (typeof window === 'undefined') { + return; + } + window.dataLayer = window.dataLayer || []; + // eslint-disable-next-line prefer-rest-params + window.dataLayer.push(arguments); +} +/** Sets the default consent state (call before Google tags load). */ export function setDefaultConsent(state) { + gtag('consent', 'default', { + ...toSignals(state), + wait_for_update: 500 + }); +} +/** Updates consent after the user decides. */ export function updateConsent(state) { + gtag('consent', 'update', toSignals(state)); +} + +//# sourceMappingURL=googleConsent.js.map \ No newline at end of file diff --git a/dist/modules/consent/googleConsent.js.map b/dist/modules/consent/googleConsent.js.map new file mode 100644 index 0000000..cccd52f --- /dev/null +++ b/dist/modules/consent/googleConsent.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/googleConsent.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\ntype ConsentValue = 'denied' | 'granted'\ntype GoogleConsentSignals = Record\n\nfunction toSignals(state: ConsentState): GoogleConsentSignals {\n const g = (b: boolean): ConsentValue => (b ? 'granted' : 'denied')\n return {\n ad_personalization: g(state.marketing),\n ad_storage: g(state.marketing),\n ad_user_data: g(state.marketing),\n analytics_storage: g(state.analytics),\n functionality_storage: g(state.functional),\n personalization_storage: g(state.functional),\n security_storage: 'granted', // necessary — always on\n }\n}\n\ndeclare global {\n interface Window {\n dataLayer?: unknown[]\n }\n}\n\n/**\n * Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.\n *\n * Pushing `arguments` rather than a plain array is not a stylistic detail.\n * Google's tags recognise a consent command by the Arguments object it arrives\n * in; a real array lands in the dataLayer as ordinary data and the command is\n * silently ignored — the tag loads, but consent never updates and analytics\n * stays denied. The rest parameter exists only to type the call sites.\n *\n * Exported for the analytics module (which needs `js`/`config` commands) —\n * intentionally not re-exported from the package's public API.\n */\nexport function gtag(..._args: unknown[]) {\n if (typeof window === 'undefined') {return}\n window.dataLayer = window.dataLayer || []\n // eslint-disable-next-line prefer-rest-params\n window.dataLayer.push(arguments)\n}\n\n/** Sets the default consent state (call before Google tags load). */\nexport function setDefaultConsent(state: ConsentState) {\n gtag('consent', 'default', {\n ...toSignals(state),\n wait_for_update: 500, // ms to wait for an update before firing\n })\n}\n\n/** Updates consent after the user decides. */\nexport function updateConsent(state: ConsentState) {\n gtag('consent', 'update', toSignals(state))\n}\n"],"names":["toSignals","state","g","b","ad_personalization","marketing","ad_storage","ad_user_data","analytics_storage","analytics","functionality_storage","functional","personalization_storage","security_storage","gtag","_args","window","dataLayer","push","arguments","setDefaultConsent","wait_for_update","updateConsent"],"mappings":"AAKA,SAASA,UAAUC,KAAmB;IACpC,MAAMC,IAAI,CAACC,IAA8BA,IAAI,YAAY;IACzD,OAAO;QACLC,oBAAoBF,EAAED,MAAMI,SAAS;QACrCC,YAAYJ,EAAED,MAAMI,SAAS;QAC7BE,cAAcL,EAAED,MAAMI,SAAS;QAC/BG,mBAAmBN,EAAED,MAAMQ,SAAS;QACpCC,uBAAuBR,EAAED,MAAMU,UAAU;QACzCC,yBAAyBV,EAAED,MAAMU,UAAU;QAC3CE,kBAAkB;IACpB;AACF;AAQA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,KAAK,GAAGC,KAAgB;IACtC,IAAI,OAAOC,WAAW,aAAa;QAAC;IAAM;IAC1CA,OAAOC,SAAS,GAAGD,OAAOC,SAAS,IAAI,EAAE;IACzC,8CAA8C;IAC9CD,OAAOC,SAAS,CAACC,IAAI,CAACC;AACxB;AAEA,mEAAmE,GACnE,OAAO,SAASC,kBAAkBnB,KAAmB;IACnDa,KAAK,WAAW,WAAW;QACzB,GAAGd,UAAUC,MAAM;QACnBoB,iBAAiB;IACnB;AACF;AAEA,4CAA4C,GAC5C,OAAO,SAASC,cAAcrB,KAAmB;IAC/Ca,KAAK,WAAW,UAAUd,UAAUC;AACtC"} \ No newline at end of file diff --git a/dist/modules/consent/index.d.js b/dist/modules/consent/index.d.js new file mode 100644 index 0000000..7823bcc --- /dev/null +++ b/dist/modules/consent/index.d.js @@ -0,0 +1,6 @@ +export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'; +export { getConsentTexts } from './getConsentTexts.js'; +export { setDefaultConsent, updateConsent } from './googleConsent.js'; +export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent } from './storage.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/index.d.js.map b/dist/modules/consent/index.d.js.map new file mode 100644 index 0000000..7d991f9 --- /dev/null +++ b/dist/modules/consent/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT, } from './categories.js';\nexport type { ConsentCategory, ConsentState } from './categories.js';\nexport { getConsentTexts } from './getConsentTexts.js';\nexport { setDefaultConsent, updateConsent } from './googleConsent.js';\nexport { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent, } from './storage.js';\nexport type { ConsentTexts } from './texts.js';\n"],"names":["ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","DEFAULT_CONSENT","REJECT_ALL_CONSENT","getConsentTexts","setDefaultConsent","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","parseConsent","serializeConsent"],"mappings":"AAAA,SAASA,kBAAkB,EAAEC,kBAAkB,EAAEC,eAAe,EAAEC,kBAAkB,QAAS,kBAAkB;AAE/G,SAASC,eAAe,QAAQ,uBAAuB;AACvD,SAASC,iBAAiB,EAAEC,aAAa,QAAQ,qBAAqB;AACtE,SAASC,cAAc,EAAEC,eAAe,EAAEC,eAAe,EAAEC,YAAY,EAAEC,gBAAgB,QAAS,eAAe"} \ No newline at end of file diff --git a/dist/modules/consent/index.d.ts b/dist/modules/consent/index.d.ts new file mode 100644 index 0000000..46883b4 --- /dev/null +++ b/dist/modules/consent/index.d.ts @@ -0,0 +1,6 @@ +export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT, } from './categories.js'; +export type { ConsentCategory, ConsentState } from './categories.js'; +export { getConsentTexts } from './getConsentTexts.js'; +export { setDefaultConsent, updateConsent } from './googleConsent.js'; +export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent, } from './storage.js'; +export type { ConsentTexts } from './texts.js'; diff --git a/dist/modules/consent/index.js b/dist/modules/consent/index.js new file mode 100644 index 0000000..4ec72b8 --- /dev/null +++ b/dist/modules/consent/index.js @@ -0,0 +1,9 @@ +// Server-safe exports (logic, types, helper). Client components (banner, +// provider, hook) are exported separately via ./client to keep the RSC/client +// boundary clean. +export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'; +export { getConsentTexts } from './getConsentTexts.js'; +export { setDefaultConsent, updateConsent } from './googleConsent.js'; +export { CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, parseConsent, serializeConsent } from './storage.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/consent/index.js.map b/dist/modules/consent/index.js.map new file mode 100644 index 0000000..c041a2c --- /dev/null +++ b/dist/modules/consent/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/index.ts"],"sourcesContent":["// Server-safe exports (logic, types, helper). Client components (banner,\n// provider, hook) are exported separately via ./client to keep the RSC/client\n// boundary clean.\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n DEFAULT_CONSENT,\n REJECT_ALL_CONSENT,\n} from './categories.js'\nexport type { ConsentCategory, ConsentState } from './categories.js'\nexport { getConsentTexts } from './getConsentTexts.js'\nexport { setDefaultConsent, updateConsent } from './googleConsent.js'\nexport {\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n parseConsent,\n serializeConsent,\n} from './storage.js'\nexport type { ConsentTexts } from './texts.js'\n"],"names":["ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","DEFAULT_CONSENT","REJECT_ALL_CONSENT","getConsentTexts","setDefaultConsent","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","parseConsent","serializeConsent"],"mappings":"AAAA,yEAAyE;AACzE,8EAA8E;AAC9E,kBAAkB;AAClB,SACEA,kBAAkB,EAClBC,kBAAkB,EAClBC,eAAe,EACfC,kBAAkB,QACb,kBAAiB;AAExB,SAASC,eAAe,QAAQ,uBAAsB;AACtD,SAASC,iBAAiB,EAAEC,aAAa,QAAQ,qBAAoB;AACrE,SACEC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,gBAAgB,QACX,eAAc"} \ No newline at end of file diff --git a/dist/modules/consent/storage.d.js b/dist/modules/consent/storage.d.js new file mode 100644 index 0000000..f9d52b7 --- /dev/null +++ b/dist/modules/consent/storage.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=storage.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/storage.d.js.map b/dist/modules/consent/storage.d.js.map new file mode 100644 index 0000000..5033ee9 --- /dev/null +++ b/dist/modules/consent/storage.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["storage.d.ts"],"sourcesContent":["import type { ConsentState } from './categories.js';\n/**\n * Consent persistence in a cookie. Stored with a version so we can invalidate\n * old consents when the policy changes, and a timestamp (GDPR: consent must be\n * dated). Readable both client-side (banner) and server-side (script gating).\n */\nexport declare const CONSENT_COOKIE = \"cookie-consent\";\nexport declare const CONSENT_VERSION = 1;\nexport declare const CONSENT_MAX_AGE: number;\nexport declare function serializeConsent(state: ConsentState): string;\nexport declare function parseConsent(raw: string | undefined): ConsentState | null;\n"],"names":[],"mappings":"AAUA,WAAmF"} \ No newline at end of file diff --git a/dist/modules/consent/storage.d.ts b/dist/modules/consent/storage.d.ts new file mode 100644 index 0000000..647066a --- /dev/null +++ b/dist/modules/consent/storage.d.ts @@ -0,0 +1,11 @@ +import type { ConsentState } from './categories.js'; +/** + * Consent persistence in a cookie. Stored with a version so we can invalidate + * old consents when the policy changes, and a timestamp (GDPR: consent must be + * dated). Readable both client-side (banner) and server-side (script gating). + */ +export declare const CONSENT_COOKIE = "cookie-consent"; +export declare const CONSENT_VERSION = 1; +export declare const CONSENT_MAX_AGE: number; +export declare function serializeConsent(state: ConsentState): string; +export declare function parseConsent(raw: string | undefined): ConsentState | null; diff --git a/dist/modules/consent/storage.js b/dist/modules/consent/storage.js new file mode 100644 index 0000000..882c8ec --- /dev/null +++ b/dist/modules/consent/storage.js @@ -0,0 +1,42 @@ +import { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'; +/** + * Consent persistence in a cookie. Stored with a version so we can invalidate + * old consents when the policy changes, and a timestamp (GDPR: consent must be + * dated). Readable both client-side (banner) and server-side (script gating). + */ export const CONSENT_COOKIE = 'cookie-consent'; +export const CONSENT_VERSION = 1; +export const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days +; +export function serializeConsent(state) { + const payload = { + state, + timestamp: new Date().toISOString(), + version: CONSENT_VERSION + }; + return encodeURIComponent(JSON.stringify(payload)); +} +export function parseConsent(raw) { + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(decodeURIComponent(raw)); + if (parsed.version !== CONSENT_VERSION) { + return null; + } + const state = { + ...DEFAULT_CONSENT + }; + for (const cat of CONSENT_CATEGORIES){ + if (typeof parsed.state?.[cat] === 'boolean') { + state[cat] = parsed.state[cat]; + } + } + state.necessary = true; + return state; + } catch { + return null; + } +} + +//# sourceMappingURL=storage.js.map \ No newline at end of file diff --git a/dist/modules/consent/storage.js.map b/dist/modules/consent/storage.js.map new file mode 100644 index 0000000..039588f --- /dev/null +++ b/dist/modules/consent/storage.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/storage.ts"],"sourcesContent":["import type { ConsentState } from './categories.js'\n\nimport { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'\n\n/**\n * Consent persistence in a cookie. Stored with a version so we can invalidate\n * old consents when the policy changes, and a timestamp (GDPR: consent must be\n * dated). Readable both client-side (banner) and server-side (script gating).\n */\nexport const CONSENT_COOKIE = 'cookie-consent'\nexport const CONSENT_VERSION = 1\nexport const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days\n\ntype StoredConsent = {\n state: ConsentState\n timestamp: string\n version: number\n}\n\nexport function serializeConsent(state: ConsentState): string {\n const payload: StoredConsent = {\n state,\n timestamp: new Date().toISOString(),\n version: CONSENT_VERSION,\n }\n return encodeURIComponent(JSON.stringify(payload))\n}\n\nexport function parseConsent(raw: string | undefined): ConsentState | null {\n if (!raw) {return null}\n try {\n const parsed = JSON.parse(decodeURIComponent(raw)) as StoredConsent\n if (parsed.version !== CONSENT_VERSION) {return null}\n const state = { ...DEFAULT_CONSENT }\n for (const cat of CONSENT_CATEGORIES) {\n if (typeof parsed.state?.[cat] === 'boolean') {state[cat] = parsed.state[cat]}\n }\n state.necessary = true\n return state\n } catch {\n return null\n }\n}\n"],"names":["CONSENT_CATEGORIES","DEFAULT_CONSENT","CONSENT_COOKIE","CONSENT_VERSION","CONSENT_MAX_AGE","serializeConsent","state","payload","timestamp","Date","toISOString","version","encodeURIComponent","JSON","stringify","parseConsent","raw","parsed","parse","decodeURIComponent","cat","necessary"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,eAAe,QAAQ,kBAAiB;AAErE;;;;CAIC,GACD,OAAO,MAAMC,iBAAiB,iBAAgB;AAC9C,OAAO,MAAMC,kBAAkB,EAAC;AAChC,OAAO,MAAMC,kBAAkB,KAAK,KAAK,KAAK,IAAI,WAAW;CAAZ;AAQjD,OAAO,SAASC,iBAAiBC,KAAmB;IAClD,MAAMC,UAAyB;QAC7BD;QACAE,WAAW,IAAIC,OAAOC,WAAW;QACjCC,SAASR;IACX;IACA,OAAOS,mBAAmBC,KAAKC,SAAS,CAACP;AAC3C;AAEA,OAAO,SAASQ,aAAaC,GAAuB;IAClD,IAAI,CAACA,KAAK;QAAC,OAAO;IAAI;IACtB,IAAI;QACF,MAAMC,SAASJ,KAAKK,KAAK,CAACC,mBAAmBH;QAC7C,IAAIC,OAAON,OAAO,KAAKR,iBAAiB;YAAC,OAAO;QAAI;QACpD,MAAMG,QAAQ;YAAE,GAAGL,eAAe;QAAC;QACnC,KAAK,MAAMmB,OAAOpB,mBAAoB;YACpC,IAAI,OAAOiB,OAAOX,KAAK,EAAE,CAACc,IAAI,KAAK,WAAW;gBAACd,KAAK,CAACc,IAAI,GAAGH,OAAOX,KAAK,CAACc,IAAI;YAAA;QAC/E;QACAd,MAAMe,SAAS,GAAG;QAClB,OAAOf;IACT,EAAE,OAAM;QACN,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/modules/consent/texts.d.js b/dist/modules/consent/texts.d.js new file mode 100644 index 0000000..2964578 --- /dev/null +++ b/dist/modules/consent/texts.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=texts.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/texts.d.js.map b/dist/modules/consent/texts.d.js.map new file mode 100644 index 0000000..0838985 --- /dev/null +++ b/dist/modules/consent/texts.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["texts.d.ts"],"sourcesContent":["import type { ConsentCategory } from './categories.js';\nexport type ConsentTexts = {\n buttons: {\n acceptAll: string;\n back: string;\n reject: string;\n save: string;\n settings: string;\n };\n categories: Record;\n message: string;\n /** null = no privacy-policy link shown */\n privacyLink: {\n href: string;\n label: string;\n } | null;\n settingsTitle: string;\n};\n"],"names":[],"mappings":"AACA,WAmBE"} \ No newline at end of file diff --git a/dist/modules/consent/texts.d.ts b/dist/modules/consent/texts.d.ts new file mode 100644 index 0000000..c3b040e --- /dev/null +++ b/dist/modules/consent/texts.d.ts @@ -0,0 +1,21 @@ +import type { ConsentCategory } from './categories.js'; +export type ConsentTexts = { + buttons: { + acceptAll: string; + back: string; + reject: string; + save: string; + settings: string; + }; + categories: Record; + message: string; + /** null = no privacy-policy link shown */ + privacyLink: { + href: string; + label: string; + } | null; + settingsTitle: string; +}; diff --git a/dist/modules/consent/texts.js b/dist/modules/consent/texts.js new file mode 100644 index 0000000..11b227e --- /dev/null +++ b/dist/modules/consent/texts.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=texts.js.map \ No newline at end of file diff --git a/dist/modules/consent/texts.js.map b/dist/modules/consent/texts.js.map new file mode 100644 index 0000000..275e5a3 --- /dev/null +++ b/dist/modules/consent/texts.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/texts.ts"],"sourcesContent":["import type { ConsentCategory } from './categories.js'\n\nexport type ConsentTexts = {\n buttons: {\n acceptAll: string\n back: string\n reject: string\n save: string\n settings: string\n }\n categories: Record\n message: string\n /** null = no privacy-policy link shown */\n privacyLink: { href: string; label: string } | null\n settingsTitle: string\n}\n"],"names":[],"mappings":"AAEA,WAaC"} \ No newline at end of file diff --git a/dist/modules/consent/useConsent.d.js b/dist/modules/consent/useConsent.d.js new file mode 100644 index 0000000..1c5a012 --- /dev/null +++ b/dist/modules/consent/useConsent.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=useConsent.d.js.map \ No newline at end of file diff --git a/dist/modules/consent/useConsent.d.js.map b/dist/modules/consent/useConsent.d.js.map new file mode 100644 index 0000000..7a38fb1 --- /dev/null +++ b/dist/modules/consent/useConsent.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["useConsent.d.ts"],"sourcesContent":["import type { ConsentCategory, ConsentState } from './categories.js';\nexport declare function useConsent(): {\n acceptAll: () => void;\n decided: boolean;\n rejectAll: () => void;\n reopen: () => void;\n savePreferences: (choices: Partial>) => void;\n state: ConsentState;\n};\n"],"names":[],"mappings":"AACA,WAOE"} \ No newline at end of file diff --git a/dist/modules/consent/useConsent.d.ts b/dist/modules/consent/useConsent.d.ts new file mode 100644 index 0000000..0622592 --- /dev/null +++ b/dist/modules/consent/useConsent.d.ts @@ -0,0 +1,9 @@ +import type { ConsentCategory, ConsentState } from './categories.js'; +export declare function useConsent(): { + acceptAll: () => void; + decided: boolean; + rejectAll: () => void; + reopen: () => void; + savePreferences: (choices: Partial>) => void; + state: ConsentState; +}; diff --git a/dist/modules/consent/useConsent.js b/dist/modules/consent/useConsent.js new file mode 100644 index 0000000..f66c2ed --- /dev/null +++ b/dist/modules/consent/useConsent.js @@ -0,0 +1,51 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; +import { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'; +import { updateConsent } from './googleConsent.js'; +import { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'; +export function useConsent() { + const [state, setState] = useState(DEFAULT_CONSENT); + const [decided, setDecided] = useState(false); + useEffect(()=>{ + const raw = document.cookie.split('; ').find((c)=>c.startsWith(`${CONSENT_COOKIE}=`))?.split('=')[1]; + const parsed = parseConsent(raw); + if (parsed) { + setState(parsed); + setDecided(true); + } + }, []); + const persist = useCallback((next)=>{ + document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`; + setState(next); + setDecided(true); + // Tell Google right away. Tags are already on the page holding whatever + // defaults Analytics set at load; without this update they keep them for + // the rest of the session and never write their cookies — the banner would + // look like it worked while nothing changed. + updateConsent(next); + }, []); + const acceptAll = useCallback(()=>persist(ACCEPT_ALL_CONSENT), [ + persist + ]); + const rejectAll = useCallback(()=>persist(REJECT_ALL_CONSENT), [ + persist + ]); + const savePreferences = useCallback((choices)=>persist({ + ...DEFAULT_CONSENT, + ...choices, + necessary: true + }), [ + persist + ]); + const reopen = useCallback(()=>setDecided(false), []); + return { + acceptAll, + decided, + rejectAll, + reopen, + savePreferences, + state + }; +} + +//# sourceMappingURL=useConsent.js.map \ No newline at end of file diff --git a/dist/modules/consent/useConsent.js.map b/dist/modules/consent/useConsent.js.map new file mode 100644 index 0000000..3a761c1 --- /dev/null +++ b/dist/modules/consent/useConsent.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/consent/useConsent.ts"],"sourcesContent":["'use client'\nimport { useCallback, useEffect, useState } from 'react'\n\nimport type { ConsentCategory, ConsentState } from './categories.js'\n\nimport { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'\nimport { updateConsent } from './googleConsent.js'\nimport { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'\n\nexport function useConsent() {\n const [state, setState] = useState(DEFAULT_CONSENT)\n const [decided, setDecided] = useState(false)\n\n useEffect(() => {\n const raw = document.cookie\n .split('; ')\n .find((c) => c.startsWith(`${CONSENT_COOKIE}=`))\n ?.split('=')[1]\n const parsed = parseConsent(raw)\n if (parsed) {\n setState(parsed)\n setDecided(true)\n }\n }, [])\n\n const persist = useCallback((next: ConsentState) => {\n document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`\n setState(next)\n setDecided(true)\n // Tell Google right away. Tags are already on the page holding whatever\n // defaults Analytics set at load; without this update they keep them for\n // the rest of the session and never write their cookies — the banner would\n // look like it worked while nothing changed.\n updateConsent(next)\n }, [])\n\n const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])\n const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])\n const savePreferences = useCallback(\n (choices: Partial>) =>\n persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }),\n [persist],\n )\n const reopen = useCallback(() => setDecided(false), [])\n\n return { acceptAll, decided, rejectAll, reopen, savePreferences, state }\n}\n"],"names":["useCallback","useEffect","useState","ACCEPT_ALL_CONSENT","DEFAULT_CONSENT","REJECT_ALL_CONSENT","updateConsent","CONSENT_COOKIE","CONSENT_MAX_AGE","parseConsent","serializeConsent","useConsent","state","setState","decided","setDecided","raw","document","cookie","split","find","c","startsWith","parsed","persist","next","acceptAll","rejectAll","savePreferences","choices","necessary","reopen"],"mappings":"AAAA;AACA,SAASA,WAAW,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAO;AAIxD,SAASC,kBAAkB,EAAEC,eAAe,EAAEC,kBAAkB,QAAQ,kBAAiB;AACzF,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,cAAc,EAAEC,eAAe,EAAEC,YAAY,EAAEC,gBAAgB,QAAQ,eAAc;AAE9F,OAAO,SAASC;IACd,MAAM,CAACC,OAAOC,SAAS,GAAGX,SAAuBE;IACjD,MAAM,CAACU,SAASC,WAAW,GAAGb,SAAS;IAEvCD,UAAU;QACR,MAAMe,MAAMC,SAASC,MAAM,CACxBC,KAAK,CAAC,MACNC,IAAI,CAAC,CAACC,IAAMA,EAAEC,UAAU,CAAC,GAAGf,eAAe,CAAC,CAAC,IAC5CY,MAAM,IAAI,CAAC,EAAE;QACjB,MAAMI,SAASd,aAAaO;QAC5B,IAAIO,QAAQ;YACVV,SAASU;YACTR,WAAW;QACb;IACF,GAAG,EAAE;IAEL,MAAMS,UAAUxB,YAAY,CAACyB;QAC3BR,SAASC,MAAM,GAAG,GAAGX,eAAe,CAAC,EAAEG,iBAAiBe,MAAM,gBAAgB,EAAEjB,gBAAgB,aAAa,CAAC;QAC9GK,SAASY;QACTV,WAAW;QACX,wEAAwE;QACxE,yEAAyE;QACzE,2EAA2E;QAC3E,6CAA6C;QAC7CT,cAAcmB;IAChB,GAAG,EAAE;IAEL,MAAMC,YAAY1B,YAAY,IAAMwB,QAAQrB,qBAAqB;QAACqB;KAAQ;IAC1E,MAAMG,YAAY3B,YAAY,IAAMwB,QAAQnB,qBAAqB;QAACmB;KAAQ;IAC1E,MAAMI,kBAAkB5B,YACtB,CAAC6B,UACCL,QAAQ;YAAE,GAAGpB,eAAe;YAAE,GAAGyB,OAAO;YAAEC,WAAW;QAAK,IAC5D;QAACN;KAAQ;IAEX,MAAMO,SAAS/B,YAAY,IAAMe,WAAW,QAAQ,EAAE;IAEtD,OAAO;QAAEW;QAAWZ;QAASa;QAAWI;QAAQH;QAAiBhB;IAAM;AACzE"} \ No newline at end of file diff --git a/dist/modules/content/archiveFields.d.js b/dist/modules/content/archiveFields.d.js new file mode 100644 index 0000000..475374e --- /dev/null +++ b/dist/modules/content/archiveFields.d.js @@ -0,0 +1,12 @@ +/** + * Builds one relationship field per content collection, pointing at the + * client's Pages collection. + * + * Sits alongside the system-page roles (homepage, privacy policy) because it's + * the same idea: a page referenced by function rather than by slug. The + * assignment is locale-agnostic — one page document — while the resulting path + * is locale-aware, since the page's slug is localized. Assign the "Artykuły" + * page once and you get /pl/artykuly and /en/articles from its own slugs. + */ export { }; + +//# sourceMappingURL=archiveFields.d.js.map \ No newline at end of file diff --git a/dist/modules/content/archiveFields.d.js.map b/dist/modules/content/archiveFields.d.js.map new file mode 100644 index 0000000..fcc6873 --- /dev/null +++ b/dist/modules/content/archiveFields.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["archiveFields.d.ts"],"sourcesContent":["import type { Field } from 'payload';\nimport type { ContentOption } from './types.js';\n/**\n * Builds one relationship field per content collection, pointing at the\n * client's Pages collection.\n *\n * Sits alongside the system-page roles (homepage, privacy policy) because it's\n * the same idea: a page referenced by function rather than by slug. The\n * assignment is locale-agnostic — one page document — while the resulting path\n * is locale-aware, since the page's slug is localized. Assign the \"Artykuły\"\n * page once and you get /pl/artykuly and /en/articles from its own slugs.\n */\nexport declare function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[];\n"],"names":[],"mappings":"AAEA;;;;;;;;;CASC,GACD,WAA+F"} \ No newline at end of file diff --git a/dist/modules/content/archiveFields.d.ts b/dist/modules/content/archiveFields.d.ts new file mode 100644 index 0000000..b41da45 --- /dev/null +++ b/dist/modules/content/archiveFields.d.ts @@ -0,0 +1,13 @@ +import type { Field } from 'payload'; +import type { ContentOption } from './types.js'; +/** + * Builds one relationship field per content collection, pointing at the + * client's Pages collection. + * + * Sits alongside the system-page roles (homepage, privacy policy) because it's + * the same idea: a page referenced by function rather than by slug. The + * assignment is locale-agnostic — one page document — while the resulting path + * is locale-aware, since the page's slug is localized. Assign the "Artykuły" + * page once and you get /pl/artykuly and /en/articles from its own slugs. + */ +export declare function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[]; diff --git a/dist/modules/content/archiveFields.js b/dist/modules/content/archiveFields.js new file mode 100644 index 0000000..f563b16 --- /dev/null +++ b/dist/modules/content/archiveFields.js @@ -0,0 +1,27 @@ +import { archiveFieldName } from './types.js'; +/** + * Builds one relationship field per content collection, pointing at the + * client's Pages collection. + * + * Sits alongside the system-page roles (homepage, privacy policy) because it's + * the same idea: a page referenced by function rather than by slug. The + * assignment is locale-agnostic — one page document — while the resulting path + * is locale-aware, since the page's slug is localized. Assign the "Artykuły" + * page once and you get /pl/artykuly and /en/articles from its own slugs. + */ export function buildArchiveFields(content, pagesSlug) { + return content.collections.map((collection)=>{ + const label = collection.label ?? collection.slug; + return { + name: archiveFieldName(collection.slug), + type: 'relationship', + admin: { + description: `Page listing ${label}. Its slug becomes the URL segment for entries (e.g. /pl/artykuly/moj-wpis).` + }, + label: `${label} — archive page`, + maxDepth: 1, + relationTo: pagesSlug + }; + }); +} + +//# sourceMappingURL=archiveFields.js.map \ No newline at end of file diff --git a/dist/modules/content/archiveFields.js.map b/dist/modules/content/archiveFields.js.map new file mode 100644 index 0000000..2f4b94e --- /dev/null +++ b/dist/modules/content/archiveFields.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/archiveFields.ts"],"sourcesContent":["import type { Field, RelationshipField } from 'payload'\n\nimport type { ContentOption } from './types.js'\n\nimport { archiveFieldName } from './types.js'\n\n/**\n * Builds one relationship field per content collection, pointing at the\n * client's Pages collection.\n *\n * Sits alongside the system-page roles (homepage, privacy policy) because it's\n * the same idea: a page referenced by function rather than by slug. The\n * assignment is locale-agnostic — one page document — while the resulting path\n * is locale-aware, since the page's slug is localized. Assign the \"Artykuły\"\n * page once and you get /pl/artykuly and /en/articles from its own slugs.\n */\nexport function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[] {\n return content.collections.map((collection): RelationshipField => {\n const label = collection.label ?? collection.slug\n\n return {\n name: archiveFieldName(collection.slug),\n type: 'relationship',\n admin: {\n description: `Page listing ${label}. Its slug becomes the URL segment for entries (e.g. /pl/artykuly/moj-wpis).`,\n },\n label: `${label} — archive page`,\n maxDepth: 1,\n relationTo: pagesSlug,\n }\n })\n}\n"],"names":["archiveFieldName","buildArchiveFields","content","pagesSlug","collections","map","collection","label","slug","name","type","admin","description","maxDepth","relationTo"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,aAAY;AAE7C;;;;;;;;;CASC,GACD,OAAO,SAASC,mBAAmBC,OAAsB,EAAEC,SAAiB;IAC1E,OAAOD,QAAQE,WAAW,CAACC,GAAG,CAAC,CAACC;QAC9B,MAAMC,QAAQD,WAAWC,KAAK,IAAID,WAAWE,IAAI;QAEjD,OAAO;YACLC,MAAMT,iBAAiBM,WAAWE,IAAI;YACtCE,MAAM;YACNC,OAAO;gBACLC,aAAa,CAAC,aAAa,EAAEL,MAAM,4EAA4E,CAAC;YAClH;YACAA,OAAO,GAAGA,MAAM,eAAe,CAAC;YAChCM,UAAU;YACVC,YAAYX;QACd;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/content/getArchiveEntries.d.js b/dist/modules/content/getArchiveEntries.d.js new file mode 100644 index 0000000..836fecf --- /dev/null +++ b/dist/modules/content/getArchiveEntries.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=getArchiveEntries.d.js.map \ No newline at end of file diff --git a/dist/modules/content/getArchiveEntries.d.js.map b/dist/modules/content/getArchiveEntries.d.js.map new file mode 100644 index 0000000..32e9f41 --- /dev/null +++ b/dist/modules/content/getArchiveEntries.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getArchiveEntries.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nexport type ArchiveEntries> = {\n docs: T[];\n hasNextPage: boolean;\n hasPrevPage: boolean;\n /** 1-based. */\n page: number;\n totalDocs: number;\n totalPages: number;\n};\ntype GetArchiveEntriesArgs = {\n /** Collection to list, e.g. 'posts'. */\n collection: string;\n /** Relationship depth. Defaults to 1 — enough for a cover image. */\n depth?: number;\n locale: string;\n /** 1-based. Values below 1 are clamped. */\n page?: number;\n payload: BasePayload;\n /** Defaults to 10. */\n perPage?: number;\n /** Payload sort string. Defaults to newest first. */\n sort?: string;\n /** Extra constraints merged into the query, e.g. { category: { equals: id } }. */\n where?: Record;\n};\n/**\n * Fetches one page of a collection's entries for an archive listing.\n *\n * Only published documents are returned when the collection has drafts enabled;\n * Payload's `where` on `_status` is left to the caller, since a collection\n * without drafts has no such field.\n *\n * Returns pagination facts rather than markup — the listing itself belongs to\n * the client (as a block on the archive page, most likely), because how a list\n * of posts should look isn't something a plugin can decide.\n */\nexport declare function getArchiveEntries>({ collection, depth, locale, page, payload, perPage, sort, where, }: GetArchiveEntriesArgs): Promise>;\nexport {};\n"],"names":[],"mappings":"AAsCA,WAAU"} \ No newline at end of file diff --git a/dist/modules/content/getArchiveEntries.d.ts b/dist/modules/content/getArchiveEntries.d.ts new file mode 100644 index 0000000..43a5b23 --- /dev/null +++ b/dist/modules/content/getArchiveEntries.d.ts @@ -0,0 +1,39 @@ +import type { BasePayload } from 'payload'; +export type ArchiveEntries> = { + docs: T[]; + hasNextPage: boolean; + hasPrevPage: boolean; + /** 1-based. */ + page: number; + totalDocs: number; + totalPages: number; +}; +type GetArchiveEntriesArgs = { + /** Collection to list, e.g. 'posts'. */ + collection: string; + /** Relationship depth. Defaults to 1 — enough for a cover image. */ + depth?: number; + locale: string; + /** 1-based. Values below 1 are clamped. */ + page?: number; + payload: BasePayload; + /** Defaults to 10. */ + perPage?: number; + /** Payload sort string. Defaults to newest first. */ + sort?: string; + /** Extra constraints merged into the query, e.g. { category: { equals: id } }. */ + where?: Record; +}; +/** + * Fetches one page of a collection's entries for an archive listing. + * + * Only published documents are returned when the collection has drafts enabled; + * Payload's `where` on `_status` is left to the caller, since a collection + * without drafts has no such field. + * + * Returns pagination facts rather than markup — the listing itself belongs to + * the client (as a block on the archive page, most likely), because how a list + * of posts should look isn't something a plugin can decide. + */ +export declare function getArchiveEntries>({ collection, depth, locale, page, payload, perPage, sort, where, }: GetArchiveEntriesArgs): Promise>; +export {}; diff --git a/dist/modules/content/getArchiveEntries.js b/dist/modules/content/getArchiveEntries.js new file mode 100644 index 0000000..d9493d9 --- /dev/null +++ b/dist/modules/content/getArchiveEntries.js @@ -0,0 +1,33 @@ +/** + * Fetches one page of a collection's entries for an archive listing. + * + * Only published documents are returned when the collection has drafts enabled; + * Payload's `where` on `_status` is left to the caller, since a collection + * without drafts has no such field. + * + * Returns pagination facts rather than markup — the listing itself belongs to + * the client (as a block on the archive page, most likely), because how a list + * of posts should look isn't something a plugin can decide. + */ export async function getArchiveEntries({ collection, depth = 1, locale, page = 1, payload, perPage = 10, sort = '-createdAt', where }) { + const result = await payload.find({ + collection: collection, + depth, + limit: perPage, + locale: locale, + page: Math.max(1, page), + sort, + ...where ? { + where: where + } : {} + }); + return { + docs: result.docs, + hasNextPage: result.hasNextPage, + hasPrevPage: result.hasPrevPage, + page: result.page ?? 1, + totalDocs: result.totalDocs, + totalPages: result.totalPages + }; +} + +//# sourceMappingURL=getArchiveEntries.js.map \ No newline at end of file diff --git a/dist/modules/content/getArchiveEntries.js.map b/dist/modules/content/getArchiveEntries.js.map new file mode 100644 index 0000000..879cde1 --- /dev/null +++ b/dist/modules/content/getArchiveEntries.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/getArchiveEntries.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nexport type ArchiveEntries> = {\n docs: T[]\n hasNextPage: boolean\n hasPrevPage: boolean\n /** 1-based. */\n page: number\n totalDocs: number\n totalPages: number\n}\n\ntype GetArchiveEntriesArgs = {\n /** Collection to list, e.g. 'posts'. */\n collection: string\n /** Relationship depth. Defaults to 1 — enough for a cover image. */\n depth?: number\n locale: string\n /** 1-based. Values below 1 are clamped. */\n page?: number\n payload: BasePayload\n /** Defaults to 10. */\n perPage?: number\n /** Payload sort string. Defaults to newest first. */\n sort?: string\n /** Extra constraints merged into the query, e.g. { category: { equals: id } }. */\n where?: Record\n}\n\n/**\n * Fetches one page of a collection's entries for an archive listing.\n *\n * Only published documents are returned when the collection has drafts enabled;\n * Payload's `where` on `_status` is left to the caller, since a collection\n * without drafts has no such field.\n *\n * Returns pagination facts rather than markup — the listing itself belongs to\n * the client (as a block on the archive page, most likely), because how a list\n * of posts should look isn't something a plugin can decide.\n */\nexport async function getArchiveEntries>({\n collection,\n depth = 1,\n locale,\n page = 1,\n payload,\n perPage = 10,\n sort = '-createdAt',\n where,\n}: GetArchiveEntriesArgs): Promise> {\n const result = await payload.find({\n collection: collection as never,\n depth,\n limit: perPage,\n locale: locale as never,\n page: Math.max(1, page),\n sort,\n ...(where ? { where: where as never } : {}),\n })\n\n return {\n docs: result.docs as T[],\n hasNextPage: result.hasNextPage,\n hasPrevPage: result.hasPrevPage,\n page: result.page ?? 1,\n totalDocs: result.totalDocs,\n totalPages: result.totalPages,\n }\n}\n"],"names":["getArchiveEntries","collection","depth","locale","page","payload","perPage","sort","where","result","find","limit","Math","max","docs","hasNextPage","hasPrevPage","totalDocs","totalPages"],"mappings":"AA6BA;;;;;;;;;;CAUC,GACD,OAAO,eAAeA,kBAA+C,EACnEC,UAAU,EACVC,QAAQ,CAAC,EACTC,MAAM,EACNC,OAAO,CAAC,EACRC,OAAO,EACPC,UAAU,EAAE,EACZC,OAAO,YAAY,EACnBC,KAAK,EACiB;IACtB,MAAMC,SAAS,MAAMJ,QAAQK,IAAI,CAAC;QAChCT,YAAYA;QACZC;QACAS,OAAOL;QACPH,QAAQA;QACRC,MAAMQ,KAAKC,GAAG,CAAC,GAAGT;QAClBG;QACA,GAAIC,QAAQ;YAAEA,OAAOA;QAAe,IAAI,CAAC,CAAC;IAC5C;IAEA,OAAO;QACLM,MAAML,OAAOK,IAAI;QACjBC,aAAaN,OAAOM,WAAW;QAC/BC,aAAaP,OAAOO,WAAW;QAC/BZ,MAAMK,OAAOL,IAAI,IAAI;QACrBa,WAAWR,OAAOQ,SAAS;QAC3BC,YAAYT,OAAOS,UAAU;IAC/B;AACF"} \ No newline at end of file diff --git a/dist/modules/content/index.d.js b/dist/modules/content/index.d.js new file mode 100644 index 0000000..727c705 --- /dev/null +++ b/dist/modules/content/index.d.js @@ -0,0 +1,7 @@ +export { buildArchiveFields } from './archiveFields.js'; +export { getArchiveEntries } from './getArchiveEntries.js'; +export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'; +export { resolveRoute } from './resolveRoute.js'; +export { archiveFieldName } from './types.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/content/index.d.js.map b/dist/modules/content/index.d.js.map new file mode 100644 index 0000000..56e0f99 --- /dev/null +++ b/dist/modules/content/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { buildArchiveFields } from './archiveFields.js';\nexport { getArchiveEntries } from './getArchiveEntries.js';\nexport type { ArchiveEntries } from './getArchiveEntries.js';\nexport { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js';\nexport { resolveRoute } from './resolveRoute.js';\nexport type { ResolvedRoute } from './resolveRoute.js';\nexport type { ContentCollectionOption, ContentOption } from './types.js';\nexport { archiveFieldName } from './types.js';\n"],"names":["buildArchiveFields","getArchiveEntries","buildArchivePath","buildEntryPath","parsePageParam","resolveRoute","archiveFieldName"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,qBAAqB;AACxD,SAASC,iBAAiB,QAAQ,yBAAyB;AAE3D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,cAAc,QAAQ,aAAa;AAC9E,SAASC,YAAY,QAAQ,oBAAoB;AAGjD,SAASC,gBAAgB,QAAQ,aAAa"} \ No newline at end of file diff --git a/dist/modules/content/index.d.ts b/dist/modules/content/index.d.ts new file mode 100644 index 0000000..d275a30 --- /dev/null +++ b/dist/modules/content/index.d.ts @@ -0,0 +1,8 @@ +export { buildArchiveFields } from './archiveFields.js'; +export { getArchiveEntries } from './getArchiveEntries.js'; +export type { ArchiveEntries } from './getArchiveEntries.js'; +export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'; +export { resolveRoute } from './resolveRoute.js'; +export type { ResolvedRoute } from './resolveRoute.js'; +export type { ContentCollectionOption, ContentOption } from './types.js'; +export { archiveFieldName } from './types.js'; diff --git a/dist/modules/content/index.js b/dist/modules/content/index.js new file mode 100644 index 0000000..385dea1 --- /dev/null +++ b/dist/modules/content/index.js @@ -0,0 +1,7 @@ +export { buildArchiveFields } from './archiveFields.js'; +export { getArchiveEntries } from './getArchiveEntries.js'; +export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'; +export { resolveRoute } from './resolveRoute.js'; +export { archiveFieldName } from './types.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/content/index.js.map b/dist/modules/content/index.js.map new file mode 100644 index 0000000..165ea1f --- /dev/null +++ b/dist/modules/content/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/index.ts"],"sourcesContent":["export { buildArchiveFields } from './archiveFields.js'\nexport { getArchiveEntries } from './getArchiveEntries.js'\nexport type { ArchiveEntries } from './getArchiveEntries.js'\nexport { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'\nexport { resolveRoute } from './resolveRoute.js'\nexport type { ResolvedRoute } from './resolveRoute.js'\nexport type { ContentCollectionOption, ContentOption } from './types.js'\nexport { archiveFieldName } from './types.js'\n"],"names":["buildArchiveFields","getArchiveEntries","buildArchivePath","buildEntryPath","parsePageParam","resolveRoute","archiveFieldName"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,qBAAoB;AACvD,SAASC,iBAAiB,QAAQ,yBAAwB;AAE1D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,cAAc,QAAQ,aAAY;AAC7E,SAASC,YAAY,QAAQ,oBAAmB;AAGhD,SAASC,gBAAgB,QAAQ,aAAY"} \ No newline at end of file diff --git a/dist/modules/content/paths.d.js b/dist/modules/content/paths.d.js new file mode 100644 index 0000000..e1876fb --- /dev/null +++ b/dist/modules/content/paths.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=paths.d.js.map \ No newline at end of file diff --git a/dist/modules/content/paths.d.js.map b/dist/modules/content/paths.d.js.map new file mode 100644 index 0000000..addb684 --- /dev/null +++ b/dist/modules/content/paths.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["paths.d.ts"],"sourcesContent":["type ArchivePathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string;\n locale: string;\n /** 1-based. Page 1 is omitted from the URL. */\n page?: number;\n};\n/**\n * Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2.\n *\n * Pagination goes in the query string rather than the path. A path segment\n * (/pl/artykuly/2) would be ambiguous with an entry slugged \"2\", and the\n * alternative (/pl/artykuly/strona/2) drags in yet another localized segment to\n * configure and translate. Google has understood ?page= for years, and\n * rel=next/prev — the reason people used to prefer path segments — was retired.\n */\nexport declare function buildArchivePath({ archiveSlug, locale, page }: ArchivePathArgs): string;\ntype EntryPathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string;\n /** Entry's slug in this locale, e.g. 'moj-post'. */\n entrySlug: string;\n locale: string;\n};\n/** Path to a single entry: /pl/artykuly/moj-post. */\nexport declare function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string;\n/**\n * Reads a page number out of a query parameter.\n *\n * Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`,\n * `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something,\n * and silently showing the first page beats a 500 on a URL a crawler invented.\n */\nexport declare function parsePageParam(value: string | string[] | undefined): number;\nexport {};\n"],"names":[],"mappings":"AAkCA,WAAU"} \ No newline at end of file diff --git a/dist/modules/content/paths.d.ts b/dist/modules/content/paths.d.ts new file mode 100644 index 0000000..b234613 --- /dev/null +++ b/dist/modules/content/paths.d.ts @@ -0,0 +1,35 @@ +type ArchivePathArgs = { + /** Archive page's slug in this locale, e.g. 'artykuly'. */ + archiveSlug: string; + locale: string; + /** 1-based. Page 1 is omitted from the URL. */ + page?: number; +}; +/** + * Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2. + * + * Pagination goes in the query string rather than the path. A path segment + * (/pl/artykuly/2) would be ambiguous with an entry slugged "2", and the + * alternative (/pl/artykuly/strona/2) drags in yet another localized segment to + * configure and translate. Google has understood ?page= for years, and + * rel=next/prev — the reason people used to prefer path segments — was retired. + */ +export declare function buildArchivePath({ archiveSlug, locale, page }: ArchivePathArgs): string; +type EntryPathArgs = { + /** Archive page's slug in this locale, e.g. 'artykuly'. */ + archiveSlug: string; + /** Entry's slug in this locale, e.g. 'moj-post'. */ + entrySlug: string; + locale: string; +}; +/** Path to a single entry: /pl/artykuly/moj-post. */ +export declare function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string; +/** + * Reads a page number out of a query parameter. + * + * Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`, + * `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something, + * and silently showing the first page beats a 500 on a URL a crawler invented. + */ +export declare function parsePageParam(value: string | string[] | undefined): number; +export {}; diff --git a/dist/modules/content/paths.js b/dist/modules/content/paths.js new file mode 100644 index 0000000..44caf07 --- /dev/null +++ b/dist/modules/content/paths.js @@ -0,0 +1,28 @@ +/** + * Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2. + * + * Pagination goes in the query string rather than the path. A path segment + * (/pl/artykuly/2) would be ambiguous with an entry slugged "2", and the + * alternative (/pl/artykuly/strona/2) drags in yet another localized segment to + * configure and translate. Google has understood ?page= for years, and + * rel=next/prev — the reason people used to prefer path segments — was retired. + */ export function buildArchivePath({ archiveSlug, locale, page = 1 }) { + const base = `/${locale}/${archiveSlug}`; + return page > 1 ? `${base}?page=${page}` : base; +} +/** Path to a single entry: /pl/artykuly/moj-post. */ export function buildEntryPath({ archiveSlug, entrySlug, locale }) { + return `/${locale}/${archiveSlug}/${entrySlug}`; +} +/** + * Reads a page number out of a query parameter. + * + * Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`, + * `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something, + * and silently showing the first page beats a 500 on a URL a crawler invented. + */ export function parsePageParam(value) { + const raw = Array.isArray(value) ? value[0] : value; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : 1; +} + +//# sourceMappingURL=paths.js.map \ No newline at end of file diff --git a/dist/modules/content/paths.js.map b/dist/modules/content/paths.js.map new file mode 100644 index 0000000..92c556b --- /dev/null +++ b/dist/modules/content/paths.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/paths.ts"],"sourcesContent":["type ArchivePathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string\n locale: string\n /** 1-based. Page 1 is omitted from the URL. */\n page?: number\n}\n\n/**\n * Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2.\n *\n * Pagination goes in the query string rather than the path. A path segment\n * (/pl/artykuly/2) would be ambiguous with an entry slugged \"2\", and the\n * alternative (/pl/artykuly/strona/2) drags in yet another localized segment to\n * configure and translate. Google has understood ?page= for years, and\n * rel=next/prev — the reason people used to prefer path segments — was retired.\n */\nexport function buildArchivePath({ archiveSlug, locale, page = 1 }: ArchivePathArgs): string {\n const base = `/${locale}/${archiveSlug}`\n return page > 1 ? `${base}?page=${page}` : base\n}\n\ntype EntryPathArgs = {\n /** Archive page's slug in this locale, e.g. 'artykuly'. */\n archiveSlug: string\n /** Entry's slug in this locale, e.g. 'moj-post'. */\n entrySlug: string\n locale: string\n}\n\n/** Path to a single entry: /pl/artykuly/moj-post. */\nexport function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string {\n return `/${locale}/${archiveSlug}/${entrySlug}`\n}\n\n/**\n * Reads a page number out of a query parameter.\n *\n * Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`,\n * `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something,\n * and silently showing the first page beats a 500 on a URL a crawler invented.\n */\nexport function parsePageParam(value: string | string[] | undefined): number {\n const raw = Array.isArray(value) ? value[0] : value\n const n = Number(raw)\n return Number.isInteger(n) && n > 0 ? n : 1\n}\n"],"names":["buildArchivePath","archiveSlug","locale","page","base","buildEntryPath","entrySlug","parsePageParam","value","raw","Array","isArray","n","Number","isInteger"],"mappings":"AAQA;;;;;;;;CAQC,GACD,OAAO,SAASA,iBAAiB,EAAEC,WAAW,EAAEC,MAAM,EAAEC,OAAO,CAAC,EAAmB;IACjF,MAAMC,OAAO,CAAC,CAAC,EAAEF,OAAO,CAAC,EAAED,aAAa;IACxC,OAAOE,OAAO,IAAI,GAAGC,KAAK,MAAM,EAAED,MAAM,GAAGC;AAC7C;AAUA,mDAAmD,GACnD,OAAO,SAASC,eAAe,EAAEJ,WAAW,EAAEK,SAAS,EAAEJ,MAAM,EAAiB;IAC9E,OAAO,CAAC,CAAC,EAAEA,OAAO,CAAC,EAAED,YAAY,CAAC,EAAEK,WAAW;AACjD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,eAAeC,KAAoC;IACjE,MAAMC,MAAMC,MAAMC,OAAO,CAACH,SAASA,KAAK,CAAC,EAAE,GAAGA;IAC9C,MAAMI,IAAIC,OAAOJ;IACjB,OAAOI,OAAOC,SAAS,CAACF,MAAMA,IAAI,IAAIA,IAAI;AAC5C"} \ No newline at end of file diff --git a/dist/modules/content/resolveRoute.d.js b/dist/modules/content/resolveRoute.d.js new file mode 100644 index 0000000..b25029e --- /dev/null +++ b/dist/modules/content/resolveRoute.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=resolveRoute.d.js.map \ No newline at end of file diff --git a/dist/modules/content/resolveRoute.d.js.map b/dist/modules/content/resolveRoute.d.js.map new file mode 100644 index 0000000..4cda8d3 --- /dev/null +++ b/dist/modules/content/resolveRoute.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["resolveRoute.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { ContentOption } from './types.js';\nexport type ResolvedRoute = \n/** Locale root — the page assigned as Homepage in System Pages. */\n{\n /** The archive page this entry lives under — its slug is the URL prefix. */\n archive: Record;\n collection: string;\n doc: Record;\n type: 'entry';\n}\n/** An ordinary page. */\n | {\n collection: string;\n doc: Record;\n /** 1-based, from ?page=. */\n page: number;\n perPage: number;\n type: 'archive';\n}\n/** A collection's archive page, e.g. /pl/artykuly. */\n | {\n doc: Record;\n type: 'home';\n}\n/** A single entry, e.g. /pl/artykuly/moj-post. */\n | {\n doc: Record;\n type: 'page';\n};\ntype ResolveRouteArgs = {\n content?: ContentOption;\n locale: string;\n /** Page number from the query string (?page=2). Defaults to 1. */\n page?: number;\n /** Client's Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string;\n payload: BasePayload;\n /** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */\n segments?: string[];\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string;\n};\n/**\n * Works out what a URL points at: the home page, an ordinary page, a\n * collection's archive, or a single entry.\n *\n * Routing only. An archive result says which collection to list and on which\n * page, but doesn't fetch the entries — that's `getArchiveEntries`, called by\n * whoever actually needs them. Keeping the two apart means listing changes\n * (sorting, filtering, pagination) never touch routing rules, and metadata\n * generation doesn't pay for a query it would discard.\n *\n * The trick is that archive prefixes aren't configured anywhere — they're the\n * slug of whichever page an editor assigned as that collection's archive. So\n * /pl/artykuly and /en/articles come from one assignment, and renaming the page\n * moves the whole section.\n *\n * Resolution order matters: a first segment matching an archive's slug wins\n * over an ordinary page of the same name, because an archive *is* a page and\n * would otherwise shadow its own entries.\n *\n * Depth beyond {archive}/{entry} isn't supported — categories in the path would\n * make canonical and hreflang ambiguous (the same entry reachable under several\n * URLs).\n */\nexport declare function resolveRoute({ content, locale, page, pagesSlug, payload, segments, settingsSlug, }: ResolveRouteArgs): Promise;\nexport {};\n"],"names":[],"mappings":"AAmEA,WAAU"} \ No newline at end of file diff --git a/dist/modules/content/resolveRoute.d.ts b/dist/modules/content/resolveRoute.d.ts new file mode 100644 index 0000000..9570715 --- /dev/null +++ b/dist/modules/content/resolveRoute.d.ts @@ -0,0 +1,68 @@ +import type { BasePayload } from 'payload'; +import type { ContentOption } from './types.js'; +export type ResolvedRoute = +/** Locale root — the page assigned as Homepage in System Pages. */ +{ + /** The archive page this entry lives under — its slug is the URL prefix. */ + archive: Record; + collection: string; + doc: Record; + type: 'entry'; +} +/** An ordinary page. */ + | { + collection: string; + doc: Record; + /** 1-based, from ?page=. */ + page: number; + perPage: number; + type: 'archive'; +} +/** A collection's archive page, e.g. /pl/artykuly. */ + | { + doc: Record; + type: 'home'; +} +/** A single entry, e.g. /pl/artykuly/moj-post. */ + | { + doc: Record; + type: 'page'; +}; +type ResolveRouteArgs = { + content?: ContentOption; + locale: string; + /** Page number from the query string (?page=2). Defaults to 1. */ + page?: number; + /** Client's Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; + payload: BasePayload; + /** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */ + segments?: string[]; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; +}; +/** + * Works out what a URL points at: the home page, an ordinary page, a + * collection's archive, or a single entry. + * + * Routing only. An archive result says which collection to list and on which + * page, but doesn't fetch the entries — that's `getArchiveEntries`, called by + * whoever actually needs them. Keeping the two apart means listing changes + * (sorting, filtering, pagination) never touch routing rules, and metadata + * generation doesn't pay for a query it would discard. + * + * The trick is that archive prefixes aren't configured anywhere — they're the + * slug of whichever page an editor assigned as that collection's archive. So + * /pl/artykuly and /en/articles come from one assignment, and renaming the page + * moves the whole section. + * + * Resolution order matters: a first segment matching an archive's slug wins + * over an ordinary page of the same name, because an archive *is* a page and + * would otherwise shadow its own entries. + * + * Depth beyond {archive}/{entry} isn't supported — categories in the path would + * make canonical and hreflang ambiguous (the same entry reachable under several + * URLs). + */ +export declare function resolveRoute({ content, locale, page, pagesSlug, payload, segments, settingsSlug, }: ResolveRouteArgs): Promise; +export {}; diff --git a/dist/modules/content/resolveRoute.js b/dist/modules/content/resolveRoute.js new file mode 100644 index 0000000..d2341fb --- /dev/null +++ b/dist/modules/content/resolveRoute.js @@ -0,0 +1,111 @@ +import { archiveFieldName } from './types.js'; +/** + * Works out what a URL points at: the home page, an ordinary page, a + * collection's archive, or a single entry. + * + * Routing only. An archive result says which collection to list and on which + * page, but doesn't fetch the entries — that's `getArchiveEntries`, called by + * whoever actually needs them. Keeping the two apart means listing changes + * (sorting, filtering, pagination) never touch routing rules, and metadata + * generation doesn't pay for a query it would discard. + * + * The trick is that archive prefixes aren't configured anywhere — they're the + * slug of whichever page an editor assigned as that collection's archive. So + * /pl/artykuly and /en/articles come from one assignment, and renaming the page + * moves the whole section. + * + * Resolution order matters: a first segment matching an archive's slug wins + * over an ordinary page of the same name, because an archive *is* a page and + * would otherwise shadow its own entries. + * + * Depth beyond {archive}/{entry} isn't supported — categories in the path would + * make canonical and hreflang ambiguous (the same entry reachable under several + * URLs). + */ export async function resolveRoute({ content, locale, page = 1, pagesSlug = 'pages', payload, segments, settingsSlug = 'site-settings' }) { + const settings = await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: locale + }); + // Locale root → Homepage from System Pages. + if (!segments?.length) { + const homepage = settings.homepage; + if (homepage && typeof homepage === 'object') { + return { + type: 'home', + doc: homepage + }; + } + return null; + } + const [first, ...rest] = segments; + // Does the first segment name an archive page? + for (const collection of content?.collections ?? []){ + const archive = settings[archiveFieldName(collection.slug)]; + if (!archive || typeof archive !== 'object') { + continue; + } + const archiveDoc = archive; + if (archiveDoc.slug !== first) { + continue; + } + // /pl/artykuly → the archive page itself. + if (rest.length === 0) { + return { + type: 'archive', + collection: collection.slug, + doc: archiveDoc, + page, + perPage: collection.perPage ?? 10 + }; + } + // /pl/artykuly/moj-post → an entry. + if (rest.length === 1) { + const found = await payload.find({ + collection: collection.slug, + depth: 2, + limit: 1, + locale: locale, + where: { + slug: { + equals: rest[0] + } + } + }); + const entry = found.docs[0]; + if (!entry) { + return null; + } + return { + type: 'entry', + archive: archiveDoc, + collection: collection.slug, + doc: entry + }; + } + // Deeper than {archive}/{entry}. + return null; + } + // An ordinary page. Joined, so nested slugs ('a/b') keep working. + const found = await payload.find({ + collection: pagesSlug, + depth: 2, + limit: 1, + locale: locale, + where: { + slug: { + equals: segments.join('/') + } + } + }); + const doc = found.docs[0]; + if (!doc) { + return null; + } + return { + type: 'page', + doc: doc + }; +} + +//# sourceMappingURL=resolveRoute.js.map \ No newline at end of file diff --git a/dist/modules/content/resolveRoute.js.map b/dist/modules/content/resolveRoute.js.map new file mode 100644 index 0000000..bb6b4dc --- /dev/null +++ b/dist/modules/content/resolveRoute.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/resolveRoute.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from './types.js'\n\nimport { archiveFieldName } from './types.js'\n\ntype ArchivePage = { id: number | string; slug?: unknown }\n\nexport type ResolvedRoute =\n /** Locale root — the page assigned as Homepage in System Pages. */\n | {\n /** The archive page this entry lives under — its slug is the URL prefix. */\n archive: Record\n collection: string\n doc: Record\n type: 'entry'\n }\n /** An ordinary page. */\n | {\n collection: string\n doc: Record\n /** 1-based, from ?page=. */\n page: number\n perPage: number\n type: 'archive'\n }\n /** A collection's archive page, e.g. /pl/artykuly. */\n | { doc: Record; type: 'home' }\n /** A single entry, e.g. /pl/artykuly/moj-post. */\n | { doc: Record; type: 'page' }\n\ntype ResolveRouteArgs = {\n content?: ContentOption\n locale: string\n /** Page number from the query string (?page=2). Defaults to 1. */\n page?: number\n /** Client's Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n payload: BasePayload\n /** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */\n segments?: string[]\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\n/**\n * Works out what a URL points at: the home page, an ordinary page, a\n * collection's archive, or a single entry.\n *\n * Routing only. An archive result says which collection to list and on which\n * page, but doesn't fetch the entries — that's `getArchiveEntries`, called by\n * whoever actually needs them. Keeping the two apart means listing changes\n * (sorting, filtering, pagination) never touch routing rules, and metadata\n * generation doesn't pay for a query it would discard.\n *\n * The trick is that archive prefixes aren't configured anywhere — they're the\n * slug of whichever page an editor assigned as that collection's archive. So\n * /pl/artykuly and /en/articles come from one assignment, and renaming the page\n * moves the whole section.\n *\n * Resolution order matters: a first segment matching an archive's slug wins\n * over an ordinary page of the same name, because an archive *is* a page and\n * would otherwise shadow its own entries.\n *\n * Depth beyond {archive}/{entry} isn't supported — categories in the path would\n * make canonical and hreflang ambiguous (the same entry reachable under several\n * URLs).\n */\nexport async function resolveRoute({\n content,\n locale,\n page = 1,\n pagesSlug = 'pages',\n payload,\n segments,\n settingsSlug = 'site-settings',\n}: ResolveRouteArgs): Promise {\n const settings = (await payload.findGlobal({\n slug: settingsSlug,\n depth: 1,\n locale: locale as never,\n })) as Record\n\n // Locale root → Homepage from System Pages.\n if (!segments?.length) {\n const homepage = settings.homepage\n if (homepage && typeof homepage === 'object') {\n return { type: 'home', doc: homepage as Record }\n }\n return null\n }\n\n const [first, ...rest] = segments\n\n // Does the first segment name an archive page?\n for (const collection of content?.collections ?? []) {\n const archive = settings[archiveFieldName(collection.slug)]\n if (!archive || typeof archive !== 'object') {continue}\n\n const archiveDoc = archive as ArchivePage\n if (archiveDoc.slug !== first) {continue}\n\n // /pl/artykuly → the archive page itself.\n if (rest.length === 0) {\n return {\n type: 'archive',\n collection: collection.slug,\n doc: archiveDoc as Record,\n page,\n perPage: collection.perPage ?? 10,\n }\n }\n\n // /pl/artykuly/moj-post → an entry.\n if (rest.length === 1) {\n const found = await payload.find({\n collection: collection.slug as never,\n depth: 2,\n limit: 1,\n locale: locale as never,\n where: { slug: { equals: rest[0] } },\n })\n const entry = found.docs[0]\n if (!entry) {return null}\n\n return {\n type: 'entry',\n archive: archiveDoc as Record,\n collection: collection.slug,\n doc: entry as Record,\n }\n }\n\n // Deeper than {archive}/{entry}.\n return null\n }\n\n // An ordinary page. Joined, so nested slugs ('a/b') keep working.\n const found = await payload.find({\n collection: pagesSlug as never,\n depth: 2,\n limit: 1,\n locale: locale as never,\n where: { slug: { equals: segments.join('/') } },\n })\n const doc = found.docs[0]\n if (!doc) {return null}\n\n return { type: 'page', doc: doc as Record }\n}\n"],"names":["archiveFieldName","resolveRoute","content","locale","page","pagesSlug","payload","segments","settingsSlug","settings","findGlobal","slug","depth","length","homepage","type","doc","first","rest","collection","collections","archive","archiveDoc","perPage","found","find","limit","where","equals","entry","docs","join"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,aAAY;AAyC7C;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,eAAeC,aAAa,EACjCC,OAAO,EACPC,MAAM,EACNC,OAAO,CAAC,EACRC,YAAY,OAAO,EACnBC,OAAO,EACPC,QAAQ,EACRC,eAAe,eAAe,EACb;IACjB,MAAMC,WAAY,MAAMH,QAAQI,UAAU,CAAC;QACzCC,MAAMH;QACNI,OAAO;QACPT,QAAQA;IACV;IAEA,4CAA4C;IAC5C,IAAI,CAACI,UAAUM,QAAQ;QACrB,MAAMC,WAAWL,SAASK,QAAQ;QAClC,IAAIA,YAAY,OAAOA,aAAa,UAAU;YAC5C,OAAO;gBAAEC,MAAM;gBAAQC,KAAKF;YAAoC;QAClE;QACA,OAAO;IACT;IAEA,MAAM,CAACG,OAAO,GAAGC,KAAK,GAAGX;IAEzB,+CAA+C;IAC/C,KAAK,MAAMY,cAAcjB,SAASkB,eAAe,EAAE,CAAE;QACnD,MAAMC,UAAUZ,QAAQ,CAACT,iBAAiBmB,WAAWR,IAAI,EAAE;QAC3D,IAAI,CAACU,WAAW,OAAOA,YAAY,UAAU;YAAC;QAAQ;QAEtD,MAAMC,aAAaD;QACnB,IAAIC,WAAWX,IAAI,KAAKM,OAAO;YAAC;QAAQ;QAExC,0CAA0C;QAC1C,IAAIC,KAAKL,MAAM,KAAK,GAAG;YACrB,OAAO;gBACLE,MAAM;gBACNI,YAAYA,WAAWR,IAAI;gBAC3BK,KAAKM;gBACLlB;gBACAmB,SAASJ,WAAWI,OAAO,IAAI;YACjC;QACF;QAEA,oCAAoC;QACpC,IAAIL,KAAKL,MAAM,KAAK,GAAG;YACrB,MAAMW,QAAQ,MAAMlB,QAAQmB,IAAI,CAAC;gBAC/BN,YAAYA,WAAWR,IAAI;gBAC3BC,OAAO;gBACPc,OAAO;gBACPvB,QAAQA;gBACRwB,OAAO;oBAAEhB,MAAM;wBAAEiB,QAAQV,IAAI,CAAC,EAAE;oBAAC;gBAAE;YACrC;YACA,MAAMW,QAAQL,MAAMM,IAAI,CAAC,EAAE;YAC3B,IAAI,CAACD,OAAO;gBAAC,OAAO;YAAI;YAExB,OAAO;gBACLd,MAAM;gBACNM,SAASC;gBACTH,YAAYA,WAAWR,IAAI;gBAC3BK,KAAKa;YACP;QACF;QAEA,iCAAiC;QACjC,OAAO;IACT;IAEA,kEAAkE;IAClE,MAAML,QAAQ,MAAMlB,QAAQmB,IAAI,CAAC;QAC/BN,YAAYd;QACZO,OAAO;QACPc,OAAO;QACPvB,QAAQA;QACRwB,OAAO;YAAEhB,MAAM;gBAAEiB,QAAQrB,SAASwB,IAAI,CAAC;YAAK;QAAE;IAChD;IACA,MAAMf,MAAMQ,MAAMM,IAAI,CAAC,EAAE;IACzB,IAAI,CAACd,KAAK;QAAC,OAAO;IAAI;IAEtB,OAAO;QAAED,MAAM;QAAQC,KAAKA;IAA+B;AAC7D"} \ No newline at end of file diff --git a/dist/modules/content/types.d.js b/dist/modules/content/types.d.js new file mode 100644 index 0000000..7c93c84 --- /dev/null +++ b/dist/modules/content/types.d.js @@ -0,0 +1,15 @@ +/** + * A collection whose entries live under an archive page, e.g. blog posts at + * /pl/artykuly/moj-post. + * + * The collection itself belongs to the client — Payload keeps its schema in + * code, so adding one is always a code change. What this option adds is the + * routing: the plugin exposes an "archive page" assignment in SiteSettings, and + * whichever page an editor picks becomes the URL segment. Rename that page from + * "Artykuły" to "Wpisy" and the path follows, per locale, with no deploy. + */ /** + * Field name holding a collection's archive page in SiteSettings. + * `posts` → `postsArchive`. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/content/types.d.js.map b/dist/modules/content/types.d.js.map new file mode 100644 index 0000000..f9965ed --- /dev/null +++ b/dist/modules/content/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["/**\n * A collection whose entries live under an archive page, e.g. blog posts at\n * /pl/artykuly/moj-post.\n *\n * The collection itself belongs to the client — Payload keeps its schema in\n * code, so adding one is always a code change. What this option adds is the\n * routing: the plugin exposes an \"archive page\" assignment in SiteSettings, and\n * whichever page an editor picks becomes the URL segment. Rename that page from\n * \"Artykuły\" to \"Wpisy\" and the path follows, per locale, with no deploy.\n */\nexport type ContentCollectionOption = {\n /** Slug of the client's collection, e.g. 'posts'. */\n slug: string;\n /** Admin label for the archive assignment. Defaults to the slug. */\n label?: string;\n /** Entries per page in listings. Defaults to 10. */\n perPage?: number;\n};\n/**\n * Plugin option wiring archive-backed collections into routing.\n */\nexport type ContentOption = {\n collections: ContentCollectionOption[];\n};\n/**\n * Field name holding a collection's archive page in SiteSettings.\n * `posts` → `postsArchive`.\n */\nexport declare function archiveFieldName(collectionSlug: string): string;\n"],"names":[],"mappings":"AAAA;;;;;;;;;CASC,GAeD;;;CAGC,GACD,WAAyE"} \ No newline at end of file diff --git a/dist/modules/content/types.d.ts b/dist/modules/content/types.d.ts new file mode 100644 index 0000000..9adf4be --- /dev/null +++ b/dist/modules/content/types.d.ts @@ -0,0 +1,29 @@ +/** + * A collection whose entries live under an archive page, e.g. blog posts at + * /pl/artykuly/moj-post. + * + * The collection itself belongs to the client — Payload keeps its schema in + * code, so adding one is always a code change. What this option adds is the + * routing: the plugin exposes an "archive page" assignment in SiteSettings, and + * whichever page an editor picks becomes the URL segment. Rename that page from + * "Artykuły" to "Wpisy" and the path follows, per locale, with no deploy. + */ +export type ContentCollectionOption = { + /** Slug of the client's collection, e.g. 'posts'. */ + slug: string; + /** Admin label for the archive assignment. Defaults to the slug. */ + label?: string; + /** Entries per page in listings. Defaults to 10. */ + perPage?: number; +}; +/** + * Plugin option wiring archive-backed collections into routing. + */ +export type ContentOption = { + collections: ContentCollectionOption[]; +}; +/** + * Field name holding a collection's archive page in SiteSettings. + * `posts` → `postsArchive`. + */ +export declare function archiveFieldName(collectionSlug: string): string; diff --git a/dist/modules/content/types.js b/dist/modules/content/types.js new file mode 100644 index 0000000..976c958 --- /dev/null +++ b/dist/modules/content/types.js @@ -0,0 +1,17 @@ +/** + * A collection whose entries live under an archive page, e.g. blog posts at + * /pl/artykuly/moj-post. + * + * The collection itself belongs to the client — Payload keeps its schema in + * code, so adding one is always a code change. What this option adds is the + * routing: the plugin exposes an "archive page" assignment in SiteSettings, and + * whichever page an editor picks becomes the URL segment. Rename that page from + * "Artykuły" to "Wpisy" and the path follows, per locale, with no deploy. + */ /** + * Field name holding a collection's archive page in SiteSettings. + * `posts` → `postsArchive`. + */ export function archiveFieldName(collectionSlug) { + return `${collectionSlug}Archive`; +} + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/content/types.js.map b/dist/modules/content/types.js.map new file mode 100644 index 0000000..c7df3bf --- /dev/null +++ b/dist/modules/content/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/content/types.ts"],"sourcesContent":["/**\n * A collection whose entries live under an archive page, e.g. blog posts at\n * /pl/artykuly/moj-post.\n *\n * The collection itself belongs to the client — Payload keeps its schema in\n * code, so adding one is always a code change. What this option adds is the\n * routing: the plugin exposes an \"archive page\" assignment in SiteSettings, and\n * whichever page an editor picks becomes the URL segment. Rename that page from\n * \"Artykuły\" to \"Wpisy\" and the path follows, per locale, with no deploy.\n */\nexport type ContentCollectionOption = {\n /** Slug of the client's collection, e.g. 'posts'. */\n slug: string\n /** Admin label for the archive assignment. Defaults to the slug. */\n label?: string\n /** Entries per page in listings. Defaults to 10. */\n perPage?: number\n}\n\n/**\n * Plugin option wiring archive-backed collections into routing.\n */\nexport type ContentOption = {\n collections: ContentCollectionOption[]\n}\n\n/**\n * Field name holding a collection's archive page in SiteSettings.\n * `posts` → `postsArchive`.\n */\nexport function archiveFieldName(collectionSlug: string): string {\n return `${collectionSlug}Archive`\n}\n"],"names":["archiveFieldName","collectionSlug"],"mappings":"AAAA;;;;;;;;;CASC,GAiBD;;;CAGC,GACD,OAAO,SAASA,iBAAiBC,cAAsB;IACrD,OAAO,GAAGA,eAAe,OAAO,CAAC;AACnC"} \ No newline at end of file diff --git a/dist/modules/email/index.d.js b/dist/modules/email/index.d.js new file mode 100644 index 0000000..cc59bb6 --- /dev/null +++ b/dist/modules/email/index.d.js @@ -0,0 +1,3 @@ +export { sendEmail } from './sendEmail.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/email/index.d.js.map b/dist/modules/email/index.d.js.map new file mode 100644 index 0000000..bd7cc88 --- /dev/null +++ b/dist/modules/email/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { sendEmail } from './sendEmail.js';\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js';\n"],"names":["sendEmail"],"mappings":"AAAA,SAASA,SAAS,QAAQ,iBAAiB"} \ No newline at end of file diff --git a/dist/modules/email/index.d.ts b/dist/modules/email/index.d.ts new file mode 100644 index 0000000..225c347 --- /dev/null +++ b/dist/modules/email/index.d.ts @@ -0,0 +1,2 @@ +export { sendEmail } from './sendEmail.js'; +export type { SendEmailArgs, SendEmailResult } from './sendEmail.js'; diff --git a/dist/modules/email/index.js b/dist/modules/email/index.js new file mode 100644 index 0000000..385c3b2 --- /dev/null +++ b/dist/modules/email/index.js @@ -0,0 +1,5 @@ +// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer) +// so this must never be imported from a client component. +export { sendEmail } from './sendEmail.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/email/index.js.map b/dist/modules/email/index.js.map new file mode 100644 index 0000000..d62dbdf --- /dev/null +++ b/dist/modules/email/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"} \ No newline at end of file diff --git a/dist/modules/email/panelSmtpAdapter.d.js b/dist/modules/email/panelSmtpAdapter.d.js new file mode 100644 index 0000000..ff104c1 --- /dev/null +++ b/dist/modules/email/panelSmtpAdapter.d.js @@ -0,0 +1,23 @@ +/** + * Payload email adapter backed by the SMTP settings in the SiteIntegrations + * global. + * + * Why this exists: Payload builds its email adapter once, at boot, from the + * config — which would normally mean SMTP credentials living in env vars and a + * redeploy to change them. This adapter instead resolves SMTP on every send, so + * an editor can change the mailbox in the admin panel and the next email uses + * it. + * + * Wiring it into the config means `payload.sendEmail` works everywhere — which + * includes the form-builder's own submission emails (the ones an editor + * configures per form under "Emails"). Those go out over the panel's SMTP with + * no extra code. + * + * Boot-time constraints shape two details: + * - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so + * they're placeholders; the real from-address is applied per message below. + * - nodemailer is imported dynamically inside sendEmail, so merely loading the + * Payload config (or running `generate:importmap`) doesn't pull it in. + */ export { }; + +//# sourceMappingURL=panelSmtpAdapter.d.js.map \ No newline at end of file diff --git a/dist/modules/email/panelSmtpAdapter.d.js.map b/dist/modules/email/panelSmtpAdapter.d.js.map new file mode 100644 index 0000000..55a7436 --- /dev/null +++ b/dist/modules/email/panelSmtpAdapter.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["panelSmtpAdapter.d.ts"],"sourcesContent":["import type { PayloadEmailAdapter } from 'payload';\nexport type PanelSmtpAdapterArgs = {\n /**\n * Used only until the panel is filled in — Payload requires a from-address\n * synchronously at boot, before any global can be read. Once SiteIntegrations\n * has an address, it wins.\n */\n fallbackFromAddress?: string;\n fallbackFromName?: string;\n};\n/**\n * Payload email adapter backed by the SMTP settings in the SiteIntegrations\n * global.\n *\n * Why this exists: Payload builds its email adapter once, at boot, from the\n * config — which would normally mean SMTP credentials living in env vars and a\n * redeploy to change them. This adapter instead resolves SMTP on every send, so\n * an editor can change the mailbox in the admin panel and the next email uses\n * it.\n *\n * Wiring it into the config means `payload.sendEmail` works everywhere — which\n * includes the form-builder's own submission emails (the ones an editor\n * configures per form under \"Emails\"). Those go out over the panel's SMTP with\n * no extra code.\n *\n * Boot-time constraints shape two details:\n * - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so\n * they're placeholders; the real from-address is applied per message below.\n * - nodemailer is imported dynamically inside sendEmail, so merely loading the\n * Payload config (or running `generate:importmap`) doesn't pull it in.\n */\nexport declare const panelSmtpAdapter: (args?: PanelSmtpAdapterArgs) => PayloadEmailAdapter;\n"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,WAA4F"} \ No newline at end of file diff --git a/dist/modules/email/panelSmtpAdapter.d.ts b/dist/modules/email/panelSmtpAdapter.d.ts new file mode 100644 index 0000000..2d34ed2 --- /dev/null +++ b/dist/modules/email/panelSmtpAdapter.d.ts @@ -0,0 +1,32 @@ +import type { PayloadEmailAdapter } from 'payload'; +export type PanelSmtpAdapterArgs = { + /** + * Used only until the panel is filled in — Payload requires a from-address + * synchronously at boot, before any global can be read. Once SiteIntegrations + * has an address, it wins. + */ + fallbackFromAddress?: string; + fallbackFromName?: string; +}; +/** + * Payload email adapter backed by the SMTP settings in the SiteIntegrations + * global. + * + * Why this exists: Payload builds its email adapter once, at boot, from the + * config — which would normally mean SMTP credentials living in env vars and a + * redeploy to change them. This adapter instead resolves SMTP on every send, so + * an editor can change the mailbox in the admin panel and the next email uses + * it. + * + * Wiring it into the config means `payload.sendEmail` works everywhere — which + * includes the form-builder's own submission emails (the ones an editor + * configures per form under "Emails"). Those go out over the panel's SMTP with + * no extra code. + * + * Boot-time constraints shape two details: + * - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so + * they're placeholders; the real from-address is applied per message below. + * - nodemailer is imported dynamically inside sendEmail, so merely loading the + * Payload config (or running `generate:importmap`) doesn't pull it in. + */ +export declare const panelSmtpAdapter: (args?: PanelSmtpAdapterArgs) => PayloadEmailAdapter; diff --git a/dist/modules/email/panelSmtpAdapter.js b/dist/modules/email/panelSmtpAdapter.js new file mode 100644 index 0000000..19133e5 --- /dev/null +++ b/dist/modules/email/panelSmtpAdapter.js @@ -0,0 +1,83 @@ +import { getSiteIntegrations } from '../payload/index.js'; +/** + * Payload email adapter backed by the SMTP settings in the SiteIntegrations + * global. + * + * Why this exists: Payload builds its email adapter once, at boot, from the + * config — which would normally mean SMTP credentials living in env vars and a + * redeploy to change them. This adapter instead resolves SMTP on every send, so + * an editor can change the mailbox in the admin panel and the next email uses + * it. + * + * Wiring it into the config means `payload.sendEmail` works everywhere — which + * includes the form-builder's own submission emails (the ones an editor + * configures per form under "Emails"). Those go out over the panel's SMTP with + * no extra code. + * + * Boot-time constraints shape two details: + * - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so + * they're placeholders; the real from-address is applied per message below. + * - nodemailer is imported dynamically inside sendEmail, so merely loading the + * Payload config (or running `generate:importmap`) doesn't pull it in. + */ export const panelSmtpAdapter = (args = {})=>({ payload })=>({ + name: 'ipal-panel-smtp', + defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost', + defaultFromName: args.fallbackFromName ?? 'Website', + sendEmail: async (message)=>{ + const smtp = await getSiteIntegrations(payload); + const host = smtp.smtpHost; + const port = smtp.smtpPort ?? 587; + const user = smtp.smtpUser; + const pass = smtp.smtpPassword; + if (!host || !user || !pass) { + payload.logger.error('[ipal] Email not sent: SMTP is not configured in Site Integrations.'); + return { + error: 'SMTP is not configured in Site Integrations.', + sent: false + }; + } + // The panel is the source of truth for the sender; fall back to whatever + // the caller set (Payload fills in defaultFromAddress when unset). + const fromAddress = smtp.smtpFromAddress; + const fromName = smtp.smtpFromName; + const from = fromAddress ? fromName ? `${fromName} <${fromAddress}>` : fromAddress : message.from; + // Defence in depth against header injection. Modern nodemailer already + // normalises CRLF in standard headers, but the form-builder interpolates + // user data into the subject via {{field}} placeholders, so strip any + // newlines from header-bound values before they reach the transport. + const stripCRLF = (v)=>String(v ?? '').replace(/[\r\n]+/g, ' ').trim(); + const { default: nodemailer } = await import('nodemailer'); + const transporter = nodemailer.createTransport({ + auth: { + pass, + user + }, + host, + port, + secure: port === 465 + }); + try { + const info = await transporter.sendMail({ + ...message, + from, + ...message.subject ? { + subject: stripCRLF(message.subject) + } : {} + }); + return { + messageId: info.messageId, + sent: true + }; + } catch (err) { + // Never surface SMTP internals to the caller — a form submission + // shouldn't fail loudly because the mailbox is misconfigured. + payload.logger.error(`[ipal] Email send failed: ${err.message}`); + return { + error: 'Failed to send email.', + sent: false + }; + } + } + }); + +//# sourceMappingURL=panelSmtpAdapter.js.map \ No newline at end of file diff --git a/dist/modules/email/panelSmtpAdapter.js.map b/dist/modules/email/panelSmtpAdapter.js.map new file mode 100644 index 0000000..06b1854 --- /dev/null +++ b/dist/modules/email/panelSmtpAdapter.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/email/panelSmtpAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\n/** SMTP fields the adapter reads from SiteIntegrations. */\ntype SmtpIntegrations = {\n smtpFromAddress?: null | string\n smtpFromName?: null | string\n smtpHost?: null | string\n smtpPassword?: null | string\n smtpPort?: null | number\n smtpUser?: null | string\n}\n\nexport type PanelSmtpAdapterArgs = {\n /**\n * Used only until the panel is filled in — Payload requires a from-address\n * synchronously at boot, before any global can be read. Once SiteIntegrations\n * has an address, it wins.\n */\n fallbackFromAddress?: string\n fallbackFromName?: string\n}\n\n/**\n * Payload email adapter backed by the SMTP settings in the SiteIntegrations\n * global.\n *\n * Why this exists: Payload builds its email adapter once, at boot, from the\n * config — which would normally mean SMTP credentials living in env vars and a\n * redeploy to change them. This adapter instead resolves SMTP on every send, so\n * an editor can change the mailbox in the admin panel and the next email uses\n * it.\n *\n * Wiring it into the config means `payload.sendEmail` works everywhere — which\n * includes the form-builder's own submission emails (the ones an editor\n * configures per form under \"Emails\"). Those go out over the panel's SMTP with\n * no extra code.\n *\n * Boot-time constraints shape two details:\n * - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so\n * they're placeholders; the real from-address is applied per message below.\n * - nodemailer is imported dynamically inside sendEmail, so merely loading the\n * Payload config (or running `generate:importmap`) doesn't pull it in.\n */\nexport const panelSmtpAdapter =\n (args: PanelSmtpAdapterArgs = {}): PayloadEmailAdapter =>\n ({ payload }) => ({\n name: 'ipal-panel-smtp',\n defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',\n defaultFromName: args.fallbackFromName ?? 'Website',\n\n sendEmail: async (message: SendEmailOptions) => {\n const smtp = await getSiteIntegrations(payload)\n\n const host = smtp.smtpHost\n const port = smtp.smtpPort ?? 587\n const user = smtp.smtpUser\n const pass = smtp.smtpPassword\n\n if (!host || !user || !pass) {\n payload.logger.error('[ipal] Email not sent: SMTP is not configured in Site Integrations.')\n return { error: 'SMTP is not configured in Site Integrations.', sent: false }\n }\n\n // The panel is the source of truth for the sender; fall back to whatever\n // the caller set (Payload fills in defaultFromAddress when unset).\n const fromAddress = smtp.smtpFromAddress\n const fromName = smtp.smtpFromName\n const from = fromAddress\n ? fromName\n ? `${fromName} <${fromAddress}>`\n : fromAddress\n : message.from\n\n // Defence in depth against header injection. Modern nodemailer already\n // normalises CRLF in standard headers, but the form-builder interpolates\n // user data into the subject via {{field}} placeholders, so strip any\n // newlines from header-bound values before they reach the transport.\n const stripCRLF = (v: unknown): string =>\n String(v ?? '')\n .replace(/[\\r\\n]+/g, ' ')\n .trim()\n\n const { default: nodemailer } = await import('nodemailer')\n\n const transporter = nodemailer.createTransport({\n auth: { pass, user },\n host,\n port,\n secure: port === 465, // implicit TLS on 465, STARTTLS otherwise\n })\n\n try {\n const info = await transporter.sendMail({\n ...message,\n from,\n ...(message.subject ? { subject: stripCRLF(message.subject) } : {}),\n })\n return { messageId: info.messageId, sent: true }\n } catch (err) {\n // Never surface SMTP internals to the caller — a form submission\n // shouldn't fail loudly because the mailbox is misconfigured.\n payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)\n return { error: 'Failed to send email.', sent: false }\n }\n },\n })\n"],"names":["getSiteIntegrations","panelSmtpAdapter","args","payload","name","defaultFromAddress","fallbackFromAddress","defaultFromName","fallbackFromName","sendEmail","message","smtp","host","smtpHost","port","smtpPort","user","smtpUser","pass","smtpPassword","logger","error","sent","fromAddress","smtpFromAddress","fromName","smtpFromName","from","stripCRLF","v","String","replace","trim","default","nodemailer","transporter","createTransport","auth","secure","info","sendMail","subject","messageId","err"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AAsBzD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,MAAMC,mBACX,CAACC,OAA6B,CAAC,CAAC,GAChC,CAAC,EAAEC,OAAO,EAAE,GAAM,CAAA;YAChBC,MAAM;YACNC,oBAAoBH,KAAKI,mBAAmB,IAAI;YAChDC,iBAAiBL,KAAKM,gBAAgB,IAAI;YAE1CC,WAAW,OAAOC;gBAChB,MAAMC,OAAO,MAAMX,oBAAsCG;gBAEzD,MAAMS,OAAOD,KAAKE,QAAQ;gBAC1B,MAAMC,OAAOH,KAAKI,QAAQ,IAAI;gBAC9B,MAAMC,OAAOL,KAAKM,QAAQ;gBAC1B,MAAMC,OAAOP,KAAKQ,YAAY;gBAE9B,IAAI,CAACP,QAAQ,CAACI,QAAQ,CAACE,MAAM;oBAC3Bf,QAAQiB,MAAM,CAACC,KAAK,CAAC;oBACrB,OAAO;wBAAEA,OAAO;wBAAgDC,MAAM;oBAAM;gBAC9E;gBAEA,yEAAyE;gBACzE,mEAAmE;gBACnE,MAAMC,cAAcZ,KAAKa,eAAe;gBACxC,MAAMC,WAAWd,KAAKe,YAAY;gBAClC,MAAMC,OAAOJ,cACTE,WACE,GAAGA,SAAS,EAAE,EAAEF,YAAY,CAAC,CAAC,GAC9BA,cACFb,QAAQiB,IAAI;gBAEhB,uEAAuE;gBACvE,yEAAyE;gBACzE,sEAAsE;gBACtE,qEAAqE;gBACrE,MAAMC,YAAY,CAACC,IACjBC,OAAOD,KAAK,IACTE,OAAO,CAAC,YAAY,KACpBC,IAAI;gBAET,MAAM,EAAEC,SAASC,UAAU,EAAE,GAAG,MAAM,MAAM,CAAC;gBAE7C,MAAMC,cAAcD,WAAWE,eAAe,CAAC;oBAC7CC,MAAM;wBAAEnB;wBAAMF;oBAAK;oBACnBJ;oBACAE;oBACAwB,QAAQxB,SAAS;gBACnB;gBAEA,IAAI;oBACF,MAAMyB,OAAO,MAAMJ,YAAYK,QAAQ,CAAC;wBACtC,GAAG9B,OAAO;wBACViB;wBACA,GAAIjB,QAAQ+B,OAAO,GAAG;4BAAEA,SAASb,UAAUlB,QAAQ+B,OAAO;wBAAE,IAAI,CAAC,CAAC;oBACpE;oBACA,OAAO;wBAAEC,WAAWH,KAAKG,SAAS;wBAAEpB,MAAM;oBAAK;gBACjD,EAAE,OAAOqB,KAAK;oBACZ,iEAAiE;oBACjE,8DAA8D;oBAC9DxC,QAAQiB,MAAM,CAACC,KAAK,CAAC,CAAC,0BAA0B,EAAE,AAACsB,IAAcjC,OAAO,EAAE;oBAC1E,OAAO;wBAAEW,OAAO;wBAAyBC,MAAM;oBAAM;gBACvD;YACF;QACF,CAAA,EAAE"} \ No newline at end of file diff --git a/dist/modules/email/sendEmail.d.js b/dist/modules/email/sendEmail.d.js new file mode 100644 index 0000000..c96dc24 --- /dev/null +++ b/dist/modules/email/sendEmail.d.js @@ -0,0 +1,3 @@ +import 'server-only'; + +//# sourceMappingURL=sendEmail.d.js.map \ No newline at end of file diff --git a/dist/modules/email/sendEmail.d.js.map b/dist/modules/email/sendEmail.d.js.map new file mode 100644 index 0000000..1ee6c16 --- /dev/null +++ b/dist/modules/email/sendEmail.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["sendEmail.d.ts"],"sourcesContent":["import 'server-only';\nimport type { BasePayload } from 'payload';\nexport type SendEmailArgs = {\n /** Override the configured from-address for this message. */\n from?: string;\n /** HTML body. */\n html: string;\n /** Payload instance — used to read SMTP config from SiteIntegrations. */\n payload: BasePayload;\n replyTo?: string;\n subject: string;\n /** Optional plain-text fallback. */\n text?: string;\n to: string | string[];\n};\nexport type SendEmailResult = {\n error: string;\n sent: false;\n} | {\n messageId: string;\n sent: true;\n};\n/**\n * Sends an email using SMTP settings from the SiteIntegrations global.\n *\n * Reads config at call time (not at boot) so editors can change SMTP in the\n * admin panel without a restart — consistent with the plugin's panel-managed\n * model. `server-only` keeps the SMTP password out of any client bundle.\n *\n * Returns a result object rather than throwing, so callers (e.g. form\n * submission) can handle failure without a try/catch and never leak SMTP\n * details to the client.\n */\nexport declare function sendEmail({ from, html, payload, replyTo, subject, text, to, }: SendEmailArgs): Promise;\n"],"names":[],"mappings":"AAAA,OAAO,cAAc"} \ No newline at end of file diff --git a/dist/modules/email/sendEmail.d.ts b/dist/modules/email/sendEmail.d.ts new file mode 100644 index 0000000..57e5eaa --- /dev/null +++ b/dist/modules/email/sendEmail.d.ts @@ -0,0 +1,34 @@ +import 'server-only'; +import type { BasePayload } from 'payload'; +export type SendEmailArgs = { + /** Override the configured from-address for this message. */ + from?: string; + /** HTML body. */ + html: string; + /** Payload instance — used to read SMTP config from SiteIntegrations. */ + payload: BasePayload; + replyTo?: string; + subject: string; + /** Optional plain-text fallback. */ + text?: string; + to: string | string[]; +}; +export type SendEmailResult = { + error: string; + sent: false; +} | { + messageId: string; + sent: true; +}; +/** + * Sends an email using SMTP settings from the SiteIntegrations global. + * + * Reads config at call time (not at boot) so editors can change SMTP in the + * admin panel without a restart — consistent with the plugin's panel-managed + * model. `server-only` keeps the SMTP password out of any client bundle. + * + * Returns a result object rather than throwing, so callers (e.g. form + * submission) can handle failure without a try/catch and never leak SMTP + * details to the client. + */ +export declare function sendEmail({ from, html, payload, replyTo, subject, text, to, }: SendEmailArgs): Promise; diff --git a/dist/modules/email/sendEmail.js b/dist/modules/email/sendEmail.js new file mode 100644 index 0000000..a208f78 --- /dev/null +++ b/dist/modules/email/sendEmail.js @@ -0,0 +1,70 @@ +import 'server-only'; +import nodemailer from 'nodemailer'; +import { getSiteIntegrations } from '../payload/index.js'; +/** + * Sends an email using SMTP settings from the SiteIntegrations global. + * + * Reads config at call time (not at boot) so editors can change SMTP in the + * admin panel without a restart — consistent with the plugin's panel-managed + * model. `server-only` keeps the SMTP password out of any client bundle. + * + * Returns a result object rather than throwing, so callers (e.g. form + * submission) can handle failure without a try/catch and never leak SMTP + * details to the client. + */ export async function sendEmail({ from, html, payload, replyTo, subject, text, to }) { + const smtp = await getSiteIntegrations(payload); + const host = smtp.smtpHost; + const port = smtp.smtpPort ?? 587; + const user = smtp.smtpUser; + const pass = smtp.smtpPassword; + if (!host || !user || !pass) { + return { + error: 'SMTP is not configured in Site Integrations.', + sent: false + }; + } + const fromAddress = from ?? smtp.smtpFromAddress; + if (!fromAddress) { + return { + error: 'No from-address configured.', + sent: false + }; + } + const fromName = smtp.smtpFromName; + const fromHeader = fromName ? `${fromName} <${fromAddress}>` : fromAddress; + const transporter = nodemailer.createTransport({ + auth: { + pass, + user + }, + host, + port, + secure: port === 465 + }); + try { + const info = await transporter.sendMail({ + from: fromHeader, + html, + subject, + to, + ...text ? { + text + } : {}, + ...replyTo ? { + replyTo + } : {} + }); + return { + messageId: info.messageId, + sent: true + }; + } catch (err) { + payload.logger.error(`[ipal] Email send failed: ${err.message}`); + return { + error: 'Failed to send email.', + sent: false + }; + } +} + +//# sourceMappingURL=sendEmail.js.map \ No newline at end of file diff --git a/dist/modules/email/sendEmail.js.map b/dist/modules/email/sendEmail.js.map new file mode 100644 index 0000000..75456cf --- /dev/null +++ b/dist/modules/email/sendEmail.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/email/sendEmail.ts"],"sourcesContent":["import 'server-only'\n\nimport type { BasePayload } from 'payload'\n\nimport nodemailer from 'nodemailer'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\n/** SMTP fields the sender reads from SiteIntegrations. */\ntype SmtpIntegrations = {\n smtpFromAddress?: null | string\n smtpFromName?: null | string\n smtpHost?: null | string\n smtpPassword?: null | string\n smtpPort?: null | number\n smtpUser?: null | string\n}\n\nexport type SendEmailArgs = {\n /** Override the configured from-address for this message. */\n from?: string\n /** HTML body. */\n html: string\n /** Payload instance — used to read SMTP config from SiteIntegrations. */\n payload: BasePayload\n replyTo?: string\n subject: string\n /** Optional plain-text fallback. */\n text?: string\n to: string | string[]\n}\n\nexport type SendEmailResult = { error: string; sent: false } | { messageId: string; sent: true }\n\n/**\n * Sends an email using SMTP settings from the SiteIntegrations global.\n *\n * Reads config at call time (not at boot) so editors can change SMTP in the\n * admin panel without a restart — consistent with the plugin's panel-managed\n * model. `server-only` keeps the SMTP password out of any client bundle.\n *\n * Returns a result object rather than throwing, so callers (e.g. form\n * submission) can handle failure without a try/catch and never leak SMTP\n * details to the client.\n */\nexport async function sendEmail({\n from,\n html,\n payload,\n replyTo,\n subject,\n text,\n to,\n}: SendEmailArgs): Promise {\n const smtp = await getSiteIntegrations(payload)\n\n const host = smtp.smtpHost\n const port = smtp.smtpPort ?? 587\n const user = smtp.smtpUser\n const pass = smtp.smtpPassword\n\n if (!host || !user || !pass) {\n return { error: 'SMTP is not configured in Site Integrations.', sent: false }\n }\n\n const fromAddress = from ?? smtp.smtpFromAddress\n if (!fromAddress) {\n return { error: 'No from-address configured.', sent: false }\n }\n\n const fromName = smtp.smtpFromName\n const fromHeader = fromName ? `${fromName} <${fromAddress}>` : fromAddress\n\n const transporter = nodemailer.createTransport({\n auth: { pass, user },\n host,\n port,\n secure: port === 465, // implicit TLS on 465, STARTTLS otherwise\n })\n\n try {\n const info = await transporter.sendMail({\n from: fromHeader,\n html,\n subject,\n to,\n ...(text ? { text } : {}),\n ...(replyTo ? { replyTo } : {}),\n })\n return { messageId: info.messageId, sent: true }\n } catch (err) {\n payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)\n return { error: 'Failed to send email.', sent: false }\n }\n}\n"],"names":["nodemailer","getSiteIntegrations","sendEmail","from","html","payload","replyTo","subject","text","to","smtp","host","smtpHost","port","smtpPort","user","smtpUser","pass","smtpPassword","error","sent","fromAddress","smtpFromAddress","fromName","smtpFromName","fromHeader","transporter","createTransport","auth","secure","info","sendMail","messageId","err","logger","message"],"mappings":"AAAA,OAAO,cAAa;AAIpB,OAAOA,gBAAgB,aAAY;AAEnC,SAASC,mBAAmB,QAAQ,sBAAqB;AA4BzD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,UAAU,EAC9BC,IAAI,EACJC,IAAI,EACJC,OAAO,EACPC,OAAO,EACPC,OAAO,EACPC,IAAI,EACJC,EAAE,EACY;IACd,MAAMC,OAAO,MAAMT,oBAAsCI;IAEzD,MAAMM,OAAOD,KAAKE,QAAQ;IAC1B,MAAMC,OAAOH,KAAKI,QAAQ,IAAI;IAC9B,MAAMC,OAAOL,KAAKM,QAAQ;IAC1B,MAAMC,OAAOP,KAAKQ,YAAY;IAE9B,IAAI,CAACP,QAAQ,CAACI,QAAQ,CAACE,MAAM;QAC3B,OAAO;YAAEE,OAAO;YAAgDC,MAAM;QAAM;IAC9E;IAEA,MAAMC,cAAclB,QAAQO,KAAKY,eAAe;IAChD,IAAI,CAACD,aAAa;QAChB,OAAO;YAAEF,OAAO;YAA+BC,MAAM;QAAM;IAC7D;IAEA,MAAMG,WAAWb,KAAKc,YAAY;IAClC,MAAMC,aAAaF,WAAW,GAAGA,SAAS,EAAE,EAAEF,YAAY,CAAC,CAAC,GAAGA;IAE/D,MAAMK,cAAc1B,WAAW2B,eAAe,CAAC;QAC7CC,MAAM;YAAEX;YAAMF;QAAK;QACnBJ;QACAE;QACAgB,QAAQhB,SAAS;IACnB;IAEA,IAAI;QACF,MAAMiB,OAAO,MAAMJ,YAAYK,QAAQ,CAAC;YACtC5B,MAAMsB;YACNrB;YACAG;YACAE;YACA,GAAID,OAAO;gBAAEA;YAAK,IAAI,CAAC,CAAC;YACxB,GAAIF,UAAU;gBAAEA;YAAQ,IAAI,CAAC,CAAC;QAChC;QACA,OAAO;YAAE0B,WAAWF,KAAKE,SAAS;YAAEZ,MAAM;QAAK;IACjD,EAAE,OAAOa,KAAK;QACZ5B,QAAQ6B,MAAM,CAACf,KAAK,CAAC,CAAC,0BAA0B,EAAE,AAACc,IAAcE,OAAO,EAAE;QAC1E,OAAO;YAAEhB,OAAO;YAAyBC,MAAM;QAAM;IACvD;AACF"} \ No newline at end of file diff --git a/dist/modules/forms/formsPluginConfig.d.js b/dist/modules/forms/formsPluginConfig.d.js new file mode 100644 index 0000000..1e18a1b --- /dev/null +++ b/dist/modules/forms/formsPluginConfig.d.js @@ -0,0 +1,10 @@ +/** + * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption. + * + * Provides the form/form-submissions collections and field types. Email + * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel + * sender (submitForm) does that, so the form-builder's built-in email (which + * needs a Payload email adapter) is left unused. + */ export { }; + +//# sourceMappingURL=formsPluginConfig.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/formsPluginConfig.d.js.map b/dist/modules/forms/formsPluginConfig.d.js.map new file mode 100644 index 0000000..1f5f356 --- /dev/null +++ b/dist/modules/forms/formsPluginConfig.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["formsPluginConfig.d.ts"],"sourcesContent":["import type { Plugin } from 'payload';\nimport type { FormsOption } from './types.js';\n/**\n * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.\n *\n * Provides the form/form-submissions collections and field types. Email\n * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel\n * sender (submitForm) does that, so the form-builder's built-in email (which\n * needs a Payload email adapter) is left unused.\n */\nexport declare function buildFormsPlugin(forms: FormsOption): Plugin;\n"],"names":[],"mappings":"AAEA;;;;;;;CAOC,GACD,WAAqE"} \ No newline at end of file diff --git a/dist/modules/forms/formsPluginConfig.d.ts b/dist/modules/forms/formsPluginConfig.d.ts new file mode 100644 index 0000000..87dcea8 --- /dev/null +++ b/dist/modules/forms/formsPluginConfig.d.ts @@ -0,0 +1,11 @@ +import type { Plugin } from 'payload'; +import type { FormsOption } from './types.js'; +/** + * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption. + * + * Provides the form/form-submissions collections and field types. Email + * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel + * sender (submitForm) does that, so the form-builder's built-in email (which + * needs a Payload email adapter) is left unused. + */ +export declare function buildFormsPlugin(forms: FormsOption): Plugin; diff --git a/dist/modules/forms/formsPluginConfig.js b/dist/modules/forms/formsPluginConfig.js new file mode 100644 index 0000000..aaec36a --- /dev/null +++ b/dist/modules/forms/formsPluginConfig.js @@ -0,0 +1,37 @@ +import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'; +/** + * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption. + * + * Provides the form/form-submissions collections and field types. Email + * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel + * sender (submitForm) does that, so the form-builder's built-in email (which + * needs a Payload email adapter) is left unused. + */ export function buildFormsPlugin(forms) { + return formBuilderPlugin({ + fields: { + checkbox: true, + email: true, + message: true, + number: true, + payment: false, + select: true, + text: true, + textarea: true, + ...forms.fields + }, + ...forms.redirectRelationships ? { + redirectRelationships: forms.redirectRelationships + } : {}, + // Client-supplied collection overrides (e.g. a per-form notification + // address). The plugin provides the hook, not the opinion about which + // extra fields a form should carry. + ...forms.formOverrides ? { + formOverrides: forms.formOverrides + } : {}, + ...forms.formSubmissionOverrides ? { + formSubmissionOverrides: forms.formSubmissionOverrides + } : {} + }); +} + +//# sourceMappingURL=formsPluginConfig.js.map \ No newline at end of file diff --git a/dist/modules/forms/formsPluginConfig.js.map b/dist/modules/forms/formsPluginConfig.js.map new file mode 100644 index 0000000..9fb3ee5 --- /dev/null +++ b/dist/modules/forms/formsPluginConfig.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/formsPluginConfig.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { formBuilderPlugin } from '@payloadcms/plugin-form-builder'\n\nimport type { FormsOption } from './types.js'\n\n/**\n * Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.\n *\n * Provides the form/form-submissions collections and field types. Email\n * delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel\n * sender (submitForm) does that, so the form-builder's built-in email (which\n * needs a Payload email adapter) is left unused.\n */\nexport function buildFormsPlugin(forms: FormsOption): Plugin {\n return formBuilderPlugin({\n fields: {\n checkbox: true,\n email: true,\n message: true,\n number: true,\n payment: false,\n select: true,\n text: true,\n textarea: true,\n ...forms.fields,\n },\n ...(forms.redirectRelationships ? { redirectRelationships: forms.redirectRelationships } : {}),\n // Client-supplied collection overrides (e.g. a per-form notification\n // address). The plugin provides the hook, not the opinion about which\n // extra fields a form should carry.\n ...(forms.formOverrides ? { formOverrides: forms.formOverrides } : {}),\n ...(forms.formSubmissionOverrides\n ? { formSubmissionOverrides: forms.formSubmissionOverrides }\n : {}),\n })\n}\n"],"names":["formBuilderPlugin","buildFormsPlugin","forms","fields","checkbox","email","message","number","payment","select","text","textarea","redirectRelationships","formOverrides","formSubmissionOverrides"],"mappings":"AAEA,SAASA,iBAAiB,QAAQ,kCAAiC;AAInE;;;;;;;CAOC,GACD,OAAO,SAASC,iBAAiBC,KAAkB;IACjD,OAAOF,kBAAkB;QACvBG,QAAQ;YACNC,UAAU;YACVC,OAAO;YACPC,SAAS;YACTC,QAAQ;YACRC,SAAS;YACTC,QAAQ;YACRC,MAAM;YACNC,UAAU;YACV,GAAGT,MAAMC,MAAM;QACjB;QACA,GAAID,MAAMU,qBAAqB,GAAG;YAAEA,uBAAuBV,MAAMU,qBAAqB;QAAC,IAAI,CAAC,CAAC;QAC7F,qEAAqE;QACrE,sEAAsE;QACtE,oCAAoC;QACpC,GAAIV,MAAMW,aAAa,GAAG;YAAEA,eAAeX,MAAMW,aAAa;QAAC,IAAI,CAAC,CAAC;QACrE,GAAIX,MAAMY,uBAAuB,GAC7B;YAAEA,yBAAyBZ,MAAMY,uBAAuB;QAAC,IACzD,CAAC,CAAC;IACR;AACF"} \ No newline at end of file diff --git a/dist/modules/forms/index.d.js b/dist/modules/forms/index.d.js new file mode 100644 index 0000000..311a50f --- /dev/null +++ b/dist/modules/forms/index.d.js @@ -0,0 +1,6 @@ +export { buildFormsPlugin } from './formsPluginConfig.js'; +export { checkRateLimit } from './rateLimit.js'; +export { submitForm } from './submitForm.js'; +export { validateSubmission } from './validateSubmission.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/index.d.js.map b/dist/modules/forms/index.d.js.map new file mode 100644 index 0000000..6607125 --- /dev/null +++ b/dist/modules/forms/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { buildFormsPlugin } from './formsPluginConfig.js';\nexport { checkRateLimit } from './rateLimit.js';\nexport type { RateLimitArgs } from './rateLimit.js';\nexport { submitForm } from './submitForm.js';\nexport type { SubmitFormArgs, SubmitFormResult } from './submitForm.js';\nexport type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js';\nexport { validateSubmission } from './validateSubmission.js';\nexport type { FormValidationResult } from './validateSubmission.js';\n"],"names":["buildFormsPlugin","checkRateLimit","submitForm","validateSubmission"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,yBAAyB;AAC1D,SAASC,cAAc,QAAQ,iBAAiB;AAEhD,SAASC,UAAU,QAAQ,kBAAkB;AAG7C,SAASC,kBAAkB,QAAQ,0BAA0B"} \ No newline at end of file diff --git a/dist/modules/forms/index.d.ts b/dist/modules/forms/index.d.ts new file mode 100644 index 0000000..ace80ae --- /dev/null +++ b/dist/modules/forms/index.d.ts @@ -0,0 +1,8 @@ +export { buildFormsPlugin } from './formsPluginConfig.js'; +export { checkRateLimit } from './rateLimit.js'; +export type { RateLimitArgs } from './rateLimit.js'; +export { submitForm } from './submitForm.js'; +export type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'; +export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'; +export { validateSubmission } from './validateSubmission.js'; +export type { FormValidationResult } from './validateSubmission.js'; diff --git a/dist/modules/forms/index.js b/dist/modules/forms/index.js new file mode 100644 index 0000000..384833d --- /dev/null +++ b/dist/modules/forms/index.js @@ -0,0 +1,8 @@ +// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only +// (Turnstile secret) — never import it from a client component. +export { buildFormsPlugin } from './formsPluginConfig.js'; +export { checkRateLimit } from './rateLimit.js'; +export { submitForm } from './submitForm.js'; +export { validateSubmission } from './validateSubmission.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/forms/index.js.map b/dist/modules/forms/index.js.map new file mode 100644 index 0000000..00c6df4 --- /dev/null +++ b/dist/modules/forms/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/index.ts"],"sourcesContent":["// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only\n// (Turnstile secret) — never import it from a client component.\nexport { buildFormsPlugin } from './formsPluginConfig.js'\nexport { checkRateLimit } from './rateLimit.js'\nexport type { RateLimitArgs } from './rateLimit.js'\nexport { submitForm } from './submitForm.js'\nexport type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'\nexport type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'\nexport { validateSubmission } from './validateSubmission.js'\nexport type { FormValidationResult } from './validateSubmission.js'\n"],"names":["buildFormsPlugin","checkRateLimit","submitForm","validateSubmission"],"mappings":"AAAA,6EAA6E;AAC7E,gEAAgE;AAChE,SAASA,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,cAAc,QAAQ,iBAAgB;AAE/C,SAASC,UAAU,QAAQ,kBAAiB;AAG5C,SAASC,kBAAkB,QAAQ,0BAAyB"} \ No newline at end of file diff --git a/dist/modules/forms/rateLimit.d.js b/dist/modules/forms/rateLimit.d.js new file mode 100644 index 0000000..44e691c --- /dev/null +++ b/dist/modules/forms/rateLimit.d.js @@ -0,0 +1,7 @@ +/** + * Returns true when the request is within the limit, false when it should be + * rejected. A missing key (no IP) is allowed through — better to accept a + * submission than to block everyone behind a proxy that strips the header. + */ export { }; + +//# sourceMappingURL=rateLimit.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/rateLimit.d.js.map b/dist/modules/forms/rateLimit.d.js.map new file mode 100644 index 0000000..b476e6c --- /dev/null +++ b/dist/modules/forms/rateLimit.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["rateLimit.d.ts"],"sourcesContent":["export type RateLimitArgs = {\n /** Identifier to limit on — typically the client IP. */\n key: string;\n /** Max submissions allowed per window. Defaults to 5. */\n max?: number;\n /** Window length in ms. Defaults to 60_000 (one minute). */\n windowMs?: number;\n};\n/**\n * Returns true when the request is within the limit, false when it should be\n * rejected. A missing key (no IP) is allowed through — better to accept a\n * submission than to block everyone behind a proxy that strips the header.\n */\nexport declare function checkRateLimit({ key, max, windowMs }: RateLimitArgs): boolean;\n"],"names":[],"mappings":"AAQA;;;;CAIC,GACD,WAAuF"} \ No newline at end of file diff --git a/dist/modules/forms/rateLimit.d.ts b/dist/modules/forms/rateLimit.d.ts new file mode 100644 index 0000000..61eeac0 --- /dev/null +++ b/dist/modules/forms/rateLimit.d.ts @@ -0,0 +1,14 @@ +export type RateLimitArgs = { + /** Identifier to limit on — typically the client IP. */ + key: string; + /** Max submissions allowed per window. Defaults to 5. */ + max?: number; + /** Window length in ms. Defaults to 60_000 (one minute). */ + windowMs?: number; +}; +/** + * Returns true when the request is within the limit, false when it should be + * rejected. A missing key (no IP) is allowed through — better to accept a + * submission than to block everyone behind a proxy that strips the header. + */ +export declare function checkRateLimit({ key, max, windowMs }: RateLimitArgs): boolean; diff --git a/dist/modules/forms/rateLimit.js b/dist/modules/forms/rateLimit.js new file mode 100644 index 0000000..1b70b31 --- /dev/null +++ b/dist/modules/forms/rateLimit.js @@ -0,0 +1,51 @@ +/** + * Per-IP sliding window, in memory. + * + * Deliberately simple: no Redis, no dependency. The trade-off is that the + * counter lives in one process — with several instances behind a load balancer + * each keeps its own, so the effective limit is per-instance, not global. For a + * contact form that's fine (it raises the cost of flooding without pretending + * to be airtight); a high-security form should put a real limiter in front. + * + * State is module-level, so it survives between requests but resets on redeploy + * — acceptable for abuse throttling. + */ const buckets = new Map(); +/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */ let lastSweep = Date.now(); +const SWEEP_INTERVAL = 60_000; +function sweep(now) { + if (now - lastSweep < SWEEP_INTERVAL) { + return; + } + lastSweep = now; + for (const [key, bucket] of buckets){ + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } +} +/** + * Returns true when the request is within the limit, false when it should be + * rejected. A missing key (no IP) is allowed through — better to accept a + * submission than to block everyone behind a proxy that strips the header. + */ export function checkRateLimit({ key, max = 5, windowMs = 60_000 }) { + if (!key) { + return true; + } + const now = Date.now(); + sweep(now); + const bucket = buckets.get(key); + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { + count: 1, + resetAt: now + windowMs + }); + return true; + } + if (bucket.count >= max) { + return false; + } + bucket.count += 1; + return true; +} + +//# sourceMappingURL=rateLimit.js.map \ No newline at end of file diff --git a/dist/modules/forms/rateLimit.js.map b/dist/modules/forms/rateLimit.js.map new file mode 100644 index 0000000..8ba4636 --- /dev/null +++ b/dist/modules/forms/rateLimit.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/rateLimit.ts"],"sourcesContent":["type Bucket = { count: number; resetAt: number }\n\n/**\n * Per-IP sliding window, in memory.\n *\n * Deliberately simple: no Redis, no dependency. The trade-off is that the\n * counter lives in one process — with several instances behind a load balancer\n * each keeps its own, so the effective limit is per-instance, not global. For a\n * contact form that's fine (it raises the cost of flooding without pretending\n * to be airtight); a high-security form should put a real limiter in front.\n *\n * State is module-level, so it survives between requests but resets on redeploy\n * — acceptable for abuse throttling.\n */\nconst buckets = new Map()\n\n/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */\nlet lastSweep = Date.now()\nconst SWEEP_INTERVAL = 60_000\n\nfunction sweep(now: number) {\n if (now - lastSweep < SWEEP_INTERVAL) {return}\n lastSweep = now\n for (const [key, bucket] of buckets) {\n if (bucket.resetAt <= now) {buckets.delete(key)}\n }\n}\n\nexport type RateLimitArgs = {\n /** Identifier to limit on — typically the client IP. */\n key: string\n /** Max submissions allowed per window. Defaults to 5. */\n max?: number\n /** Window length in ms. Defaults to 60_000 (one minute). */\n windowMs?: number\n}\n\n/**\n * Returns true when the request is within the limit, false when it should be\n * rejected. A missing key (no IP) is allowed through — better to accept a\n * submission than to block everyone behind a proxy that strips the header.\n */\nexport function checkRateLimit({ key, max = 5, windowMs = 60_000 }: RateLimitArgs): boolean {\n if (!key) {return true}\n\n const now = Date.now()\n sweep(now)\n\n const bucket = buckets.get(key)\n\n if (!bucket || bucket.resetAt <= now) {\n buckets.set(key, { count: 1, resetAt: now + windowMs })\n return true\n }\n\n if (bucket.count >= max) {\n return false\n }\n\n bucket.count += 1\n return true\n}\n"],"names":["buckets","Map","lastSweep","Date","now","SWEEP_INTERVAL","sweep","key","bucket","resetAt","delete","checkRateLimit","max","windowMs","get","set","count"],"mappings":"AAEA;;;;;;;;;;;CAWC,GACD,MAAMA,UAAU,IAAIC;AAEpB,0EAA0E,GAC1E,IAAIC,YAAYC,KAAKC,GAAG;AACxB,MAAMC,iBAAiB;AAEvB,SAASC,MAAMF,GAAW;IACxB,IAAIA,MAAMF,YAAYG,gBAAgB;QAAC;IAAM;IAC7CH,YAAYE;IACZ,KAAK,MAAM,CAACG,KAAKC,OAAO,IAAIR,QAAS;QACnC,IAAIQ,OAAOC,OAAO,IAAIL,KAAK;YAACJ,QAAQU,MAAM,CAACH;QAAI;IACjD;AACF;AAWA;;;;CAIC,GACD,OAAO,SAASI,eAAe,EAAEJ,GAAG,EAAEK,MAAM,CAAC,EAAEC,WAAW,MAAM,EAAiB;IAC/E,IAAI,CAACN,KAAK;QAAC,OAAO;IAAI;IAEtB,MAAMH,MAAMD,KAAKC,GAAG;IACpBE,MAAMF;IAEN,MAAMI,SAASR,QAAQc,GAAG,CAACP;IAE3B,IAAI,CAACC,UAAUA,OAAOC,OAAO,IAAIL,KAAK;QACpCJ,QAAQe,GAAG,CAACR,KAAK;YAAES,OAAO;YAAGP,SAASL,MAAMS;QAAS;QACrD,OAAO;IACT;IAEA,IAAIL,OAAOQ,KAAK,IAAIJ,KAAK;QACvB,OAAO;IACT;IAEAJ,OAAOQ,KAAK,IAAI;IAChB,OAAO;AACT"} \ No newline at end of file diff --git a/dist/modules/forms/submitForm.d.js b/dist/modules/forms/submitForm.d.js new file mode 100644 index 0000000..cad4ef2 --- /dev/null +++ b/dist/modules/forms/submitForm.d.js @@ -0,0 +1,3 @@ +import 'server-only'; + +//# sourceMappingURL=submitForm.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/submitForm.d.js.map b/dist/modules/forms/submitForm.d.js.map new file mode 100644 index 0000000..087e061 --- /dev/null +++ b/dist/modules/forms/submitForm.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["submitForm.d.ts"],"sourcesContent":["import 'server-only';\nimport type { BasePayload } from 'payload';\nexport type SubmitFormArgs = {\n /** Submitted field data — shape matches the form's fields. */\n data: Record;\n /** Form-builder form ID this submission belongs to. */\n formId: string;\n /** Client IP — used for Turnstile and rate limiting. */\n ip?: string;\n /**\n * Rate limit: max submissions per IP per minute. Defaults to 5.\n * Set to 0 to disable (e.g. when a real limiter sits in front).\n */\n maxPerMinute?: number;\n payload: BasePayload;\n /** Turnstile token; when present it is verified, when absent it is skipped. */\n turnstileToken?: string;\n};\n/**\n * Why a submission failed — a code, never a user-facing string.\n *\n * The plugin knows what went wrong; it deliberately doesn't decide how to say\n * it. The frontend maps these to its own copy, in its own language, and renders\n * whatever component fits — a field error, a toast, a full message. The plugin\n * has no business choosing the wording or the locale.\n *\n * - `rate_limited` — too many submissions from this IP\n * - `turnstile` — bot check failed\n * - `validation` — a field is missing/too long, or unknown keys were sent;\n * `field` and `kind` narrow it down when a specific field is\n * at fault (absent for whole-payload problems like unknown keys)\n * - `not_found` — no form with this id\n * - `error` — persistence failed unexpectedly\n */\nexport type SubmitFailure = {\n /** The offending field's name, when one field is at fault. */\n field?: string;\n /** What was wrong with it. */\n kind?: 'required' | 'too_long' | 'unknown_fields';\n reason: 'validation';\n success: false;\n} | {\n reason: 'error';\n success: false;\n} | {\n reason: 'not_found';\n success: false;\n} | {\n reason: 'rate_limited';\n success: false;\n} | {\n reason: 'turnstile';\n success: false;\n};\nexport type SubmitFormResult = {\n submissionId: number | string;\n success: true;\n} | SubmitFailure;\n/**\n * Handles a form submission end to end: rate limit, verify Turnstile, validate\n * against the form's own schema, then store.\n *\n * The order is cost-ascending on purpose — the cheapest checks reject first, so\n * a flood never reaches Turnstile's network call or the database.\n *\n * Emails aren't sent here. The form-builder sends whatever an editor configured\n * under the form's \"Emails\" tab (form-submissions hook → payload.sendEmail),\n * which goes out over panelSmtpAdapter. Storing the submission is enough.\n *\n * server-only: touches the Turnstile secret.\n */\nexport declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise;\n"],"names":[],"mappings":"AAAA,OAAO,cAAc"} \ No newline at end of file diff --git a/dist/modules/forms/submitForm.d.ts b/dist/modules/forms/submitForm.d.ts new file mode 100644 index 0000000..b2b2814 --- /dev/null +++ b/dist/modules/forms/submitForm.d.ts @@ -0,0 +1,72 @@ +import 'server-only'; +import type { BasePayload } from 'payload'; +export type SubmitFormArgs = { + /** Submitted field data — shape matches the form's fields. */ + data: Record; + /** Form-builder form ID this submission belongs to. */ + formId: string; + /** Client IP — used for Turnstile and rate limiting. */ + ip?: string; + /** + * Rate limit: max submissions per IP per minute. Defaults to 5. + * Set to 0 to disable (e.g. when a real limiter sits in front). + */ + maxPerMinute?: number; + payload: BasePayload; + /** Turnstile token; when present it is verified, when absent it is skipped. */ + turnstileToken?: string; +}; +/** + * Why a submission failed — a code, never a user-facing string. + * + * The plugin knows what went wrong; it deliberately doesn't decide how to say + * it. The frontend maps these to its own copy, in its own language, and renders + * whatever component fits — a field error, a toast, a full message. The plugin + * has no business choosing the wording or the locale. + * + * - `rate_limited` — too many submissions from this IP + * - `turnstile` — bot check failed + * - `validation` — a field is missing/too long, or unknown keys were sent; + * `field` and `kind` narrow it down when a specific field is + * at fault (absent for whole-payload problems like unknown keys) + * - `not_found` — no form with this id + * - `error` — persistence failed unexpectedly + */ +export type SubmitFailure = { + /** The offending field's name, when one field is at fault. */ + field?: string; + /** What was wrong with it. */ + kind?: 'required' | 'too_long' | 'unknown_fields'; + reason: 'validation'; + success: false; +} | { + reason: 'error'; + success: false; +} | { + reason: 'not_found'; + success: false; +} | { + reason: 'rate_limited'; + success: false; +} | { + reason: 'turnstile'; + success: false; +}; +export type SubmitFormResult = { + submissionId: number | string; + success: true; +} | SubmitFailure; +/** + * Handles a form submission end to end: rate limit, verify Turnstile, validate + * against the form's own schema, then store. + * + * The order is cost-ascending on purpose — the cheapest checks reject first, so + * a flood never reaches Turnstile's network call or the database. + * + * Emails aren't sent here. The form-builder sends whatever an editor configured + * under the form's "Emails" tab (form-submissions hook → payload.sendEmail), + * which goes out over panelSmtpAdapter. Storing the submission is enough. + * + * server-only: touches the Turnstile secret. + */ +export declare function submitForm({ data, formId, ip, maxPerMinute, payload, turnstileToken, }: SubmitFormArgs): Promise; diff --git a/dist/modules/forms/submitForm.js b/dist/modules/forms/submitForm.js new file mode 100644 index 0000000..cf5217d --- /dev/null +++ b/dist/modules/forms/submitForm.js @@ -0,0 +1,91 @@ +import 'server-only'; +import { verifyTurnstile } from '../turnstile/index.js'; +import { checkRateLimit } from './rateLimit.js'; +import { validateSubmission } from './validateSubmission.js'; +/** + * Handles a form submission end to end: rate limit, verify Turnstile, validate + * against the form's own schema, then store. + * + * The order is cost-ascending on purpose — the cheapest checks reject first, so + * a flood never reaches Turnstile's network call or the database. + * + * Emails aren't sent here. The form-builder sends whatever an editor configured + * under the form's "Emails" tab (form-submissions hook → payload.sendEmail), + * which goes out over panelSmtpAdapter. Storing the submission is enough. + * + * server-only: touches the Turnstile secret. + */ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) { + // 1. Rate limit — cheapest gate, drops a flood before any real work. + if (maxPerMinute > 0 && ip) { + if (!checkRateLimit({ + key: ip, + max: maxPerMinute + })) { + return { + reason: 'rate_limited', + success: false + }; + } + } + // 2. Turnstile — verify when a token is supplied; reject on failure. + if (turnstileToken !== undefined) { + const ok = await verifyTurnstile({ + ip, + payload, + token: turnstileToken + }); + if (!ok) { + return { + reason: 'turnstile', + success: false + }; + } + } + // 3. Validate against the form's schema. A public endpoint can't trust the + // shape of `data` — drop unknown keys, enforce required, cap length. + const validation = await validateSubmission(payload, formId, data); + if (!validation.ok) { + if (validation.reason === 'not_found') { + return { + reason: 'not_found', + success: false + }; + } + return { + reason: 'validation', + success: false, + ...validation.field ? { + field: validation.field + } : {}, + ...validation.kind ? { + kind: validation.kind + } : {} + }; + } + // 4. Persist (form-builder shape: submissionData array). Triggers the email + // hook. Only validated, known fields are stored. + try { + const submission = await payload.create({ + collection: 'form-submissions', + data: { + form: formId, + submissionData: Object.entries(validation.cleaned).map(([field, value])=>({ + field, + value: value == null ? '' : String(value) + })) + } + }); + return { + submissionId: submission.id, + success: true + }; + } catch (err) { + payload.logger.error(`[ipal] Form submission failed: ${err.message}`); + return { + reason: 'error', + success: false + }; + } +} + +//# sourceMappingURL=submitForm.js.map \ No newline at end of file diff --git a/dist/modules/forms/submitForm.js.map b/dist/modules/forms/submitForm.js.map new file mode 100644 index 0000000..b289675 --- /dev/null +++ b/dist/modules/forms/submitForm.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/submitForm.ts"],"sourcesContent":["import 'server-only'\n\nimport type { BasePayload } from 'payload'\n\nimport { verifyTurnstile } from '../turnstile/index.js'\nimport { checkRateLimit } from './rateLimit.js'\nimport { validateSubmission } from './validateSubmission.js'\n\nexport type SubmitFormArgs = {\n /** Submitted field data — shape matches the form's fields. */\n data: Record\n /** Form-builder form ID this submission belongs to. */\n formId: string\n /** Client IP — used for Turnstile and rate limiting. */\n ip?: string\n /**\n * Rate limit: max submissions per IP per minute. Defaults to 5.\n * Set to 0 to disable (e.g. when a real limiter sits in front).\n */\n maxPerMinute?: number\n payload: BasePayload\n /** Turnstile token; when present it is verified, when absent it is skipped. */\n turnstileToken?: string\n}\n\n/**\n * Why a submission failed — a code, never a user-facing string.\n *\n * The plugin knows what went wrong; it deliberately doesn't decide how to say\n * it. The frontend maps these to its own copy, in its own language, and renders\n * whatever component fits — a field error, a toast, a full message. The plugin\n * has no business choosing the wording or the locale.\n *\n * - `rate_limited` — too many submissions from this IP\n * - `turnstile` — bot check failed\n * - `validation` — a field is missing/too long, or unknown keys were sent;\n * `field` and `kind` narrow it down when a specific field is\n * at fault (absent for whole-payload problems like unknown keys)\n * - `not_found` — no form with this id\n * - `error` — persistence failed unexpectedly\n */\nexport type SubmitFailure =\n | {\n /** The offending field's name, when one field is at fault. */\n field?: string\n /** What was wrong with it. */\n kind?: 'required' | 'too_long' | 'unknown_fields'\n reason: 'validation'\n success: false\n }\n | { reason: 'error'; success: false }\n | { reason: 'not_found'; success: false }\n | { reason: 'rate_limited'; success: false }\n | { reason: 'turnstile'; success: false }\n\nexport type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure\n\n/**\n * Handles a form submission end to end: rate limit, verify Turnstile, validate\n * against the form's own schema, then store.\n *\n * The order is cost-ascending on purpose — the cheapest checks reject first, so\n * a flood never reaches Turnstile's network call or the database.\n *\n * Emails aren't sent here. The form-builder sends whatever an editor configured\n * under the form's \"Emails\" tab (form-submissions hook → payload.sendEmail),\n * which goes out over panelSmtpAdapter. Storing the submission is enough.\n *\n * server-only: touches the Turnstile secret.\n */\nexport async function submitForm({\n data,\n formId,\n ip,\n maxPerMinute = 5,\n payload,\n turnstileToken,\n}: SubmitFormArgs): Promise {\n // 1. Rate limit — cheapest gate, drops a flood before any real work.\n if (maxPerMinute > 0 && ip) {\n if (!checkRateLimit({ key: ip, max: maxPerMinute })) {\n return { reason: 'rate_limited', success: false }\n }\n }\n\n // 2. Turnstile — verify when a token is supplied; reject on failure.\n if (turnstileToken !== undefined) {\n const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })\n if (!ok) {\n return { reason: 'turnstile', success: false }\n }\n }\n\n // 3. Validate against the form's schema. A public endpoint can't trust the\n // shape of `data` — drop unknown keys, enforce required, cap length.\n const validation = await validateSubmission(payload, formId, data)\n if (!validation.ok) {\n if (validation.reason === 'not_found') {\n return { reason: 'not_found', success: false }\n }\n return {\n reason: 'validation',\n success: false,\n ...(validation.field ? { field: validation.field } : {}),\n ...(validation.kind ? { kind: validation.kind } : {}),\n }\n }\n\n // 4. Persist (form-builder shape: submissionData array). Triggers the email\n // hook. Only validated, known fields are stored.\n try {\n const submission = await payload.create({\n collection: 'form-submissions',\n data: {\n form: formId,\n submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({\n field,\n value: value == null ? '' : String(value),\n })),\n } as never,\n })\n return { submissionId: submission.id, success: true }\n } catch (err) {\n payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)\n return { reason: 'error', success: false }\n }\n}\n"],"names":["verifyTurnstile","checkRateLimit","validateSubmission","submitForm","data","formId","ip","maxPerMinute","payload","turnstileToken","key","max","reason","success","undefined","ok","token","validation","field","kind","submission","create","collection","form","submissionData","Object","entries","cleaned","map","value","String","submissionId","id","err","logger","error","message"],"mappings":"AAAA,OAAO,cAAa;AAIpB,SAASA,eAAe,QAAQ,wBAAuB;AACvD,SAASC,cAAc,QAAQ,iBAAgB;AAC/C,SAASC,kBAAkB,QAAQ,0BAAyB;AAmD5D;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,WAAW,EAC/BC,IAAI,EACJC,MAAM,EACNC,EAAE,EACFC,eAAe,CAAC,EAChBC,OAAO,EACPC,cAAc,EACC;IACf,qEAAqE;IACrE,IAAIF,eAAe,KAAKD,IAAI;QAC1B,IAAI,CAACL,eAAe;YAAES,KAAKJ;YAAIK,KAAKJ;QAAa,IAAI;YACnD,OAAO;gBAAEK,QAAQ;gBAAgBC,SAAS;YAAM;QAClD;IACF;IAEA,qEAAqE;IACrE,IAAIJ,mBAAmBK,WAAW;QAChC,MAAMC,KAAK,MAAMf,gBAAgB;YAAEM;YAAIE;YAASQ,OAAOP;QAAe;QACtE,IAAI,CAACM,IAAI;YACP,OAAO;gBAAEH,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;IACF;IAEA,2EAA2E;IAC3E,wEAAwE;IACxE,MAAMI,aAAa,MAAMf,mBAAmBM,SAASH,QAAQD;IAC7D,IAAI,CAACa,WAAWF,EAAE,EAAE;QAClB,IAAIE,WAAWL,MAAM,KAAK,aAAa;YACrC,OAAO;gBAAEA,QAAQ;gBAAaC,SAAS;YAAM;QAC/C;QACA,OAAO;YACLD,QAAQ;YACRC,SAAS;YACT,GAAII,WAAWC,KAAK,GAAG;gBAAEA,OAAOD,WAAWC,KAAK;YAAC,IAAI,CAAC,CAAC;YACvD,GAAID,WAAWE,IAAI,GAAG;gBAAEA,MAAMF,WAAWE,IAAI;YAAC,IAAI,CAAC,CAAC;QACtD;IACF;IAEA,4EAA4E;IAC5E,oDAAoD;IACpD,IAAI;QACF,MAAMC,aAAa,MAAMZ,QAAQa,MAAM,CAAC;YACtCC,YAAY;YACZlB,MAAM;gBACJmB,MAAMlB;gBACNmB,gBAAgBC,OAAOC,OAAO,CAACT,WAAWU,OAAO,EAAEC,GAAG,CAAC,CAAC,CAACV,OAAOW,MAAM,GAAM,CAAA;wBAC1EX;wBACAW,OAAOA,SAAS,OAAO,KAAKC,OAAOD;oBACrC,CAAA;YACF;QACF;QACA,OAAO;YAAEE,cAAcX,WAAWY,EAAE;YAAEnB,SAAS;QAAK;IACtD,EAAE,OAAOoB,KAAK;QACZzB,QAAQ0B,MAAM,CAACC,KAAK,CAAC,CAAC,+BAA+B,EAAE,AAACF,IAAcG,OAAO,EAAE;QAC/E,OAAO;YAAExB,QAAQ;YAASC,SAAS;QAAM;IAC3C;AACF"} \ No newline at end of file diff --git a/dist/modules/forms/types.d.js b/dist/modules/forms/types.d.js new file mode 100644 index 0000000..57d91a1 --- /dev/null +++ b/dist/modules/forms/types.d.js @@ -0,0 +1,6 @@ +/** + * Forms configuration — mirrors the fields a client enables in the + * form-builder plugin. Kept minimal; the plugin passes these through. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/types.d.js.map b/dist/modules/forms/types.d.js.map new file mode 100644 index 0000000..3426f21 --- /dev/null +++ b/dist/modules/forms/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload';\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: {\n defaultFields: Field[];\n}) => Field[];\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride;\n} & Partial>;\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean;\n email?: boolean;\n message?: boolean;\n number?: boolean;\n payment?: boolean;\n select?: boolean;\n text?: boolean;\n textarea?: boolean;\n };\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides;\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides;\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[];\n};\n"],"names":[],"mappings":"AAeA;;;CAGC,GACD,WA6BE"} \ No newline at end of file diff --git a/dist/modules/forms/types.d.ts b/dist/modules/forms/types.d.ts new file mode 100644 index 0000000..70192b2 --- /dev/null +++ b/dist/modules/forms/types.d.ts @@ -0,0 +1,49 @@ +import type { CollectionConfig, Field } from 'payload'; +/** + * Receives the collection's default fields and returns the final list — add, + * remove, or reorder. Same shape the form-builder uses. + */ +export type FormsFieldsOverride = (args: { + defaultFields: Field[]; +}) => Field[]; +/** + * Overrides for a forms-related collection: replace the fields and/or any + * other collection setting (admin, access, hooks…). + */ +export type FormsCollectionOverrides = { + fields?: FormsFieldsOverride; +} & Partial>; +/** + * Forms configuration — mirrors the fields a client enables in the + * form-builder plugin. Kept minimal; the plugin passes these through. + */ +export type FormsOption = { + /** Field types available in the form builder. Sensible defaults applied. */ + fields?: { + checkbox?: boolean; + email?: boolean; + message?: boolean; + number?: boolean; + payment?: boolean; + select?: boolean; + text?: boolean; + textarea?: boolean; + }; + /** + * Override the forms collection. The plugin stays opinion-free about what a + * form needs beyond its fields — a client that wants, say, a per-form + * notification address adds it here: + * + * formOverrides: { + * fields: ({ defaultFields }) => [ + * ...defaultFields, + * { name: 'notificationEmail', type: 'email' }, + * ], + * } + */ + formOverrides?: FormsCollectionOverrides; + /** Override the form-submissions collection (same shape). */ + formSubmissionOverrides?: FormsCollectionOverrides; + /** Collections a form can redirect to (e.g. ['pages']). */ + redirectRelationships?: string[]; +}; diff --git a/dist/modules/forms/types.js b/dist/modules/forms/types.js new file mode 100644 index 0000000..6f3a4f2 --- /dev/null +++ b/dist/modules/forms/types.js @@ -0,0 +1,6 @@ +/** + * Forms configuration — mirrors the fields a client enables in the + * form-builder plugin. Kept minimal; the plugin passes these through. + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/forms/types.js.map b/dist/modules/forms/types.js.map new file mode 100644 index 0000000..c58be3d --- /dev/null +++ b/dist/modules/forms/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"} \ No newline at end of file diff --git a/dist/modules/forms/validateSubmission.d.js b/dist/modules/forms/validateSubmission.d.js new file mode 100644 index 0000000..c22931a --- /dev/null +++ b/dist/modules/forms/validateSubmission.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=validateSubmission.d.js.map \ No newline at end of file diff --git a/dist/modules/forms/validateSubmission.d.js.map b/dist/modules/forms/validateSubmission.d.js.map new file mode 100644 index 0000000..5e67022 --- /dev/null +++ b/dist/modules/forms/validateSubmission.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["validateSubmission.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string;\n label?: string;\n name?: string;\n required?: boolean | null;\n};\ntype FormDoc = {\n fields?: FormField[];\n id: number | string;\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string;\n title?: string;\n};\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult = {\n /** Offending field, when a single field is at fault. */\n field?: string;\n kind: 'required' | 'too_long' | 'unknown_fields';\n ok: false;\n reason: 'invalid';\n} | {\n cleaned: Record;\n form: FormDoc;\n ok: true;\n} | {\n ok: false;\n reason: 'not_found';\n};\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport declare function validateSubmission(payload: BasePayload, formId: string, data: Record): Promise;\nexport {};\n"],"names":[],"mappings":"AA+CA,WAAU"} \ No newline at end of file diff --git a/dist/modules/forms/validateSubmission.d.ts b/dist/modules/forms/validateSubmission.d.ts new file mode 100644 index 0000000..536d3a0 --- /dev/null +++ b/dist/modules/forms/validateSubmission.d.ts @@ -0,0 +1,48 @@ +import type { BasePayload } from 'payload'; +/** A form-builder field, trimmed to what validation needs. */ +type FormField = { + blockType?: string; + label?: string; + name?: string; + required?: boolean | null; +}; +type FormDoc = { + fields?: FormField[]; + id: number | string; + /** Per-form notification address, when the client added the field. */ + notificationEmail?: string; + title?: string; +}; +/** + * Validation outcome — codes, not user-facing strings. The frontend turns these + * into its own copy (see SubmitFailure in submitForm). + */ +export type FormValidationResult = { + /** Offending field, when a single field is at fault. */ + field?: string; + kind: 'required' | 'too_long' | 'unknown_fields'; + ok: false; + reason: 'invalid'; +} | { + cleaned: Record; + form: FormDoc; + ok: true; +} | { + ok: false; + reason: 'not_found'; +}; +/** + * Checks submitted data against the form's own definition, rather than trusting + * whatever arrived. + * + * The server action is a public endpoint: a caller can skip the rendered form + * and post arbitrary keys. Without this, unknown fields would be stored, + * required fields could be missing, and an oversized value could sail through. + * So we load the form, keep only keys that are real fields, reject when a + * required one is blank, and cap length. + * + * Returns the loaded form on success so the caller doesn't fetch it twice, and + * a code + offending field on failure so the frontend can point at it. + */ +export declare function validateSubmission(payload: BasePayload, formId: string, data: Record): Promise; +export {}; diff --git a/dist/modules/forms/validateSubmission.js b/dist/modules/forms/validateSubmission.js new file mode 100644 index 0000000..97aae90 --- /dev/null +++ b/dist/modules/forms/validateSubmission.js @@ -0,0 +1,79 @@ +/** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([ + 'message' +]); +/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000; +/** + * Checks submitted data against the form's own definition, rather than trusting + * whatever arrived. + * + * The server action is a public endpoint: a caller can skip the rendered form + * and post arbitrary keys. Without this, unknown fields would be stored, + * required fields could be missing, and an oversized value could sail through. + * So we load the form, keep only keys that are real fields, reject when a + * required one is blank, and cap length. + * + * Returns the loaded form on success so the caller doesn't fetch it twice, and + * a code + offending field on failure so the frontend can point at it. + */ export async function validateSubmission(payload, formId, data) { + let form; + try { + form = await payload.findByID({ + id: formId, + collection: 'forms', + depth: 0 + }); + } catch { + return { + ok: false, + reason: 'not_found' + }; + } + const fields = (form.fields ?? []).filter((f)=>typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? '')); + const known = new Map(fields.map((f)=>[ + f.name, + f + ])); + const cleaned = {}; + for (const field of fields){ + const value = data[field.name]; + const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false; + if (field.required && isBlank) { + return { + field: field.name, + kind: 'required', + ok: false, + reason: 'invalid' + }; + } + if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) { + return { + field: field.name, + kind: 'too_long', + ok: false, + reason: 'invalid' + }; + } + // Only carry through keys that belong to the form — unknown keys from a + // hand-crafted request are dropped, not stored. + if (value !== undefined) { + cleaned[field.name] = value; + } + } + // Reject outright if the payload carried keys the form doesn't define — a + // sign the request wasn't produced by the rendered form. + const unknownKeys = Object.keys(data).filter((k)=>!known.has(k)); + if (unknownKeys.length > 0) { + return { + kind: 'unknown_fields', + ok: false, + reason: 'invalid' + }; + } + return { + cleaned, + form, + ok: true + }; +} + +//# sourceMappingURL=validateSubmission.js.map \ No newline at end of file diff --git a/dist/modules/forms/validateSubmission.js.map b/dist/modules/forms/validateSubmission.js.map new file mode 100644 index 0000000..7717af9 --- /dev/null +++ b/dist/modules/forms/validateSubmission.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/forms/validateSubmission.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string\n label?: string\n name?: string\n required?: boolean | null\n}\n\ntype FormDoc = {\n fields?: FormField[]\n id: number | string\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string\n title?: string\n}\n\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult =\n | {\n /** Offending field, when a single field is at fault. */\n field?: string\n kind: 'required' | 'too_long' | 'unknown_fields'\n ok: false\n reason: 'invalid'\n }\n | { cleaned: Record; form: FormDoc; ok: true }\n | { ok: false; reason: 'not_found' }\n\n/** Field block types that don't carry a submittable value. */\nconst NON_DATA_BLOCKS = new Set(['message'])\n\n/** Hard ceiling on a single field's length, independent of the form config. */\nconst MAX_FIELD_LENGTH = 5000\n\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport async function validateSubmission(\n payload: BasePayload,\n formId: string,\n data: Record,\n): Promise {\n let form: FormDoc\n try {\n form = (await payload.findByID({\n id: formId,\n collection: 'forms',\n depth: 0,\n })) as FormDoc\n } catch {\n return { ok: false, reason: 'not_found' }\n }\n\n const fields = (form.fields ?? []).filter(\n (f): f is { name: string } & FormField =>\n typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),\n )\n const known = new Map(fields.map((f) => [f.name, f]))\n\n const cleaned: Record = {}\n\n for (const field of fields) {\n const value = data[field.name]\n const isBlank =\n value == null || (typeof value === 'string' && value.trim() === '') || value === false\n\n if (field.required && isBlank) {\n return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }\n }\n\n if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {\n return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }\n }\n\n // Only carry through keys that belong to the form — unknown keys from a\n // hand-crafted request are dropped, not stored.\n if (value !== undefined) {\n cleaned[field.name] = value\n }\n }\n\n // Reject outright if the payload carried keys the form doesn't define — a\n // sign the request wasn't produced by the rendered form.\n const unknownKeys = Object.keys(data).filter((k) => !known.has(k))\n if (unknownKeys.length > 0) {\n return { kind: 'unknown_fields', ok: false, reason: 'invalid' }\n }\n\n return { cleaned, form, ok: true }\n}\n"],"names":["NON_DATA_BLOCKS","Set","MAX_FIELD_LENGTH","validateSubmission","payload","formId","data","form","findByID","id","collection","depth","ok","reason","fields","filter","f","name","has","blockType","known","Map","map","cleaned","field","value","isBlank","trim","required","kind","length","undefined","unknownKeys","Object","keys","k"],"mappings":"AAiCA,4DAA4D,GAC5D,MAAMA,kBAAkB,IAAIC,IAAI;IAAC;CAAU;AAE3C,6EAA6E,GAC7E,MAAMC,mBAAmB;AAEzB;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,mBACpBC,OAAoB,EACpBC,MAAc,EACdC,IAA6B;IAE7B,IAAIC;IACJ,IAAI;QACFA,OAAQ,MAAMH,QAAQI,QAAQ,CAAC;YAC7BC,IAAIJ;YACJK,YAAY;YACZC,OAAO;QACT;IACF,EAAE,OAAM;QACN,OAAO;YAAEC,IAAI;YAAOC,QAAQ;QAAY;IAC1C;IAEA,MAAMC,SAAS,AAACP,CAAAA,KAAKO,MAAM,IAAI,EAAE,AAAD,EAAGC,MAAM,CACvC,CAACC,IACC,OAAOA,EAAEC,IAAI,KAAK,YAAY,CAACjB,gBAAgBkB,GAAG,CAACF,EAAEG,SAAS,IAAI;IAEtE,MAAMC,QAAQ,IAAIC,IAAIP,OAAOQ,GAAG,CAAC,CAACN,IAAM;YAACA,EAAEC,IAAI;YAAED;SAAE;IAEnD,MAAMO,UAAmC,CAAC;IAE1C,KAAK,MAAMC,SAASV,OAAQ;QAC1B,MAAMW,QAAQnB,IAAI,CAACkB,MAAMP,IAAI,CAAC;QAC9B,MAAMS,UACJD,SAAS,QAAS,OAAOA,UAAU,YAAYA,MAAME,IAAI,OAAO,MAAOF,UAAU;QAEnF,IAAID,MAAMI,QAAQ,IAAIF,SAAS;YAC7B,OAAO;gBAAEF,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,IAAI,OAAOY,UAAU,YAAYA,MAAMK,MAAM,GAAG5B,kBAAkB;YAChE,OAAO;gBAAEsB,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,wEAAwE;QACxE,gDAAgD;QAChD,IAAIY,UAAUM,WAAW;YACvBR,OAAO,CAACC,MAAMP,IAAI,CAAC,GAAGQ;QACxB;IACF;IAEA,0EAA0E;IAC1E,yDAAyD;IACzD,MAAMO,cAAcC,OAAOC,IAAI,CAAC5B,MAAMS,MAAM,CAAC,CAACoB,IAAM,CAACf,MAAMF,GAAG,CAACiB;IAC/D,IAAIH,YAAYF,MAAM,GAAG,GAAG;QAC1B,OAAO;YAAED,MAAM;YAAkBjB,IAAI;YAAOC,QAAQ;QAAU;IAChE;IAEA,OAAO;QAAEU;QAAShB;QAAMK,IAAI;IAAK;AACnC"} \ No newline at end of file diff --git a/dist/modules/frontend/createContentHelpers.d.js b/dist/modules/frontend/createContentHelpers.d.js new file mode 100644 index 0000000..dc3727a --- /dev/null +++ b/dist/modules/frontend/createContentHelpers.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=createContentHelpers.d.js.map \ No newline at end of file diff --git a/dist/modules/frontend/createContentHelpers.d.js.map b/dist/modules/frontend/createContentHelpers.d.js.map new file mode 100644 index 0000000..ff48a22 --- /dev/null +++ b/dist/modules/frontend/createContentHelpers.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["createContentHelpers.d.ts"],"sourcesContent":["import type { BasePayload, SanitizedConfig } from 'payload';\nimport type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';\nimport type { I18nConfig } from '../i18n/index.js';\nimport type { RobotsRules, SitemapEntry } from '../seo/index.js';\ntype CreateContentHelpersArgs = {\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string;\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig;\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption;\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig;\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string;\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string;\n};\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): {\n getCachedPayload: () => Promise;\n getConfiguredLocales: () => Promise;\n getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise;\n getSettings: (locale: string) => Promise;\n resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise;\n robots: () => RobotsRules;\n sitemap: () => Promise;\n};\nexport {};\n"],"names":[],"mappings":"AA+DA,WAAU"} \ No newline at end of file diff --git a/dist/modules/frontend/createContentHelpers.d.ts b/dist/modules/frontend/createContentHelpers.d.ts new file mode 100644 index 0000000..47b2150 --- /dev/null +++ b/dist/modules/frontend/createContentHelpers.d.ts @@ -0,0 +1,64 @@ +import type { BasePayload, SanitizedConfig } from 'payload'; +import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'; +import type { I18nConfig } from '../i18n/index.js'; +import type { RobotsRules, SitemapEntry } from '../seo/index.js'; +type CreateContentHelpersArgs = { + /** + * Absolute site origin for sitemap/robots URLs. Falls back to + * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers + * reject, so set one in production). + */ + baseUrl?: string; + /** + * The client's payload config promise (the default export of payload.config). + * Passed in because the plugin never imports the client's config directly. + */ + config: Promise | SanitizedConfig; + /** Archive-backed collections, same value as the plugin option. */ + content?: ContentOption; + /** + * i18n config. Required only if you want the ready-made `sitemap` / `robots` + * handlers — they need the locale list to emit hreflang. + */ + i18n?: I18nConfig; + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; +}; +/** + * Bundles the per-request data helpers a frontend needs — the same cached + * wrappers every project was writing by hand (getPayload, settings, locale + * list, route resolution, archive entries). + * + * Everything is wrapped in React `cache()`, so within one request a value is + * fetched once no matter how many times it's asked for — which matters because + * Next runs generateMetadata and the page component separately, and both hit + * these. Crucially the Payload instance is cached *here*, once, so every helper + * shares it; that's why this is a factory and not loose functions importing a + * shared module. + * + * ```ts + * // src/lib/content.ts + * import { createContentHelpers } from 'ipal-kit' + * import config from '@/payload.config' + * import { contentConfig } from '@/content.config' + * + * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } = + * createContentHelpers({ config, content: contentConfig }) + * ``` + * + * `resolveRoute` and `getEntries` are separate on purpose: routing decides what + * a URL is, fetching gets the listing. Metadata generation needs the first and + * not the second, and a page component composes them in two obvious lines. + */ +export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): { + getCachedPayload: () => Promise; + getConfiguredLocales: () => Promise; + getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise; + getSettings: (locale: string) => Promise; + resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise; + robots: () => RobotsRules; + sitemap: () => Promise; +}; +export {}; diff --git a/dist/modules/frontend/createContentHelpers.js b/dist/modules/frontend/createContentHelpers.js new file mode 100644 index 0000000..1342ee6 --- /dev/null +++ b/dist/modules/frontend/createContentHelpers.js @@ -0,0 +1,111 @@ +import { getPayload } from 'payload'; +import { cache } from 'react'; +import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'; +import { buildRobots, buildSitemapEntries } from '../seo/index.js'; +/** + * Bundles the per-request data helpers a frontend needs — the same cached + * wrappers every project was writing by hand (getPayload, settings, locale + * list, route resolution, archive entries). + * + * Everything is wrapped in React `cache()`, so within one request a value is + * fetched once no matter how many times it's asked for — which matters because + * Next runs generateMetadata and the page component separately, and both hit + * these. Crucially the Payload instance is cached *here*, once, so every helper + * shares it; that's why this is a factory and not loose functions importing a + * shared module. + * + * ```ts + * // src/lib/content.ts + * import { createContentHelpers } from 'ipal-kit' + * import config from '@/payload.config' + * import { contentConfig } from '@/content.config' + * + * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } = + * createContentHelpers({ config, content: contentConfig }) + * ``` + * + * `resolveRoute` and `getEntries` are separate on purpose: routing decides what + * a URL is, fetching gets the listing. Metadata generation needs the first and + * not the second, and a page component composes them in two obvious lines. + */ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) { + const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''; + const getCachedPayload = cache(async ()=>getPayload({ + config: await config + })); + const getConfiguredLocales = cache(async ()=>{ + const c = await config; + return c.localization ? c.localization.locales.map((l)=>l.code) : []; + }); + const getSettings = cache(async (locale)=>{ + const payload = await getCachedPayload(); + return payload.findGlobal({ + slug: settingsSlug, + depth: 2, + locale: locale + }); + }); + /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{ + const payload = await getCachedPayload(); + return resolveRouteRaw({ + content, + locale, + page, + pagesSlug, + payload, + segments, + settingsSlug + }); + }); + /** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{ + const payload = await getCachedPayload(); + return getArchiveEntries({ + collection, + locale, + page, + payload, + perPage + }); + }); + /** + * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with + * per-URL hreflang and lastmod. Re-export it directly: + * + * ```ts + * // app/sitemap.ts + * export { sitemap as default } from '@/lib/content' + * ``` + */ const sitemap = cache(async ()=>{ + if (!i18n) { + throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.'); + } + return buildSitemapEntries({ + baseUrl: origin, + config: i18n, + content, + pagesSlug, + payload: await getCachedPayload(), + settingsSlug + }); + }); + /** + * Ready-made handler for Next's `app/robots.ts`. Re-export directly: + * + * ```ts + * // app/robots.ts + * export { robots as default } from '@/lib/content' + * ``` + */ const robots = ()=>buildRobots({ + baseUrl: origin + }); + return { + getCachedPayload, + getConfiguredLocales, + getEntries, + getSettings, + resolveRoute, + robots, + sitemap + }; +} + +//# sourceMappingURL=createContentHelpers.js.map \ No newline at end of file diff --git a/dist/modules/frontend/createContentHelpers.js.map b/dist/modules/frontend/createContentHelpers.js.map new file mode 100644 index 0000000..c0b4c3d --- /dev/null +++ b/dist/modules/frontend/createContentHelpers.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/frontend/createContentHelpers.ts"],"sourcesContent":["import type { BasePayload, SanitizedConfig } from 'payload'\n\nimport { getPayload } from 'payload'\nimport { cache } from 'react'\n\nimport type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { RobotsRules, SitemapEntry } from '../seo/index.js'\n\nimport { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'\nimport { buildRobots, buildSitemapEntries } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport function createContentHelpers({\n baseUrl,\n config,\n content,\n i18n,\n pagesSlug = 'pages',\n settingsSlug = 'site-settings',\n}: CreateContentHelpersArgs) {\n const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''\n const getCachedPayload = cache(async (): Promise =>\n getPayload({ config: await config }),\n )\n\n const getConfiguredLocales = cache(async (): Promise => {\n const c = await config\n return c.localization ? c.localization.locales.map((l) => l.code) : []\n })\n\n const getSettings = cache(async (locale: string) => {\n const payload = await getCachedPayload()\n return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })\n })\n\n /** What does this URL point at? Routing only — no listing data. */\n const resolveRoute = cache(\n async (\n locale: string,\n segments: string[] | undefined,\n page: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })\n },\n )\n\n /** One page of a collection's entries, for an archive listing. */\n const getEntries = cache(\n async (\n collection: string,\n locale: string,\n page: number,\n perPage: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return getArchiveEntries({ collection, locale, page, payload, perPage })\n },\n )\n\n /**\n * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with\n * per-URL hreflang and lastmod. Re-export it directly:\n *\n * ```ts\n * // app/sitemap.ts\n * export { sitemap as default } from '@/lib/content'\n * ```\n */\n const sitemap = cache(async (): Promise => {\n if (!i18n) {\n throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')\n }\n return buildSitemapEntries({\n baseUrl: origin,\n config: i18n,\n content,\n pagesSlug,\n payload: await getCachedPayload(),\n settingsSlug,\n })\n })\n\n /**\n * Ready-made handler for Next's `app/robots.ts`. Re-export directly:\n *\n * ```ts\n * // app/robots.ts\n * export { robots as default } from '@/lib/content'\n * ```\n */\n const robots = (): RobotsRules => buildRobots({ baseUrl: origin })\n\n return {\n getCachedPayload,\n getConfiguredLocales,\n getEntries,\n getSettings,\n resolveRoute,\n robots,\n sitemap,\n }\n}\n"],"names":["getPayload","cache","getArchiveEntries","resolveRoute","resolveRouteRaw","buildRobots","buildSitemapEntries","createContentHelpers","baseUrl","config","content","i18n","pagesSlug","settingsSlug","origin","process","env","NEXT_PUBLIC_SERVER_URL","getCachedPayload","getConfiguredLocales","c","localization","locales","map","l","code","getSettings","locale","payload","findGlobal","slug","depth","segments","page","getEntries","collection","perPage","sitemap","Error","robots"],"mappings":"AAEA,SAASA,UAAU,QAAQ,UAAS;AACpC,SAASC,KAAK,QAAQ,QAAO;AAM7B,SAASC,iBAAiB,EAAEC,gBAAgBC,eAAe,QAAQ,sBAAqB;AACxF,SAASC,WAAW,EAAEC,mBAAmB,QAAQ,kBAAiB;AA2BlE;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,qBAAqB,EACnCC,OAAO,EACPC,MAAM,EACNC,OAAO,EACPC,IAAI,EACJC,YAAY,OAAO,EACnBC,eAAe,eAAe,EACL;IACzB,MAAMC,SAASN,WAAWO,QAAQC,GAAG,CAACC,sBAAsB,IAAI;IAChE,MAAMC,mBAAmBjB,MAAM,UAC7BD,WAAW;YAAES,QAAQ,MAAMA;QAAO;IAGpC,MAAMU,uBAAuBlB,MAAM;QACjC,MAAMmB,IAAI,MAAMX;QAChB,OAAOW,EAAEC,YAAY,GAAGD,EAAEC,YAAY,CAACC,OAAO,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAI,EAAE;IACxE;IAEA,MAAMC,cAAczB,MAAM,OAAO0B;QAC/B,MAAMC,UAAU,MAAMV;QACtB,OAAOU,QAAQC,UAAU,CAAC;YAAEC,MAAMjB;YAAuBkB,OAAO;YAAGJ,QAAQA;QAAgB;IAC7F;IAEA,iEAAiE,GACjE,MAAMxB,eAAeF,MACnB,OACE0B,QACAK,UACAC;QAEA,MAAML,UAAU,MAAMV;QACtB,OAAOd,gBAAgB;YAAEM;YAASiB;YAAQM;YAAMrB;YAAWgB;YAASI;YAAUnB;QAAa;IAC7F;IAGF,gEAAgE,GAChE,MAAMqB,aAAajC,MACjB,OACEkC,YACAR,QACAM,MACAG;QAEA,MAAMR,UAAU,MAAMV;QACtB,OAAOhB,kBAAkB;YAAEiC;YAAYR;YAAQM;YAAML;YAASQ;QAAQ;IACxE;IAGF;;;;;;;;GAQC,GACD,MAAMC,UAAUpC,MAAM;QACpB,IAAI,CAACU,MAAM;YACT,MAAM,IAAI2B,MAAM;QAClB;QACA,OAAOhC,oBAAoB;YACzBE,SAASM;YACTL,QAAQE;YACRD;YACAE;YACAgB,SAAS,MAAMV;YACfL;QACF;IACF;IAEA;;;;;;;GAOC,GACD,MAAM0B,SAAS,IAAmBlC,YAAY;YAAEG,SAASM;QAAO;IAEhE,OAAO;QACLI;QACAC;QACAe;QACAR;QACAvB;QACAoC;QACAF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/frontend/index.d.js b/dist/modules/frontend/index.d.js new file mode 100644 index 0000000..63add7f --- /dev/null +++ b/dist/modules/frontend/index.d.js @@ -0,0 +1,3 @@ +export { createContentHelpers } from './createContentHelpers.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/frontend/index.d.js.map b/dist/modules/frontend/index.d.js.map new file mode 100644 index 0000000..19392b6 --- /dev/null +++ b/dist/modules/frontend/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { createContentHelpers } from './createContentHelpers.js';\n"],"names":["createContentHelpers"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA4B"} \ No newline at end of file diff --git a/dist/modules/frontend/index.d.ts b/dist/modules/frontend/index.d.ts new file mode 100644 index 0000000..5c23c74 --- /dev/null +++ b/dist/modules/frontend/index.d.ts @@ -0,0 +1 @@ +export { createContentHelpers } from './createContentHelpers.js'; diff --git a/dist/modules/frontend/index.js b/dist/modules/frontend/index.js new file mode 100644 index 0000000..889f6c0 --- /dev/null +++ b/dist/modules/frontend/index.js @@ -0,0 +1,3 @@ +export { createContentHelpers } from './createContentHelpers.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/frontend/index.js.map b/dist/modules/frontend/index.js.map new file mode 100644 index 0000000..1f67e27 --- /dev/null +++ b/dist/modules/frontend/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/frontend/index.ts"],"sourcesContent":["export { createContentHelpers } from './createContentHelpers.js'\n"],"names":["createContentHelpers"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"} \ No newline at end of file diff --git a/dist/modules/i18n/getLocalizedSlugs.d.js b/dist/modules/i18n/getLocalizedSlugs.d.js new file mode 100644 index 0000000..5692e77 --- /dev/null +++ b/dist/modules/i18n/getLocalizedSlugs.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=getLocalizedSlugs.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/getLocalizedSlugs.d.js.map b/dist/modules/i18n/getLocalizedSlugs.d.js.map new file mode 100644 index 0000000..0ce6e55 --- /dev/null +++ b/dist/modules/i18n/getLocalizedSlugs.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getLocalizedSlugs.d.ts"],"sourcesContent":["import type { LocalizedSlugs } from './localizedPath.js';\nimport type { I18nConfig } from './types.js';\n/**\n * A localized field as Payload returns it when queried with `locale: 'all'`:\n * a map of locale code → value.\n */\ntype LocalizedField = Record;\ntype GetLocalizedSlugsArgs = {\n config: I18nConfig;\n /**\n * The document's localized slug field, as returned by Payload with\n * `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.\n */\n slugField: LocalizedField | null | undefined;\n};\n/**\n * Normalizes a document's localized slug field into the LocalizedSlugs\n * contract consumed by buildLocalizedPath / switchLocalePath.\n *\n * Pure function — the template fetches the document (payload.findByID with\n * `locale: 'all'`, where the slug field must be `localized: true`) and passes\n * the raw field in. The plugin never touches the database.\n *\n * Keeps only entries whose locale is configured and whose slug is a\n * non-empty string, so callers get a clean, trustworthy map.\n *\n * @example\n * const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })\n * const slugs = getLocalizedSlugs({ slugField: doc.slug, config })\n * // → { pl: 'strona-glowna', en: 'home' }\n * switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'\n */\nexport declare function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs;\nexport {};\n"],"names":[],"mappings":"AAiCA,WAAU"} \ No newline at end of file diff --git a/dist/modules/i18n/getLocalizedSlugs.d.ts b/dist/modules/i18n/getLocalizedSlugs.d.ts new file mode 100644 index 0000000..9347dc9 --- /dev/null +++ b/dist/modules/i18n/getLocalizedSlugs.d.ts @@ -0,0 +1,34 @@ +import type { LocalizedSlugs } from './localizedPath.js'; +import type { I18nConfig } from './types.js'; +/** + * A localized field as Payload returns it when queried with `locale: 'all'`: + * a map of locale code → value. + */ +type LocalizedField = Record; +type GetLocalizedSlugsArgs = { + config: I18nConfig; + /** + * The document's localized slug field, as returned by Payload with + * `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }. + */ + slugField: LocalizedField | null | undefined; +}; +/** + * Normalizes a document's localized slug field into the LocalizedSlugs + * contract consumed by buildLocalizedPath / switchLocalePath. + * + * Pure function — the template fetches the document (payload.findByID with + * `locale: 'all'`, where the slug field must be `localized: true`) and passes + * the raw field in. The plugin never touches the database. + * + * Keeps only entries whose locale is configured and whose slug is a + * non-empty string, so callers get a clean, trustworthy map. + * + * @example + * const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' }) + * const slugs = getLocalizedSlugs({ slugField: doc.slug, config }) + * // → { pl: 'strona-glowna', en: 'home' } + * switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en' + */ +export declare function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs; +export {}; diff --git a/dist/modules/i18n/getLocalizedSlugs.js b/dist/modules/i18n/getLocalizedSlugs.js new file mode 100644 index 0000000..c7a27f7 --- /dev/null +++ b/dist/modules/i18n/getLocalizedSlugs.js @@ -0,0 +1,39 @@ +import { isValidLocale } from './helpers.js'; +/** + * Normalizes a document's localized slug field into the LocalizedSlugs + * contract consumed by buildLocalizedPath / switchLocalePath. + * + * Pure function — the template fetches the document (payload.findByID with + * `locale: 'all'`, where the slug field must be `localized: true`) and passes + * the raw field in. The plugin never touches the database. + * + * Keeps only entries whose locale is configured and whose slug is a + * non-empty string, so callers get a clean, trustworthy map. + * + * @example + * const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' }) + * const slugs = getLocalizedSlugs({ slugField: doc.slug, config }) + * // → { pl: 'strona-glowna', en: 'home' } + * switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en' + */ export function getLocalizedSlugs({ config, slugField }) { + const result = {}; + if (!slugField || typeof slugField !== 'object') { + return result; + } + for (const [locale, value] of Object.entries(slugField)){ + if (!isValidLocale(locale, config)) { + continue; + } + if (typeof value !== 'string') { + continue; + } + const slug = value.trim(); + if (!slug) { + continue; + } + result[locale] = slug; + } + return result; +} + +//# sourceMappingURL=getLocalizedSlugs.js.map \ No newline at end of file diff --git a/dist/modules/i18n/getLocalizedSlugs.js.map b/dist/modules/i18n/getLocalizedSlugs.js.map new file mode 100644 index 0000000..8885d1b --- /dev/null +++ b/dist/modules/i18n/getLocalizedSlugs.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/getLocalizedSlugs.ts"],"sourcesContent":["import type { LocalizedSlugs } from './localizedPath.js'\nimport type { I18nConfig } from './types.js'\n\nimport { isValidLocale } from './helpers.js'\n\n/**\n * A localized field as Payload returns it when queried with `locale: 'all'`:\n * a map of locale code → value.\n */\ntype LocalizedField = Record\n\ntype GetLocalizedSlugsArgs = {\n config: I18nConfig\n /**\n * The document's localized slug field, as returned by Payload with\n * `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.\n */\n slugField: LocalizedField | null | undefined\n}\n\n/**\n * Normalizes a document's localized slug field into the LocalizedSlugs\n * contract consumed by buildLocalizedPath / switchLocalePath.\n *\n * Pure function — the template fetches the document (payload.findByID with\n * `locale: 'all'`, where the slug field must be `localized: true`) and passes\n * the raw field in. The plugin never touches the database.\n *\n * Keeps only entries whose locale is configured and whose slug is a\n * non-empty string, so callers get a clean, trustworthy map.\n *\n * @example\n * const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })\n * const slugs = getLocalizedSlugs({ slugField: doc.slug, config })\n * // → { pl: 'strona-glowna', en: 'home' }\n * switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'\n */\nexport function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs {\n const result: LocalizedSlugs = {}\n\n if (!slugField || typeof slugField !== 'object') {\n return result\n }\n\n for (const [locale, value] of Object.entries(slugField)) {\n if (!isValidLocale(locale, config)) {continue}\n if (typeof value !== 'string') {continue}\n\n const slug = value.trim()\n if (!slug) {continue}\n\n result[locale] = slug\n }\n\n return result\n}\n"],"names":["isValidLocale","getLocalizedSlugs","config","slugField","result","locale","value","Object","entries","slug","trim"],"mappings":"AAGA,SAASA,aAAa,QAAQ,eAAc;AAiB5C;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,kBAAkB,EAAEC,MAAM,EAAEC,SAAS,EAAyB;IAC5E,MAAMC,SAAyB,CAAC;IAEhC,IAAI,CAACD,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOC;IACT;IAEA,KAAK,MAAM,CAACC,QAAQC,MAAM,IAAIC,OAAOC,OAAO,CAACL,WAAY;QACvD,IAAI,CAACH,cAAcK,QAAQH,SAAS;YAAC;QAAQ;QAC7C,IAAI,OAAOI,UAAU,UAAU;YAAC;QAAQ;QAExC,MAAMG,OAAOH,MAAMI,IAAI;QACvB,IAAI,CAACD,MAAM;YAAC;QAAQ;QAEpBL,MAAM,CAACC,OAAO,GAAGI;IACnB;IAEA,OAAOL;AACT"} \ No newline at end of file diff --git a/dist/modules/i18n/helpers.d.js b/dist/modules/i18n/helpers.d.js new file mode 100644 index 0000000..1926a00 --- /dev/null +++ b/dist/modules/i18n/helpers.d.js @@ -0,0 +1,5 @@ +/** + * Returns the full locale definition for a given code, or undefined if not found. + */ export { }; + +//# sourceMappingURL=helpers.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/helpers.d.js.map b/dist/modules/i18n/helpers.d.js.map new file mode 100644 index 0000000..6f74cba --- /dev/null +++ b/dist/modules/i18n/helpers.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["helpers.d.ts"],"sourcesContent":["import type { I18nConfig, LocaleDefinition } from './types.js';\n/**\n * Returns all configured locale codes.\n */\nexport declare function getLocaleCodes(config: I18nConfig): string[];\n/**\n * Returns the default locale code.\n */\nexport declare function getDefaultLocale(config: I18nConfig): string;\n/**\n * Checks if a string is a valid configured locale code.\n */\nexport declare function isValidLocale(code: string, config: I18nConfig): boolean;\n/**\n * Returns the full locale definition for a given code, or undefined if not found.\n */\nexport declare function getLocaleDefinition(code: string, config: I18nConfig): LocaleDefinition | undefined;\n"],"names":[],"mappings":"AAaA;;CAEC,GACD,WAA4G"} \ No newline at end of file diff --git a/dist/modules/i18n/helpers.d.ts b/dist/modules/i18n/helpers.d.ts new file mode 100644 index 0000000..11e70af --- /dev/null +++ b/dist/modules/i18n/helpers.d.ts @@ -0,0 +1,17 @@ +import type { I18nConfig, LocaleDefinition } from './types.js'; +/** + * Returns all configured locale codes. + */ +export declare function getLocaleCodes(config: I18nConfig): string[]; +/** + * Returns the default locale code. + */ +export declare function getDefaultLocale(config: I18nConfig): string; +/** + * Checks if a string is a valid configured locale code. + */ +export declare function isValidLocale(code: string, config: I18nConfig): boolean; +/** + * Returns the full locale definition for a given code, or undefined if not found. + */ +export declare function getLocaleDefinition(code: string, config: I18nConfig): LocaleDefinition | undefined; diff --git a/dist/modules/i18n/helpers.js b/dist/modules/i18n/helpers.js new file mode 100644 index 0000000..deec843 --- /dev/null +++ b/dist/modules/i18n/helpers.js @@ -0,0 +1,22 @@ +/** + * Returns all configured locale codes. + */ export function getLocaleCodes(config) { + return config.locales.map((locale)=>locale.code); +} +/** + * Returns the default locale code. + */ export function getDefaultLocale(config) { + return config.defaultLocale; +} +/** + * Checks if a string is a valid configured locale code. + */ export function isValidLocale(code, config) { + return config.locales.some((locale)=>locale.code === code); +} +/** + * Returns the full locale definition for a given code, or undefined if not found. + */ export function getLocaleDefinition(code, config) { + return config.locales.find((locale)=>locale.code === code); +} + +//# sourceMappingURL=helpers.js.map \ No newline at end of file diff --git a/dist/modules/i18n/helpers.js.map b/dist/modules/i18n/helpers.js.map new file mode 100644 index 0000000..8db416a --- /dev/null +++ b/dist/modules/i18n/helpers.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/helpers.ts"],"sourcesContent":["import type { I18nConfig, LocaleDefinition } from './types.js'\n\n/**\n * Returns all configured locale codes.\n */\nexport function getLocaleCodes(config: I18nConfig): string[] {\n return config.locales.map((locale) => locale.code)\n}\n\n/**\n * Returns the default locale code.\n */\nexport function getDefaultLocale(config: I18nConfig): string {\n return config.defaultLocale\n}\n\n/**\n * Checks if a string is a valid configured locale code.\n */\nexport function isValidLocale(code: string, config: I18nConfig): boolean {\n return config.locales.some((locale) => locale.code === code)\n}\n\n/**\n * Returns the full locale definition for a given code, or undefined if not found.\n */\nexport function getLocaleDefinition(\n code: string,\n config: I18nConfig,\n): LocaleDefinition | undefined {\n return config.locales.find((locale) => locale.code === code)\n}\n"],"names":["getLocaleCodes","config","locales","map","locale","code","getDefaultLocale","defaultLocale","isValidLocale","some","getLocaleDefinition","find"],"mappings":"AAEA;;CAEC,GACD,OAAO,SAASA,eAAeC,MAAkB;IAC/C,OAAOA,OAAOC,OAAO,CAACC,GAAG,CAAC,CAACC,SAAWA,OAAOC,IAAI;AACnD;AAEA;;CAEC,GACD,OAAO,SAASC,iBAAiBL,MAAkB;IACjD,OAAOA,OAAOM,aAAa;AAC7B;AAEA;;CAEC,GACD,OAAO,SAASC,cAAcH,IAAY,EAAEJ,MAAkB;IAC5D,OAAOA,OAAOC,OAAO,CAACO,IAAI,CAAC,CAACL,SAAWA,OAAOC,IAAI,KAAKA;AACzD;AAEA;;CAEC,GACD,OAAO,SAASK,oBACdL,IAAY,EACZJ,MAAkB;IAElB,OAAOA,OAAOC,OAAO,CAACS,IAAI,CAAC,CAACP,SAAWA,OAAOC,IAAI,KAAKA;AACzD"} \ No newline at end of file diff --git a/dist/modules/i18n/index.d.js b/dist/modules/i18n/index.d.js new file mode 100644 index 0000000..7b6f6c6 --- /dev/null +++ b/dist/modules/i18n/index.d.js @@ -0,0 +1,9 @@ +export { getLocalizedSlugs } from './getLocalizedSlugs.js'; +export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'; +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'; +export { buildLocalizationConfig } from './localizationConfig.js'; +export { buildLocalizedPath, switchLocalePath } from './localizedPath.js'; +export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'; +export { validateI18nConfig } from './validation.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/index.d.js.map b/dist/modules/i18n/index.d.js.map new file mode 100644 index 0000000..59d02ea --- /dev/null +++ b/dist/modules/i18n/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { getLocalizedSlugs } from './getLocalizedSlugs.js';\nexport { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js';\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js';\nexport type { LocaleMiddlewareResult } from './localeMiddleware.js';\nexport { buildLocalizationConfig } from './localizationConfig.js';\nexport { buildLocalizedPath, switchLocalePath } from './localizedPath.js';\nexport type { LocalizedSlugs } from './localizedPath.js';\nexport { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js';\nexport type { I18nConfig, LocaleDefinition } from './types.js';\nexport { validateI18nConfig } from './validation.js';\n"],"names":["getLocalizedSlugs","getDefaultLocale","getLocaleCodes","getLocaleDefinition","isValidLocale","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","buildLocalizationConfig","buildLocalizedPath","switchLocalePath","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","validateI18nConfig"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,yBAAyB;AAC3D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,mBAAmB,EAAEC,aAAa,QAAQ,eAAe;AACpG,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,wBAAwB;AAE3F,SAASC,uBAAuB,QAAQ,0BAA0B;AAClE,SAASC,kBAAkB,EAAEC,gBAAgB,QAAQ,qBAAqB;AAE1E,SAASC,kBAAkB,EAAEC,mBAAmB,EAAEC,eAAe,QAAQ,uBAAuB;AAEhG,SAASC,kBAAkB,QAAQ,kBAAkB"} \ No newline at end of file diff --git a/dist/modules/i18n/index.d.ts b/dist/modules/i18n/index.d.ts new file mode 100644 index 0000000..2db9940 --- /dev/null +++ b/dist/modules/i18n/index.d.ts @@ -0,0 +1,10 @@ +export { getLocalizedSlugs } from './getLocalizedSlugs.js'; +export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'; +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'; +export type { LocaleMiddlewareResult } from './localeMiddleware.js'; +export { buildLocalizationConfig } from './localizationConfig.js'; +export { buildLocalizedPath, switchLocalePath } from './localizedPath.js'; +export type { LocalizedSlugs } from './localizedPath.js'; +export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'; +export type { I18nConfig, LocaleDefinition } from './types.js'; +export { validateI18nConfig } from './validation.js'; diff --git a/dist/modules/i18n/index.js b/dist/modules/i18n/index.js new file mode 100644 index 0000000..6dbd28a --- /dev/null +++ b/dist/modules/i18n/index.js @@ -0,0 +1,9 @@ +export { getLocalizedSlugs } from './getLocalizedSlugs.js'; +export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'; +export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'; +export { buildLocalizationConfig } from './localizationConfig.js'; +export { buildLocalizedPath, switchLocalePath } from './localizedPath.js'; +export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'; +export { validateI18nConfig } from './validation.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/i18n/index.js.map b/dist/modules/i18n/index.js.map new file mode 100644 index 0000000..9708da6 --- /dev/null +++ b/dist/modules/i18n/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/index.ts"],"sourcesContent":["export { getLocalizedSlugs } from './getLocalizedSlugs.js'\nexport { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'\nexport type { LocaleMiddlewareResult } from './localeMiddleware.js'\nexport { buildLocalizationConfig } from './localizationConfig.js'\nexport { buildLocalizedPath, switchLocalePath } from './localizedPath.js'\nexport type { LocalizedSlugs } from './localizedPath.js'\nexport { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'\nexport type { I18nConfig, LocaleDefinition } from './types.js'\nexport { validateI18nConfig } from './validation.js'\n"],"names":["getLocalizedSlugs","getDefaultLocale","getLocaleCodes","getLocaleDefinition","isValidLocale","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","buildLocalizationConfig","buildLocalizedPath","switchLocalePath","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","validateI18nConfig"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,yBAAwB;AAC1D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,mBAAmB,EAAEC,aAAa,QAAQ,eAAc;AACnG,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,wBAAuB;AAE1F,SAASC,uBAAuB,QAAQ,0BAAyB;AACjE,SAASC,kBAAkB,EAAEC,gBAAgB,QAAQ,qBAAoB;AAEzE,SAASC,kBAAkB,EAAEC,mBAAmB,EAAEC,eAAe,QAAQ,uBAAsB;AAE/F,SAASC,kBAAkB,QAAQ,kBAAiB"} \ No newline at end of file diff --git a/dist/modules/i18n/localeMiddleware.d.js b/dist/modules/i18n/localeMiddleware.d.js new file mode 100644 index 0000000..127b794 --- /dev/null +++ b/dist/modules/i18n/localeMiddleware.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=localeMiddleware.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localeMiddleware.d.js.map b/dist/modules/i18n/localeMiddleware.d.js.map new file mode 100644 index 0000000..14666b2 --- /dev/null +++ b/dist/modules/i18n/localeMiddleware.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["localeMiddleware.d.ts"],"sourcesContent":["import type { I18nConfig } from './types.js';\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n cookies: {\n get: (name: string) => {\n value: string;\n } | undefined;\n };\n headers: {\n get: (name: string) => null | string;\n };\n nextUrl: {\n clone: () => URL;\n pathname: string;\n search: string;\n };\n url: string;\n};\n/**\n * What the factory returns — the caller (in Next next-middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult = {\n cookie: {\n name: string;\n value: string;\n };\n location: string;\n type: 'redirect';\n} | {\n type: 'next';\n};\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig;\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string;\n};\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin next-middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that next-middleware.ts must physically live in the client app.\n *\n * @example\n * // next-middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function middleware(req) {\n * const r = localeMiddleware(req)\n * if (r.type === 'next') return NextResponse.next()\n * const res = NextResponse.redirect(r.location)\n * res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport declare function createLocaleMiddleware({ config, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport declare const DEFAULT_MIDDLEWARE_MATCHER: string[];\nexport {};\n"],"names":[],"mappings":"AAwEA,WAAU"} \ No newline at end of file diff --git a/dist/modules/i18n/localeMiddleware.d.ts b/dist/modules/i18n/localeMiddleware.d.ts new file mode 100644 index 0000000..cfc518f --- /dev/null +++ b/dist/modules/i18n/localeMiddleware.d.ts @@ -0,0 +1,73 @@ +import type { I18nConfig } from './types.js'; +/** + * Minimal request shape the middleware reads. Kept structural so the plugin + * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it. + */ +type MiddlewareRequest = { + cookies: { + get: (name: string) => { + value: string; + } | undefined; + }; + headers: { + get: (name: string) => null | string; + }; + nextUrl: { + clone: () => URL; + pathname: string; + search: string; + }; + url: string; +}; +/** + * What the factory returns — the caller (in Next next-middleware.ts) decides how to + * act: `redirect` means send a 307 to `location` and set the locale cookie; + * `next` means let the request pass through untouched. + */ +export type LocaleMiddlewareResult = { + cookie: { + name: string; + value: string; + }; + location: string; + type: 'redirect'; +} | { + type: 'next'; +}; +type CreateLocaleMiddlewareArgs = { + config: I18nConfig; + /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */ + cookieName?: string; +}; +/** + * Builds locale-routing logic for Next.js middleware. + * + * Behavior: + * - path already starts with a valid locale (/pl/...) → pass through + * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale + * comes from negotiateLocale (cookie → Accept-Language → default) + * - the chosen locale is written to a cookie so the next visit is stable + * + * The plugin returns a decision; the thin next-middleware.ts in the client project + * turns it into a NextResponse. This keeps all logic in the plugin while + * respecting that next-middleware.ts must physically live in the client app. + * + * @example + * // next-middleware.ts (client project) — one wiring file, no logic: + * import { NextResponse } from 'next/server' + * import { localeMiddleware } from './ipal.middleware' // created from this factory + * export function middleware(req) { + * const r = localeMiddleware(req) + * if (r.type === 'next') return NextResponse.next() + * const res = NextResponse.redirect(r.location) + * res.cookies.set(r.cookie.name, r.cookie.value) + * return res + * } + */ +export declare function createLocaleMiddleware({ config, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult; +/** + * Default Next.js middleware matcher: run on everything except API routes, the + * admin panel, Next internals, and files with an extension (static assets). + */ +export declare const DEFAULT_MIDDLEWARE_MATCHER: string[]; +export {}; diff --git a/dist/modules/i18n/localeMiddleware.js b/dist/modules/i18n/localeMiddleware.js new file mode 100644 index 0000000..e251ad4 --- /dev/null +++ b/dist/modules/i18n/localeMiddleware.js @@ -0,0 +1,67 @@ +import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'; +/** + * First path segment of a URL pathname, or '' for root. + * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''. + */ function firstSegment(pathname) { + return pathname.split('/').filter(Boolean)[0] ?? ''; +} +/** + * Builds locale-routing logic for Next.js middleware. + * + * Behavior: + * - path already starts with a valid locale (/pl/...) → pass through + * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale + * comes from negotiateLocale (cookie → Accept-Language → default) + * - the chosen locale is written to a cookie so the next visit is stable + * + * The plugin returns a decision; the thin next-middleware.ts in the client project + * turns it into a NextResponse. This keeps all logic in the plugin while + * respecting that next-middleware.ts must physically live in the client app. + * + * @example + * // next-middleware.ts (client project) — one wiring file, no logic: + * import { NextResponse } from 'next/server' + * import { localeMiddleware } from './ipal.middleware' // created from this factory + * export function middleware(req) { + * const r = localeMiddleware(req) + * if (r.type === 'next') return NextResponse.next() + * const res = NextResponse.redirect(r.location) + * res.cookies.set(r.cookie.name, r.cookie.value) + * return res + * } + */ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME }) { + return function localeMiddleware(request) { + const { pathname } = request.nextUrl; + // Already locale-prefixed → nothing to do + if (isValidLocale(firstSegment(pathname), config)) { + return { + type: 'next' + }; + } + // Resolve the locale to use + const locale = negotiateLocale({ + acceptLanguage: request.headers.get('accept-language'), + config, + cookieLocale: request.cookies.get(cookieName)?.value ?? null + }); + // Redirect to the locale-prefixed path, preserving the rest + const url = request.nextUrl.clone(); + url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`; + return { + type: 'redirect', + cookie: { + name: cookieName, + value: locale + }, + location: url.toString() + }; + }; +} +/** + * Default Next.js middleware matcher: run on everything except API routes, the + * admin panel, Next internals, and files with an extension (static assets). + */ export const DEFAULT_MIDDLEWARE_MATCHER = [ + '/((?!api|admin|_next|.*\\..*).*)' +]; + +//# sourceMappingURL=localeMiddleware.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localeMiddleware.js.map b/dist/modules/i18n/localeMiddleware.js.map new file mode 100644 index 0000000..de77963 --- /dev/null +++ b/dist/modules/i18n/localeMiddleware.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'\n\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n cookies: { get: (name: string) => { value: string } | undefined }\n headers: { get: (name: string) => null | string }\n nextUrl: { clone: () => URL; pathname: string; search: string }\n url: string\n}\n\n/**\n * What the factory returns — the caller (in Next next-middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult =\n { cookie: { name: string; value: string }; location: string; type: 'redirect' } | { type: 'next' }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string\n}\n\n/**\n * First path segment of a URL pathname, or '' for root.\n * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.\n */\nfunction firstSegment(pathname: string): string {\n return pathname.split('/').filter(Boolean)[0] ?? ''\n}\n\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin next-middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that next-middleware.ts must physically live in the client app.\n *\n * @example\n * // next-middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function middleware(req) {\n * const r = localeMiddleware(req)\n * if (r.type === 'next') return NextResponse.next()\n * const res = NextResponse.redirect(r.location)\n * res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport function createLocaleMiddleware({\n config,\n cookieName = LOCALE_COOKIE_NAME,\n}: CreateLocaleMiddlewareArgs) {\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Already locale-prefixed → nothing to do\n if (isValidLocale(firstSegment(pathname), config)) {\n return { type: 'next' }\n }\n\n // Resolve the locale to use\n const locale = negotiateLocale({\n acceptLanguage: request.headers.get('accept-language'),\n config,\n cookieLocale: request.cookies.get(cookieName)?.value ?? null,\n })\n\n // Redirect to the locale-prefixed path, preserving the rest\n const url = request.nextUrl.clone()\n url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`\n\n return {\n type: 'redirect',\n cookie: { name: cookieName, value: locale },\n location: url.toString(),\n }\n }\n}\n\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\\\..*).*)']\n"],"names":["isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","cookieName","localeMiddleware","request","nextUrl","type","locale","acceptLanguage","headers","get","cookieLocale","cookies","value","url","clone","cookie","name","location","toString","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAEA,SAASA,aAAa,EAAEC,kBAAkB,EAAEC,eAAe,QAAQ,mBAAkB;AA2BrF;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,aAAaT,kBAAkB,EACJ;IAC3B,OAAO,SAASU,iBAAiBC,OAA0B;QACzD,MAAM,EAAER,QAAQ,EAAE,GAAGQ,QAAQC,OAAO;QAEpC,0CAA0C;QAC1C,IAAIb,cAAcG,aAAaC,WAAWK,SAAS;YACjD,OAAO;gBAAEK,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMC,SAASb,gBAAgB;YAC7Bc,gBAAgBJ,QAAQK,OAAO,CAACC,GAAG,CAAC;YACpCT;YACAU,cAAcP,QAAQQ,OAAO,CAACF,GAAG,CAACR,aAAaW,SAAS;QAC1D;QAEA,4DAA4D;QAC5D,MAAMC,MAAMV,QAAQC,OAAO,CAACU,KAAK;QACjCD,IAAIlB,QAAQ,GAAG,CAAC,CAAC,EAAEW,SAASX,aAAa,MAAM,KAAKA,UAAU;QAE9D,OAAO;YACLU,MAAM;YACNU,QAAQ;gBAAEC,MAAMf;gBAAYW,OAAON;YAAO;YAC1CW,UAAUJ,IAAIK,QAAQ;QACxB;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMC,6BAA6B;IAAC;CAAmC,CAAA"} \ No newline at end of file diff --git a/dist/modules/i18n/localizationConfig.d.js b/dist/modules/i18n/localizationConfig.d.js new file mode 100644 index 0000000..a4e7abe --- /dev/null +++ b/dist/modules/i18n/localizationConfig.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=localizationConfig.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localizationConfig.d.js.map b/dist/modules/i18n/localizationConfig.d.js.map new file mode 100644 index 0000000..a6810bc --- /dev/null +++ b/dist/modules/i18n/localizationConfig.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["localizationConfig.d.ts"],"sourcesContent":["import type { Config } from 'payload';\nimport type { I18nConfig } from './types.js';\ntype PayloadLocalizationConfig = NonNullable;\n/**\n * Transforms validated i18n config into Payload's localization object.\n * Pure function — no side effects, no validation (caller validates first).\n */\nexport declare function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig;\nexport {};\n"],"names":[],"mappings":"AAQA,WAAU"} \ No newline at end of file diff --git a/dist/modules/i18n/localizationConfig.d.ts b/dist/modules/i18n/localizationConfig.d.ts new file mode 100644 index 0000000..910a7fd --- /dev/null +++ b/dist/modules/i18n/localizationConfig.d.ts @@ -0,0 +1,9 @@ +import type { Config } from 'payload'; +import type { I18nConfig } from './types.js'; +type PayloadLocalizationConfig = NonNullable; +/** + * Transforms validated i18n config into Payload's localization object. + * Pure function — no side effects, no validation (caller validates first). + */ +export declare function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig; +export {}; diff --git a/dist/modules/i18n/localizationConfig.js b/dist/modules/i18n/localizationConfig.js new file mode 100644 index 0000000..8471755 --- /dev/null +++ b/dist/modules/i18n/localizationConfig.js @@ -0,0 +1,19 @@ +/** + * Transforms validated i18n config into Payload's localization object. + * Pure function — no side effects, no validation (caller validates first). + */ export function buildLocalizationConfig(config) { + const { defaultLocale, fallback = true, locales } = config; + return { + defaultLocale, + fallback, + locales: locales.map((locale)=>({ + code: locale.code, + label: locale.label, + ...locale.rtl && { + rtl: true + } + })) + }; +} + +//# sourceMappingURL=localizationConfig.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localizationConfig.js.map b/dist/modules/i18n/localizationConfig.js.map new file mode 100644 index 0000000..0beb8b9 --- /dev/null +++ b/dist/modules/i18n/localizationConfig.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/localizationConfig.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { I18nConfig } from './types.js'\n\ntype PayloadLocalizationConfig = NonNullable\n\n/**\n * Transforms validated i18n config into Payload's localization object.\n * Pure function — no side effects, no validation (caller validates first).\n */\nexport function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig {\n const { defaultLocale, fallback = true, locales } = config\n\n return {\n defaultLocale,\n fallback,\n locales: locales.map((locale) => ({\n code: locale.code,\n label: locale.label,\n ...(locale.rtl && { rtl: true }),\n })),\n }\n}\n"],"names":["buildLocalizationConfig","config","defaultLocale","fallback","locales","map","locale","code","label","rtl"],"mappings":"AAMA;;;CAGC,GACD,OAAO,SAASA,wBAAwBC,MAAkB;IACxD,MAAM,EAAEC,aAAa,EAAEC,WAAW,IAAI,EAAEC,OAAO,EAAE,GAAGH;IAEpD,OAAO;QACLC;QACAC;QACAC,SAASA,QAAQC,GAAG,CAAC,CAACC,SAAY,CAAA;gBAChCC,MAAMD,OAAOC,IAAI;gBACjBC,OAAOF,OAAOE,KAAK;gBACnB,GAAIF,OAAOG,GAAG,IAAI;oBAAEA,KAAK;gBAAK,CAAC;YACjC,CAAA;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/i18n/localizedPath.d.js b/dist/modules/i18n/localizedPath.d.js new file mode 100644 index 0000000..030480a --- /dev/null +++ b/dist/modules/i18n/localizedPath.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=localizedPath.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localizedPath.d.js.map b/dist/modules/i18n/localizedPath.d.js.map new file mode 100644 index 0000000..3fc4571 --- /dev/null +++ b/dist/modules/i18n/localizedPath.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["localizedPath.d.ts"],"sourcesContent":["import type { I18nConfig } from './types.js';\n/**\n * Minimal shape the path builder needs from a document.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on\n * this contract: a map of locale code → slug for that locale. The template\n * supplies it (e.g. by reading the localized slug field across locales).\n */\nexport type LocalizedSlugs = Record;\ntype BuildPathArgs = {\n config: I18nConfig;\n /**\n * Slug that represents the site root (served at /{locale} with no trailing\n * segment). Defaults to 'home'. Matched against the slug in the target locale.\n */\n homeSlug?: string;\n /** Target locale to build the path for */\n locale: string;\n /**\n * Localized segment the document lives under, e.g.\n * `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.\n *\n * These are the slugs of the collection's archive page, so the prefix is\n * whatever an editor named that page — and it differs per locale for free.\n * A document under a prefix is never the home page, so homeSlug is ignored.\n */\n prefix?: LocalizedSlugs;\n /** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */\n slugs: LocalizedSlugs;\n};\n/**\n * Builds a locale-prefixed path for a document in a target locale.\n *\n * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug\n * collapses to the locale root. Returns undefined if the document has no slug\n * in the target locale (caller decides fallback behavior).\n *\n * @example\n * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })\n * // → '/en' (home slug collapses to root)\n *\n * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })\n * // → '/en/about'\n *\n * buildLocalizedPath({\n * slugs: { pl: 'moj-post', en: 'my-post' },\n * prefix: { pl: 'artykuly', en: 'articles' },\n * locale: 'en',\n * config,\n * })\n * // → '/en/articles/my-post'\n */\nexport declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined;\ntype SwitchLocaleArgs = {\n config: I18nConfig;\n homeSlug?: string;\n prefix?: LocalizedSlugs;\n slugs: LocalizedSlugs;\n targetLocale: string;\n};\n/**\n * Resolves the equivalent path for the same document in a different locale —\n * the language-switcher use case (/pl/strona-glowna → /en/home).\n *\n * Never dead-ends on a 404. When the document has no slug in the target locale,\n * falls back to the archive it belongs to (/en/articles) if there is one, and\n * to the locale root otherwise — the closest place the visitor would want.\n */\nexport declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string;\nexport {};\n"],"names":[],"mappings":"AAqEA,WAAU"} \ No newline at end of file diff --git a/dist/modules/i18n/localizedPath.d.ts b/dist/modules/i18n/localizedPath.d.ts new file mode 100644 index 0000000..112706a --- /dev/null +++ b/dist/modules/i18n/localizedPath.d.ts @@ -0,0 +1,70 @@ +import type { I18nConfig } from './types.js'; +/** + * Minimal shape the path builder needs from a document. + * + * The plugin doesn't know the client's Pages type, so it depends only on + * this contract: a map of locale code → slug for that locale. The template + * supplies it (e.g. by reading the localized slug field across locales). + */ +export type LocalizedSlugs = Record; +type BuildPathArgs = { + config: I18nConfig; + /** + * Slug that represents the site root (served at /{locale} with no trailing + * segment). Defaults to 'home'. Matched against the slug in the target locale. + */ + homeSlug?: string; + /** Target locale to build the path for */ + locale: string; + /** + * Localized segment the document lives under, e.g. + * `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post. + * + * These are the slugs of the collection's archive page, so the prefix is + * whatever an editor named that page — and it differs per locale for free. + * A document under a prefix is never the home page, so homeSlug is ignored. + */ + prefix?: LocalizedSlugs; + /** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */ + slugs: LocalizedSlugs; +}; +/** + * Builds a locale-prefixed path for a document in a target locale. + * + * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug + * collapses to the locale root. Returns undefined if the document has no slug + * in the target locale (caller decides fallback behavior). + * + * @example + * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config }) + * // → '/en' (home slug collapses to root) + * + * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config }) + * // → '/en/about' + * + * buildLocalizedPath({ + * slugs: { pl: 'moj-post', en: 'my-post' }, + * prefix: { pl: 'artykuly', en: 'articles' }, + * locale: 'en', + * config, + * }) + * // → '/en/articles/my-post' + */ +export declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined; +type SwitchLocaleArgs = { + config: I18nConfig; + homeSlug?: string; + prefix?: LocalizedSlugs; + slugs: LocalizedSlugs; + targetLocale: string; +}; +/** + * Resolves the equivalent path for the same document in a different locale — + * the language-switcher use case (/pl/strona-glowna → /en/home). + * + * Never dead-ends on a 404. When the document has no slug in the target locale, + * falls back to the archive it belongs to (/en/articles) if there is one, and + * to the locale root otherwise — the closest place the visitor would want. + */ +export declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string; +export {}; diff --git a/dist/modules/i18n/localizedPath.js b/dist/modules/i18n/localizedPath.js new file mode 100644 index 0000000..40a37be --- /dev/null +++ b/dist/modules/i18n/localizedPath.js @@ -0,0 +1,71 @@ +import { isValidLocale } from './helpers.js'; +/** + * Builds a locale-prefixed path for a document in a target locale. + * + * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug + * collapses to the locale root. Returns undefined if the document has no slug + * in the target locale (caller decides fallback behavior). + * + * @example + * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config }) + * // → '/en' (home slug collapses to root) + * + * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config }) + * // → '/en/about' + * + * buildLocalizedPath({ + * slugs: { pl: 'moj-post', en: 'my-post' }, + * prefix: { pl: 'artykuly', en: 'articles' }, + * locale: 'en', + * config, + * }) + * // → '/en/articles/my-post' + */ export function buildLocalizedPath({ config, homeSlug = 'home', locale, prefix, slugs }) { + if (!isValidLocale(locale, config)) { + return undefined; + } + const slug = slugs[locale]; + if (!slug) { + return undefined; + } + if (prefix) { + const segment = prefix[locale]; + // No archive slug in this locale means the entry is unreachable there — + // there's no path to point at, so hreflang should omit it rather than + // invent /en/moj-post. + if (!segment) { + return undefined; + } + return `/${locale}/${segment}/${slug}`; + } + if (slug === homeSlug) { + return `/${locale}`; + } + return `/${locale}/${slug}`; +} +/** + * Resolves the equivalent path for the same document in a different locale — + * the language-switcher use case (/pl/strona-glowna → /en/home). + * + * Never dead-ends on a 404. When the document has no slug in the target locale, + * falls back to the archive it belongs to (/en/articles) if there is one, and + * to the locale root otherwise — the closest place the visitor would want. + */ export function switchLocalePath({ config, homeSlug = 'home', prefix, slugs, targetLocale }) { + const path = buildLocalizedPath({ + config, + homeSlug, + locale: targetLocale, + prefix, + slugs + }); + if (path) { + return path; + } + const archiveSegment = prefix?.[targetLocale]; + if (archiveSegment) { + return `/${targetLocale}/${archiveSegment}`; + } + return `/${targetLocale}`; +} + +//# sourceMappingURL=localizedPath.js.map \ No newline at end of file diff --git a/dist/modules/i18n/localizedPath.js.map b/dist/modules/i18n/localizedPath.js.map new file mode 100644 index 0000000..1f73694 --- /dev/null +++ b/dist/modules/i18n/localizedPath.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/localizedPath.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { isValidLocale } from './helpers.js'\n\n/**\n * Minimal shape the path builder needs from a document.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on\n * this contract: a map of locale code → slug for that locale. The template\n * supplies it (e.g. by reading the localized slug field across locales).\n */\nexport type LocalizedSlugs = Record\n\ntype BuildPathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root (served at /{locale} with no trailing\n * segment). Defaults to 'home'. Matched against the slug in the target locale.\n */\n homeSlug?: string\n /** Target locale to build the path for */\n locale: string\n /**\n * Localized segment the document lives under, e.g.\n * `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.\n *\n * These are the slugs of the collection's archive page, so the prefix is\n * whatever an editor named that page — and it differs per locale for free.\n * A document under a prefix is never the home page, so homeSlug is ignored.\n */\n prefix?: LocalizedSlugs\n /** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a locale-prefixed path for a document in a target locale.\n *\n * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug\n * collapses to the locale root. Returns undefined if the document has no slug\n * in the target locale (caller decides fallback behavior).\n *\n * @example\n * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })\n * // → '/en' (home slug collapses to root)\n *\n * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })\n * // → '/en/about'\n *\n * buildLocalizedPath({\n * slugs: { pl: 'moj-post', en: 'my-post' },\n * prefix: { pl: 'artykuly', en: 'articles' },\n * locale: 'en',\n * config,\n * })\n * // → '/en/articles/my-post'\n */\nexport function buildLocalizedPath({\n config,\n homeSlug = 'home',\n locale,\n prefix,\n slugs,\n}: BuildPathArgs): string | undefined {\n if (!isValidLocale(locale, config)) {\n return undefined\n }\n\n const slug = slugs[locale]\n if (!slug) {\n return undefined\n }\n\n if (prefix) {\n const segment = prefix[locale]\n // No archive slug in this locale means the entry is unreachable there —\n // there's no path to point at, so hreflang should omit it rather than\n // invent /en/moj-post.\n if (!segment) {\n return undefined\n }\n return `/${locale}/${segment}/${slug}`\n }\n\n if (slug === homeSlug) {\n return `/${locale}`\n }\n\n return `/${locale}/${slug}`\n}\n\ntype SwitchLocaleArgs = {\n config: I18nConfig\n homeSlug?: string\n prefix?: LocalizedSlugs\n slugs: LocalizedSlugs\n targetLocale: string\n}\n\n/**\n * Resolves the equivalent path for the same document in a different locale —\n * the language-switcher use case (/pl/strona-glowna → /en/home).\n *\n * Never dead-ends on a 404. When the document has no slug in the target locale,\n * falls back to the archive it belongs to (/en/articles) if there is one, and\n * to the locale root otherwise — the closest place the visitor would want.\n */\nexport function switchLocalePath({\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n targetLocale,\n}: SwitchLocaleArgs): string {\n const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })\n if (path) {return path}\n\n const archiveSegment = prefix?.[targetLocale]\n if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}\n\n return `/${targetLocale}`\n}\n"],"names":["isValidLocale","buildLocalizedPath","config","homeSlug","locale","prefix","slugs","undefined","slug","segment","switchLocalePath","targetLocale","path","archiveSegment"],"mappings":"AAEA,SAASA,aAAa,QAAQ,eAAc;AAiC5C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC,mBAAmB,EACjCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,MAAM,EACNC,KAAK,EACS;IACd,IAAI,CAACN,cAAcI,QAAQF,SAAS;QAClC,OAAOK;IACT;IAEA,MAAMC,OAAOF,KAAK,CAACF,OAAO;IAC1B,IAAI,CAACI,MAAM;QACT,OAAOD;IACT;IAEA,IAAIF,QAAQ;QACV,MAAMI,UAAUJ,MAAM,CAACD,OAAO;QAC9B,wEAAwE;QACxE,sEAAsE;QACtE,uBAAuB;QACvB,IAAI,CAACK,SAAS;YACZ,OAAOF;QACT;QACA,OAAO,CAAC,CAAC,EAAEH,OAAO,CAAC,EAAEK,QAAQ,CAAC,EAAED,MAAM;IACxC;IAEA,IAAIA,SAASL,UAAU;QACrB,OAAO,CAAC,CAAC,EAAEC,QAAQ;IACrB;IAEA,OAAO,CAAC,CAAC,EAAEA,OAAO,CAAC,EAAEI,MAAM;AAC7B;AAUA;;;;;;;CAOC,GACD,OAAO,SAASE,iBAAiB,EAC/BR,MAAM,EACNC,WAAW,MAAM,EACjBE,MAAM,EACNC,KAAK,EACLK,YAAY,EACK;IACjB,MAAMC,OAAOX,mBAAmB;QAAEC;QAAQC;QAAUC,QAAQO;QAAcN;QAAQC;IAAM;IACxF,IAAIM,MAAM;QAAC,OAAOA;IAAI;IAEtB,MAAMC,iBAAiBR,QAAQ,CAACM,aAAa;IAC7C,IAAIE,gBAAgB;QAAC,OAAO,CAAC,CAAC,EAAEF,aAAa,CAAC,EAAEE,gBAAgB;IAAA;IAEhE,OAAO,CAAC,CAAC,EAAEF,cAAc;AAC3B"} \ No newline at end of file diff --git a/dist/modules/i18n/negotiateLocale.d.js b/dist/modules/i18n/negotiateLocale.d.js new file mode 100644 index 0000000..4bef334 --- /dev/null +++ b/dist/modules/i18n/negotiateLocale.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=negotiateLocale.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/negotiateLocale.d.js.map b/dist/modules/i18n/negotiateLocale.d.js.map new file mode 100644 index 0000000..8b8714a --- /dev/null +++ b/dist/modules/i18n/negotiateLocale.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["negotiateLocale.d.ts"],"sourcesContent":["import type { I18nConfig } from './types.js';\n/** Cookie name the template uses to persist a visitor's locale choice. */\nexport declare const LOCALE_COOKIE_NAME = \"ipal-locale\";\ntype NegotiateLocaleArgs = {\n /** Raw Accept-Language header value */\n acceptLanguage?: null | string;\n config: I18nConfig;\n /** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */\n cookieLocale?: null | string;\n};\n/**\n * Resolves which locale to serve, in priority order:\n * 1. Cookie (explicit prior choice)\n * 2. Accept-Language header (best match against configured locales)\n * 3. Configured default locale\n *\n * Pure function — the template feeds it request data and acts on the result\n * (redirect, cookie set). No Next.js or request objects here.\n */\nexport declare function negotiateLocale({ acceptLanguage, config, cookieLocale, }: NegotiateLocaleArgs): string;\n/**\n * Parses an Accept-Language header and returns the best-matching configured\n * locale, or undefined if none match.\n *\n * Matches on the primary subtag (e.g. \"en-US\" matches configured \"en\"),\n * respecting the header's quality-value ordering.\n */\nexport declare function matchAcceptLanguage(acceptLanguage: null | string | undefined, config: I18nConfig): string | undefined;\nexport {};\n"],"names":[],"mappings":"AA4BA,WAAU"} \ No newline at end of file diff --git a/dist/modules/i18n/negotiateLocale.d.ts b/dist/modules/i18n/negotiateLocale.d.ts new file mode 100644 index 0000000..9527eb3 --- /dev/null +++ b/dist/modules/i18n/negotiateLocale.d.ts @@ -0,0 +1,29 @@ +import type { I18nConfig } from './types.js'; +/** Cookie name the template uses to persist a visitor's locale choice. */ +export declare const LOCALE_COOKIE_NAME = "ipal-locale"; +type NegotiateLocaleArgs = { + /** Raw Accept-Language header value */ + acceptLanguage?: null | string; + config: I18nConfig; + /** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */ + cookieLocale?: null | string; +}; +/** + * Resolves which locale to serve, in priority order: + * 1. Cookie (explicit prior choice) + * 2. Accept-Language header (best match against configured locales) + * 3. Configured default locale + * + * Pure function — the template feeds it request data and acts on the result + * (redirect, cookie set). No Next.js or request objects here. + */ +export declare function negotiateLocale({ acceptLanguage, config, cookieLocale, }: NegotiateLocaleArgs): string; +/** + * Parses an Accept-Language header and returns the best-matching configured + * locale, or undefined if none match. + * + * Matches on the primary subtag (e.g. "en-US" matches configured "en"), + * respecting the header's quality-value ordering. + */ +export declare function matchAcceptLanguage(acceptLanguage: null | string | undefined, config: I18nConfig): string | undefined; +export {}; diff --git a/dist/modules/i18n/negotiateLocale.js b/dist/modules/i18n/negotiateLocale.js new file mode 100644 index 0000000..7996d0e --- /dev/null +++ b/dist/modules/i18n/negotiateLocale.js @@ -0,0 +1,68 @@ +import { getLocaleCodes, isValidLocale } from './helpers.js'; +/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale'; +/** + * Resolves which locale to serve, in priority order: + * 1. Cookie (explicit prior choice) + * 2. Accept-Language header (best match against configured locales) + * 3. Configured default locale + * + * Pure function — the template feeds it request data and acts on the result + * (redirect, cookie set). No Next.js or request objects here. + */ export function negotiateLocale({ acceptLanguage, config, cookieLocale }) { + // 1. Explicit prior choice wins + if (cookieLocale && isValidLocale(cookieLocale, config)) { + return cookieLocale; + } + // 2. Best match from Accept-Language + const fromHeader = matchAcceptLanguage(acceptLanguage, config); + if (fromHeader) { + return fromHeader; + } + // 3. Fall back to configured default + return config.defaultLocale; +} +/** + * Parses an Accept-Language header and returns the best-matching configured + * locale, or undefined if none match. + * + * Matches on the primary subtag (e.g. "en-US" matches configured "en"), + * respecting the header's quality-value ordering. + */ export function matchAcceptLanguage(acceptLanguage, config) { + if (!acceptLanguage) { + return undefined; + } + const available = getLocaleCodes(config); + const ranked = parseAcceptLanguage(acceptLanguage); + for (const tag of ranked){ + // Exact match (e.g. "pt-BR" === "pt-BR") + const exact = available.find((code)=>code.toLowerCase() === tag); + if (exact) { + return exact; + } + // Primary-subtag match (e.g. "en-us" → "en") + const primary = tag.split('-')[0]; + const partial = available.find((code)=>code.toLowerCase().split('-')[0] === primary); + if (partial) { + return partial; + } + } + return undefined; +} +/** + * Parses an Accept-Language header into locale tags ordered by descending + * quality value. Tags are lowercased for comparison. + * + * "en-US,en;q=0.9,pl;q=0.8" → ["en-us", "en", "pl"] + */ function parseAcceptLanguage(header) { + return header.split(',').map((part)=>{ + const [tag, ...params] = part.trim().split(';'); + const qParam = params.find((p)=>p.trim().startsWith('q=')); + const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1; + return { + quality: Number.isNaN(quality) ? 0 : quality, + tag: tag.trim().toLowerCase() + }; + }).filter((entry)=>entry.tag && entry.tag !== '*').sort((a, b)=>b.quality - a.quality).map((entry)=>entry.tag); +} + +//# sourceMappingURL=negotiateLocale.js.map \ No newline at end of file diff --git a/dist/modules/i18n/negotiateLocale.js.map b/dist/modules/i18n/negotiateLocale.js.map new file mode 100644 index 0000000..a5dd6ac --- /dev/null +++ b/dist/modules/i18n/negotiateLocale.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/negotiateLocale.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { getLocaleCodes, isValidLocale } from './helpers.js'\n\n/** Cookie name the template uses to persist a visitor's locale choice. */\nexport const LOCALE_COOKIE_NAME = 'ipal-locale'\n\ntype NegotiateLocaleArgs = {\n /** Raw Accept-Language header value */\n acceptLanguage?: null | string\n config: I18nConfig\n /** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */\n cookieLocale?: null | string\n}\n\n/**\n * Resolves which locale to serve, in priority order:\n * 1. Cookie (explicit prior choice)\n * 2. Accept-Language header (best match against configured locales)\n * 3. Configured default locale\n *\n * Pure function — the template feeds it request data and acts on the result\n * (redirect, cookie set). No Next.js or request objects here.\n */\nexport function negotiateLocale({\n acceptLanguage,\n config,\n cookieLocale,\n}: NegotiateLocaleArgs): string {\n // 1. Explicit prior choice wins\n if (cookieLocale && isValidLocale(cookieLocale, config)) {\n return cookieLocale\n }\n\n // 2. Best match from Accept-Language\n const fromHeader = matchAcceptLanguage(acceptLanguage, config)\n if (fromHeader) {\n return fromHeader\n }\n\n // 3. Fall back to configured default\n return config.defaultLocale\n}\n\n/**\n * Parses an Accept-Language header and returns the best-matching configured\n * locale, or undefined if none match.\n *\n * Matches on the primary subtag (e.g. \"en-US\" matches configured \"en\"),\n * respecting the header's quality-value ordering.\n */\nexport function matchAcceptLanguage(\n acceptLanguage: null | string | undefined,\n config: I18nConfig,\n): string | undefined {\n if (!acceptLanguage) {return undefined}\n\n const available = getLocaleCodes(config)\n const ranked = parseAcceptLanguage(acceptLanguage)\n\n for (const tag of ranked) {\n // Exact match (e.g. \"pt-BR\" === \"pt-BR\")\n const exact = available.find((code) => code.toLowerCase() === tag)\n if (exact) {return exact}\n\n // Primary-subtag match (e.g. \"en-us\" → \"en\")\n const primary = tag.split('-')[0]\n const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)\n if (partial) {return partial}\n }\n\n return undefined\n}\n\n/**\n * Parses an Accept-Language header into locale tags ordered by descending\n * quality value. Tags are lowercased for comparison.\n *\n * \"en-US,en;q=0.9,pl;q=0.8\" → [\"en-us\", \"en\", \"pl\"]\n */\nfunction parseAcceptLanguage(header: string): string[] {\n return header\n .split(',')\n .map((part) => {\n const [tag, ...params] = part.trim().split(';')\n const qParam = params.find((p) => p.trim().startsWith('q='))\n const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1\n return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }\n })\n .filter((entry) => entry.tag && entry.tag !== '*')\n .sort((a, b) => b.quality - a.quality)\n .map((entry) => entry.tag)\n}\n"],"names":["getLocaleCodes","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","acceptLanguage","config","cookieLocale","fromHeader","matchAcceptLanguage","defaultLocale","undefined","available","ranked","parseAcceptLanguage","tag","exact","find","code","toLowerCase","primary","split","partial","header","map","part","params","trim","qParam","p","startsWith","quality","parseFloat","Number","isNaN","filter","entry","sort","a","b"],"mappings":"AAEA,SAASA,cAAc,EAAEC,aAAa,QAAQ,eAAc;AAE5D,wEAAwE,GACxE,OAAO,MAAMC,qBAAqB,cAAa;AAU/C;;;;;;;;CAQC,GACD,OAAO,SAASC,gBAAgB,EAC9BC,cAAc,EACdC,MAAM,EACNC,YAAY,EACQ;IACpB,gCAAgC;IAChC,IAAIA,gBAAgBL,cAAcK,cAAcD,SAAS;QACvD,OAAOC;IACT;IAEA,qCAAqC;IACrC,MAAMC,aAAaC,oBAAoBJ,gBAAgBC;IACvD,IAAIE,YAAY;QACd,OAAOA;IACT;IAEA,qCAAqC;IACrC,OAAOF,OAAOI,aAAa;AAC7B;AAEA;;;;;;CAMC,GACD,OAAO,SAASD,oBACdJ,cAAyC,EACzCC,MAAkB;IAElB,IAAI,CAACD,gBAAgB;QAAC,OAAOM;IAAS;IAEtC,MAAMC,YAAYX,eAAeK;IACjC,MAAMO,SAASC,oBAAoBT;IAEnC,KAAK,MAAMU,OAAOF,OAAQ;QACxB,yCAAyC;QACzC,MAAMG,QAAQJ,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,OAAOJ;QAC9D,IAAIC,OAAO;YAAC,OAAOA;QAAK;QAExB,6CAA6C;QAC7C,MAAMI,UAAUL,IAAIM,KAAK,CAAC,IAAI,CAAC,EAAE;QACjC,MAAMC,UAAUV,UAAUK,IAAI,CAAC,CAACC,OAASA,KAAKC,WAAW,GAAGE,KAAK,CAAC,IAAI,CAAC,EAAE,KAAKD;QAC9E,IAAIE,SAAS;YAAC,OAAOA;QAAO;IAC9B;IAEA,OAAOX;AACT;AAEA;;;;;CAKC,GACD,SAASG,oBAAoBS,MAAc;IACzC,OAAOA,OACJF,KAAK,CAAC,KACNG,GAAG,CAAC,CAACC;QACJ,MAAM,CAACV,KAAK,GAAGW,OAAO,GAAGD,KAAKE,IAAI,GAAGN,KAAK,CAAC;QAC3C,MAAMO,SAASF,OAAOT,IAAI,CAAC,CAACY,IAAMA,EAAEF,IAAI,GAAGG,UAAU,CAAC;QACtD,MAAMC,UAAUH,SAASI,WAAWJ,OAAOP,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI;QAC5D,OAAO;YAAEU,SAASE,OAAOC,KAAK,CAACH,WAAW,IAAIA;YAAShB,KAAKA,IAAIY,IAAI,GAAGR,WAAW;QAAG;IACvF,GACCgB,MAAM,CAAC,CAACC,QAAUA,MAAMrB,GAAG,IAAIqB,MAAMrB,GAAG,KAAK,KAC7CsB,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAER,OAAO,GAAGO,EAAEP,OAAO,EACpCP,GAAG,CAAC,CAACY,QAAUA,MAAMrB,GAAG;AAC7B"} \ No newline at end of file diff --git a/dist/modules/i18n/types.d.js b/dist/modules/i18n/types.d.js new file mode 100644 index 0000000..8d20344 --- /dev/null +++ b/dist/modules/i18n/types.d.js @@ -0,0 +1,7 @@ +/** + * Single locale definition provided by the client project. + */ /** + * Full i18n configuration passed through plugin options. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/types.d.js.map b/dist/modules/i18n/types.d.js.map new file mode 100644 index 0000000..14c4e7b --- /dev/null +++ b/dist/modules/i18n/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["/**\n * Single locale definition provided by the client project.\n */\nexport type LocaleDefinition = {\n /** BCP-47 language code, e.g. 'pl', 'en', 'de' */\n code: string;\n /** Human-readable label, e.g. 'Polski', 'English' */\n label: string;\n /** Right-to-left script (defaults to false) */\n rtl?: boolean;\n};\n/**\n * Full i18n configuration passed through plugin options.\n */\nexport type I18nConfig = {\n defaultLocale: string;\n /** Enable locale fallback when content is missing (defaults to true) */\n fallback?: boolean;\n locales: [LocaleDefinition, ...LocaleDefinition[]];\n};\n"],"names":[],"mappings":"AAAA;;CAEC,GASD;;CAEC,GACD,WAKE"} \ No newline at end of file diff --git a/dist/modules/i18n/types.d.ts b/dist/modules/i18n/types.d.ts new file mode 100644 index 0000000..764df84 --- /dev/null +++ b/dist/modules/i18n/types.d.ts @@ -0,0 +1,20 @@ +/** + * Single locale definition provided by the client project. + */ +export type LocaleDefinition = { + /** BCP-47 language code, e.g. 'pl', 'en', 'de' */ + code: string; + /** Human-readable label, e.g. 'Polski', 'English' */ + label: string; + /** Right-to-left script (defaults to false) */ + rtl?: boolean; +}; +/** + * Full i18n configuration passed through plugin options. + */ +export type I18nConfig = { + defaultLocale: string; + /** Enable locale fallback when content is missing (defaults to true) */ + fallback?: boolean; + locales: readonly [LocaleDefinition, ...LocaleDefinition[]]; +}; diff --git a/dist/modules/i18n/types.js b/dist/modules/i18n/types.js new file mode 100644 index 0000000..550d3a4 --- /dev/null +++ b/dist/modules/i18n/types.js @@ -0,0 +1,7 @@ +/** + * Single locale definition provided by the client project. + */ /** + * Full i18n configuration passed through plugin options. + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/i18n/types.js.map b/dist/modules/i18n/types.js.map new file mode 100644 index 0000000..5aaa89d --- /dev/null +++ b/dist/modules/i18n/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/types.ts"],"sourcesContent":["/**\n * Single locale definition provided by the client project.\n */\nexport type LocaleDefinition = {\n /** BCP-47 language code, e.g. 'pl', 'en', 'de' */\n code: string\n /** Human-readable label, e.g. 'Polski', 'English' */\n label: string\n /** Right-to-left script (defaults to false) */\n rtl?: boolean\n}\n\n/**\n * Full i18n configuration passed through plugin options.\n */\nexport type I18nConfig = {\n defaultLocale: string\n /** Enable locale fallback when content is missing (defaults to true) */\n fallback?: boolean\n locales: readonly [LocaleDefinition, ...LocaleDefinition[]]\n}\n"],"names":[],"mappings":"AAAA;;CAEC,GAUD;;CAEC,GACD,WAKC"} \ No newline at end of file diff --git a/dist/modules/i18n/validation.d.js b/dist/modules/i18n/validation.d.js new file mode 100644 index 0000000..3699299 --- /dev/null +++ b/dist/modules/i18n/validation.d.js @@ -0,0 +1,6 @@ +/** + * Validates i18n config at plugin initialization. + * Throws descriptive errors — fail fast, no silent defaults. + */ export { }; + +//# sourceMappingURL=validation.d.js.map \ No newline at end of file diff --git a/dist/modules/i18n/validation.d.js.map b/dist/modules/i18n/validation.d.js.map new file mode 100644 index 0000000..838e443 --- /dev/null +++ b/dist/modules/i18n/validation.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["validation.d.ts"],"sourcesContent":["import type { I18nConfig } from './types.js';\n/**\n * Validates i18n config at plugin initialization.\n * Throws descriptive errors — fail fast, no silent defaults.\n */\nexport declare function validateI18nConfig(config: I18nConfig): void;\n"],"names":[],"mappings":"AACA;;;CAGC,GACD,WAAqE"} \ No newline at end of file diff --git a/dist/modules/i18n/validation.d.ts b/dist/modules/i18n/validation.d.ts new file mode 100644 index 0000000..90734bc --- /dev/null +++ b/dist/modules/i18n/validation.d.ts @@ -0,0 +1,6 @@ +import type { I18nConfig } from './types.js'; +/** + * Validates i18n config at plugin initialization. + * Throws descriptive errors — fail fast, no silent defaults. + */ +export declare function validateI18nConfig(config: I18nConfig): void; diff --git a/dist/modules/i18n/validation.js b/dist/modules/i18n/validation.js new file mode 100644 index 0000000..0f82961 --- /dev/null +++ b/dist/modules/i18n/validation.js @@ -0,0 +1,30 @@ +const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/; +/** + * Validates i18n config at plugin initialization. + * Throws descriptive errors — fail fast, no silent defaults. + */ export function validateI18nConfig(config) { + const { defaultLocale, locales } = config; + if (!locales?.length) { + throw new Error('[ipal] i18n: "locales" must contain at least one locale.'); + } + const codes = new Set(); + for (const locale of locales){ + if (!locale.code || !locale.label) { + throw new Error(`[ipal] i18n: Every locale must have "code" and "label". Received: ${JSON.stringify(locale)}`); + } + if (!LOCALE_CODE_PATTERN.test(locale.code)) { + throw new Error(`[ipal] i18n: Invalid locale code "${locale.code}". Expected format: "pl", "en", "pt-BR".`); + } + if (codes.has(locale.code)) { + throw new Error(`[ipal] i18n: Duplicate locale code "${locale.code}".`); + } + codes.add(locale.code); + } + if (!codes.has(defaultLocale)) { + throw new Error(`[ipal] i18n: defaultLocale "${defaultLocale}" not found in locales [${[ + ...codes + ].join(', ')}].`); + } +} + +//# sourceMappingURL=validation.js.map \ No newline at end of file diff --git a/dist/modules/i18n/validation.js.map b/dist/modules/i18n/validation.js.map new file mode 100644 index 0000000..e231400 --- /dev/null +++ b/dist/modules/i18n/validation.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/i18n/validation.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nconst LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/\n\n/**\n * Validates i18n config at plugin initialization.\n * Throws descriptive errors — fail fast, no silent defaults.\n */\nexport function validateI18nConfig(config: I18nConfig): void {\n const { defaultLocale, locales } = config\n\n if (!locales?.length) {\n throw new Error('[ipal] i18n: \"locales\" must contain at least one locale.')\n }\n\n const codes = new Set()\n\n for (const locale of locales) {\n if (!locale.code || !locale.label) {\n throw new Error(\n `[ipal] i18n: Every locale must have \"code\" and \"label\". Received: ${JSON.stringify(locale)}`,\n )\n }\n\n if (!LOCALE_CODE_PATTERN.test(locale.code)) {\n throw new Error(\n `[ipal] i18n: Invalid locale code \"${locale.code}\". Expected format: \"pl\", \"en\", \"pt-BR\".`,\n )\n }\n\n if (codes.has(locale.code)) {\n throw new Error(`[ipal] i18n: Duplicate locale code \"${locale.code}\".`)\n }\n\n codes.add(locale.code)\n }\n\n if (!codes.has(defaultLocale)) {\n throw new Error(\n `[ipal] i18n: defaultLocale \"${defaultLocale}\" not found in locales [${[...codes].join(', ')}].`,\n )\n }\n}\n"],"names":["LOCALE_CODE_PATTERN","validateI18nConfig","config","defaultLocale","locales","length","Error","codes","Set","locale","code","label","JSON","stringify","test","has","add","join"],"mappings":"AAEA,MAAMA,sBAAsB;AAE5B;;;CAGC,GACD,OAAO,SAASC,mBAAmBC,MAAkB;IACnD,MAAM,EAAEC,aAAa,EAAEC,OAAO,EAAE,GAAGF;IAEnC,IAAI,CAACE,SAASC,QAAQ;QACpB,MAAM,IAAIC,MAAM;IAClB;IAEA,MAAMC,QAAQ,IAAIC;IAElB,KAAK,MAAMC,UAAUL,QAAS;QAC5B,IAAI,CAACK,OAAOC,IAAI,IAAI,CAACD,OAAOE,KAAK,EAAE;YACjC,MAAM,IAAIL,MACR,CAAC,kEAAkE,EAAEM,KAAKC,SAAS,CAACJ,SAAS;QAEjG;QAEA,IAAI,CAACT,oBAAoBc,IAAI,CAACL,OAAOC,IAAI,GAAG;YAC1C,MAAM,IAAIJ,MACR,CAAC,kCAAkC,EAAEG,OAAOC,IAAI,CAAC,wCAAwC,CAAC;QAE9F;QAEA,IAAIH,MAAMQ,GAAG,CAACN,OAAOC,IAAI,GAAG;YAC1B,MAAM,IAAIJ,MAAM,CAAC,oCAAoC,EAAEG,OAAOC,IAAI,CAAC,EAAE,CAAC;QACxE;QAEAH,MAAMS,GAAG,CAACP,OAAOC,IAAI;IACvB;IAEA,IAAI,CAACH,MAAMQ,GAAG,CAACZ,gBAAgB;QAC7B,MAAM,IAAIG,MACR,CAAC,4BAA4B,EAAEH,cAAc,wBAAwB,EAAE;eAAII;SAAM,CAACU,IAAI,CAAC,MAAM,EAAE,CAAC;IAEpG;AACF"} \ No newline at end of file diff --git a/dist/modules/pages/getSystemPagePath.d.js b/dist/modules/pages/getSystemPagePath.d.js new file mode 100644 index 0000000..1b1542f --- /dev/null +++ b/dist/modules/pages/getSystemPagePath.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=getSystemPagePath.d.js.map \ No newline at end of file diff --git a/dist/modules/pages/getSystemPagePath.d.js.map b/dist/modules/pages/getSystemPagePath.d.js.map new file mode 100644 index 0000000..47c2f48 --- /dev/null +++ b/dist/modules/pages/getSystemPagePath.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getSystemPagePath.d.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js';\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record;\n};\ntype GetSystemPagePathArgs = {\n config: I18nConfig;\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string;\n /** Target locale to build the path for. */\n locale: string;\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined;\n};\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport declare function getSystemPagePath({ config, homeSlug, locale, page, }: GetSystemPagePathArgs): string | undefined;\nexport {};\n"],"names":[],"mappings":"AAgDA,WAAU"} \ No newline at end of file diff --git a/dist/modules/pages/getSystemPagePath.d.ts b/dist/modules/pages/getSystemPagePath.d.ts new file mode 100644 index 0000000..45532ec --- /dev/null +++ b/dist/modules/pages/getSystemPagePath.d.ts @@ -0,0 +1,49 @@ +import type { I18nConfig } from '../i18n/index.js'; +/** + * The shape we need from an assigned system-page document: its localized slug + * field, as returned by Payload when the parent is read with `locale: 'all'`. + * + * The plugin doesn't know the client's Pages type, so it depends only on this + * minimal contract. + */ +type AssignedPage = { + slug?: null | Record; +}; +type GetSystemPagePathArgs = { + config: I18nConfig; + /** + * Slug that represents the site root. Defaults to 'home'. When the assigned + * page's slug in the target locale equals this, the path collapses to the + * locale root (/pl, /en). + */ + homeSlug?: string; + /** Target locale to build the path for. */ + locale: string; + /** + * The resolved system-page assignment from SiteSettings — the related + * document object (not just an ID), read with `locale: 'all'` so its slug + * is a locale→value map. Pass null/undefined if the role is unassigned. + */ + page: AssignedPage | null | undefined; +}; +/** + * Resolves a system-page assignment to a locale-aware path. + * + * Bridges the Pages module (which document plays a role) and the i18n module + * (how that document's localized slug becomes a URL). This is what powers + * "visit /pl → serve the homepage": read SiteSettings.homepage, pass the + * related document here, get /pl/strona-glowna (or /pl if it's the home slug). + * + * Returns undefined when the role is unassigned or the assigned page has no + * slug in the target locale — the caller decides the fallback (e.g. 404, + * redirect to default locale). + * + * @example + * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 }) + * getSystemPagePath({ page: settings.homepage, locale: 'pl', config }) + * // → '/pl' (home slug collapses to root) + * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config }) + * // → '/en/privacy-policy' + */ +export declare function getSystemPagePath({ config, homeSlug, locale, page, }: GetSystemPagePathArgs): string | undefined; +export {}; diff --git a/dist/modules/pages/getSystemPagePath.js b/dist/modules/pages/getSystemPagePath.js new file mode 100644 index 0000000..2e69747 --- /dev/null +++ b/dist/modules/pages/getSystemPagePath.js @@ -0,0 +1,36 @@ +import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'; +/** + * Resolves a system-page assignment to a locale-aware path. + * + * Bridges the Pages module (which document plays a role) and the i18n module + * (how that document's localized slug becomes a URL). This is what powers + * "visit /pl → serve the homepage": read SiteSettings.homepage, pass the + * related document here, get /pl/strona-glowna (or /pl if it's the home slug). + * + * Returns undefined when the role is unassigned or the assigned page has no + * slug in the target locale — the caller decides the fallback (e.g. 404, + * redirect to default locale). + * + * @example + * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 }) + * getSystemPagePath({ page: settings.homepage, locale: 'pl', config }) + * // → '/pl' (home slug collapses to root) + * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config }) + * // → '/en/privacy-policy' + */ export function getSystemPagePath({ config, homeSlug = 'home', locale, page }) { + if (!page?.slug) { + return undefined; + } + const slugs = getLocalizedSlugs({ + config, + slugField: page.slug + }); + return buildLocalizedPath({ + config, + homeSlug, + locale, + slugs + }); +} + +//# sourceMappingURL=getSystemPagePath.js.map \ No newline at end of file diff --git a/dist/modules/pages/getSystemPagePath.js.map b/dist/modules/pages/getSystemPagePath.js.map new file mode 100644 index 0000000..5ca5cca --- /dev/null +++ b/dist/modules/pages/getSystemPagePath.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/pages/getSystemPagePath.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport type { SystemPageRole } from './types.js'\n\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * The shape we need from an assigned system-page document: its localized slug\n * field, as returned by Payload when the parent is read with `locale: 'all'`.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on this\n * minimal contract.\n */\ntype AssignedPage = {\n slug?: null | Record\n}\n\ntype GetSystemPagePathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root. Defaults to 'home'. When the assigned\n * page's slug in the target locale equals this, the path collapses to the\n * locale root (/pl, /en).\n */\n homeSlug?: string\n /** Target locale to build the path for. */\n locale: string\n /**\n * The resolved system-page assignment from SiteSettings — the related\n * document object (not just an ID), read with `locale: 'all'` so its slug\n * is a locale→value map. Pass null/undefined if the role is unassigned.\n */\n page: AssignedPage | null | undefined\n}\n\n/**\n * Resolves a system-page assignment to a locale-aware path.\n *\n * Bridges the Pages module (which document plays a role) and the i18n module\n * (how that document's localized slug becomes a URL). This is what powers\n * \"visit /pl → serve the homepage\": read SiteSettings.homepage, pass the\n * related document here, get /pl/strona-glowna (or /pl if it's the home slug).\n *\n * Returns undefined when the role is unassigned or the assigned page has no\n * slug in the target locale — the caller decides the fallback (e.g. 404,\n * redirect to default locale).\n *\n * @example\n * const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })\n * getSystemPagePath({ page: settings.homepage, locale: 'pl', config })\n * // → '/pl' (home slug collapses to root)\n * getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })\n * // → '/en/privacy-policy'\n */\nexport function getSystemPagePath({\n config,\n homeSlug = 'home',\n locale,\n page,\n}: GetSystemPagePathArgs): string | undefined {\n if (!page?.slug) {\n return undefined\n }\n\n const slugs = getLocalizedSlugs({ config, slugField: page.slug })\n return buildLocalizedPath({ config, homeSlug, locale, slugs })\n}\n"],"names":["buildLocalizedPath","getLocalizedSlugs","getSystemPagePath","config","homeSlug","locale","page","slug","undefined","slugs","slugField"],"mappings":"AAGA,SAASA,kBAAkB,EAAEC,iBAAiB,QAAQ,mBAAkB;AA+BxE;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkB,EAChCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,IAAI,EACkB;IACtB,IAAI,CAACA,MAAMC,MAAM;QACf,OAAOC;IACT;IAEA,MAAMC,QAAQR,kBAAkB;QAAEE;QAAQO,WAAWJ,KAAKC,IAAI;IAAC;IAC/D,OAAOP,mBAAmB;QAAEG;QAAQC;QAAUC;QAAQI;IAAM;AAC9D"} \ No newline at end of file diff --git a/dist/modules/pages/index.d.js b/dist/modules/pages/index.d.js new file mode 100644 index 0000000..d145377 --- /dev/null +++ b/dist/modules/pages/index.d.js @@ -0,0 +1,5 @@ +export { getSystemPagePath } from './getSystemPagePath.js'; +export { buildSystemPagesFields } from './systemPagesFields.js'; +export { ALL_SYSTEM_PAGE_ROLES } from './types.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/pages/index.d.js.map b/dist/modules/pages/index.d.js.map new file mode 100644 index 0000000..6eb71e9 --- /dev/null +++ b/dist/modules/pages/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { getSystemPagePath } from './getSystemPagePath.js';\nexport { buildSystemPagesFields } from './systemPagesFields.js';\nexport type { PagesOption, SystemPageRole } from './types.js';\nexport { ALL_SYSTEM_PAGE_ROLES } from './types.js';\n"],"names":["getSystemPagePath","buildSystemPagesFields","ALL_SYSTEM_PAGE_ROLES"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,yBAAyB;AAC3D,SAASC,sBAAsB,QAAQ,yBAAyB;AAEhE,SAASC,qBAAqB,QAAQ,aAAa"} \ No newline at end of file diff --git a/dist/modules/pages/index.d.ts b/dist/modules/pages/index.d.ts new file mode 100644 index 0000000..8f64cb1 --- /dev/null +++ b/dist/modules/pages/index.d.ts @@ -0,0 +1,4 @@ +export { getSystemPagePath } from './getSystemPagePath.js'; +export { buildSystemPagesFields } from './systemPagesFields.js'; +export type { PagesOption, SystemPageRole } from './types.js'; +export { ALL_SYSTEM_PAGE_ROLES } from './types.js'; diff --git a/dist/modules/pages/index.js b/dist/modules/pages/index.js new file mode 100644 index 0000000..1a4d929 --- /dev/null +++ b/dist/modules/pages/index.js @@ -0,0 +1,5 @@ +export { getSystemPagePath } from './getSystemPagePath.js'; +export { buildSystemPagesFields } from './systemPagesFields.js'; +export { ALL_SYSTEM_PAGE_ROLES } from './types.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/pages/index.js.map b/dist/modules/pages/index.js.map new file mode 100644 index 0000000..14e6426 --- /dev/null +++ b/dist/modules/pages/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/pages/index.ts"],"sourcesContent":["export { getSystemPagePath } from './getSystemPagePath.js'\nexport { buildSystemPagesFields } from './systemPagesFields.js'\nexport type { PagesOption, SystemPageRole } from './types.js'\nexport { ALL_SYSTEM_PAGE_ROLES } from './types.js'\n"],"names":["getSystemPagePath","buildSystemPagesFields","ALL_SYSTEM_PAGE_ROLES"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,yBAAwB;AAC1D,SAASC,sBAAsB,QAAQ,yBAAwB;AAE/D,SAASC,qBAAqB,QAAQ,aAAY"} \ No newline at end of file diff --git a/dist/modules/pages/systemPagesFields.d.js b/dist/modules/pages/systemPagesFields.d.js new file mode 100644 index 0000000..947a5c4 --- /dev/null +++ b/dist/modules/pages/systemPagesFields.d.js @@ -0,0 +1,11 @@ +/** + * Builds one relationship field per configured system-page role. + * + * Each field points at the client's Pages collection (via the provided slug) + * and holds a single document reference. The referenced document's slug is + * localized, so the same assignment resolves to /pl/strona-glowna and + * /en/home at runtime — role assignment is locale-agnostic, path resolution + * is locale-aware (see getSystemPagePath). + */ export { }; + +//# sourceMappingURL=systemPagesFields.d.js.map \ No newline at end of file diff --git a/dist/modules/pages/systemPagesFields.d.js.map b/dist/modules/pages/systemPagesFields.d.js.map new file mode 100644 index 0000000..263d480 --- /dev/null +++ b/dist/modules/pages/systemPagesFields.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["systemPagesFields.d.ts"],"sourcesContent":["import type { Field } from 'payload';\nimport type { PagesOption } from './types.js';\n/**\n * Builds one relationship field per configured system-page role.\n *\n * Each field points at the client's Pages collection (via the provided slug)\n * and holds a single document reference. The referenced document's slug is\n * localized, so the same assignment resolves to /pl/strona-glowna and\n * /en/home at runtime — role assignment is locale-agnostic, path resolution\n * is locale-aware (see getSystemPagePath).\n */\nexport declare function buildSystemPagesFields(pages: PagesOption): Field[];\n"],"names":[],"mappings":"AAEA;;;;;;;;CAQC,GACD,WAA4E"} \ No newline at end of file diff --git a/dist/modules/pages/systemPagesFields.d.ts b/dist/modules/pages/systemPagesFields.d.ts new file mode 100644 index 0000000..818f9ef --- /dev/null +++ b/dist/modules/pages/systemPagesFields.d.ts @@ -0,0 +1,12 @@ +import type { Field } from 'payload'; +import type { PagesOption } from './types.js'; +/** + * Builds one relationship field per configured system-page role. + * + * Each field points at the client's Pages collection (via the provided slug) + * and holds a single document reference. The referenced document's slug is + * localized, so the same assignment resolves to /pl/strona-glowna and + * /en/home at runtime — role assignment is locale-agnostic, path resolution + * is locale-aware (see getSystemPagePath). + */ +export declare function buildSystemPagesFields(pages: PagesOption): Field[]; diff --git a/dist/modules/pages/systemPagesFields.js b/dist/modules/pages/systemPagesFields.js new file mode 100644 index 0000000..8ce13d4 --- /dev/null +++ b/dist/modules/pages/systemPagesFields.js @@ -0,0 +1,36 @@ +import { ALL_SYSTEM_PAGE_ROLES } from './types.js'; +/** Admin-facing labels per role. */ const ROLE_LABELS = { + cookiePolicy: 'Cookie Policy', + homepage: 'Homepage', + privacyPolicy: 'Privacy Policy' +}; +/** Admin descriptions per role. */ const ROLE_DESCRIPTIONS = { + cookiePolicy: 'Page linked from the cookie consent banner.', + homepage: 'Page served at the locale root (e.g. /pl, /en).', + privacyPolicy: 'Page linked as the privacy policy.' +}; +/** + * Builds one relationship field per configured system-page role. + * + * Each field points at the client's Pages collection (via the provided slug) + * and holds a single document reference. The referenced document's slug is + * localized, so the same assignment resolves to /pl/strona-glowna and + * /en/home at runtime — role assignment is locale-agnostic, path resolution + * is locale-aware (see getSystemPagePath). + */ export function buildSystemPagesFields(pages) { + const roles = pages.roles?.length ? pages.roles : [ + ...ALL_SYSTEM_PAGE_ROLES + ]; + return roles.map((role)=>({ + name: role, + type: 'relationship', + admin: { + description: ROLE_DESCRIPTIONS[role] + }, + label: ROLE_LABELS[role], + maxDepth: 1, + relationTo: pages.slug + })); +} + +//# sourceMappingURL=systemPagesFields.js.map \ No newline at end of file diff --git a/dist/modules/pages/systemPagesFields.js.map b/dist/modules/pages/systemPagesFields.js.map new file mode 100644 index 0000000..c8cfe91 --- /dev/null +++ b/dist/modules/pages/systemPagesFields.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/pages/systemPagesFields.ts"],"sourcesContent":["import type { Field, RelationshipField } from 'payload'\n\nimport type { PagesOption, SystemPageRole } from './types.js'\n\nimport { ALL_SYSTEM_PAGE_ROLES } from './types.js'\n\n/** Admin-facing labels per role. */\nconst ROLE_LABELS: Record = {\n cookiePolicy: 'Cookie Policy',\n homepage: 'Homepage',\n privacyPolicy: 'Privacy Policy',\n}\n\n/** Admin descriptions per role. */\nconst ROLE_DESCRIPTIONS: Record = {\n cookiePolicy: 'Page linked from the cookie consent banner.',\n homepage: 'Page served at the locale root (e.g. /pl, /en).',\n privacyPolicy: 'Page linked as the privacy policy.',\n}\n\n/**\n * Builds one relationship field per configured system-page role.\n *\n * Each field points at the client's Pages collection (via the provided slug)\n * and holds a single document reference. The referenced document's slug is\n * localized, so the same assignment resolves to /pl/strona-glowna and\n * /en/home at runtime — role assignment is locale-agnostic, path resolution\n * is locale-aware (see getSystemPagePath).\n */\nexport function buildSystemPagesFields(pages: PagesOption): Field[] {\n const roles = pages.roles?.length ? pages.roles : [...ALL_SYSTEM_PAGE_ROLES]\n\n return roles.map((role): RelationshipField => ({\n name: role,\n type: 'relationship',\n admin: {\n description: ROLE_DESCRIPTIONS[role],\n },\n label: ROLE_LABELS[role],\n maxDepth: 1,\n relationTo: pages.slug,\n }))\n}\n"],"names":["ALL_SYSTEM_PAGE_ROLES","ROLE_LABELS","cookiePolicy","homepage","privacyPolicy","ROLE_DESCRIPTIONS","buildSystemPagesFields","pages","roles","length","map","role","name","type","admin","description","label","maxDepth","relationTo","slug"],"mappings":"AAIA,SAASA,qBAAqB,QAAQ,aAAY;AAElD,kCAAkC,GAClC,MAAMC,cAA8C;IAClDC,cAAc;IACdC,UAAU;IACVC,eAAe;AACjB;AAEA,iCAAiC,GACjC,MAAMC,oBAAoD;IACxDH,cAAc;IACdC,UAAU;IACVC,eAAe;AACjB;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,uBAAuBC,KAAkB;IACvD,MAAMC,QAAQD,MAAMC,KAAK,EAAEC,SAASF,MAAMC,KAAK,GAAG;WAAIR;KAAsB;IAE5E,OAAOQ,MAAME,GAAG,CAAC,CAACC,OAA6B,CAAA;YAC7CC,MAAMD;YACNE,MAAM;YACNC,OAAO;gBACLC,aAAaV,iBAAiB,CAACM,KAAK;YACtC;YACAK,OAAOf,WAAW,CAACU,KAAK;YACxBM,UAAU;YACVC,YAAYX,MAAMY,IAAI;QACxB,CAAA;AACF"} \ No newline at end of file diff --git a/dist/modules/pages/types.d.js b/dist/modules/pages/types.d.js new file mode 100644 index 0000000..3b4a70d --- /dev/null +++ b/dist/modules/pages/types.d.js @@ -0,0 +1,13 @@ +/** + * Built-in "system page" roles — documents from the client's Pages + * collection that the site references by function rather than by slug. + */ /** + * Plugin option enabling system-page assignments in SiteSettings. + * + * The plugin doesn't own the Pages collection — the client creates it in + * their own project. This option hands the plugin the collection's slug so + * it can build relationship fields pointing at it. No hardcoding: the slug + * always comes from the client. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/pages/types.d.js.map b/dist/modules/pages/types.d.js.map new file mode 100644 index 0000000..28b5c46 --- /dev/null +++ b/dist/modules/pages/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["/**\n * Built-in \"system page\" roles — documents from the client's Pages\n * collection that the site references by function rather than by slug.\n */\nexport type SystemPageRole = 'cookiePolicy' | 'homepage' | 'privacyPolicy';\n/**\n * All system roles in a stable order (used when no subset is configured).\n */\nexport declare const ALL_SYSTEM_PAGE_ROLES: readonly SystemPageRole[];\n/**\n * Plugin option enabling system-page assignments in SiteSettings.\n *\n * The plugin doesn't own the Pages collection — the client creates it in\n * their own project. This option hands the plugin the collection's slug so\n * it can build relationship fields pointing at it. No hardcoding: the slug\n * always comes from the client.\n */\nexport type PagesOption = {\n /**\n * Which system roles to expose as assignable fields.\n * Defaults to all roles when omitted.\n */\n roles?: SystemPageRole[];\n /** Slug of the client's Pages collection, e.g. 'pages'. */\n slug: string;\n};\n"],"names":[],"mappings":"AAAA;;;CAGC,GAMD;;;;;;;CAOC,GACD,WAQE"} \ No newline at end of file diff --git a/dist/modules/pages/types.d.ts b/dist/modules/pages/types.d.ts new file mode 100644 index 0000000..9b62a00 --- /dev/null +++ b/dist/modules/pages/types.d.ts @@ -0,0 +1,26 @@ +/** + * Built-in "system page" roles — documents from the client's Pages + * collection that the site references by function rather than by slug. + */ +export type SystemPageRole = 'cookiePolicy' | 'homepage' | 'privacyPolicy'; +/** + * All system roles in a stable order (used when no subset is configured). + */ +export declare const ALL_SYSTEM_PAGE_ROLES: readonly SystemPageRole[]; +/** + * Plugin option enabling system-page assignments in SiteSettings. + * + * The plugin doesn't own the Pages collection — the client creates it in + * their own project. This option hands the plugin the collection's slug so + * it can build relationship fields pointing at it. No hardcoding: the slug + * always comes from the client. + */ +export type PagesOption = { + /** + * Which system roles to expose as assignable fields. + * Defaults to all roles when omitted. + */ + roles?: SystemPageRole[]; + /** Slug of the client's Pages collection, e.g. 'pages'. */ + slug: string; +}; diff --git a/dist/modules/pages/types.js b/dist/modules/pages/types.js new file mode 100644 index 0000000..c29e5d6 --- /dev/null +++ b/dist/modules/pages/types.js @@ -0,0 +1,12 @@ +/** + * Built-in "system page" roles — documents from the client's Pages + * collection that the site references by function rather than by slug. + */ /** + * All system roles in a stable order (used when no subset is configured). + */ export const ALL_SYSTEM_PAGE_ROLES = [ + 'homepage', + 'privacyPolicy', + 'cookiePolicy' +]; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/pages/types.js.map b/dist/modules/pages/types.js.map new file mode 100644 index 0000000..ae8ef81 --- /dev/null +++ b/dist/modules/pages/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/pages/types.ts"],"sourcesContent":["/**\n * Built-in \"system page\" roles — documents from the client's Pages\n * collection that the site references by function rather than by slug.\n */\nexport type SystemPageRole = 'cookiePolicy' | 'homepage' | 'privacyPolicy'\n\n/**\n * All system roles in a stable order (used when no subset is configured).\n */\nexport const ALL_SYSTEM_PAGE_ROLES: readonly SystemPageRole[] = [\n 'homepage',\n 'privacyPolicy',\n 'cookiePolicy',\n]\n\n/**\n * Plugin option enabling system-page assignments in SiteSettings.\n *\n * The plugin doesn't own the Pages collection — the client creates it in\n * their own project. This option hands the plugin the collection's slug so\n * it can build relationship fields pointing at it. No hardcoding: the slug\n * always comes from the client.\n */\nexport type PagesOption = {\n /**\n * Which system roles to expose as assignable fields.\n * Defaults to all roles when omitted.\n */\n roles?: SystemPageRole[]\n /** Slug of the client's Pages collection, e.g. 'pages'. */\n slug: string\n}\n"],"names":["ALL_SYSTEM_PAGE_ROLES"],"mappings":"AAAA;;;CAGC,GAGD;;CAEC,GACD,OAAO,MAAMA,wBAAmD;IAC9D;IACA;IACA;CACD,CAAA"} \ No newline at end of file diff --git a/dist/modules/payload/getGlobal.d.js b/dist/modules/payload/getGlobal.d.js new file mode 100644 index 0000000..ef9988a --- /dev/null +++ b/dist/modules/payload/getGlobal.d.js @@ -0,0 +1,8 @@ +/** + * Thin wrapper over payload.findGlobal that keeps locale/depth handling in one + * place. The plugin never calls getPayload itself — the caller passes the + * instance (from getPayload in their app, or req.payload in a hook), matching + * the plugin's data-access rule: logic here, instance from outside. + */ export { }; + +//# sourceMappingURL=getGlobal.d.js.map \ No newline at end of file diff --git a/dist/modules/payload/getGlobal.d.js.map b/dist/modules/payload/getGlobal.d.js.map new file mode 100644 index 0000000..c6e0f85 --- /dev/null +++ b/dist/modules/payload/getGlobal.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getGlobal.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\n/** Locale argument accepted by the global helpers. */\nexport type GlobalQueryOptions = {\n /** Relationship population depth. Defaults to Payload's config default. */\n depth?: number;\n /** Locale to fetch, or 'all' for every locale's values. Defaults to Payload's default. */\n locale?: string;\n};\n/**\n * Thin wrapper over payload.findGlobal that keeps locale/depth handling in one\n * place. The plugin never calls getPayload itself — the caller passes the\n * instance (from getPayload in their app, or req.payload in a hook), matching\n * the plugin's data-access rule: logic here, instance from outside.\n */\nexport declare function getGlobal>(payload: BasePayload, slug: string, options?: GlobalQueryOptions): Promise;\n"],"names":[],"mappings":"AAQA;;;;;CAKC,GACD,WAA6I"} \ No newline at end of file diff --git a/dist/modules/payload/getGlobal.d.ts b/dist/modules/payload/getGlobal.d.ts new file mode 100644 index 0000000..c3e6be2 --- /dev/null +++ b/dist/modules/payload/getGlobal.d.ts @@ -0,0 +1,15 @@ +import type { BasePayload } from 'payload'; +/** Locale argument accepted by the global helpers. */ +export type GlobalQueryOptions = { + /** Relationship population depth. Defaults to Payload's config default. */ + depth?: number; + /** Locale to fetch, or 'all' for every locale's values. Defaults to Payload's default. */ + locale?: string; +}; +/** + * Thin wrapper over payload.findGlobal that keeps locale/depth handling in one + * place. The plugin never calls getPayload itself — the caller passes the + * instance (from getPayload in their app, or req.payload in a hook), matching + * the plugin's data-access rule: logic here, instance from outside. + */ +export declare function getGlobal>(payload: BasePayload, slug: string, options?: GlobalQueryOptions): Promise; diff --git a/dist/modules/payload/getGlobal.js b/dist/modules/payload/getGlobal.js new file mode 100644 index 0000000..4f4dcbf --- /dev/null +++ b/dist/modules/payload/getGlobal.js @@ -0,0 +1,19 @@ +/** + * Thin wrapper over payload.findGlobal that keeps locale/depth handling in one + * place. The plugin never calls getPayload itself — the caller passes the + * instance (from getPayload in their app, or req.payload in a hook), matching + * the plugin's data-access rule: logic here, instance from outside. + */ export async function getGlobal(payload, slug, options = {}) { + const result = await payload.findGlobal({ + slug, + ...options.locale ? { + locale: options.locale + } : {}, + ...typeof options.depth === 'number' ? { + depth: options.depth + } : {} + }); + return result; +} + +//# sourceMappingURL=getGlobal.js.map \ No newline at end of file diff --git a/dist/modules/payload/getGlobal.js.map b/dist/modules/payload/getGlobal.js.map new file mode 100644 index 0000000..f2be8f4 --- /dev/null +++ b/dist/modules/payload/getGlobal.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/payload/getGlobal.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** Locale argument accepted by the global helpers. */\nexport type GlobalQueryOptions = {\n /** Relationship population depth. Defaults to Payload's config default. */\n depth?: number\n /** Locale to fetch, or 'all' for every locale's values. Defaults to Payload's default. */\n locale?: string\n}\n\n/**\n * Thin wrapper over payload.findGlobal that keeps locale/depth handling in one\n * place. The plugin never calls getPayload itself — the caller passes the\n * instance (from getPayload in their app, or req.payload in a hook), matching\n * the plugin's data-access rule: logic here, instance from outside.\n */\nexport async function getGlobal>(\n payload: BasePayload,\n slug: string,\n options: GlobalQueryOptions = {},\n): Promise {\n const result = await payload.findGlobal({\n slug,\n ...(options.locale ? { locale: options.locale as never } : {}),\n ...(typeof options.depth === 'number' ? { depth: options.depth } : {}),\n })\n\n return result as T\n}\n"],"names":["getGlobal","payload","slug","options","result","findGlobal","locale","depth"],"mappings":"AAUA;;;;;CAKC,GACD,OAAO,eAAeA,UACpBC,OAAoB,EACpBC,IAAY,EACZC,UAA8B,CAAC,CAAC;IAEhC,MAAMC,SAAS,MAAMH,QAAQI,UAAU,CAAC;QACtCH;QACA,GAAIC,QAAQG,MAAM,GAAG;YAAEA,QAAQH,QAAQG,MAAM;QAAU,IAAI,CAAC,CAAC;QAC7D,GAAI,OAAOH,QAAQI,KAAK,KAAK,WAAW;YAAEA,OAAOJ,QAAQI,KAAK;QAAC,IAAI,CAAC,CAAC;IACvE;IAEA,OAAOH;AACT"} \ No newline at end of file diff --git a/dist/modules/payload/getSiteIntegrations.d.js b/dist/modules/payload/getSiteIntegrations.d.js new file mode 100644 index 0000000..a952372 --- /dev/null +++ b/dist/modules/payload/getSiteIntegrations.d.js @@ -0,0 +1,18 @@ +/** + * Fetches the SiteIntegrations global. + * + * This global is admin-only through access control, but the Local API bypasses + * access control by default (overrideAccess: true), so server-side callers get + * the secrets they need (SMTP password, Turnstile secret, R2 keys). Never + * expose the raw result to the client — read the specific values you need + * server-side and pass only what's safe to the browser. + * + * Generic over the return type so the client can pass their generated + * `SiteIntegration` type. + * + * @example + * import type { SiteIntegration } from '@/payload-types' + * const integrations = await getSiteIntegrations(payload) + */ export { }; + +//# sourceMappingURL=getSiteIntegrations.d.js.map \ No newline at end of file diff --git a/dist/modules/payload/getSiteIntegrations.d.js.map b/dist/modules/payload/getSiteIntegrations.d.js.map new file mode 100644 index 0000000..401c9e2 --- /dev/null +++ b/dist/modules/payload/getSiteIntegrations.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getSiteIntegrations.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { GlobalQueryOptions } from './getGlobal.js';\n/** Slug of the SiteIntegrations global defined by the plugin. */\nexport declare const SITE_INTEGRATIONS_SLUG = \"site-integrations\";\n/**\n * Fetches the SiteIntegrations global.\n *\n * This global is admin-only through access control, but the Local API bypasses\n * access control by default (overrideAccess: true), so server-side callers get\n * the secrets they need (SMTP password, Turnstile secret, R2 keys). Never\n * expose the raw result to the client — read the specific values you need\n * server-side and pass only what's safe to the browser.\n *\n * Generic over the return type so the client can pass their generated\n * `SiteIntegration` type.\n *\n * @example\n * import type { SiteIntegration } from '@/payload-types'\n * const integrations = await getSiteIntegrations(payload)\n */\nexport declare function getSiteIntegrations>(payload: BasePayload, options?: GlobalQueryOptions): Promise;\n"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;CAeC,GACD,WAAyI"} \ No newline at end of file diff --git a/dist/modules/payload/getSiteIntegrations.d.ts b/dist/modules/payload/getSiteIntegrations.d.ts new file mode 100644 index 0000000..a8c9d30 --- /dev/null +++ b/dist/modules/payload/getSiteIntegrations.d.ts @@ -0,0 +1,21 @@ +import type { BasePayload } from 'payload'; +import type { GlobalQueryOptions } from './getGlobal.js'; +/** Slug of the SiteIntegrations global defined by the plugin. */ +export declare const SITE_INTEGRATIONS_SLUG = "site-integrations"; +/** + * Fetches the SiteIntegrations global. + * + * This global is admin-only through access control, but the Local API bypasses + * access control by default (overrideAccess: true), so server-side callers get + * the secrets they need (SMTP password, Turnstile secret, R2 keys). Never + * expose the raw result to the client — read the specific values you need + * server-side and pass only what's safe to the browser. + * + * Generic over the return type so the client can pass their generated + * `SiteIntegration` type. + * + * @example + * import type { SiteIntegration } from '@/payload-types' + * const integrations = await getSiteIntegrations(payload) + */ +export declare function getSiteIntegrations>(payload: BasePayload, options?: GlobalQueryOptions): Promise; diff --git a/dist/modules/payload/getSiteIntegrations.js b/dist/modules/payload/getSiteIntegrations.js new file mode 100644 index 0000000..e8b85ae --- /dev/null +++ b/dist/modules/payload/getSiteIntegrations.js @@ -0,0 +1,22 @@ +import { getGlobal } from './getGlobal.js'; +/** Slug of the SiteIntegrations global defined by the plugin. */ export const SITE_INTEGRATIONS_SLUG = 'site-integrations'; +/** + * Fetches the SiteIntegrations global. + * + * This global is admin-only through access control, but the Local API bypasses + * access control by default (overrideAccess: true), so server-side callers get + * the secrets they need (SMTP password, Turnstile secret, R2 keys). Never + * expose the raw result to the client — read the specific values you need + * server-side and pass only what's safe to the browser. + * + * Generic over the return type so the client can pass their generated + * `SiteIntegration` type. + * + * @example + * import type { SiteIntegration } from '@/payload-types' + * const integrations = await getSiteIntegrations(payload) + */ export function getSiteIntegrations(payload, options) { + return getGlobal(payload, SITE_INTEGRATIONS_SLUG, options); +} + +//# sourceMappingURL=getSiteIntegrations.js.map \ No newline at end of file diff --git a/dist/modules/payload/getSiteIntegrations.js.map b/dist/modules/payload/getSiteIntegrations.js.map new file mode 100644 index 0000000..63c6b31 --- /dev/null +++ b/dist/modules/payload/getSiteIntegrations.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/payload/getSiteIntegrations.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { GlobalQueryOptions } from './getGlobal.js'\n\nimport { getGlobal } from './getGlobal.js'\n\n/** Slug of the SiteIntegrations global defined by the plugin. */\nexport const SITE_INTEGRATIONS_SLUG = 'site-integrations'\n\n/**\n * Fetches the SiteIntegrations global.\n *\n * This global is admin-only through access control, but the Local API bypasses\n * access control by default (overrideAccess: true), so server-side callers get\n * the secrets they need (SMTP password, Turnstile secret, R2 keys). Never\n * expose the raw result to the client — read the specific values you need\n * server-side and pass only what's safe to the browser.\n *\n * Generic over the return type so the client can pass their generated\n * `SiteIntegration` type.\n *\n * @example\n * import type { SiteIntegration } from '@/payload-types'\n * const integrations = await getSiteIntegrations(payload)\n */\nexport function getSiteIntegrations>(\n payload: BasePayload,\n options?: GlobalQueryOptions,\n): Promise {\n return getGlobal(payload, SITE_INTEGRATIONS_SLUG, options)\n}\n"],"names":["getGlobal","SITE_INTEGRATIONS_SLUG","getSiteIntegrations","payload","options"],"mappings":"AAIA,SAASA,SAAS,QAAQ,iBAAgB;AAE1C,+DAA+D,GAC/D,OAAO,MAAMC,yBAAyB,oBAAmB;AAEzD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,oBACdC,OAAoB,EACpBC,OAA4B;IAE5B,OAAOJ,UAAaG,SAASF,wBAAwBG;AACvD"} \ No newline at end of file diff --git a/dist/modules/payload/getSiteSettings.d.js b/dist/modules/payload/getSiteSettings.d.js new file mode 100644 index 0000000..8e9a9d1 --- /dev/null +++ b/dist/modules/payload/getSiteSettings.d.js @@ -0,0 +1,12 @@ +/** + * Fetches the SiteSettings global. + * + * Generic over the return type so the client can pass their generated + * `SiteSetting` type for full type safety, while still working without it. + * + * @example + * import type { SiteSetting } from '@/payload-types' + * const settings = await getSiteSettings(payload, { locale: 'pl' }) + */ export { }; + +//# sourceMappingURL=getSiteSettings.d.js.map \ No newline at end of file diff --git a/dist/modules/payload/getSiteSettings.d.js.map b/dist/modules/payload/getSiteSettings.d.js.map new file mode 100644 index 0000000..c67b4be --- /dev/null +++ b/dist/modules/payload/getSiteSettings.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["getSiteSettings.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { GlobalQueryOptions } from './getGlobal.js';\n/** Slug of the SiteSettings global defined by the plugin. */\nexport declare const SITE_SETTINGS_SLUG = \"site-settings\";\n/**\n * Fetches the SiteSettings global.\n *\n * Generic over the return type so the client can pass their generated\n * `SiteSetting` type for full type safety, while still working without it.\n *\n * @example\n * import type { SiteSetting } from '@/payload-types'\n * const settings = await getSiteSettings(payload, { locale: 'pl' })\n */\nexport declare function getSiteSettings>(payload: BasePayload, options?: GlobalQueryOptions): Promise;\n"],"names":[],"mappings":"AAIA;;;;;;;;;CASC,GACD,WAAqI"} \ No newline at end of file diff --git a/dist/modules/payload/getSiteSettings.d.ts b/dist/modules/payload/getSiteSettings.d.ts new file mode 100644 index 0000000..56f84f1 --- /dev/null +++ b/dist/modules/payload/getSiteSettings.d.ts @@ -0,0 +1,15 @@ +import type { BasePayload } from 'payload'; +import type { GlobalQueryOptions } from './getGlobal.js'; +/** Slug of the SiteSettings global defined by the plugin. */ +export declare const SITE_SETTINGS_SLUG = "site-settings"; +/** + * Fetches the SiteSettings global. + * + * Generic over the return type so the client can pass their generated + * `SiteSetting` type for full type safety, while still working without it. + * + * @example + * import type { SiteSetting } from '@/payload-types' + * const settings = await getSiteSettings(payload, { locale: 'pl' }) + */ +export declare function getSiteSettings>(payload: BasePayload, options?: GlobalQueryOptions): Promise; diff --git a/dist/modules/payload/getSiteSettings.js b/dist/modules/payload/getSiteSettings.js new file mode 100644 index 0000000..bbea669 --- /dev/null +++ b/dist/modules/payload/getSiteSettings.js @@ -0,0 +1,16 @@ +import { getGlobal } from './getGlobal.js'; +/** Slug of the SiteSettings global defined by the plugin. */ export const SITE_SETTINGS_SLUG = 'site-settings'; +/** + * Fetches the SiteSettings global. + * + * Generic over the return type so the client can pass their generated + * `SiteSetting` type for full type safety, while still working without it. + * + * @example + * import type { SiteSetting } from '@/payload-types' + * const settings = await getSiteSettings(payload, { locale: 'pl' }) + */ export function getSiteSettings(payload, options) { + return getGlobal(payload, SITE_SETTINGS_SLUG, options); +} + +//# sourceMappingURL=getSiteSettings.js.map \ No newline at end of file diff --git a/dist/modules/payload/getSiteSettings.js.map b/dist/modules/payload/getSiteSettings.js.map new file mode 100644 index 0000000..daeec8f --- /dev/null +++ b/dist/modules/payload/getSiteSettings.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/payload/getSiteSettings.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { GlobalQueryOptions } from './getGlobal.js'\n\nimport { getGlobal } from './getGlobal.js'\n\n/** Slug of the SiteSettings global defined by the plugin. */\nexport const SITE_SETTINGS_SLUG = 'site-settings'\n\n/**\n * Fetches the SiteSettings global.\n *\n * Generic over the return type so the client can pass their generated\n * `SiteSetting` type for full type safety, while still working without it.\n *\n * @example\n * import type { SiteSetting } from '@/payload-types'\n * const settings = await getSiteSettings(payload, { locale: 'pl' })\n */\nexport function getSiteSettings>(\n payload: BasePayload,\n options?: GlobalQueryOptions,\n): Promise {\n return getGlobal(payload, SITE_SETTINGS_SLUG, options)\n}\n"],"names":["getGlobal","SITE_SETTINGS_SLUG","getSiteSettings","payload","options"],"mappings":"AAIA,SAASA,SAAS,QAAQ,iBAAgB;AAE1C,2DAA2D,GAC3D,OAAO,MAAMC,qBAAqB,gBAAe;AAEjD;;;;;;;;;CASC,GACD,OAAO,SAASC,gBACdC,OAAoB,EACpBC,OAA4B;IAE5B,OAAOJ,UAAaG,SAASF,oBAAoBG;AACnD"} \ No newline at end of file diff --git a/dist/modules/payload/index.d.js b/dist/modules/payload/index.d.js new file mode 100644 index 0000000..ec52723 --- /dev/null +++ b/dist/modules/payload/index.d.js @@ -0,0 +1,5 @@ +export { getGlobal } from './getGlobal.js'; +export { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'; +export { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/payload/index.d.js.map b/dist/modules/payload/index.d.js.map new file mode 100644 index 0000000..852a1bb --- /dev/null +++ b/dist/modules/payload/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export type { GlobalQueryOptions } from './getGlobal.js';\nexport { getGlobal } from './getGlobal.js';\nexport { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js';\nexport { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js';\n"],"names":["getGlobal","getSiteIntegrations","SITE_INTEGRATIONS_SLUG","getSiteSettings","SITE_SETTINGS_SLUG"],"mappings":"AACA,SAASA,SAAS,QAAQ,iBAAiB;AAC3C,SAASC,mBAAmB,EAAEC,sBAAsB,QAAQ,2BAA2B;AACvF,SAASC,eAAe,EAAEC,kBAAkB,QAAQ,uBAAuB"} \ No newline at end of file diff --git a/dist/modules/payload/index.d.ts b/dist/modules/payload/index.d.ts new file mode 100644 index 0000000..ece927a --- /dev/null +++ b/dist/modules/payload/index.d.ts @@ -0,0 +1,4 @@ +export type { GlobalQueryOptions } from './getGlobal.js'; +export { getGlobal } from './getGlobal.js'; +export { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'; +export { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'; diff --git a/dist/modules/payload/index.js b/dist/modules/payload/index.js new file mode 100644 index 0000000..f85eef0 --- /dev/null +++ b/dist/modules/payload/index.js @@ -0,0 +1,5 @@ +export { getGlobal } from './getGlobal.js'; +export { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'; +export { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/payload/index.js.map b/dist/modules/payload/index.js.map new file mode 100644 index 0000000..46813a1 --- /dev/null +++ b/dist/modules/payload/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/payload/index.ts"],"sourcesContent":["export type { GlobalQueryOptions } from './getGlobal.js'\nexport { getGlobal } from './getGlobal.js'\nexport { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'\nexport { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'\n"],"names":["getGlobal","getSiteIntegrations","SITE_INTEGRATIONS_SLUG","getSiteSettings","SITE_SETTINGS_SLUG"],"mappings":"AACA,SAASA,SAAS,QAAQ,iBAAgB;AAC1C,SAASC,mBAAmB,EAAEC,sBAAsB,QAAQ,2BAA0B;AACtF,SAASC,eAAe,EAAEC,kBAAkB,QAAQ,uBAAsB"} \ No newline at end of file diff --git a/dist/modules/seo/autoFillMeta.d.js b/dist/modules/seo/autoFillMeta.d.js new file mode 100644 index 0000000..4a1aea6 --- /dev/null +++ b/dist/modules/seo/autoFillMeta.d.js @@ -0,0 +1,11 @@ +/** + * Builds a beforeChange hook that fills empty SEO meta fields from document + * content. Only fills when the meta field is blank — never overwrites what an + * editor typed. This runs server-side on every create/update, so editors get + * sensible meta without clicking "auto-generate". + * + * The plugin doesn't know the client's collection shape, so the field mapping + * is configurable; defaults follow the website template. + */ export { }; + +//# sourceMappingURL=autoFillMeta.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/autoFillMeta.d.js.map b/dist/modules/seo/autoFillMeta.d.js.map new file mode 100644 index 0000000..3354528 --- /dev/null +++ b/dist/modules/seo/autoFillMeta.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["autoFillMeta.d.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload';\n/**\n * Maps document fields to SEO meta fields for auto-fill.\n * Defaults match the Payload website template (title → meta.title).\n */\nexport type AutoFillMapping = {\n /** Document field used to fill meta.description when empty. */\n description?: string;\n /** Document field used to fill meta.title when empty. Default: 'title'. */\n title?: string;\n};\n/**\n * Builds a beforeChange hook that fills empty SEO meta fields from document\n * content. Only fills when the meta field is blank — never overwrites what an\n * editor typed. This runs server-side on every create/update, so editors get\n * sensible meta without clicking \"auto-generate\".\n *\n * The plugin doesn't know the client's collection shape, so the field mapping\n * is configurable; defaults follow the website template.\n */\nexport declare function buildAutoFillMetaHook(mapping?: AutoFillMapping): CollectionBeforeChangeHook;\n"],"names":[],"mappings":"AAWA;;;;;;;;CAQC,GACD,WAAqG"} \ No newline at end of file diff --git a/dist/modules/seo/autoFillMeta.d.ts b/dist/modules/seo/autoFillMeta.d.ts new file mode 100644 index 0000000..e927d54 --- /dev/null +++ b/dist/modules/seo/autoFillMeta.d.ts @@ -0,0 +1,21 @@ +import type { CollectionBeforeChangeHook } from 'payload'; +/** + * Maps document fields to SEO meta fields for auto-fill. + * Defaults match the Payload website template (title → meta.title). + */ +export type AutoFillMapping = { + /** Document field used to fill meta.description when empty. */ + description?: string; + /** Document field used to fill meta.title when empty. Default: 'title'. */ + title?: string; +}; +/** + * Builds a beforeChange hook that fills empty SEO meta fields from document + * content. Only fills when the meta field is blank — never overwrites what an + * editor typed. This runs server-side on every create/update, so editors get + * sensible meta without clicking "auto-generate". + * + * The plugin doesn't know the client's collection shape, so the field mapping + * is configurable; defaults follow the website template. + */ +export declare function buildAutoFillMetaHook(mapping?: AutoFillMapping): CollectionBeforeChangeHook; diff --git a/dist/modules/seo/autoFillMeta.js b/dist/modules/seo/autoFillMeta.js new file mode 100644 index 0000000..5f548da --- /dev/null +++ b/dist/modules/seo/autoFillMeta.js @@ -0,0 +1,47 @@ +/** + * Reads a possibly-localized field value into a plain string. + * With localized fields at this hook stage the value is the active-locale + * string, so we just coerce defensively. + */ function readString(value) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + return undefined; +} +/** + * Builds a beforeChange hook that fills empty SEO meta fields from document + * content. Only fills when the meta field is blank — never overwrites what an + * editor typed. This runs server-side on every create/update, so editors get + * sensible meta without clicking "auto-generate". + * + * The plugin doesn't know the client's collection shape, so the field mapping + * is configurable; defaults follow the website template. + */ export function buildAutoFillMetaHook(mapping = {}) { + const titleField = mapping.title ?? 'title'; + const descriptionField = mapping.description; + return ({ data })=>{ + if (!data) { + return data; + } + // plugin-seo stores meta as a group under `meta` + const meta = data.meta ?? {}; + // Fill meta.title from the document title when empty + if (!readString(meta.title)) { + const sourceTitle = readString(data[titleField]); + if (sourceTitle) { + meta.title = sourceTitle; + } + } + // Fill meta.description from a configured field when empty + if (descriptionField && !readString(meta.description)) { + const sourceDescription = readString(data[descriptionField]); + if (sourceDescription) { + meta.description = sourceDescription; + } + } + data.meta = meta; + return data; + }; +} + +//# sourceMappingURL=autoFillMeta.js.map \ No newline at end of file diff --git a/dist/modules/seo/autoFillMeta.js.map b/dist/modules/seo/autoFillMeta.js.map new file mode 100644 index 0000000..3239280 --- /dev/null +++ b/dist/modules/seo/autoFillMeta.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/autoFillMeta.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\n/**\n * Maps document fields to SEO meta fields for auto-fill.\n * Defaults match the Payload website template (title → meta.title).\n */\nexport type AutoFillMapping = {\n /** Document field used to fill meta.description when empty. */\n description?: string\n /** Document field used to fill meta.title when empty. Default: 'title'. */\n title?: string\n}\n\n/**\n * Reads a possibly-localized field value into a plain string.\n * With localized fields at this hook stage the value is the active-locale\n * string, so we just coerce defensively.\n */\nfunction readString(value: unknown): string | undefined {\n if (typeof value === 'string' && value.trim()) {return value.trim()}\n return undefined\n}\n\n/**\n * Builds a beforeChange hook that fills empty SEO meta fields from document\n * content. Only fills when the meta field is blank — never overwrites what an\n * editor typed. This runs server-side on every create/update, so editors get\n * sensible meta without clicking \"auto-generate\".\n *\n * The plugin doesn't know the client's collection shape, so the field mapping\n * is configurable; defaults follow the website template.\n */\nexport function buildAutoFillMetaHook(mapping: AutoFillMapping = {}): CollectionBeforeChangeHook {\n const titleField = mapping.title ?? 'title'\n const descriptionField = mapping.description\n\n return ({ data }) => {\n if (!data) {return data}\n\n // plugin-seo stores meta as a group under `meta`\n const meta = (data.meta as Record | undefined) ?? {}\n\n // Fill meta.title from the document title when empty\n if (!readString(meta.title)) {\n const sourceTitle = readString(data[titleField])\n if (sourceTitle) {meta.title = sourceTitle}\n }\n\n // Fill meta.description from a configured field when empty\n if (descriptionField && !readString(meta.description)) {\n const sourceDescription = readString(data[descriptionField])\n if (sourceDescription) {meta.description = sourceDescription}\n }\n\n data.meta = meta\n return data\n }\n}\n"],"names":["readString","value","trim","undefined","buildAutoFillMetaHook","mapping","titleField","title","descriptionField","description","data","meta","sourceTitle","sourceDescription"],"mappings":"AAaA;;;;CAIC,GACD,SAASA,WAAWC,KAAc;IAChC,IAAI,OAAOA,UAAU,YAAYA,MAAMC,IAAI,IAAI;QAAC,OAAOD,MAAMC,IAAI;IAAE;IACnE,OAAOC;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,sBAAsBC,UAA2B,CAAC,CAAC;IACjE,MAAMC,aAAaD,QAAQE,KAAK,IAAI;IACpC,MAAMC,mBAAmBH,QAAQI,WAAW;IAE5C,OAAO,CAAC,EAAEC,IAAI,EAAE;QACd,IAAI,CAACA,MAAM;YAAC,OAAOA;QAAI;QAEvB,iDAAiD;QACjD,MAAMC,OAAO,AAACD,KAAKC,IAAI,IAA4C,CAAC;QAEpE,qDAAqD;QACrD,IAAI,CAACX,WAAWW,KAAKJ,KAAK,GAAG;YAC3B,MAAMK,cAAcZ,WAAWU,IAAI,CAACJ,WAAW;YAC/C,IAAIM,aAAa;gBAACD,KAAKJ,KAAK,GAAGK;YAAW;QAC5C;QAEA,2DAA2D;QAC3D,IAAIJ,oBAAoB,CAACR,WAAWW,KAAKF,WAAW,GAAG;YACrD,MAAMI,oBAAoBb,WAAWU,IAAI,CAACF,iBAAiB;YAC3D,IAAIK,mBAAmB;gBAACF,KAAKF,WAAW,GAAGI;YAAiB;QAC9D;QAEAH,KAAKC,IAAI,GAAGA;QACZ,OAAOD;IACT;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildMetadata.d.js b/dist/modules/seo/buildMetadata.d.js new file mode 100644 index 0000000..57039d2 --- /dev/null +++ b/dist/modules/seo/buildMetadata.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=buildMetadata.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildMetadata.d.js.map b/dist/modules/seo/buildMetadata.d.js.map new file mode 100644 index 0000000..6cdc909 --- /dev/null +++ b/dist/modules/seo/buildMetadata.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["buildMetadata.d.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';\nimport type { TitleOrder } from './composeTitle.js';\nimport type { SeoMeta } from './types.js';\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string;\n languages?: Record;\n };\n description?: string;\n openGraph?: {\n description?: string;\n images?: {\n url: string;\n }[];\n locale?: string;\n title: string;\n };\n title: string;\n};\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string;\n config: I18nConfig;\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string;\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string;\n /** Current locale being rendered. */\n locale: string;\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta;\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder;\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs;\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string;\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string;\n /** Site name for title composition and OG. */\n siteName?: null | string;\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs;\n};\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;\nexport {};\n"],"names":[],"mappings":"AAoEA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/buildMetadata.d.ts b/dist/modules/seo/buildMetadata.d.ts new file mode 100644 index 0000000..9d30e57 --- /dev/null +++ b/dist/modules/seo/buildMetadata.d.ts @@ -0,0 +1,69 @@ +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'; +import type { TitleOrder } from './composeTitle.js'; +import type { SeoMeta } from './types.js'; +/** + * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin + * doesn't depend on `next` types; the shape is assignable to Next's Metadata. + */ +export type PageMetadata = { + alternates?: { + canonical?: string; + languages?: Record; + }; + description?: string; + openGraph?: { + description?: string; + images?: { + url: string; + }[]; + locale?: string; + title: string; + }; + title: string; +}; +type BuildMetadataArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string; + config: I18nConfig; + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string; + /** Resolved OG image URL (page image or site defaultShareImage). */ + imageUrl?: null | string; + /** Current locale being rendered. */ + locale: string; + /** SEO meta from the document (plugin-seo group). */ + meta?: null | SeoMeta; + /** Page title or site name first. Defaults to 'page-first'. */ + order?: TitleOrder; + /** + * Localized segment the document lives under (an archive page's slugs). + * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and + * /en/articles/my-post point at each other correctly. + */ + prefix?: LocalizedSlugs; + /** + * Query string appended to canonical and every hreflang, e.g. '?page=2'. + * + * A paginated listing must be canonical to itself — pointing page 2 at page 1 + * tells Google the entries on it don't exist. Alternates carry the same page, + * since /pl/artykuly?page=2 corresponds to /en/articles?page=2. + */ + query?: string; + /** Separator between page title and site name. Defaults to ' | '. */ + separator?: string; + /** Site name for title composition and OG. */ + siteName?: null | string; + /** slug per locale for this document — drives canonical + hreflang. */ + slugs: LocalizedSlugs; +}; +/** + * Assembles a Next.js-compatible Metadata object from document SEO fields and + * site-level data. Locale-aware: canonical points at the current locale's + * path, and hreflang alternates cover every locale the document exists in. + * + * Designed for use inside Next.js `generateMetadata`. The caller resolves the + * pieces (meta group, site name, image URL, localized slugs) and passes them + * in — the plugin composes, it doesn't fetch. + */ +export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata; +export {}; diff --git a/dist/modules/seo/buildMetadata.js b/dist/modules/seo/buildMetadata.js new file mode 100644 index 0000000..1a08dc3 --- /dev/null +++ b/dist/modules/seo/buildMetadata.js @@ -0,0 +1,76 @@ +import { buildLocalizedPath } from '../i18n/index.js'; +import { composeTitle } from './composeTitle.js'; +import { buildHreflangAlternates } from './hreflang.js'; +/** + * Assembles a Next.js-compatible Metadata object from document SEO fields and + * site-level data. Locale-aware: canonical points at the current locale's + * path, and hreflang alternates cover every locale the document exists in. + * + * Designed for use inside Next.js `generateMetadata`. The caller resolves the + * pieces (meta group, site name, image URL, localized slugs) and passes them + * in — the plugin composes, it doesn't fetch. + */ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) { + // titleOverride wins outright: an editor who filled it in wants that exact + // string in the tab, not a composition. + const override = meta?.titleOverride?.trim(); + const title = override || composeTitle({ + order, + pageTitle: meta?.title, + separator, + siteName + }); + const description = meta?.description?.trim() || undefined; + const origin = baseUrl?.replace(/\/$/, '') ?? ''; + const suffix = query ?? ''; + const currentPath = buildLocalizedPath({ + config, + homeSlug, + locale, + prefix, + slugs + }); + const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined; + const languages = buildHreflangAlternates({ + baseUrl, + config, + homeSlug, + prefix, + slugs + }); + if (suffix) { + for (const code of Object.keys(languages)){ + languages[code] = `${languages[code]}${suffix}`; + } + } + const images = imageUrl ? [ + { + url: imageUrl + } + ] : undefined; + return { + title, + ...description && { + description + }, + alternates: { + ...canonical && { + canonical + }, + ...Object.keys(languages).length > 0 && { + languages + } + }, + openGraph: { + title, + ...description && { + description + }, + ...images && { + images + }, + locale + } + }; +} + +//# sourceMappingURL=buildMetadata.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildMetadata.js.map b/dist/modules/seo/buildMetadata.js.map new file mode 100644 index 0000000..5b52d9a --- /dev/null +++ b/dist/modules/seo/buildMetadata.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildMetadata.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { TitleOrder } from './composeTitle.js'\nimport type { SeoMeta } from './types.js'\n\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { composeTitle } from './composeTitle.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string\n languages?: Record\n }\n description?: string\n openGraph?: {\n description?: string\n images?: { url: string }[]\n locale?: string\n title: string\n }\n title: string\n}\n\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string\n /** Current locale being rendered. */\n locale: string\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name for title composition and OG. */\n siteName?: null | string\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs\n}\n\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport function buildMetadata({\n baseUrl,\n config,\n homeSlug = 'home',\n imageUrl,\n locale,\n meta,\n order,\n prefix,\n query,\n separator,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // titleOverride wins outright: an editor who filled it in wants that exact\n // string in the tab, not a composition.\n const override = meta?.titleOverride?.trim()\n const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })\n const description = meta?.description?.trim() || undefined\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n\n const suffix = query ?? ''\n\n const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined\n\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n if (suffix) {\n for (const code of Object.keys(languages)) {\n languages[code] = `${languages[code]}${suffix}`\n }\n }\n\n const images = imageUrl ? [{ url: imageUrl }] : undefined\n\n return {\n title,\n ...(description && { description }),\n alternates: {\n ...(canonical && { canonical }),\n ...(Object.keys(languages).length > 0 && { languages }),\n },\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n locale,\n },\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","prefix","query","separator","siteName","slugs","override","titleOverride","trim","title","pageTitle","description","undefined","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","openGraph"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AAyDvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,wCAAwC;IACxC,MAAMC,WAAWP,MAAMQ,eAAeC;IACtC,MAAMC,QAAQH,YAAYf,aAAa;QAAES;QAAOU,WAAWX,MAAMU;QAAON;QAAWC;IAAS;IAC5F,MAAMO,cAAcZ,MAAMY,aAAaH,UAAUI;IACjD,MAAMC,SAASnB,SAASoB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASb,SAAS;IAExB,MAAMc,cAAc1B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQG;QAAQI;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGH;IAErE,MAAMM,YAAY1B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUK;QAAQI;IAAM;IACrF,IAAIU,QAAQ;QACV,KAAK,MAAMI,QAAQC,OAAOC,IAAI,CAACH,WAAY;YACzCA,SAAS,CAACC,KAAK,GAAG,GAAGD,SAAS,CAACC,KAAK,GAAGJ,QAAQ;QACjD;IACF;IAEA,MAAMO,SAASzB,WAAW;QAAC;YAAE0B,KAAK1B;QAAS;KAAE,GAAGe;IAEhD,OAAO;QACLH;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCa,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACAQ,WAAW;YACTjB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIW,UAAU;gBAAEA;YAAO,CAAC;YACxBxB;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildRobots.d.js b/dist/modules/seo/buildRobots.d.js new file mode 100644 index 0000000..da4c12c --- /dev/null +++ b/dist/modules/seo/buildRobots.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=buildRobots.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildRobots.d.js.map b/dist/modules/seo/buildRobots.d.js.map new file mode 100644 index 0000000..de11dd2 --- /dev/null +++ b/dist/modules/seo/buildRobots.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["buildRobots.d.ts"],"sourcesContent":["type BuildRobotsArgs = {\n /** Absolute origin, e.g. 'https://example.com'. */\n baseUrl: string;\n /** Paths to disallow. Defaults to Payload's admin and API. */\n disallow?: string[];\n /** Whether to advertise the sitemap. Defaults to true. */\n sitemap?: boolean;\n};\n/**\n * Shape for Next's `app/robots.ts`.\n */\nexport type RobotsRules = {\n host?: string;\n rules: {\n allow?: string;\n disallow?: string[];\n userAgent: string;\n };\n sitemap?: string;\n};\n/**\n * Builds robots rules that allow crawling everything except the admin panel and\n * API, and point crawlers at the sitemap.\n *\n * ```ts\n * // app/robots.ts\n * export default function robots() {\n * return buildRobots({ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL! })\n * }\n * ```\n *\n * The admin and API are disallowed by default — no reason for them in the index,\n * and it keeps crawl budget on real content.\n */\nexport declare function buildRobots({ baseUrl, disallow, sitemap, }: BuildRobotsArgs): RobotsRules;\nexport {};\n"],"names":[],"mappings":"AAmCA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/buildRobots.d.ts b/dist/modules/seo/buildRobots.d.ts new file mode 100644 index 0000000..6369a0a --- /dev/null +++ b/dist/modules/seo/buildRobots.d.ts @@ -0,0 +1,36 @@ +type BuildRobotsArgs = { + /** Absolute origin, e.g. 'https://example.com'. */ + baseUrl: string; + /** Paths to disallow. Defaults to Payload's admin and API. */ + disallow?: string[]; + /** Whether to advertise the sitemap. Defaults to true. */ + sitemap?: boolean; +}; +/** + * Shape for Next's `app/robots.ts`. + */ +export type RobotsRules = { + host?: string; + rules: { + allow?: string; + disallow?: string[]; + userAgent: string; + }; + sitemap?: string; +}; +/** + * Builds robots rules that allow crawling everything except the admin panel and + * API, and point crawlers at the sitemap. + * + * ```ts + * // app/robots.ts + * export default function robots() { + * return buildRobots({ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL! }) + * } + * ``` + * + * The admin and API are disallowed by default — no reason for them in the index, + * and it keeps crawl budget on real content. + */ +export declare function buildRobots({ baseUrl, disallow, sitemap, }: BuildRobotsArgs): RobotsRules; +export {}; diff --git a/dist/modules/seo/buildRobots.js b/dist/modules/seo/buildRobots.js new file mode 100644 index 0000000..226441f --- /dev/null +++ b/dist/modules/seo/buildRobots.js @@ -0,0 +1,32 @@ +/** + * Builds robots rules that allow crawling everything except the admin panel and + * API, and point crawlers at the sitemap. + * + * ```ts + * // app/robots.ts + * export default function robots() { + * return buildRobots({ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL! }) + * } + * ``` + * + * The admin and API are disallowed by default — no reason for them in the index, + * and it keeps crawl budget on real content. + */ export function buildRobots({ baseUrl, disallow = [ + '/admin', + '/api' +], sitemap = true }) { + const origin = baseUrl.replace(/\/$/, ''); + return { + rules: { + allow: '/', + disallow, + userAgent: '*' + }, + ...sitemap ? { + sitemap: `${origin}/sitemap.xml` + } : {}, + host: origin + }; +} + +//# sourceMappingURL=buildRobots.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildRobots.js.map b/dist/modules/seo/buildRobots.js.map new file mode 100644 index 0000000..e27a67b --- /dev/null +++ b/dist/modules/seo/buildRobots.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildRobots.ts"],"sourcesContent":["type BuildRobotsArgs = {\n /** Absolute origin, e.g. 'https://example.com'. */\n baseUrl: string\n /** Paths to disallow. Defaults to Payload's admin and API. */\n disallow?: string[]\n /** Whether to advertise the sitemap. Defaults to true. */\n sitemap?: boolean\n}\n\n/**\n * Shape for Next's `app/robots.ts`.\n */\nexport type RobotsRules = {\n host?: string\n rules: { allow?: string; disallow?: string[]; userAgent: string }\n sitemap?: string\n}\n\n/**\n * Builds robots rules that allow crawling everything except the admin panel and\n * API, and point crawlers at the sitemap.\n *\n * ```ts\n * // app/robots.ts\n * export default function robots() {\n * return buildRobots({ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL! })\n * }\n * ```\n *\n * The admin and API are disallowed by default — no reason for them in the index,\n * and it keeps crawl budget on real content.\n */\nexport function buildRobots({\n baseUrl,\n disallow = ['/admin', '/api'],\n sitemap = true,\n}: BuildRobotsArgs): RobotsRules {\n const origin = baseUrl.replace(/\\/$/, '')\n\n return {\n rules: {\n allow: '/',\n disallow,\n userAgent: '*',\n },\n ...(sitemap ? { sitemap: `${origin}/sitemap.xml` } : {}),\n host: origin,\n }\n}\n"],"names":["buildRobots","baseUrl","disallow","sitemap","origin","replace","rules","allow","userAgent","host"],"mappings":"AAkBA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASA,YAAY,EAC1BC,OAAO,EACPC,WAAW;IAAC;IAAU;CAAO,EAC7BC,UAAU,IAAI,EACE;IAChB,MAAMC,SAASH,QAAQI,OAAO,CAAC,OAAO;IAEtC,OAAO;QACLC,OAAO;YACLC,OAAO;YACPL;YACAM,WAAW;QACb;QACA,GAAIL,UAAU;YAAEA,SAAS,GAAGC,OAAO,YAAY,CAAC;QAAC,IAAI,CAAC,CAAC;QACvDK,MAAML;IACR;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/buildSitemapEntries.d.js b/dist/modules/seo/buildSitemapEntries.d.js new file mode 100644 index 0000000..a315fd7 --- /dev/null +++ b/dist/modules/seo/buildSitemapEntries.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=buildSitemapEntries.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildSitemapEntries.d.js.map b/dist/modules/seo/buildSitemapEntries.d.js.map new file mode 100644 index 0000000..ba4feab --- /dev/null +++ b/dist/modules/seo/buildSitemapEntries.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["buildSitemapEntries.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { ContentOption } from '../content/index.js';\nimport type { I18nConfig } from '../i18n/index.js';\n/**\n * One sitemap entry, shaped for Next's `app/sitemap.ts`.\n *\n * `alternates.languages` is the important part for a multilingual site: Next\n * renders it as `` per URL, which is\n * exactly what Google uses to connect language versions. A sitemap without it\n * is the common, weaker kind.\n */\nexport type SitemapEntry = {\n alternates?: {\n languages: Record;\n };\n changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';\n lastModified?: Date | string;\n priority?: number;\n url: string;\n};\ntype BuildSitemapArgs = {\n /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */\n baseUrl: string;\n changeFrequency?: SitemapEntry['changeFrequency'];\n config: I18nConfig;\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption;\n /**\n * Slug of the page that is the site root (collapses to /{locale}).\n * Read from System Pages when omitted.\n */\n homeSlug?: string;\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string;\n payload: BasePayload;\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string;\n};\n/**\n * Collects every public URL — pages and archive entries — as sitemap entries\n * with per-URL hreflang and lastmod.\n *\n * Reuses the same path/hreflang builders as page metadata, so the sitemap can't\n * drift from what the pages actually render (a classic source of sitemap bugs:\n * a URL listed one way and served another). Drafts and noindex documents are\n * omitted.\n *\n * ```ts\n * // app/sitemap.ts\n * export default async function sitemap() {\n * return buildSitemapEntries({\n * payload: await getPayload({ config }),\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,\n * content: contentConfig,\n * })\n * }\n * ```\n */\nexport declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise;\nexport {};\n"],"names":[],"mappings":"AA4DA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/buildSitemapEntries.d.ts b/dist/modules/seo/buildSitemapEntries.d.ts new file mode 100644 index 0000000..c739317 --- /dev/null +++ b/dist/modules/seo/buildSitemapEntries.d.ts @@ -0,0 +1,61 @@ +import type { BasePayload } from 'payload'; +import type { ContentOption } from '../content/index.js'; +import type { I18nConfig } from '../i18n/index.js'; +/** + * One sitemap entry, shaped for Next's `app/sitemap.ts`. + * + * `alternates.languages` is the important part for a multilingual site: Next + * renders it as `` per URL, which is + * exactly what Google uses to connect language versions. A sitemap without it + * is the common, weaker kind. + */ +export type SitemapEntry = { + alternates?: { + languages: Record; + }; + changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'; + lastModified?: Date | string; + priority?: number; + url: string; +}; +type BuildSitemapArgs = { + /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ + baseUrl: string; + changeFrequency?: SitemapEntry['changeFrequency']; + config: I18nConfig; + /** Archive-backed collections, same value as the plugin option. */ + content?: ContentOption; + /** + * Slug of the page that is the site root (collapses to /{locale}). + * Read from System Pages when omitted. + */ + homeSlug?: string; + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; + payload: BasePayload; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; +}; +/** + * Collects every public URL — pages and archive entries — as sitemap entries + * with per-URL hreflang and lastmod. + * + * Reuses the same path/hreflang builders as page metadata, so the sitemap can't + * drift from what the pages actually render (a classic source of sitemap bugs: + * a URL listed one way and served another). Drafts and noindex documents are + * omitted. + * + * ```ts + * // app/sitemap.ts + * export default async function sitemap() { + * return buildSitemapEntries({ + * payload: await getPayload({ config }), + * config: i18nConfig, + * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!, + * content: contentConfig, + * }) + * } + * ``` + */ +export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise; +export {}; diff --git a/dist/modules/seo/buildSitemapEntries.js b/dist/modules/seo/buildSitemapEntries.js new file mode 100644 index 0000000..53f83ce --- /dev/null +++ b/dist/modules/seo/buildSitemapEntries.js @@ -0,0 +1,158 @@ +import { archiveFieldName } from '../content/index.js'; +import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'; +import { buildHreflangAlternates } from './hreflang.js'; +/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) { + if (doc._status && doc._status !== 'published') { + return false; + } + if (doc.meta?.noindex) { + return false; + } + return true; +} +/** + * Turns one document into a sitemap entry per default locale isn't needed — + * one entry with all languages as alternates is the correct, de-duplicated + * shape. The `url` is the current locale's path; `alternates.languages` carries + * every locale (including itself, per Google's guidance). + */ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) { + const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ + config, + slugField: doc.slug + }) : {}; + const path = buildLocalizedPath({ + config, + homeSlug, + locale, + prefix, + slugs + }); + if (!path) { + return null; + } + const origin = baseUrl.replace(/\/$/, ''); + const languages = buildHreflangAlternates({ + baseUrl, + config, + homeSlug, + prefix, + slugs + }); + return { + url: `${origin}${path}`, + ...doc.updatedAt ? { + lastModified: doc.updatedAt + } : {}, + ...changeFrequency ? { + changeFrequency + } : {}, + ...Object.keys(languages).length ? { + alternates: { + languages + } + } : {} + }; +} +/** + * Collects every public URL — pages and archive entries — as sitemap entries + * with per-URL hreflang and lastmod. + * + * Reuses the same path/hreflang builders as page metadata, so the sitemap can't + * drift from what the pages actually render (a classic source of sitemap bugs: + * a URL listed one way and served another). Drafts and noindex documents are + * omitted. + * + * ```ts + * // app/sitemap.ts + * export default async function sitemap() { + * return buildSitemapEntries({ + * payload: await getPayload({ config }), + * config: i18nConfig, + * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!, + * content: contentConfig, + * }) + * } + * ``` + */ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) { + const locales = config.locales.map((l)=>l.code); + const defaultLocale = config.defaultLocale; + // Resolve homeSlug and archive prefixes from System Pages (read once, in all + // locales so archive prefixes are available per language). + const settings = await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: 'all' + }); + const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'; + // Which collections to walk: pages (no prefix) + each content collection with + // its archive-page slugs as the localized prefix. + const collections = [ + { + slug: pagesSlug + } + ]; + for (const c of content?.collections ?? []){ + const archive = settings[archiveFieldName(c.slug)]; + const prefixSlugs = archive ? slugMapAllLocales(archive, locales) : undefined; + collections.push({ + slug: c.slug, + prefixSlugs + }); + } + const entries = []; + for (const collection of collections){ + // Read every doc once, across locales, so one row yields all its language + // alternates without re-querying per locale. + const result = await payload.find({ + collection: collection.slug, + depth: 0, + limit: 0, + locale: 'all', + pagination: false + }); + for (const raw of result.docs){ + if (!isIndexable(raw)) { + continue; + } + // Emit the entry under the default locale's URL; alternates cover the rest. + const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency); + if (entry) { + entries.push(entry); + } + } + } + return entries; +} +/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) { + if (!rel || typeof rel !== 'object') { + return undefined; + } + const slug = rel.slug; + if (typeof slug === 'string') { + return slug; + } + if (slug && typeof slug === 'object') { + const v = slug[locale]; + return typeof v === 'string' ? v : undefined; + } + return undefined; +} +/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) { + if (!rel || typeof rel !== 'object') { + return undefined; + } + const slug = rel.slug; + if (!slug || typeof slug !== 'object') { + return undefined; + } + const map = {}; + for (const locale of locales){ + const v = slug[locale]; + if (typeof v === 'string') { + map[locale] = v; + } + } + return Object.keys(map).length ? map : undefined; +} + +//# sourceMappingURL=buildSitemapEntries.js.map \ No newline at end of file diff --git a/dist/modules/seo/buildSitemapEntries.js.map b/dist/modules/seo/buildSitemapEntries.js.map new file mode 100644 index 0000000..ccf0101 --- /dev/null +++ b/dist/modules/seo/buildSitemapEntries.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/buildSitemapEntries.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\n\nimport { archiveFieldName } from '../content/index.js'\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * One sitemap entry, shaped for Next's `app/sitemap.ts`.\n *\n * `alternates.languages` is the important part for a multilingual site: Next\n * renders it as `` per URL, which is\n * exactly what Google uses to connect language versions. A sitemap without it\n * is the common, weaker kind.\n */\nexport type SitemapEntry = {\n alternates?: { languages: Record }\n changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'\n lastModified?: Date | string\n priority?: number\n url: string\n}\n\ntype CollectionEntry = {\n /** Localized segment for entries (archive page slugs), when applicable. */\n prefixSlugs?: Record\n slug: string\n}\n\ntype BuildSitemapArgs = {\n /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */\n baseUrl: string\n changeFrequency?: SitemapEntry['changeFrequency']\n config: I18nConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * Slug of the page that is the site root (collapses to /{locale}).\n * Read from System Pages when omitted.\n */\n homeSlug?: string\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n payload: BasePayload\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\ntype DocRow = {\n _status?: string\n id: number | string\n meta?: { noindex?: boolean } | null\n slug?: unknown\n updatedAt?: string\n}\n\n/** Skip drafts and anything flagged noindex in the SEO tab. */\nfunction isIndexable(doc: DocRow): boolean {\n if (doc._status && doc._status !== 'published') {return false}\n if (doc.meta?.noindex) {return false}\n return true\n}\n\n/**\n * Turns one document into a sitemap entry per default locale isn't needed —\n * one entry with all languages as alternates is the correct, de-duplicated\n * shape. The `url` is the current locale's path; `alternates.languages` carries\n * every locale (including itself, per Google's guidance).\n */\nfunction entryFor(\n doc: DocRow,\n locale: string,\n config: I18nConfig,\n baseUrl: string,\n homeSlug: string | undefined,\n prefix: Record | undefined,\n changeFrequency: SitemapEntry['changeFrequency'],\n): null | SitemapEntry {\n const slugs =\n doc.slug && typeof doc.slug === 'object'\n ? getLocalizedSlugs({ config, slugField: doc.slug as Record })\n : {}\n\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (!path) {return null}\n\n const origin = baseUrl.replace(/\\/$/, '')\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n\n return {\n url: `${origin}${path}`,\n ...(doc.updatedAt ? { lastModified: doc.updatedAt } : {}),\n ...(changeFrequency ? { changeFrequency } : {}),\n ...(Object.keys(languages).length ? { alternates: { languages } } : {}),\n }\n}\n\n/**\n * Collects every public URL — pages and archive entries — as sitemap entries\n * with per-URL hreflang and lastmod.\n *\n * Reuses the same path/hreflang builders as page metadata, so the sitemap can't\n * drift from what the pages actually render (a classic source of sitemap bugs:\n * a URL listed one way and served another). Drafts and noindex documents are\n * omitted.\n *\n * ```ts\n * // app/sitemap.ts\n * export default async function sitemap() {\n * return buildSitemapEntries({\n * payload: await getPayload({ config }),\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,\n * content: contentConfig,\n * })\n * }\n * ```\n */\nexport async function buildSitemapEntries({\n baseUrl,\n changeFrequency = 'weekly',\n config,\n content,\n homeSlug,\n pagesSlug = 'pages',\n payload,\n settingsSlug = 'site-settings',\n}: BuildSitemapArgs): Promise {\n const locales = config.locales.map((l) => l.code)\n const defaultLocale = config.defaultLocale\n\n // Resolve homeSlug and archive prefixes from System Pages (read once, in all\n // locales so archive prefixes are available per language).\n const settings = (await payload.findGlobal({\n slug: settingsSlug as never,\n depth: 1,\n locale: 'all' as never,\n })) as Record\n\n const resolvedHomeSlug =\n homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'\n\n // Which collections to walk: pages (no prefix) + each content collection with\n // its archive-page slugs as the localized prefix.\n const collections: CollectionEntry[] = [{ slug: pagesSlug }]\n\n for (const c of content?.collections ?? []) {\n const archive = settings[archiveFieldName(c.slug)]\n const prefixSlugs = archive ? slugMapAllLocales(archive, locales) : undefined\n collections.push({ slug: c.slug, prefixSlugs })\n }\n\n const entries: SitemapEntry[] = []\n\n for (const collection of collections) {\n // Read every doc once, across locales, so one row yields all its language\n // alternates without re-querying per locale.\n const result = await payload.find({\n collection: collection.slug as never,\n depth: 0,\n limit: 0, // no pagination — sitemap wants everything\n locale: 'all' as never,\n pagination: false as never,\n })\n\n for (const raw of result.docs as DocRow[]) {\n if (!isIndexable(raw)) {continue}\n\n // Emit the entry under the default locale's URL; alternates cover the rest.\n const entry = entryFor(\n raw,\n defaultLocale,\n config,\n baseUrl,\n resolvedHomeSlug,\n collection.prefixSlugs,\n changeFrequency,\n )\n if (entry) {entries.push(entry)}\n }\n }\n\n return entries\n}\n\n/** Pulls a slug string from a populated relationship in a specific locale. */\nfunction extractSlugInLocale(rel: unknown, locale: string): string | undefined {\n if (!rel || typeof rel !== 'object') {return undefined}\n const slug = (rel as { slug?: unknown }).slug\n if (typeof slug === 'string') {return slug}\n if (slug && typeof slug === 'object') {\n const v = (slug as Record)[locale]\n return typeof v === 'string' ? v : undefined\n }\n return undefined\n}\n\n/** Builds a locale→slug map from a populated archive relationship. */\nfunction slugMapAllLocales(rel: unknown, locales: string[]): Record | undefined {\n if (!rel || typeof rel !== 'object') {return undefined}\n const slug = (rel as { slug?: unknown }).slug\n if (!slug || typeof slug !== 'object') {return undefined}\n\n const map: Record = {}\n for (const locale of locales) {\n const v = (slug as Record)[locale]\n if (typeof v === 'string') {map[locale] = v}\n }\n return Object.keys(map).length ? map : undefined\n}\n"],"names":["archiveFieldName","buildLocalizedPath","getLocalizedSlugs","buildHreflangAlternates","isIndexable","doc","_status","meta","noindex","entryFor","locale","config","baseUrl","homeSlug","prefix","changeFrequency","slugs","slug","slugField","path","origin","replace","languages","url","updatedAt","lastModified","Object","keys","length","alternates","buildSitemapEntries","content","pagesSlug","payload","settingsSlug","locales","map","l","code","defaultLocale","settings","findGlobal","depth","resolvedHomeSlug","extractSlugInLocale","homepage","collections","c","archive","prefixSlugs","slugMapAllLocales","undefined","push","entries","collection","result","find","limit","pagination","raw","docs","entry","rel","v"],"mappings":"AAKA,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SAASC,kBAAkB,EAAEC,iBAAiB,QAAS,mBAAkB;AACzE,SAASC,uBAAuB,QAAQ,gBAAe;AAmDvD,6DAA6D,GAC7D,SAASC,YAAYC,GAAW;IAC9B,IAAIA,IAAIC,OAAO,IAAID,IAAIC,OAAO,KAAK,aAAa;QAAC,OAAO;IAAK;IAC7D,IAAID,IAAIE,IAAI,EAAEC,SAAS;QAAC,OAAO;IAAK;IACpC,OAAO;AACT;AAEA;;;;;CAKC,GACD,SAASC,SACPJ,GAAW,EACXK,MAAc,EACdC,MAAkB,EAClBC,OAAe,EACfC,QAA4B,EAC5BC,MAA0C,EAC1CC,eAAgD;IAEhD,MAAMC,QACJX,IAAIY,IAAI,IAAI,OAAOZ,IAAIY,IAAI,KAAK,WAC5Bf,kBAAkB;QAAES;QAAQO,WAAWb,IAAIY,IAAI;IAA4B,KAC3E,CAAC;IAEP,MAAME,OAAOlB,mBAAmB;QAAEU;QAAQE;QAAUH;QAAQI;QAAQE;IAAM;IAC1E,IAAI,CAACG,MAAM;QAAC,OAAO;IAAI;IAEvB,MAAMC,SAASR,QAAQS,OAAO,CAAC,OAAO;IACtC,MAAMC,YAAYnB,wBAAwB;QAAES;QAASD;QAAQE;QAAUC;QAAQE;IAAM;IAErF,OAAO;QACLO,KAAK,GAAGH,SAASD,MAAM;QACvB,GAAId,IAAImB,SAAS,GAAG;YAAEC,cAAcpB,IAAImB,SAAS;QAAC,IAAI,CAAC,CAAC;QACxD,GAAIT,kBAAkB;YAAEA;QAAgB,IAAI,CAAC,CAAC;QAC9C,GAAIW,OAAOC,IAAI,CAACL,WAAWM,MAAM,GAAG;YAAEC,YAAY;gBAAEP;YAAU;QAAE,IAAI,CAAC,CAAC;IACxE;AACF;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,eAAeQ,oBAAoB,EACxClB,OAAO,EACPG,kBAAkB,QAAQ,EAC1BJ,MAAM,EACNoB,OAAO,EACPlB,QAAQ,EACRmB,YAAY,OAAO,EACnBC,OAAO,EACPC,eAAe,eAAe,EACb;IACjB,MAAMC,UAAUxB,OAAOwB,OAAO,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI;IAChD,MAAMC,gBAAgB5B,OAAO4B,aAAa;IAE1C,6EAA6E;IAC7E,2DAA2D;IAC3D,MAAMC,WAAY,MAAMP,QAAQQ,UAAU,CAAC;QACzCxB,MAAMiB;QACNQ,OAAO;QACPhC,QAAQ;IACV;IAEA,MAAMiC,mBACJ9B,YAAY+B,oBAAoBJ,SAASK,QAAQ,EAAEN,kBAAkB;IAEvE,8EAA8E;IAC9E,kDAAkD;IAClD,MAAMO,cAAiC;QAAC;YAAE7B,MAAMe;QAAU;KAAE;IAE5D,KAAK,MAAMe,KAAKhB,SAASe,eAAe,EAAE,CAAE;QAC1C,MAAME,UAAUR,QAAQ,CAACxC,iBAAiB+C,EAAE9B,IAAI,EAAE;QAClD,MAAMgC,cAAcD,UAAUE,kBAAkBF,SAASb,WAAWgB;QACpEL,YAAYM,IAAI,CAAC;YAAEnC,MAAM8B,EAAE9B,IAAI;YAAEgC;QAAY;IAC/C;IAEA,MAAMI,UAA0B,EAAE;IAElC,KAAK,MAAMC,cAAcR,YAAa;QACpC,0EAA0E;QAC1E,6CAA6C;QAC7C,MAAMS,SAAS,MAAMtB,QAAQuB,IAAI,CAAC;YAChCF,YAAYA,WAAWrC,IAAI;YAC3ByB,OAAO;YACPe,OAAO;YACP/C,QAAQ;YACRgD,YAAY;QACd;QAEA,KAAK,MAAMC,OAAOJ,OAAOK,IAAI,CAAc;YACzC,IAAI,CAACxD,YAAYuD,MAAM;gBAAC;YAAQ;YAEhC,4EAA4E;YAC5E,MAAME,QAAQpD,SACZkD,KACApB,eACA5B,QACAC,SACA+B,kBACAW,WAAWL,WAAW,EACtBlC;YAEF,IAAI8C,OAAO;gBAACR,QAAQD,IAAI,CAACS;YAAM;QACjC;IACF;IAEA,OAAOR;AACT;AAEA,4EAA4E,GAC5E,SAAST,oBAAoBkB,GAAY,EAAEpD,MAAc;IACvD,IAAI,CAACoD,OAAO,OAAOA,QAAQ,UAAU;QAAC,OAAOX;IAAS;IACtD,MAAMlC,OAAO,AAAC6C,IAA2B7C,IAAI;IAC7C,IAAI,OAAOA,SAAS,UAAU;QAAC,OAAOA;IAAI;IAC1C,IAAIA,QAAQ,OAAOA,SAAS,UAAU;QACpC,MAAM8C,IAAI,AAAC9C,IAAgC,CAACP,OAAO;QACnD,OAAO,OAAOqD,MAAM,WAAWA,IAAIZ;IACrC;IACA,OAAOA;AACT;AAEA,oEAAoE,GACpE,SAASD,kBAAkBY,GAAY,EAAE3B,OAAiB;IACxD,IAAI,CAAC2B,OAAO,OAAOA,QAAQ,UAAU;QAAC,OAAOX;IAAS;IACtD,MAAMlC,OAAO,AAAC6C,IAA2B7C,IAAI;IAC7C,IAAI,CAACA,QAAQ,OAAOA,SAAS,UAAU;QAAC,OAAOkC;IAAS;IAExD,MAAMf,MAA8B,CAAC;IACrC,KAAK,MAAM1B,UAAUyB,QAAS;QAC5B,MAAM4B,IAAI,AAAC9C,IAAgC,CAACP,OAAO;QACnD,IAAI,OAAOqD,MAAM,UAAU;YAAC3B,GAAG,CAAC1B,OAAO,GAAGqD;QAAC;IAC7C;IACA,OAAOrC,OAAOC,IAAI,CAACS,KAAKR,MAAM,GAAGQ,MAAMe;AACzC"} \ No newline at end of file diff --git a/dist/modules/seo/composeTitle.d.js b/dist/modules/seo/composeTitle.d.js new file mode 100644 index 0000000..71b8d29 --- /dev/null +++ b/dist/modules/seo/composeTitle.d.js @@ -0,0 +1,3 @@ +/** Which part comes first in a composed title. */ export { }; + +//# sourceMappingURL=composeTitle.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/composeTitle.d.js.map b/dist/modules/seo/composeTitle.d.js.map new file mode 100644 index 0000000..8271494 --- /dev/null +++ b/dist/modules/seo/composeTitle.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["composeTitle.d.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first';\ntype ComposeTitleArgs = {\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder;\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: null | string;\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string;\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: null | string;\n};\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport declare function composeTitle({ order, pageTitle, separator, siteName, }: ComposeTitleArgs): string;\nexport {};\n"],"names":[],"mappings":"AAAA,gDAAgD,GAwBhD,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/composeTitle.d.ts b/dist/modules/seo/composeTitle.d.ts new file mode 100644 index 0000000..3c92e24 --- /dev/null +++ b/dist/modules/seo/composeTitle.d.ts @@ -0,0 +1,25 @@ +/** Which part comes first in a composed title. */ +export type TitleOrder = 'page-first' | 'site-first'; +type ComposeTitleArgs = { + /** Defaults to 'page-first' — the page title is what a visitor scans for. */ + order?: TitleOrder; + /** Page-specific title, e.g. 'About Us'. */ + pageTitle?: null | string; + /** Separator between page title and site name. Defaults to ' | '. */ + separator?: string; + /** Site name, e.g. 'Acme Inc'. */ + siteName?: null | string; +}; +/** + * Composes a full document title from a page title and the site name. + * + * - both, page-first: "About Us | Acme Inc" + * - both, site-first: "Acme Inc | About Us" + * - page only: "About Us" + * - site only: "Acme Inc" + * - neither: "" + * + * Pure function — no dependency on Payload or request state. + */ +export declare function composeTitle({ order, pageTitle, separator, siteName, }: ComposeTitleArgs): string; +export {}; diff --git a/dist/modules/seo/composeTitle.js b/dist/modules/seo/composeTitle.js new file mode 100644 index 0000000..5d5b958 --- /dev/null +++ b/dist/modules/seo/composeTitle.js @@ -0,0 +1,20 @@ +/** Which part comes first in a composed title. */ /** + * Composes a full document title from a page title and the site name. + * + * - both, page-first: "About Us | Acme Inc" + * - both, site-first: "Acme Inc | About Us" + * - page only: "About Us" + * - site only: "Acme Inc" + * - neither: "" + * + * Pure function — no dependency on Payload or request state. + */ export function composeTitle({ order = 'page-first', pageTitle, separator = ' | ', siteName }) { + const page = pageTitle?.trim(); + const site = siteName?.trim(); + if (page && site) { + return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`; + } + return page || site || ''; +} + +//# sourceMappingURL=composeTitle.js.map \ No newline at end of file diff --git a/dist/modules/seo/composeTitle.js.map b/dist/modules/seo/composeTitle.js.map new file mode 100644 index 0000000..bfefe1e --- /dev/null +++ b/dist/modules/seo/composeTitle.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/composeTitle.ts"],"sourcesContent":["/** Which part comes first in a composed title. */\nexport type TitleOrder = 'page-first' | 'site-first'\n\ntype ComposeTitleArgs = {\n /** Defaults to 'page-first' — the page title is what a visitor scans for. */\n order?: TitleOrder\n /** Page-specific title, e.g. 'About Us'. */\n pageTitle?: null | string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name, e.g. 'Acme Inc'. */\n siteName?: null | string\n}\n\n/**\n * Composes a full document title from a page title and the site name.\n *\n * - both, page-first: \"About Us | Acme Inc\"\n * - both, site-first: \"Acme Inc | About Us\"\n * - page only: \"About Us\"\n * - site only: \"Acme Inc\"\n * - neither: \"\"\n *\n * Pure function — no dependency on Payload or request state.\n */\nexport function composeTitle({\n order = 'page-first',\n pageTitle,\n separator = ' | ',\n siteName,\n}: ComposeTitleArgs): string {\n const page = pageTitle?.trim()\n const site = siteName?.trim()\n\n if (page && site) {\n return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`\n }\n return page || site || ''\n}\n"],"names":["composeTitle","order","pageTitle","separator","siteName","page","trim","site"],"mappings":"AAAA,gDAAgD,GAchD;;;;;;;;;;CAUC,GACD,OAAO,SAASA,aAAa,EAC3BC,QAAQ,YAAY,EACpBC,SAAS,EACTC,YAAY,KAAK,EACjBC,QAAQ,EACS;IACjB,MAAMC,OAAOH,WAAWI;IACxB,MAAMC,OAAOH,UAAUE;IAEvB,IAAID,QAAQE,MAAM;QAChB,OAAON,UAAU,eAAe,GAAGM,OAAOJ,YAAYE,MAAM,GAAG,GAAGA,OAAOF,YAAYI,MAAM;IAC7F;IACA,OAAOF,QAAQE,QAAQ;AACzB"} \ No newline at end of file diff --git a/dist/modules/seo/createMetadataGenerator.d.js b/dist/modules/seo/createMetadataGenerator.d.js new file mode 100644 index 0000000..0ccaf81 --- /dev/null +++ b/dist/modules/seo/createMetadataGenerator.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=createMetadataGenerator.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/createMetadataGenerator.d.js.map b/dist/modules/seo/createMetadataGenerator.d.js.map new file mode 100644 index 0000000..3266fc7 --- /dev/null +++ b/dist/modules/seo/createMetadataGenerator.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["createMetadataGenerator.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { I18nConfig } from '../i18n/index.js';\nimport type { PageMetadata } from './buildMetadata.js';\nimport type { SeoMeta } from './types.js';\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta;\n slug?: null | Record;\n};\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string;\n config: I18nConfig;\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string;\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string;\n params: Record;\n payload: BasePayload;\n }) => Promise;\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument;\n locale: string;\n payload: BasePayload;\n }) => Promise;\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: {\n locale: string;\n payload: BasePayload;\n }) => Promise;\n};\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport declare function createMetadataGenerator(args: CreateMetadataGeneratorArgs): (context: {\n locale: string;\n params: Record;\n payload: BasePayload;\n}) => Promise;\nexport {};\n"],"names":[],"mappings":"AAyEA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/createMetadataGenerator.d.ts b/dist/modules/seo/createMetadataGenerator.d.ts new file mode 100644 index 0000000..ffe4467 --- /dev/null +++ b/dist/modules/seo/createMetadataGenerator.d.ts @@ -0,0 +1,74 @@ +import type { BasePayload } from 'payload'; +import type { I18nConfig } from '../i18n/index.js'; +import type { PageMetadata } from './buildMetadata.js'; +import type { SeoMeta } from './types.js'; +/** + * A document as needed for metadata: its SEO meta group and localized slug. + */ +type MetadataDocument = { + meta?: null | SeoMeta; + slug?: null | Record; +}; +type CreateMetadataGeneratorArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string; + config: I18nConfig; + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string; + /** + * Resolves the document to build metadata for, given route params. + * The client supplies this (they own the collections and routing); it should + * fetch with `locale: 'all'` so the slug field is a locale→value map. + */ + resolveDocument: (args: { + locale: string; + params: Record; + payload: BasePayload; + }) => Promise; + /** Resolves the OG image URL for the document, if any. */ + resolveImageUrl?: (args: { + doc: MetadataDocument; + locale: string; + payload: BasePayload; + }) => Promise; + /** Resolves the site name (e.g. from SiteSettings). */ + resolveSiteName?: (args: { + locale: string; + payload: BasePayload; + }) => Promise; +}; +/** + * Builds a metadata generator, collapsing the usual generateMetadata + * boilerplate into a single wired-up function. + * + * The client provides resolvers (they own collections/routing); the plugin + * owns the assembly (title composition, canonical, hreflang, OG). + * + * The returned function takes `{ payload, params, locale }` — Next calls + * `generateMetadata({ params })` without those, and the plugin never calls + * getPayload itself, so the client wraps it in their route file: + * + * ```ts + * // app/(frontend)/[locale]/[[...slug]]/page.tsx + * const generate = createMetadataGenerator({ + * config: i18nConfig, + * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, + * resolveDocument: async ({ payload, params, locale }) => { ... }, + * }) + * + * export async function generateMetadata({ params }) { + * const { locale, slug } = await params + * const payload = await getPayload({ config: await config }) + * return generate({ payload, params: { slug: slug ?? [] }, locale }) + * } + * ``` + * + * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes + * back as a locale→value map — that's what hreflang alternates are built from. + */ +export declare function createMetadataGenerator(args: CreateMetadataGeneratorArgs): (context: { + locale: string; + params: Record; + payload: BasePayload; +}) => Promise; +export {}; diff --git a/dist/modules/seo/createMetadataGenerator.js b/dist/modules/seo/createMetadataGenerator.js new file mode 100644 index 0000000..4ce37ab --- /dev/null +++ b/dist/modules/seo/createMetadataGenerator.js @@ -0,0 +1,68 @@ +import { getLocalizedSlugs } from '../i18n/index.js'; +import { buildMetadata } from './buildMetadata.js'; +/** + * Builds a metadata generator, collapsing the usual generateMetadata + * boilerplate into a single wired-up function. + * + * The client provides resolvers (they own collections/routing); the plugin + * owns the assembly (title composition, canonical, hreflang, OG). + * + * The returned function takes `{ payload, params, locale }` — Next calls + * `generateMetadata({ params })` without those, and the plugin never calls + * getPayload itself, so the client wraps it in their route file: + * + * ```ts + * // app/(frontend)/[locale]/[[...slug]]/page.tsx + * const generate = createMetadataGenerator({ + * config: i18nConfig, + * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, + * resolveDocument: async ({ payload, params, locale }) => { ... }, + * }) + * + * export async function generateMetadata({ params }) { + * const { locale, slug } = await params + * const payload = await getPayload({ config: await config }) + * return generate({ payload, params: { slug: slug ?? [] }, locale }) + * } + * ``` + * + * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes + * back as a locale→value map — that's what hreflang alternates are built from. + */ export function createMetadataGenerator(args) { + const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args; + return async function generateMetadata(context) { + const { locale, params, payload } = context; + const doc = await resolveDocument({ + locale, + params, + payload + }); + const slugs = doc?.slug ? getLocalizedSlugs({ + config, + slugField: doc.slug + }) : {}; + const [siteName, imageUrl] = await Promise.all([ + resolveSiteName?.({ + locale, + payload + }) ?? Promise.resolve(null), + doc && resolveImageUrl ? resolveImageUrl({ + doc, + locale, + payload + }) : Promise.resolve(null) + ]); + return buildMetadata({ + baseUrl, + config, + homeSlug, + imageUrl, + locale, + meta: doc?.meta, + siteName, + slugs + }); + }; +} + +//# sourceMappingURL=createMetadataGenerator.js.map \ No newline at end of file diff --git a/dist/modules/seo/createMetadataGenerator.js.map b/dist/modules/seo/createMetadataGenerator.js.map new file mode 100644 index 0000000..bd001e5 --- /dev/null +++ b/dist/modules/seo/createMetadataGenerator.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/createMetadataGenerator.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildMetadata } from './buildMetadata.js'\n\n/**\n * A document as needed for metadata: its SEO meta group and localized slug.\n */\ntype MetadataDocument = {\n meta?: null | SeoMeta\n slug?: null | Record\n}\n\ntype CreateMetadataGeneratorArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Resolves the document to build metadata for, given route params.\n * The client supplies this (they own the collections and routing); it should\n * fetch with `locale: 'all'` so the slug field is a locale→value map.\n */\n resolveDocument: (args: {\n locale: string\n params: Record\n payload: BasePayload\n }) => Promise\n /** Resolves the OG image URL for the document, if any. */\n resolveImageUrl?: (args: {\n doc: MetadataDocument\n locale: string\n payload: BasePayload\n }) => Promise\n /** Resolves the site name (e.g. from SiteSettings). */\n resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise\n}\n\n/**\n * Builds a metadata generator, collapsing the usual generateMetadata\n * boilerplate into a single wired-up function.\n *\n * The client provides resolvers (they own collections/routing); the plugin\n * owns the assembly (title composition, canonical, hreflang, OG).\n *\n * The returned function takes `{ payload, params, locale }` — Next calls\n * `generateMetadata({ params })` without those, and the plugin never calls\n * getPayload itself, so the client wraps it in their route file:\n *\n * ```ts\n * // app/(frontend)/[locale]/[[...slug]]/page.tsx\n * const generate = createMetadataGenerator({\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,\n * resolveDocument: async ({ payload, params, locale }) => { ... },\n * })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * const payload = await getPayload({ config: await config })\n * return generate({ payload, params: { slug: slug ?? [] }, locale })\n * }\n * ```\n *\n * `resolveDocument` should fetch with `locale: 'all'` so the slug field comes\n * back as a locale→value map — that's what hreflang alternates are built from.\n */\nexport function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {\n const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args\n\n return async function generateMetadata(context: {\n locale: string\n params: Record\n payload: BasePayload\n }): Promise {\n const { locale, params, payload } = context\n\n const doc = await resolveDocument({ locale, params, payload })\n\n const slugs: LocalizedSlugs = doc?.slug\n ? getLocalizedSlugs({ config, slugField: doc.slug })\n : {}\n\n const [siteName, imageUrl] = await Promise.all([\n resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),\n doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),\n ])\n\n return buildMetadata({\n baseUrl,\n config,\n homeSlug,\n imageUrl,\n locale,\n meta: doc?.meta,\n siteName,\n slugs,\n })\n }\n}\n"],"names":["getLocalizedSlugs","buildMetadata","createMetadataGenerator","args","baseUrl","config","homeSlug","resolveDocument","resolveImageUrl","resolveSiteName","generateMetadata","context","locale","params","payload","doc","slugs","slug","slugField","siteName","imageUrl","Promise","all","resolve","meta"],"mappings":"AAMA,SAASA,iBAAiB,QAAQ,mBAAkB;AACpD,SAASC,aAAa,QAAQ,qBAAoB;AAoClD;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,MAAM,EAAEC,OAAO,EAAEC,MAAM,EAAEC,QAAQ,EAAEC,eAAe,EAAEC,eAAe,EAAEC,eAAe,EAAE,GAAGN;IAEzF,OAAO,eAAeO,iBAAiBC,OAItC;QACC,MAAM,EAAEC,MAAM,EAAEC,MAAM,EAAEC,OAAO,EAAE,GAAGH;QAEpC,MAAMI,MAAM,MAAMR,gBAAgB;YAAEK;YAAQC;YAAQC;QAAQ;QAE5D,MAAME,QAAwBD,KAAKE,OAC/BjB,kBAAkB;YAAEK;YAAQa,WAAWH,IAAIE,IAAI;QAAC,KAChD,CAAC;QAEL,MAAM,CAACE,UAAUC,SAAS,GAAG,MAAMC,QAAQC,GAAG,CAAC;YAC7Cb,kBAAkB;gBAAEG;gBAAQE;YAAQ,MAAMO,QAAQE,OAAO,CAAC;YAC1DR,OAAOP,kBAAkBA,gBAAgB;gBAAEO;gBAAKH;gBAAQE;YAAQ,KAAKO,QAAQE,OAAO,CAAC;SACtF;QAED,OAAOtB,cAAc;YACnBG;YACAC;YACAC;YACAc;YACAR;YACAY,MAAMT,KAAKS;YACXL;YACAH;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/createPageMetadata.d.js b/dist/modules/seo/createPageMetadata.d.js new file mode 100644 index 0000000..ca5b5e1 --- /dev/null +++ b/dist/modules/seo/createPageMetadata.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=createPageMetadata.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/createPageMetadata.d.js.map b/dist/modules/seo/createPageMetadata.d.js.map new file mode 100644 index 0000000..c97008e --- /dev/null +++ b/dist/modules/seo/createPageMetadata.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["createPageMetadata.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { ContentOption } from '../content/index.js';\nimport type { I18nConfig } from '../i18n/index.js';\nimport type { PageMetadata } from './buildMetadata.js';\ntype CreatePageMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string;\n /** Collection holding pages. Defaults to 'pages'. */\n collection?: string;\n config: I18nConfig;\n /**\n * Archive-backed collections, same value as the plugin option. Pass it and\n * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang;\n * omit it and only pages are handled.\n */\n content?: ContentOption;\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string;\n /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */\n siteNameField?: string;\n};\ntype PageMetadataContext = {\n locale: string;\n /**\n * Page number from ?page= on an archive listing. Pass it and page 2 gets a\n * canonical to itself; leave it out and every page claims to be page 1.\n */\n page?: number;\n payload: BasePayload;\n /** Route slug segments; empty/undefined means the locale root. */\n slug?: string[];\n};\n/**\n * Metadata for the page route, with every resolver already wired.\n *\n * `createMetadataGenerator` asks the client for resolvers because it can't know\n * their collections. But for the plugin's own conventions it does know: pages\n * live in one collection, the site name sits on SiteSettings, the OG image sits\n * on the plugin-seo `meta` group, the home page is whatever System Pages points\n * at, and — with `content` — entries live under their collection's archive page.\n * Re-declaring all that in every project is copy-paste, so this resolves it.\n *\n * Reach for `createMetadataGenerator` instead when a route doesn't follow those\n * conventions — custom image logic, a different global, hand-rolled paths.\n *\n * The plugin never calls getPayload, and Next calls generateMetadata without a\n * payload, so the client keeps a small wrapper:\n *\n * ```ts\n * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * return pageMetadata({ payload: await getPayload({ config }), locale, slug })\n * }\n * ```\n */\nexport declare function createPageMetadata(args: CreatePageMetadataArgs): ({ slug, locale, page, payload, }: PageMetadataContext) => Promise;\nexport {};\n"],"names":[],"mappings":"AA0DA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/createPageMetadata.d.ts b/dist/modules/seo/createPageMetadata.d.ts new file mode 100644 index 0000000..8bc8dbd --- /dev/null +++ b/dist/modules/seo/createPageMetadata.d.ts @@ -0,0 +1,59 @@ +import type { BasePayload } from 'payload'; +import type { ContentOption } from '../content/index.js'; +import type { I18nConfig } from '../i18n/index.js'; +import type { PageMetadata } from './buildMetadata.js'; +type CreatePageMetadataArgs = { + /** Absolute site origin, e.g. 'https://example.com'. */ + baseUrl?: string; + /** Collection holding pages. Defaults to 'pages'. */ + collection?: string; + config: I18nConfig; + /** + * Archive-backed collections, same value as the plugin option. Pass it and + * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang; + * omit it and only pages are handled. + */ + content?: ContentOption; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; + /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */ + siteNameField?: string; +}; +type PageMetadataContext = { + locale: string; + /** + * Page number from ?page= on an archive listing. Pass it and page 2 gets a + * canonical to itself; leave it out and every page claims to be page 1. + */ + page?: number; + payload: BasePayload; + /** Route slug segments; empty/undefined means the locale root. */ + slug?: string[]; +}; +/** + * Metadata for the page route, with every resolver already wired. + * + * `createMetadataGenerator` asks the client for resolvers because it can't know + * their collections. But for the plugin's own conventions it does know: pages + * live in one collection, the site name sits on SiteSettings, the OG image sits + * on the plugin-seo `meta` group, the home page is whatever System Pages points + * at, and — with `content` — entries live under their collection's archive page. + * Re-declaring all that in every project is copy-paste, so this resolves it. + * + * Reach for `createMetadataGenerator` instead when a route doesn't follow those + * conventions — custom image logic, a different global, hand-rolled paths. + * + * The plugin never calls getPayload, and Next calls generateMetadata without a + * payload, so the client keeps a small wrapper: + * + * ```ts + * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content }) + * + * export async function generateMetadata({ params }) { + * const { locale, slug } = await params + * return pageMetadata({ payload: await getPayload({ config }), locale, slug }) + * } + * ``` + */ +export declare function createPageMetadata(args: CreatePageMetadataArgs): ({ slug, locale, page, payload, }: PageMetadataContext) => Promise; +export {}; diff --git a/dist/modules/seo/createPageMetadata.js b/dist/modules/seo/createPageMetadata.js new file mode 100644 index 0000000..0880024 --- /dev/null +++ b/dist/modules/seo/createPageMetadata.js @@ -0,0 +1,100 @@ +import { resolveRoute } from '../content/index.js'; +import { buildMetadata } from './buildMetadata.js'; +import { readSiteMetaConfig } from './readSiteMetaConfig.js'; +import { slugsAcrossLocales } from './slugsAcrossLocales.js'; +/** plugin-seo stores the OG image as an upload relationship. */ function resolveOgImage(doc) { + const image = doc.meta?.image; + if (image && typeof image === 'object' && 'url' in image) { + return image.url ?? null; + } + return null; +} +/** + * Metadata for the page route, with every resolver already wired. + * + * `createMetadataGenerator` asks the client for resolvers because it can't know + * their collections. But for the plugin's own conventions it does know: pages + * live in one collection, the site name sits on SiteSettings, the OG image sits + * on the plugin-seo `meta` group, the home page is whatever System Pages points + * at, and — with `content` — entries live under their collection's archive page. + * Re-declaring all that in every project is copy-paste, so this resolves it. + * + * Reach for `createMetadataGenerator` instead when a route doesn't follow those + * conventions — custom image logic, a different global, hand-rolled paths. + * + * The plugin never calls getPayload, and Next calls generateMetadata without a + * payload, so the client keeps a small wrapper: + * + * ```ts + * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content }) + * + * export async function generateMetadata({ params }) { + * const { locale, slug } = await params + * return pageMetadata({ payload: await getPayload({ config }), locale, slug }) + * } + * ``` + */ export function createPageMetadata(args) { + const { baseUrl, collection = 'pages', config, content, settingsSlug = 'site-settings', siteNameField = 'siteName' } = args; + return async function pageMetadata({ slug, locale, page, payload }) { + const site = await readSiteMetaConfig({ + locale, + payload, + settingsSlug, + siteNameField + }); + const base = { + baseUrl, + config, + homeSlug: site.homeSlug, + locale, + order: site.order, + separator: site.separator, + siteName: site.siteName + }; + const route = await resolveRoute({ + content, + locale, + page, + pagesSlug: collection, + payload, + segments: slug, + settingsSlug + }); + // Unknown route (the page component will 404) — still return something + // coherent rather than throwing during metadata generation. + if (!route) { + return buildMetadata({ + ...base, + meta: null, + slugs: {} + }); + } + const doc = route.doc; + // An entry sits under its archive, so its URLs need that segment — and the + // segment differs per locale, since it's the archive page's own slug. + const prefix = route.type === 'entry' ? await slugsAcrossLocales({ + id: route.archive.id, + collection, + config, + payload + }) : undefined; + const slugs = await slugsAcrossLocales({ + id: doc.id, + collection: route.type === 'entry' ? route.collection : collection, + config, + payload + }); + // Page 2 of a listing is its own URL, not a variant of page 1. + const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined; + return buildMetadata({ + ...base, + imageUrl: resolveOgImage(doc), + meta: doc.meta, + prefix, + query, + slugs + }); + }; +} + +//# sourceMappingURL=createPageMetadata.js.map \ No newline at end of file diff --git a/dist/modules/seo/createPageMetadata.js.map b/dist/modules/seo/createPageMetadata.js.map new file mode 100644 index 0000000..e1b3faf --- /dev/null +++ b/dist/modules/seo/createPageMetadata.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/createPageMetadata.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { resolveRoute } from '../content/index.js'\nimport { buildMetadata } from './buildMetadata.js'\nimport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nimport { slugsAcrossLocales } from './slugsAcrossLocales.js'\n\ntype CreatePageMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n /** Collection holding pages. Defaults to 'pages'. */\n collection?: string\n config: I18nConfig\n /**\n * Archive-backed collections, same value as the plugin option. Pass it and\n * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang;\n * omit it and only pages are handled.\n */\n content?: ContentOption\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */\n siteNameField?: string\n}\n\ntype PageMetadataContext = {\n locale: string\n /**\n * Page number from ?page= on an archive listing. Pass it and page 2 gets a\n * canonical to itself; leave it out and every page claims to be page 1.\n */\n page?: number\n payload: BasePayload\n /** Route slug segments; empty/undefined means the locale root. */\n slug?: string[]\n}\n\ntype DocShape = {\n id: number | string\n meta?: null | SeoMeta\n}\n\n/** plugin-seo stores the OG image as an upload relationship. */\nfunction resolveOgImage(doc: DocShape): null | string {\n const image = (doc.meta as { image?: unknown } | null | undefined)?.image\n if (image && typeof image === 'object' && 'url' in image) {\n return (image as { url: string }).url ?? null\n }\n return null\n}\n\n/**\n * Metadata for the page route, with every resolver already wired.\n *\n * `createMetadataGenerator` asks the client for resolvers because it can't know\n * their collections. But for the plugin's own conventions it does know: pages\n * live in one collection, the site name sits on SiteSettings, the OG image sits\n * on the plugin-seo `meta` group, the home page is whatever System Pages points\n * at, and — with `content` — entries live under their collection's archive page.\n * Re-declaring all that in every project is copy-paste, so this resolves it.\n *\n * Reach for `createMetadataGenerator` instead when a route doesn't follow those\n * conventions — custom image logic, a different global, hand-rolled paths.\n *\n * The plugin never calls getPayload, and Next calls generateMetadata without a\n * payload, so the client keeps a small wrapper:\n *\n * ```ts\n * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * return pageMetadata({ payload: await getPayload({ config }), locale, slug })\n * }\n * ```\n */\nexport function createPageMetadata(args: CreatePageMetadataArgs) {\n const {\n baseUrl,\n collection = 'pages',\n config,\n content,\n settingsSlug = 'site-settings',\n siteNameField = 'siteName',\n } = args\n\n return async function pageMetadata({\n slug,\n locale,\n page,\n payload,\n }: PageMetadataContext): Promise {\n const site = await readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField })\n\n const base = {\n baseUrl,\n config,\n homeSlug: site.homeSlug,\n locale,\n order: site.order,\n separator: site.separator,\n siteName: site.siteName,\n }\n\n const route = await resolveRoute({\n content,\n locale,\n page,\n pagesSlug: collection,\n payload,\n segments: slug,\n settingsSlug,\n })\n\n // Unknown route (the page component will 404) — still return something\n // coherent rather than throwing during metadata generation.\n if (!route) {\n return buildMetadata({ ...base, meta: null, slugs: {} })\n }\n\n const doc = route.doc as DocShape\n\n // An entry sits under its archive, so its URLs need that segment — and the\n // segment differs per locale, since it's the archive page's own slug.\n const prefix =\n route.type === 'entry'\n ? await slugsAcrossLocales({\n id: (route.archive as DocShape).id,\n collection,\n config,\n payload,\n })\n : undefined\n\n const slugs = await slugsAcrossLocales({\n id: doc.id,\n collection: route.type === 'entry' ? route.collection : collection,\n config,\n payload,\n })\n\n // Page 2 of a listing is its own URL, not a variant of page 1.\n const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined\n\n return buildMetadata({\n ...base,\n imageUrl: resolveOgImage(doc),\n meta: doc.meta,\n prefix,\n query,\n slugs,\n })\n }\n}\n"],"names":["resolveRoute","buildMetadata","readSiteMetaConfig","slugsAcrossLocales","resolveOgImage","doc","image","meta","url","createPageMetadata","args","baseUrl","collection","config","content","settingsSlug","siteNameField","pageMetadata","slug","locale","page","payload","site","base","homeSlug","order","separator","siteName","route","pagesSlug","segments","slugs","prefix","type","id","archive","undefined","query","imageUrl"],"mappings":"AAOA,SAASA,YAAY,QAAQ,sBAAqB;AAClD,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,kBAAkB,QAAQ,0BAAyB;AAqC5D,8DAA8D,GAC9D,SAASC,eAAeC,GAAa;IACnC,MAAMC,QAASD,IAAIE,IAAI,EAA6CD;IACpE,IAAIA,SAAS,OAAOA,UAAU,YAAY,SAASA,OAAO;QACxD,OAAO,AAACA,MAA0BE,GAAG,IAAI;IAC3C;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,mBAAmBC,IAA4B;IAC7D,MAAM,EACJC,OAAO,EACPC,aAAa,OAAO,EACpBC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,gBAAgB,UAAU,EAC3B,GAAGN;IAEJ,OAAO,eAAeO,aAAa,EACjCC,IAAI,EACJC,MAAM,EACNC,IAAI,EACJC,OAAO,EACa;QACpB,MAAMC,OAAO,MAAMpB,mBAAmB;YAAEiB;YAAQE;YAASN;YAAcC;QAAc;QAErF,MAAMO,OAAO;YACXZ;YACAE;YACAW,UAAUF,KAAKE,QAAQ;YACvBL;YACAM,OAAOH,KAAKG,KAAK;YACjBC,WAAWJ,KAAKI,SAAS;YACzBC,UAAUL,KAAKK,QAAQ;QACzB;QAEA,MAAMC,QAAQ,MAAM5B,aAAa;YAC/Bc;YACAK;YACAC;YACAS,WAAWjB;YACXS;YACAS,UAAUZ;YACVH;QACF;QAEA,uEAAuE;QACvE,4DAA4D;QAC5D,IAAI,CAACa,OAAO;YACV,OAAO3B,cAAc;gBAAE,GAAGsB,IAAI;gBAAEhB,MAAM;gBAAMwB,OAAO,CAAC;YAAE;QACxD;QAEA,MAAM1B,MAAMuB,MAAMvB,GAAG;QAErB,2EAA2E;QAC3E,sEAAsE;QACtE,MAAM2B,SACJJ,MAAMK,IAAI,KAAK,UACX,MAAM9B,mBAAmB;YACvB+B,IAAI,AAACN,MAAMO,OAAO,CAAcD,EAAE;YAClCtB;YACAC;YACAQ;QACF,KACAe;QAEN,MAAML,QAAQ,MAAM5B,mBAAmB;YACrC+B,IAAI7B,IAAI6B,EAAE;YACVtB,YAAYgB,MAAMK,IAAI,KAAK,UAAUL,MAAMhB,UAAU,GAAGA;YACxDC;YACAQ;QACF;QAEA,+DAA+D;QAC/D,MAAMgB,QAAQT,MAAMK,IAAI,KAAK,aAAaL,MAAMR,IAAI,GAAG,IAAI,CAAC,MAAM,EAAEQ,MAAMR,IAAI,EAAE,GAAGgB;QAEnF,OAAOnC,cAAc;YACnB,GAAGsB,IAAI;YACPe,UAAUlC,eAAeC;YACzBE,MAAMF,IAAIE,IAAI;YACdyB;YACAK;YACAN;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/hreflang.d.js b/dist/modules/seo/hreflang.d.js new file mode 100644 index 0000000..187a70a --- /dev/null +++ b/dist/modules/seo/hreflang.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=hreflang.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/hreflang.d.js.map b/dist/modules/seo/hreflang.d.js.map new file mode 100644 index 0000000..95e305b --- /dev/null +++ b/dist/modules/seo/hreflang.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["hreflang.d.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string;\n config: I18nConfig;\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string;\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs;\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs;\n};\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record;\nexport {};\n"],"names":[],"mappings":"AAiCA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/hreflang.d.ts b/dist/modules/seo/hreflang.d.ts new file mode 100644 index 0000000..f559f0d --- /dev/null +++ b/dist/modules/seo/hreflang.d.ts @@ -0,0 +1,34 @@ +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'; +type BuildHreflangArgs = { + /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */ + baseUrl?: string; + config: I18nConfig; + /** Home slug that collapses to the locale root. Defaults to 'home'. */ + homeSlug?: string; + /** + * Localized segment the document lives under (an archive page's slugs), + * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix + * are omitted — an entry with no archive in that language has no URL there. + */ + prefix?: LocalizedSlugs; + /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */ + slugs: LocalizedSlugs; +}; +/** + * Builds a map of locale → URL for hreflang alternate links, suitable for + * Next.js Metadata `alternates.languages`. + * + * Bridges SEO and i18n: for each configured locale that the document has a + * slug in, it produces the locale-aware path (via buildLocalizedPath), + * optionally prefixed with an absolute origin. + * + * @example + * buildHreflangAlternates({ + * slugs: { pl: 'o-nas', en: 'about' }, + * config, + * baseUrl: 'https://example.com', + * }) + * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' } + */ +export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record; +export {}; diff --git a/dist/modules/seo/hreflang.js b/dist/modules/seo/hreflang.js new file mode 100644 index 0000000..0a74d4e --- /dev/null +++ b/dist/modules/seo/hreflang.js @@ -0,0 +1,35 @@ +import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'; +/** + * Builds a map of locale → URL for hreflang alternate links, suitable for + * Next.js Metadata `alternates.languages`. + * + * Bridges SEO and i18n: for each configured locale that the document has a + * slug in, it produces the locale-aware path (via buildLocalizedPath), + * optionally prefixed with an absolute origin. + * + * @example + * buildHreflangAlternates({ + * slugs: { pl: 'o-nas', en: 'about' }, + * config, + * baseUrl: 'https://example.com', + * }) + * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' } + */ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) { + const origin = baseUrl?.replace(/\/$/, '') ?? ''; + const alternates = {}; + for (const locale of getLocaleCodes(config)){ + const path = buildLocalizedPath({ + config, + homeSlug, + locale, + prefix, + slugs + }); + if (path) { + alternates[locale] = `${origin}${path}`; + } + } + return alternates; +} + +//# sourceMappingURL=hreflang.js.map \ No newline at end of file diff --git a/dist/modules/seo/hreflang.js.map b/dist/modules/seo/hreflang.js.map new file mode 100644 index 0000000..4ca97fe --- /dev/null +++ b/dist/modules/seo/hreflang.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOZ,mBAAmB;YAAEI;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,OAAOF;AACT"} \ No newline at end of file diff --git a/dist/modules/seo/index.d.js b/dist/modules/seo/index.d.js new file mode 100644 index 0000000..aefea18 --- /dev/null +++ b/dist/modules/seo/index.d.js @@ -0,0 +1,15 @@ +export { buildAutoFillMetaHook } from './autoFillMeta.js'; +export { buildMetadata } from './buildMetadata.js'; +export { buildRobots } from './buildRobots.js'; +export { buildSitemapEntries } from './buildSitemapEntries.js'; +export { composeTitle } from './composeTitle.js'; +export { createMetadataGenerator } from './createMetadataGenerator.js'; +export { createPageMetadata } from './createPageMetadata.js'; +export { buildHreflangAlternates } from './hreflang.js'; +export { injectAutoFillMeta } from './injectAutoFillMeta.js'; +export { injectSeoTabs } from './injectSeoTabs.js'; +export { readSiteMetaConfig } from './readSiteMetaConfig.js'; +export { buildSeoPlugin } from './seoPluginConfig.js'; +export { slugsAcrossLocales } from './slugsAcrossLocales.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/index.d.js.map b/dist/modules/seo/index.d.js.map new file mode 100644 index 0000000..ce33326 --- /dev/null +++ b/dist/modules/seo/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js';\nexport type { AutoFillMapping } from './autoFillMeta.js';\nexport { buildMetadata } from './buildMetadata.js';\nexport type { PageMetadata } from './buildMetadata.js';\nexport { buildRobots } from './buildRobots.js';\nexport type { RobotsRules } from './buildRobots.js';\nexport { buildSitemapEntries } from './buildSitemapEntries.js';\nexport type { SitemapEntry } from './buildSitemapEntries.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';\n"],"names":["buildAutoFillMetaHook","buildMetadata","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAoB;AAE1D,SAASC,aAAa,QAAQ,qBAAqB;AAEnD,SAASC,WAAW,QAAQ,mBAAmB;AAE/C,SAASC,mBAAmB,QAAQ,2BAA2B;AAE/D,SAASC,YAAY,QAAQ,oBAAoB;AAEjD,SAASC,uBAAuB,QAAQ,+BAA+B;AACvE,SAASC,kBAAkB,QAAQ,0BAA0B;AAC7D,SAASC,uBAAuB,QAAQ,gBAAgB;AACxD,SAASC,kBAAkB,QAAQ,0BAA0B;AAC7D,SAASC,aAAa,QAAQ,qBAAqB;AACnD,SAASC,kBAAkB,QAAQ,0BAA0B;AAE7D,SAASC,cAAc,QAAQ,uBAAuB;AACtD,SAASC,kBAAkB,QAAQ,0BAA0B"} \ No newline at end of file diff --git a/dist/modules/seo/index.d.ts b/dist/modules/seo/index.d.ts new file mode 100644 index 0000000..46841ff --- /dev/null +++ b/dist/modules/seo/index.d.ts @@ -0,0 +1,20 @@ +export { buildAutoFillMetaHook } from './autoFillMeta.js'; +export type { AutoFillMapping } from './autoFillMeta.js'; +export { buildMetadata } from './buildMetadata.js'; +export type { PageMetadata } from './buildMetadata.js'; +export { buildRobots } from './buildRobots.js'; +export type { RobotsRules } from './buildRobots.js'; +export { buildSitemapEntries } from './buildSitemapEntries.js'; +export type { SitemapEntry } from './buildSitemapEntries.js'; +export { composeTitle } from './composeTitle.js'; +export type { TitleOrder } from './composeTitle.js'; +export { createMetadataGenerator } from './createMetadataGenerator.js'; +export { createPageMetadata } from './createPageMetadata.js'; +export { buildHreflangAlternates } from './hreflang.js'; +export { injectAutoFillMeta } from './injectAutoFillMeta.js'; +export { injectSeoTabs } from './injectSeoTabs.js'; +export { readSiteMetaConfig } from './readSiteMetaConfig.js'; +export type { SiteMetaConfig } from './readSiteMetaConfig.js'; +export { buildSeoPlugin } from './seoPluginConfig.js'; +export { slugsAcrossLocales } from './slugsAcrossLocales.js'; +export type { SeoMeta, SeoOption } from './types.js'; diff --git a/dist/modules/seo/index.js b/dist/modules/seo/index.js new file mode 100644 index 0000000..ef3cc66 --- /dev/null +++ b/dist/modules/seo/index.js @@ -0,0 +1,15 @@ +export { buildAutoFillMetaHook } from './autoFillMeta.js'; +export { buildMetadata } from './buildMetadata.js'; +export { buildRobots } from './buildRobots.js'; +export { buildSitemapEntries } from './buildSitemapEntries.js'; +export { composeTitle } from './composeTitle.js'; +export { createMetadataGenerator } from './createMetadataGenerator.js'; +export { createPageMetadata } from './createPageMetadata.js'; +export { buildHreflangAlternates } from './hreflang.js'; +export { injectAutoFillMeta } from './injectAutoFillMeta.js'; +export { injectSeoTabs } from './injectSeoTabs.js'; +export { readSiteMetaConfig } from './readSiteMetaConfig.js'; +export { buildSeoPlugin } from './seoPluginConfig.js'; +export { slugsAcrossLocales } from './slugsAcrossLocales.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/seo/index.js.map b/dist/modules/seo/index.js.map new file mode 100644 index 0000000..7917a96 --- /dev/null +++ b/dist/modules/seo/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.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'\n"],"names":["buildAutoFillMetaHook","buildMetadata","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,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"} \ No newline at end of file diff --git a/dist/modules/seo/injectAutoFillMeta.d.js b/dist/modules/seo/injectAutoFillMeta.d.js new file mode 100644 index 0000000..221c8f4 --- /dev/null +++ b/dist/modules/seo/injectAutoFillMeta.d.js @@ -0,0 +1,10 @@ +/** + * Injects the auto-fill meta hook into every SEO-enabled collection. + * + * Runs after @payloadcms/plugin-seo has added the meta group, appending a + * beforeChange hook that fills empty meta from document content. Existing + * hooks are preserved (plugin hook runs last, so editor input and other hooks + * win first). + */ export { }; + +//# sourceMappingURL=injectAutoFillMeta.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/injectAutoFillMeta.d.js.map b/dist/modules/seo/injectAutoFillMeta.d.js.map new file mode 100644 index 0000000..68545b3 --- /dev/null +++ b/dist/modules/seo/injectAutoFillMeta.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["injectAutoFillMeta.d.ts"],"sourcesContent":["import type { Config } from 'payload';\nimport type { SeoOption } from './types.js';\n/**\n * Injects the auto-fill meta hook into every SEO-enabled collection.\n *\n * Runs after @payloadcms/plugin-seo has added the meta group, appending a\n * beforeChange hook that fills empty meta from document content. Existing\n * hooks are preserved (plugin hook runs last, so editor input and other hooks\n * win first).\n */\nexport declare function injectAutoFillMeta(config: Config, seo: SeoOption): Config;\n"],"names":[],"mappings":"AAEA;;;;;;;CAOC,GACD,WAAmF"} \ No newline at end of file diff --git a/dist/modules/seo/injectAutoFillMeta.d.ts b/dist/modules/seo/injectAutoFillMeta.d.ts new file mode 100644 index 0000000..5f4aa09 --- /dev/null +++ b/dist/modules/seo/injectAutoFillMeta.d.ts @@ -0,0 +1,11 @@ +import type { Config } from 'payload'; +import type { SeoOption } from './types.js'; +/** + * Injects the auto-fill meta hook into every SEO-enabled collection. + * + * Runs after @payloadcms/plugin-seo has added the meta group, appending a + * beforeChange hook that fills empty meta from document content. Existing + * hooks are preserved (plugin hook runs last, so editor input and other hooks + * win first). + */ +export declare function injectAutoFillMeta(config: Config, seo: SeoOption): Config; diff --git a/dist/modules/seo/injectAutoFillMeta.js b/dist/modules/seo/injectAutoFillMeta.js new file mode 100644 index 0000000..cdb8518 --- /dev/null +++ b/dist/modules/seo/injectAutoFillMeta.js @@ -0,0 +1,35 @@ +import { buildAutoFillMetaHook } from './autoFillMeta.js'; +/** + * Injects the auto-fill meta hook into every SEO-enabled collection. + * + * Runs after @payloadcms/plugin-seo has added the meta group, appending a + * beforeChange hook that fills empty meta from document content. Existing + * hooks are preserved (plugin hook runs last, so editor input and other hooks + * win first). + */ export function injectAutoFillMeta(config, seo) { + // autoFill disabled explicitly — leave collections untouched + if (seo.autoFill === false) { + return config; + } + const hook = buildAutoFillMetaHook(seo.autoFill); + return { + ...config, + collections: (config.collections ?? []).map((collection)=>{ + if (!seo.collections.includes(collection.slug)) { + return collection; + } + return { + ...collection, + hooks: { + ...collection.hooks, + beforeChange: [ + ...collection.hooks?.beforeChange ?? [], + hook + ] + } + }; + }) + }; +} + +//# sourceMappingURL=injectAutoFillMeta.js.map \ No newline at end of file diff --git a/dist/modules/seo/injectAutoFillMeta.js.map b/dist/modules/seo/injectAutoFillMeta.js.map new file mode 100644 index 0000000..8d2a54b --- /dev/null +++ b/dist/modules/seo/injectAutoFillMeta.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/injectAutoFillMeta.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { SeoOption } from './types.js'\n\nimport { buildAutoFillMetaHook } from './autoFillMeta.js'\n\n/**\n * Injects the auto-fill meta hook into every SEO-enabled collection.\n *\n * Runs after @payloadcms/plugin-seo has added the meta group, appending a\n * beforeChange hook that fills empty meta from document content. Existing\n * hooks are preserved (plugin hook runs last, so editor input and other hooks\n * win first).\n */\nexport function injectAutoFillMeta(config: Config, seo: SeoOption): Config {\n // autoFill disabled explicitly — leave collections untouched\n if (seo.autoFill === false) {\n return config\n }\n\n const hook = buildAutoFillMetaHook(seo.autoFill)\n\n return {\n ...config,\n collections: (config.collections ?? []).map((collection) => {\n if (!seo.collections.includes(collection.slug)) {\n return collection\n }\n return {\n ...collection,\n hooks: {\n ...collection.hooks,\n beforeChange: [...(collection.hooks?.beforeChange ?? []), hook],\n },\n }\n }),\n }\n}\n"],"names":["buildAutoFillMetaHook","injectAutoFillMeta","config","seo","autoFill","hook","collections","map","collection","includes","slug","hooks","beforeChange"],"mappings":"AAIA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD;;;;;;;CAOC,GACD,OAAO,SAASC,mBAAmBC,MAAc,EAAEC,GAAc;IAC/D,6DAA6D;IAC7D,IAAIA,IAAIC,QAAQ,KAAK,OAAO;QAC1B,OAAOF;IACT;IAEA,MAAMG,OAAOL,sBAAsBG,IAAIC,QAAQ;IAE/C,OAAO;QACL,GAAGF,MAAM;QACTI,aAAa,AAACJ,CAAAA,OAAOI,WAAW,IAAI,EAAE,AAAD,EAAGC,GAAG,CAAC,CAACC;YAC3C,IAAI,CAACL,IAAIG,WAAW,CAACG,QAAQ,CAACD,WAAWE,IAAI,GAAG;gBAC9C,OAAOF;YACT;YACA,OAAO;gBACL,GAAGA,UAAU;gBACbG,OAAO;oBACL,GAAGH,WAAWG,KAAK;oBACnBC,cAAc;2BAAKJ,WAAWG,KAAK,EAAEC,gBAAgB,EAAE;wBAAGP;qBAAK;gBACjE;YACF;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/injectSeoTabs.d.js b/dist/modules/seo/injectSeoTabs.d.js new file mode 100644 index 0000000..2053e8e --- /dev/null +++ b/dist/modules/seo/injectSeoTabs.d.js @@ -0,0 +1,16 @@ +/** + * Wraps each SEO-enabled collection's fields into a tabbed UI: a "Content" tab + * holding the collection's own fields, and an "SEO" tab holding the `meta` + * group that @payloadcms/plugin-seo added. + * + * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting + * the first field and breaks when other plugins/fields (roles, slug) already + * sit in the collection — producing an empty SEO tab. Running this AFTER + * plugin-seo (so `meta` already exists) and after other field injections lets + * us build the tabs deterministically. + * + * If a collection's first field is already a tabs field, the SEO tab is + * appended to it instead of creating a new wrapper. + */ export { }; + +//# sourceMappingURL=injectSeoTabs.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/injectSeoTabs.d.js.map b/dist/modules/seo/injectSeoTabs.d.js.map new file mode 100644 index 0000000..b53750b --- /dev/null +++ b/dist/modules/seo/injectSeoTabs.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["injectSeoTabs.d.ts"],"sourcesContent":["import type { Config } from 'payload';\nimport type { SeoOption } from './types.js';\n/**\n * Wraps each SEO-enabled collection's fields into a tabbed UI: a \"Content\" tab\n * holding the collection's own fields, and an \"SEO\" tab holding the `meta`\n * group that @payloadcms/plugin-seo added.\n *\n * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting\n * the first field and breaks when other plugins/fields (roles, slug) already\n * sit in the collection — producing an empty SEO tab. Running this AFTER\n * plugin-seo (so `meta` already exists) and after other field injections lets\n * us build the tabs deterministically.\n *\n * If a collection's first field is already a tabs field, the SEO tab is\n * appended to it instead of creating a new wrapper.\n */\nexport declare function injectSeoTabs(config: Config, seo: SeoOption): Config;\n"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;CAaC,GACD,WAA8E"} \ No newline at end of file diff --git a/dist/modules/seo/injectSeoTabs.d.ts b/dist/modules/seo/injectSeoTabs.d.ts new file mode 100644 index 0000000..a725771 --- /dev/null +++ b/dist/modules/seo/injectSeoTabs.d.ts @@ -0,0 +1,17 @@ +import type { Config } from 'payload'; +import type { SeoOption } from './types.js'; +/** + * Wraps each SEO-enabled collection's fields into a tabbed UI: a "Content" tab + * holding the collection's own fields, and an "SEO" tab holding the `meta` + * group that @payloadcms/plugin-seo added. + * + * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting + * the first field and breaks when other plugins/fields (roles, slug) already + * sit in the collection — producing an empty SEO tab. Running this AFTER + * plugin-seo (so `meta` already exists) and after other field injections lets + * us build the tabs deterministically. + * + * If a collection's first field is already a tabs field, the SEO tab is + * appended to it instead of creating a new wrapper. + */ +export declare function injectSeoTabs(config: Config, seo: SeoOption): Config; diff --git a/dist/modules/seo/injectSeoTabs.js b/dist/modules/seo/injectSeoTabs.js new file mode 100644 index 0000000..c7ba4c4 --- /dev/null +++ b/dist/modules/seo/injectSeoTabs.js @@ -0,0 +1,79 @@ +/** + * Wraps each SEO-enabled collection's fields into a tabbed UI: a "Content" tab + * holding the collection's own fields, and an "SEO" tab holding the `meta` + * group that @payloadcms/plugin-seo added. + * + * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting + * the first field and breaks when other plugins/fields (roles, slug) already + * sit in the collection — producing an empty SEO tab. Running this AFTER + * plugin-seo (so `meta` already exists) and after other field injections lets + * us build the tabs deterministically. + * + * If a collection's first field is already a tabs field, the SEO tab is + * appended to it instead of creating a new wrapper. + */ export function injectSeoTabs(config, seo) { + return { + ...config, + collections: (config.collections ?? []).map((collection)=>{ + if (!seo.collections.includes(collection.slug)) { + return collection; + } + const fields = collection.fields ?? []; + // Separate the meta group (added by plugin-seo) from the rest. + const metaField = fields.find((f)=>'name' in f && f.name === 'meta'); + const otherFields = fields.filter((f)=>!('name' in f && f.name === 'meta')); + // Nothing to do if plugin-seo hasn't added meta (shouldn't happen). + if (!metaField) { + return collection; + } + // If the collection already leads with a tabs field, append an SEO tab. + const firstField = otherFields[0]; + if (firstField && firstField.type === 'tabs') { + const existingTabs = firstField; + const withSeoTab = { + ...existingTabs, + tabs: [ + ...existingTabs.tabs, + { + fields: [ + metaField + ], + label: 'SEO' + } + ] + }; + return { + ...collection, + fields: [ + withSeoTab, + ...otherFields.slice(1) + ] + }; + } + // Otherwise wrap everything: Content tab + SEO tab. + const tabs = { + type: 'tabs', + tabs: [ + { + fields: otherFields, + label: collection.labels?.singular?.toString() || 'Content' + }, + { + fields: [ + metaField + ], + label: 'SEO' + } + ] + }; + return { + ...collection, + fields: [ + tabs + ] + }; + }) + }; +} + +//# sourceMappingURL=injectSeoTabs.js.map \ No newline at end of file diff --git a/dist/modules/seo/injectSeoTabs.js.map b/dist/modules/seo/injectSeoTabs.js.map new file mode 100644 index 0000000..0b5dcfc --- /dev/null +++ b/dist/modules/seo/injectSeoTabs.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/injectSeoTabs.ts"],"sourcesContent":["import type { Config, Field, TabsField } from 'payload'\n\nimport type { SeoOption } from './types.js'\n\n/**\n * Wraps each SEO-enabled collection's fields into a tabbed UI: a \"Content\" tab\n * holding the collection's own fields, and an \"SEO\" tab holding the `meta`\n * group that @payloadcms/plugin-seo added.\n *\n * This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting\n * the first field and breaks when other plugins/fields (roles, slug) already\n * sit in the collection — producing an empty SEO tab. Running this AFTER\n * plugin-seo (so `meta` already exists) and after other field injections lets\n * us build the tabs deterministically.\n *\n * If a collection's first field is already a tabs field, the SEO tab is\n * appended to it instead of creating a new wrapper.\n */\nexport function injectSeoTabs(config: Config, seo: SeoOption): Config {\n return {\n ...config,\n collections: (config.collections ?? []).map((collection) => {\n if (!seo.collections.includes(collection.slug)) {\n return collection\n }\n\n const fields = collection.fields ?? []\n\n // Separate the meta group (added by plugin-seo) from the rest.\n const metaField = fields.find(\n (f): f is { name: string } & Field => 'name' in f && f.name === 'meta',\n )\n const otherFields = fields.filter(\n (f) => !('name' in f && f.name === 'meta'),\n )\n\n // Nothing to do if plugin-seo hasn't added meta (shouldn't happen).\n if (!metaField) {return collection}\n\n // If the collection already leads with a tabs field, append an SEO tab.\n const firstField = otherFields[0]\n if (firstField && firstField.type === 'tabs') {\n const existingTabs = firstField\n const withSeoTab: TabsField = {\n ...existingTabs,\n tabs: [...existingTabs.tabs, { fields: [metaField], label: 'SEO' }],\n }\n return {\n ...collection,\n fields: [withSeoTab, ...otherFields.slice(1)],\n }\n }\n\n // Otherwise wrap everything: Content tab + SEO tab.\n const tabs: TabsField = {\n type: 'tabs',\n tabs: [\n { fields: otherFields, label: collection.labels?.singular?.toString() || 'Content' },\n { fields: [metaField], label: 'SEO' },\n ],\n }\n\n return {\n ...collection,\n fields: [tabs],\n }\n }),\n }\n}\n"],"names":["injectSeoTabs","config","seo","collections","map","collection","includes","slug","fields","metaField","find","f","name","otherFields","filter","firstField","type","existingTabs","withSeoTab","tabs","label","slice","labels","singular","toString"],"mappings":"AAIA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASA,cAAcC,MAAc,EAAEC,GAAc;IAC1D,OAAO;QACL,GAAGD,MAAM;QACTE,aAAa,AAACF,CAAAA,OAAOE,WAAW,IAAI,EAAE,AAAD,EAAGC,GAAG,CAAC,CAACC;YAC3C,IAAI,CAACH,IAAIC,WAAW,CAACG,QAAQ,CAACD,WAAWE,IAAI,GAAG;gBAC9C,OAAOF;YACT;YAEA,MAAMG,SAASH,WAAWG,MAAM,IAAI,EAAE;YAEtC,+DAA+D;YAC/D,MAAMC,YAAYD,OAAOE,IAAI,CAC3B,CAACC,IAAqC,UAAUA,KAAKA,EAAEC,IAAI,KAAK;YAElE,MAAMC,cAAcL,OAAOM,MAAM,CAC/B,CAACH,IAAM,CAAE,CAAA,UAAUA,KAAKA,EAAEC,IAAI,KAAK,MAAK;YAG1C,oEAAoE;YACpE,IAAI,CAACH,WAAW;gBAAC,OAAOJ;YAAU;YAElC,wEAAwE;YACxE,MAAMU,aAAaF,WAAW,CAAC,EAAE;YACjC,IAAIE,cAAcA,WAAWC,IAAI,KAAK,QAAQ;gBAC5C,MAAMC,eAAeF;gBACrB,MAAMG,aAAwB;oBAC5B,GAAGD,YAAY;oBACfE,MAAM;2BAAIF,aAAaE,IAAI;wBAAE;4BAAEX,QAAQ;gCAACC;6BAAU;4BAAEW,OAAO;wBAAM;qBAAE;gBACrE;gBACA,OAAO;oBACL,GAAGf,UAAU;oBACbG,QAAQ;wBAACU;2BAAeL,YAAYQ,KAAK,CAAC;qBAAG;gBAC/C;YACF;YAEA,oDAAoD;YACpD,MAAMF,OAAkB;gBACtBH,MAAM;gBACNG,MAAM;oBACJ;wBAAEX,QAAQK;wBAAaO,OAAOf,WAAWiB,MAAM,EAAEC,UAAUC,cAAc;oBAAU;oBACnF;wBAAEhB,QAAQ;4BAACC;yBAAU;wBAAEW,OAAO;oBAAM;iBACrC;YACH;YAEA,OAAO;gBACL,GAAGf,UAAU;gBACbG,QAAQ;oBAACW;iBAAK;YAChB;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/readSiteMetaConfig.d.js b/dist/modules/seo/readSiteMetaConfig.d.js new file mode 100644 index 0000000..3503ba4 --- /dev/null +++ b/dist/modules/seo/readSiteMetaConfig.d.js @@ -0,0 +1,10 @@ +/** + * Reads the site-wide inputs to title and URL composition from SiteSettings. + * + * Split out of metadata assembly because these are one concern with one source: + * whatever an editor set in the panel. Metadata generation shouldn't also know + * that the separator arrives unpadded, or that the home slug hides inside a + * relationship — it should receive a resolved config. + */ export { }; + +//# sourceMappingURL=readSiteMetaConfig.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/readSiteMetaConfig.d.js.map b/dist/modules/seo/readSiteMetaConfig.d.js.map new file mode 100644 index 0000000..2f17e5d --- /dev/null +++ b/dist/modules/seo/readSiteMetaConfig.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["readSiteMetaConfig.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { TitleOrder } from './composeTitle.js';\nexport type SiteMetaConfig = {\n /** Slug of the page assigned as Homepage; collapses to the locale root. */\n homeSlug?: string;\n order?: TitleOrder;\n /** Padded separator, e.g. ' | ' — the panel stores the bare character. */\n separator?: string;\n siteName: null | string;\n};\n/**\n * Reads the site-wide inputs to title and URL composition from SiteSettings.\n *\n * Split out of metadata assembly because these are one concern with one source:\n * whatever an editor set in the panel. Metadata generation shouldn't also know\n * that the separator arrives unpadded, or that the home slug hides inside a\n * relationship — it should receive a resolved config.\n */\nexport declare function readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField, }: {\n locale: string;\n payload: BasePayload;\n settingsSlug?: string;\n siteNameField?: string;\n}): Promise;\n"],"names":[],"mappings":"AAUA;;;;;;;CAOC,GACD,WAK4B"} \ No newline at end of file diff --git a/dist/modules/seo/readSiteMetaConfig.d.ts b/dist/modules/seo/readSiteMetaConfig.d.ts new file mode 100644 index 0000000..a7ca12b --- /dev/null +++ b/dist/modules/seo/readSiteMetaConfig.d.ts @@ -0,0 +1,24 @@ +import type { BasePayload } from 'payload'; +import type { TitleOrder } from './composeTitle.js'; +export type SiteMetaConfig = { + /** Slug of the page assigned as Homepage; collapses to the locale root. */ + homeSlug?: string; + order?: TitleOrder; + /** Padded separator, e.g. ' | ' — the panel stores the bare character. */ + separator?: string; + siteName: null | string; +}; +/** + * Reads the site-wide inputs to title and URL composition from SiteSettings. + * + * Split out of metadata assembly because these are one concern with one source: + * whatever an editor set in the panel. Metadata generation shouldn't also know + * that the separator arrives unpadded, or that the home slug hides inside a + * relationship — it should receive a resolved config. + */ +export declare function readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField, }: { + locale: string; + payload: BasePayload; + settingsSlug?: string; + siteNameField?: string; +}): Promise; diff --git a/dist/modules/seo/readSiteMetaConfig.js b/dist/modules/seo/readSiteMetaConfig.js new file mode 100644 index 0000000..f6f8b61 --- /dev/null +++ b/dist/modules/seo/readSiteMetaConfig.js @@ -0,0 +1,26 @@ +/** + * Reads the site-wide inputs to title and URL composition from SiteSettings. + * + * Split out of metadata assembly because these are one concern with one source: + * whatever an editor set in the panel. Metadata generation shouldn't also know + * that the separator arrives unpadded, or that the home slug hides inside a + * relationship — it should receive a resolved config. + */ export async function readSiteMetaConfig({ locale, payload, settingsSlug = 'site-settings', siteNameField = 'siteName' }) { + // depth 1 populates the homepage relationship, so its slug is available + // without a second query. + const settings = await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: locale + }); + const homepage = settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null; + return { + siteName: settings[siteNameField] ?? null, + // The panel stores '|'; titles need it padded. + homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined, + order: settings.titleOrder ?? undefined, + separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined + }; +} + +//# sourceMappingURL=readSiteMetaConfig.js.map \ No newline at end of file diff --git a/dist/modules/seo/readSiteMetaConfig.js.map b/dist/modules/seo/readSiteMetaConfig.js.map new file mode 100644 index 0000000..940557a --- /dev/null +++ b/dist/modules/seo/readSiteMetaConfig.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/readSiteMetaConfig.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { TitleOrder } from './composeTitle.js'\n\ntype SettingsShape = {\n [key: string]: unknown\n homepage?: { id: number | string; slug?: unknown } | null | number | string\n titleOrder?: null | TitleOrder\n titleSeparator?: null | string\n}\n\nexport type SiteMetaConfig = {\n /** Slug of the page assigned as Homepage; collapses to the locale root. */\n homeSlug?: string\n order?: TitleOrder\n /** Padded separator, e.g. ' | ' — the panel stores the bare character. */\n separator?: string\n siteName: null | string\n}\n\n/**\n * Reads the site-wide inputs to title and URL composition from SiteSettings.\n *\n * Split out of metadata assembly because these are one concern with one source:\n * whatever an editor set in the panel. Metadata generation shouldn't also know\n * that the separator arrives unpadded, or that the home slug hides inside a\n * relationship — it should receive a resolved config.\n */\nexport async function readSiteMetaConfig({\n locale,\n payload,\n settingsSlug = 'site-settings',\n siteNameField = 'siteName',\n}: {\n locale: string\n payload: BasePayload\n settingsSlug?: string\n siteNameField?: string\n}): Promise {\n // depth 1 populates the homepage relationship, so its slug is available\n // without a second query.\n const settings = (await payload.findGlobal({\n slug: settingsSlug as never,\n depth: 1,\n locale: locale as never,\n })) as SettingsShape\n\n const homepage =\n settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null\n\n return {\n siteName: (settings[siteNameField] as string | undefined) ?? null,\n // The panel stores '|'; titles need it padded.\n homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,\n order: settings.titleOrder ?? undefined,\n separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,\n }\n}\n"],"names":["readSiteMetaConfig","locale","payload","settingsSlug","siteNameField","settings","findGlobal","slug","depth","homepage","siteName","homeSlug","undefined","order","titleOrder","separator","titleSeparator"],"mappings":"AAoBA;;;;;;;CAOC,GACD,OAAO,eAAeA,mBAAmB,EACvCC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,gBAAgB,UAAU,EAM3B;IACC,wEAAwE;IACxE,0BAA0B;IAC1B,MAAMC,WAAY,MAAMH,QAAQI,UAAU,CAAC;QACzCC,MAAMJ;QACNK,OAAO;QACPP,QAAQA;IACV;IAEA,MAAMQ,WACJJ,SAASI,QAAQ,IAAI,OAAOJ,SAASI,QAAQ,KAAK,WAAWJ,SAASI,QAAQ,GAAG;IAEnF,OAAO;QACLC,UAAU,AAACL,QAAQ,CAACD,cAAc,IAA2B;QAC7D,+CAA+C;QAC/CO,UAAU,OAAOF,UAAUF,SAAS,WAAWE,SAASF,IAAI,GAAGK;QAC/DC,OAAOR,SAASS,UAAU,IAAIF;QAC9BG,WAAWV,SAASW,cAAc,GAAG,CAAC,CAAC,EAAEX,SAASW,cAAc,CAAC,CAAC,CAAC,GAAGJ;IACxE;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/seoPluginConfig.d.js b/dist/modules/seo/seoPluginConfig.d.js new file mode 100644 index 0000000..a2e4af3 --- /dev/null +++ b/dist/modules/seo/seoPluginConfig.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=seoPluginConfig.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/seoPluginConfig.d.js.map b/dist/modules/seo/seoPluginConfig.d.js.map new file mode 100644 index 0000000..e8ee499 --- /dev/null +++ b/dist/modules/seo/seoPluginConfig.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["seoPluginConfig.d.ts"],"sourcesContent":["import type { Plugin } from 'payload';\nimport type { SeoOption } from './types.js';\ntype BuildSeoPluginArgs = {\n seo: SeoOption;\n /** Upload collection slug for the meta image (typically 'media'). */\n uploadsCollection?: string;\n};\n/**\n * Configures @payloadcms/plugin-seo from IPAL's SeoOption.\n *\n * Adds the `meta` field group (title, description, image) to the client's\n * chosen collections. tabbedUI is intentionally NOT used — it merges tabs by\n * inspecting the first field, which breaks when other plugins/fields (roles,\n * slug) already sit in the collection, leaving an empty SEO tab. Instead the\n * SEO UI fields are injected into a controlled tab by injectSeoTabs.\n */\nexport declare function buildSeoPlugin({ seo, uploadsCollection }: BuildSeoPluginArgs): Plugin;\nexport {};\n"],"names":[],"mappings":"AAiBA,WAAU"} \ No newline at end of file diff --git a/dist/modules/seo/seoPluginConfig.d.ts b/dist/modules/seo/seoPluginConfig.d.ts new file mode 100644 index 0000000..8060fc1 --- /dev/null +++ b/dist/modules/seo/seoPluginConfig.d.ts @@ -0,0 +1,18 @@ +import type { Plugin } from 'payload'; +import type { SeoOption } from './types.js'; +type BuildSeoPluginArgs = { + seo: SeoOption; + /** Upload collection slug for the meta image (typically 'media'). */ + uploadsCollection?: string; +}; +/** + * Configures @payloadcms/plugin-seo from IPAL's SeoOption. + * + * Adds the `meta` field group (title, description, image) to the client's + * chosen collections. tabbedUI is intentionally NOT used — it merges tabs by + * inspecting the first field, which breaks when other plugins/fields (roles, + * slug) already sit in the collection, leaving an empty SEO tab. Instead the + * SEO UI fields are injected into a controlled tab by injectSeoTabs. + */ +export declare function buildSeoPlugin({ seo, uploadsCollection }: BuildSeoPluginArgs): Plugin; +export {}; diff --git a/dist/modules/seo/seoPluginConfig.js b/dist/modules/seo/seoPluginConfig.js new file mode 100644 index 0000000..7976dc2 --- /dev/null +++ b/dist/modules/seo/seoPluginConfig.js @@ -0,0 +1,40 @@ +import { seoPlugin } from '@payloadcms/plugin-seo'; +/** + * Escape hatch from automatic title composition: whatever is typed here becomes + * the entire title — no site name, no separator. Useful on a home page, where + * composing would produce "Intecion Software | Intecion Software". + */ const titleOverrideField = { + name: 'titleOverride', + type: 'text', + admin: { + description: 'Use this exact text as the browser-tab title — no site name, no separator. Leave empty to compose the title automatically.' + }, + localized: true +}; +/** + * Configures @payloadcms/plugin-seo from IPAL's SeoOption. + * + * Adds the `meta` field group (title, description, image) to the client's + * chosen collections. tabbedUI is intentionally NOT used — it merges tabs by + * inspecting the first field, which breaks when other plugins/fields (roles, + * slug) already sit in the collection, leaving an empty SEO tab. Instead the + * SEO UI fields are injected into a controlled tab by injectSeoTabs. + */ export function buildSeoPlugin({ seo, uploadsCollection = 'media' }) { + return seoPlugin({ + collections: seo.collections, + fields: ({ defaultFields })=>[ + ...defaultFields, + titleOverrideField, + ...seo.fields ?? [] + ], + uploadsCollection, + ...seo.generateTitle && { + generateTitle: seo.generateTitle + }, + ...seo.generateDescription && { + generateDescription: seo.generateDescription + } + }); +} + +//# sourceMappingURL=seoPluginConfig.js.map \ No newline at end of file diff --git a/dist/modules/seo/seoPluginConfig.js.map b/dist/modules/seo/seoPluginConfig.js.map new file mode 100644 index 0000000..f6d6337 --- /dev/null +++ b/dist/modules/seo/seoPluginConfig.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/seoPluginConfig.ts"],"sourcesContent":["import type { Field, Plugin } from 'payload'\n\nimport { seoPlugin } from '@payloadcms/plugin-seo'\n\nimport type { SeoOption } from './types.js'\n\ntype BuildSeoPluginArgs = {\n seo: SeoOption\n /** Upload collection slug for the meta image (typically 'media'). */\n uploadsCollection?: string\n}\n\n/**\n * Escape hatch from automatic title composition: whatever is typed here becomes\n * the entire title — no site name, no separator. Useful on a home page, where\n * composing would produce \"Intecion Software | Intecion Software\".\n */\nconst titleOverrideField: Field = {\n name: 'titleOverride',\n type: 'text',\n admin: {\n description:\n 'Use this exact text as the browser-tab title — no site name, no separator. Leave empty to compose the title automatically.',\n },\n localized: true,\n}\n\n/**\n * Configures @payloadcms/plugin-seo from IPAL's SeoOption.\n *\n * Adds the `meta` field group (title, description, image) to the client's\n * chosen collections. tabbedUI is intentionally NOT used — it merges tabs by\n * inspecting the first field, which breaks when other plugins/fields (roles,\n * slug) already sit in the collection, leaving an empty SEO tab. Instead the\n * SEO UI fields are injected into a controlled tab by injectSeoTabs.\n */\nexport function buildSeoPlugin({ seo, uploadsCollection = 'media' }: BuildSeoPluginArgs): Plugin {\n return seoPlugin({\n collections: seo.collections,\n fields: ({ defaultFields }) => [...defaultFields, titleOverrideField, ...(seo.fields ?? [])],\n uploadsCollection,\n ...(seo.generateTitle && { generateTitle: seo.generateTitle }),\n ...(seo.generateDescription && { generateDescription: seo.generateDescription }),\n })\n}\n"],"names":["seoPlugin","titleOverrideField","name","type","admin","description","localized","buildSeoPlugin","seo","uploadsCollection","collections","fields","defaultFields","generateTitle","generateDescription"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAUlD;;;;CAIC,GACD,MAAMC,qBAA4B;IAChCC,MAAM;IACNC,MAAM;IACNC,OAAO;QACLC,aACE;IACJ;IACAC,WAAW;AACb;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,eAAe,EAAEC,GAAG,EAAEC,oBAAoB,OAAO,EAAsB;IACrF,OAAOT,UAAU;QACfU,aAAaF,IAAIE,WAAW;QAC5BC,QAAQ,CAAC,EAAEC,aAAa,EAAE,GAAK;mBAAIA;gBAAeX;mBAAwBO,IAAIG,MAAM,IAAI,EAAE;aAAE;QAC5FF;QACA,GAAID,IAAIK,aAAa,IAAI;YAAEA,eAAeL,IAAIK,aAAa;QAAC,CAAC;QAC7D,GAAIL,IAAIM,mBAAmB,IAAI;YAAEA,qBAAqBN,IAAIM,mBAAmB;QAAC,CAAC;IACjF;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/slugsAcrossLocales.d.js b/dist/modules/seo/slugsAcrossLocales.d.js new file mode 100644 index 0000000..6547b06 --- /dev/null +++ b/dist/modules/seo/slugsAcrossLocales.d.js @@ -0,0 +1,12 @@ +/** + * Reads a document's slug in every locale — the map hreflang alternates are + * built from. + * + * Needs its own query because a document fetched in one locale returns `slug` + * as a plain string. Fetching with `locale: 'all'` turns *every* localized + * field into a locale→value map, which is right for the slug and wrong for + * everything else (a title map would break title composition), so this asks + * only for what it needs and at depth 0. + */ export { }; + +//# sourceMappingURL=slugsAcrossLocales.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/slugsAcrossLocales.d.js.map b/dist/modules/seo/slugsAcrossLocales.d.js.map new file mode 100644 index 0000000..4ce6bda --- /dev/null +++ b/dist/modules/seo/slugsAcrossLocales.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["slugsAcrossLocales.d.ts"],"sourcesContent":["import type { BasePayload } from 'payload';\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';\n/**\n * Reads a document's slug in every locale — the map hreflang alternates are\n * built from.\n *\n * Needs its own query because a document fetched in one locale returns `slug`\n * as a plain string. Fetching with `locale: 'all'` turns *every* localized\n * field into a locale→value map, which is right for the slug and wrong for\n * everything else (a title map would break title composition), so this asks\n * only for what it needs and at depth 0.\n */\nexport declare function slugsAcrossLocales({ id, collection, config, payload, }: {\n collection: string;\n config: I18nConfig;\n id: number | string;\n payload: BasePayload;\n}): Promise;\n"],"names":[],"mappings":"AAEA;;;;;;;;;CASC,GACD,WAK4B"} \ No newline at end of file diff --git a/dist/modules/seo/slugsAcrossLocales.d.ts b/dist/modules/seo/slugsAcrossLocales.d.ts new file mode 100644 index 0000000..2f1df3c --- /dev/null +++ b/dist/modules/seo/slugsAcrossLocales.d.ts @@ -0,0 +1,18 @@ +import type { BasePayload } from 'payload'; +import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'; +/** + * Reads a document's slug in every locale — the map hreflang alternates are + * built from. + * + * Needs its own query because a document fetched in one locale returns `slug` + * as a plain string. Fetching with `locale: 'all'` turns *every* localized + * field into a locale→value map, which is right for the slug and wrong for + * everything else (a title map would break title composition), so this asks + * only for what it needs and at depth 0. + */ +export declare function slugsAcrossLocales({ id, collection, config, payload, }: { + collection: string; + config: I18nConfig; + id: number | string; + payload: BasePayload; +}): Promise; diff --git a/dist/modules/seo/slugsAcrossLocales.js b/dist/modules/seo/slugsAcrossLocales.js new file mode 100644 index 0000000..b01ba98 --- /dev/null +++ b/dist/modules/seo/slugsAcrossLocales.js @@ -0,0 +1,24 @@ +import { getLocalizedSlugs } from '../i18n/index.js'; +/** + * Reads a document's slug in every locale — the map hreflang alternates are + * built from. + * + * Needs its own query because a document fetched in one locale returns `slug` + * as a plain string. Fetching with `locale: 'all'` turns *every* localized + * field into a locale→value map, which is right for the slug and wrong for + * everything else (a title map would break title composition), so this asks + * only for what it needs and at depth 0. + */ export async function slugsAcrossLocales({ id, collection, config, payload }) { + const doc = await payload.findByID({ + id, + collection: collection, + depth: 0, + locale: 'all' + }); + return doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ + config, + slugField: doc.slug + }) : {}; +} + +//# sourceMappingURL=slugsAcrossLocales.js.map \ No newline at end of file diff --git a/dist/modules/seo/slugsAcrossLocales.js.map b/dist/modules/seo/slugsAcrossLocales.js.map new file mode 100644 index 0000000..28ab795 --- /dev/null +++ b/dist/modules/seo/slugsAcrossLocales.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/slugsAcrossLocales.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { getLocalizedSlugs } from '../i18n/index.js'\n\n/**\n * Reads a document's slug in every locale — the map hreflang alternates are\n * built from.\n *\n * Needs its own query because a document fetched in one locale returns `slug`\n * as a plain string. Fetching with `locale: 'all'` turns *every* localized\n * field into a locale→value map, which is right for the slug and wrong for\n * everything else (a title map would break title composition), so this asks\n * only for what it needs and at depth 0.\n */\nexport async function slugsAcrossLocales({\n id,\n collection,\n config,\n payload,\n}: {\n collection: string\n config: I18nConfig\n id: number | string\n payload: BasePayload\n}): Promise {\n const doc = (await payload.findByID({\n id,\n collection: collection as never,\n depth: 0,\n locale: 'all',\n })) as { slug?: unknown }\n\n return doc.slug && typeof doc.slug === 'object'\n ? getLocalizedSlugs({ config, slugField: doc.slug as Record })\n : {}\n}\n"],"names":["getLocalizedSlugs","slugsAcrossLocales","id","collection","config","payload","doc","findByID","depth","locale","slug","slugField"],"mappings":"AAIA,SAASA,iBAAiB,QAAQ,mBAAkB;AAEpD;;;;;;;;;CASC,GACD,OAAO,eAAeC,mBAAmB,EACvCC,EAAE,EACFC,UAAU,EACVC,MAAM,EACNC,OAAO,EAMR;IACC,MAAMC,MAAO,MAAMD,QAAQE,QAAQ,CAAC;QAClCL;QACAC,YAAYA;QACZK,OAAO;QACPC,QAAQ;IACV;IAEA,OAAOH,IAAII,IAAI,IAAI,OAAOJ,IAAII,IAAI,KAAK,WACnCV,kBAAkB;QAAEI;QAAQO,WAAWL,IAAII,IAAI;IAA4B,KAC3E,CAAC;AACP"} \ No newline at end of file diff --git a/dist/modules/seo/types.d.js b/dist/modules/seo/types.d.js new file mode 100644 index 0000000..faa1288 --- /dev/null +++ b/dist/modules/seo/types.d.js @@ -0,0 +1,7 @@ +/** + * Minimal shape of the SEO meta group as stored on a document by + * @payloadcms/plugin-seo. The client's generated types are richer; helpers + * depend only on this. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/modules/seo/types.d.js.map b/dist/modules/seo/types.d.js.map new file mode 100644 index 0000000..996fb77 --- /dev/null +++ b/dist/modules/seo/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["import type { Field } from 'payload';\nimport type { AutoFillMapping } from './autoFillMeta.js';\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false;\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[];\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[];\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: {\n doc: Record;\n }) => string;\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: {\n doc: Record;\n }) => string;\n};\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string;\n image?: unknown;\n title?: null | string;\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string;\n};\n"],"names":[],"mappings":"AA4BA;;;;CAIC,GACD,WAME"} \ No newline at end of file diff --git a/dist/modules/seo/types.d.ts b/dist/modules/seo/types.d.ts new file mode 100644 index 0000000..1965441 --- /dev/null +++ b/dist/modules/seo/types.d.ts @@ -0,0 +1,40 @@ +import type { Field } from 'payload'; +import type { AutoFillMapping } from './autoFillMeta.js'; +/** + * SEO configuration. + * + * The plugin wires @payloadcms/plugin-seo into the client's config and adds + * locale-aware metadata helpers on top. Collections come from options because + * the plugin doesn't own the client's content collections (e.g. Pages). + */ +export type SeoOption = { + /** + * Auto-fill empty meta from document fields on save. Defaults to the website + * template mapping (title → meta.title). Set to false to disable. + */ + autoFill?: AutoFillMapping | false; + /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */ + collections: string[]; + /** Extra fields appended to the SEO group in the admin. */ + fields?: Field[]; + /** Optional: customize how meta descriptions are generated. */ + generateDescription?: (args: { + doc: Record; + }) => string; + /** Optional: customize how meta titles are generated in the admin preview. */ + generateTitle?: (args: { + doc: Record; + }) => string; +}; +/** + * Minimal shape of the SEO meta group as stored on a document by + * @payloadcms/plugin-seo. The client's generated types are richer; helpers + * depend only on this. + */ +export type SeoMeta = { + description?: null | string; + image?: unknown; + title?: null | string; + /** When set, used as the whole title — no site name, no separator. */ + titleOverride?: null | string; +}; diff --git a/dist/modules/seo/types.js b/dist/modules/seo/types.js new file mode 100644 index 0000000..7544304 --- /dev/null +++ b/dist/modules/seo/types.js @@ -0,0 +1,7 @@ +/** + * Minimal shape of the SEO meta group as stored on a document by + * @payloadcms/plugin-seo. The client's generated types are richer; helpers + * depend only on this. + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/modules/seo/types.js.map b/dist/modules/seo/types.js.map new file mode 100644 index 0000000..6d93404 --- /dev/null +++ b/dist/modules/seo/types.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/seo/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AutoFillMapping } from './autoFillMeta.js'\n\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[]\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[]\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: { doc: Record }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record }) => string\n}\n\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string\n image?: unknown\n title?: null | string\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string\n}\n"],"names":[],"mappings":"AA2BA;;;;CAIC,GACD,WAMC"} \ No newline at end of file diff --git a/dist/modules/slug/buildSlugField.d.js b/dist/modules/slug/buildSlugField.d.js new file mode 100644 index 0000000..7d8609f --- /dev/null +++ b/dist/modules/slug/buildSlugField.d.js @@ -0,0 +1,3 @@ +export { }; + +//# sourceMappingURL=buildSlugField.d.js.map \ No newline at end of file diff --git a/dist/modules/slug/buildSlugField.d.js.map b/dist/modules/slug/buildSlugField.d.js.map new file mode 100644 index 0000000..bd237af --- /dev/null +++ b/dist/modules/slug/buildSlugField.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["buildSlugField.d.ts"],"sourcesContent":["import type { Field, TextField } from 'payload';\n/**\n * Normalizes a string into a URL slug. slugify handles diacritics out of the\n * box (Polish included: \"Strona główna\" → \"strona-glowna\").\n */\nexport declare function toSlug(input: string): string;\ntype BuildSlugFieldOptions = {\n /** Source field to derive the slug from. Default: 'title'. */\n from?: string;\n /** Whether the slug is localized (per-locale). Default: true. */\n localized?: boolean;\n /** Field name for the slug. Default: 'slug'. */\n name?: string;\n /** Override or extend the generated field config. */\n overrides?: Partial;\n /** Whether the slug is required. Default: true. */\n required?: boolean;\n};\n/**\n * Builds a slug field that auto-generates from a source field (default 'title')\n * only when left empty — an editor's manual slug is never overwritten (mode 4a).\n *\n * Localized-safe: on a localized field Payload runs the hook per locale, so\n * `value` is the slug for the active locale and `siblingData[from]` is the\n * source in that same locale. Editing the doc in 'pl' fills slug.pl from\n * title.pl; editing in 'en' fills slug.en from title.en — giving genuinely\n * per-locale slugs (strona-glowna / homepage) that the language switcher needs.\n */\nexport declare function buildSlugField(options?: BuildSlugFieldOptions): Field;\nexport {};\n"],"names":[],"mappings":"AA6BA,WAAU"} \ No newline at end of file diff --git a/dist/modules/slug/buildSlugField.d.ts b/dist/modules/slug/buildSlugField.d.ts new file mode 100644 index 0000000..c91bfcd --- /dev/null +++ b/dist/modules/slug/buildSlugField.d.ts @@ -0,0 +1,30 @@ +import type { Field, TextField } from 'payload'; +/** + * Normalizes a string into a URL slug. slugify handles diacritics out of the + * box (Polish included: "Strona główna" → "strona-glowna"). + */ +export declare function toSlug(input: string): string; +type BuildSlugFieldOptions = { + /** Source field to derive the slug from. Default: 'title'. */ + from?: string; + /** Whether the slug is localized (per-locale). Default: true. */ + localized?: boolean; + /** Field name for the slug. Default: 'slug'. */ + name?: string; + /** Override or extend the generated field config. */ + overrides?: Partial; + /** Whether the slug is required. Default: true. */ + required?: boolean; +}; +/** + * Builds a slug field that auto-generates from a source field (default 'title') + * only when left empty — an editor's manual slug is never overwritten (mode 4a). + * + * Localized-safe: on a localized field Payload runs the hook per locale, so + * `value` is the slug for the active locale and `siblingData[from]` is the + * source in that same locale. Editing the doc in 'pl' fills slug.pl from + * title.pl; editing in 'en' fills slug.en from title.en — giving genuinely + * per-locale slugs (strona-glowna / homepage) that the language switcher needs. + */ +export declare function buildSlugField(options?: BuildSlugFieldOptions): Field; +export {}; diff --git a/dist/modules/slug/buildSlugField.js b/dist/modules/slug/buildSlugField.js new file mode 100644 index 0000000..4352966 --- /dev/null +++ b/dist/modules/slug/buildSlugField.js @@ -0,0 +1,55 @@ +import slugify from 'slugify'; +/** + * Normalizes a string into a URL slug. slugify handles diacritics out of the + * box (Polish included: "Strona główna" → "strona-glowna"). + */ export function toSlug(input) { + return slugify(input, { + lower: true, + strict: true, + trim: true + }); +} +/** + * Builds a slug field that auto-generates from a source field (default 'title') + * only when left empty — an editor's manual slug is never overwritten (mode 4a). + * + * Localized-safe: on a localized field Payload runs the hook per locale, so + * `value` is the slug for the active locale and `siblingData[from]` is the + * source in that same locale. Editing the doc in 'pl' fills slug.pl from + * title.pl; editing in 'en' fills slug.en from title.en — giving genuinely + * per-locale slugs (strona-glowna / homepage) that the language switcher needs. + */ export function buildSlugField(options = {}) { + const { name = 'slug', from = 'title', localized = true, overrides = {}, required = true } = options; + const autoGenerate = ({ originalDoc, siblingData, value })=>{ + // Respect a manually entered slug — only generate when empty (mode 4a). + if (typeof value === 'string' && value.trim()) { + return toSlug(value) // still normalize what the editor typed + ; + } + // Derive from the source field in the current locale. + const source = siblingData?.[from] ?? originalDoc?.[from]; + if (typeof source === 'string' && source.trim()) { + return toSlug(source); + } + return value; + }; + return { + name, + type: 'text', + admin: { + description: 'Auto-generated from the title when left empty. You can override it.', + ...overrides.admin + }, + hooks: { + beforeValidate: [ + autoGenerate + ] + }, + index: true, + localized, + required, + ...overrides + }; +} + +//# sourceMappingURL=buildSlugField.js.map \ No newline at end of file diff --git a/dist/modules/slug/buildSlugField.js.map b/dist/modules/slug/buildSlugField.js.map new file mode 100644 index 0000000..a6e53f5 --- /dev/null +++ b/dist/modules/slug/buildSlugField.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/slug/buildSlugField.ts"],"sourcesContent":["import type { Field, FieldHook, TextField } from 'payload'\n\nimport slugify from 'slugify'\n\n/**\n * Normalizes a string into a URL slug. slugify handles diacritics out of the\n * box (Polish included: \"Strona główna\" → \"strona-glowna\").\n */\nexport function toSlug(input: string): string {\n return slugify(input, {\n lower: true,\n strict: true, // drop characters that aren't url-safe\n trim: true,\n })\n}\n\ntype BuildSlugFieldOptions = {\n /** Source field to derive the slug from. Default: 'title'. */\n from?: string\n /** Whether the slug is localized (per-locale). Default: true. */\n localized?: boolean\n /** Field name for the slug. Default: 'slug'. */\n name?: string\n /** Override or extend the generated field config. */\n overrides?: Partial\n /** Whether the slug is required. Default: true. */\n required?: boolean\n}\n\n/**\n * Builds a slug field that auto-generates from a source field (default 'title')\n * only when left empty — an editor's manual slug is never overwritten (mode 4a).\n *\n * Localized-safe: on a localized field Payload runs the hook per locale, so\n * `value` is the slug for the active locale and `siblingData[from]` is the\n * source in that same locale. Editing the doc in 'pl' fills slug.pl from\n * title.pl; editing in 'en' fills slug.en from title.en — giving genuinely\n * per-locale slugs (strona-glowna / homepage) that the language switcher needs.\n */\nexport function buildSlugField(options: BuildSlugFieldOptions = {}): Field {\n const {\n name = 'slug',\n from = 'title',\n localized = true,\n overrides = {},\n required = true,\n } = options\n\n const autoGenerate: FieldHook = ({ originalDoc, siblingData, value }) => {\n // Respect a manually entered slug — only generate when empty (mode 4a).\n if (typeof value === 'string' && value.trim()) {\n return toSlug(value) // still normalize what the editor typed\n }\n\n // Derive from the source field in the current locale.\n const source =\n (siblingData as Record)?.[from] ??\n (originalDoc as Record)?.[from]\n\n if (typeof source === 'string' && source.trim()) {\n return toSlug(source)\n }\n\n return value\n }\n\n return {\n name,\n type: 'text',\n admin: {\n description: 'Auto-generated from the title when left empty. You can override it.',\n ...overrides.admin,\n },\n hooks: {\n beforeValidate: [autoGenerate],\n },\n index: true,\n localized,\n required,\n ...overrides,\n } as TextField\n}\n"],"names":["slugify","toSlug","input","lower","strict","trim","buildSlugField","options","name","from","localized","overrides","required","autoGenerate","originalDoc","siblingData","value","source","type","admin","description","hooks","beforeValidate","index"],"mappings":"AAEA,OAAOA,aAAa,UAAS;AAE7B;;;CAGC,GACD,OAAO,SAASC,OAAOC,KAAa;IAClC,OAAOF,QAAQE,OAAO;QACpBC,OAAO;QACPC,QAAQ;QACRC,MAAM;IACR;AACF;AAeA;;;;;;;;;CASC,GACD,OAAO,SAASC,eAAeC,UAAiC,CAAC,CAAC;IAChE,MAAM,EACJC,OAAO,MAAM,EACbC,OAAO,OAAO,EACdC,YAAY,IAAI,EAChBC,YAAY,CAAC,CAAC,EACdC,WAAW,IAAI,EAChB,GAAGL;IAEJ,MAAMM,eAA0B,CAAC,EAAEC,WAAW,EAAEC,WAAW,EAAEC,KAAK,EAAE;QAClE,wEAAwE;QACxE,IAAI,OAAOA,UAAU,YAAYA,MAAMX,IAAI,IAAI;YAC7C,OAAOJ,OAAOe,OAAO,wCAAwC;;QAC/D;QAEA,sDAAsD;QACtD,MAAMC,SACJ,AAACF,aAAyC,CAACN,KAAK,IAC/CK,aAAyC,CAACL,KAAK;QAElD,IAAI,OAAOQ,WAAW,YAAYA,OAAOZ,IAAI,IAAI;YAC/C,OAAOJ,OAAOgB;QAChB;QAEA,OAAOD;IACT;IAEA,OAAO;QACLR;QACAU,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,GAAGT,UAAUQ,KAAK;QACpB;QACAE,OAAO;YACLC,gBAAgB;gBAACT;aAAa;QAChC;QACAU,OAAO;QACPb;QACAE;QACA,GAAGD,SAAS;IACd;AACF"} \ No newline at end of file diff --git a/dist/modules/slug/index.d.js b/dist/modules/slug/index.d.js new file mode 100644 index 0000000..64e64b4 --- /dev/null +++ b/dist/modules/slug/index.d.js @@ -0,0 +1,3 @@ +export { buildSlugField, toSlug } from './buildSlugField.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/slug/index.d.js.map b/dist/modules/slug/index.d.js.map new file mode 100644 index 0000000..911da2d --- /dev/null +++ b/dist/modules/slug/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { buildSlugField, toSlug } from './buildSlugField.js';\n"],"names":["buildSlugField","toSlug"],"mappings":"AAAA,SAASA,cAAc,EAAEC,MAAM,QAAQ,sBAAsB"} \ No newline at end of file diff --git a/dist/modules/slug/index.d.ts b/dist/modules/slug/index.d.ts new file mode 100644 index 0000000..4152718 --- /dev/null +++ b/dist/modules/slug/index.d.ts @@ -0,0 +1 @@ +export { buildSlugField, toSlug } from './buildSlugField.js'; diff --git a/dist/modules/slug/index.js b/dist/modules/slug/index.js new file mode 100644 index 0000000..acfd35f --- /dev/null +++ b/dist/modules/slug/index.js @@ -0,0 +1,3 @@ +export { buildSlugField, toSlug } from './buildSlugField.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/slug/index.js.map b/dist/modules/slug/index.js.map new file mode 100644 index 0000000..a337955 --- /dev/null +++ b/dist/modules/slug/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/slug/index.ts"],"sourcesContent":["export { buildSlugField, toSlug } from './buildSlugField.js'\n"],"names":["buildSlugField","toSlug"],"mappings":"AAAA,SAASA,cAAc,EAAEC,MAAM,QAAQ,sBAAqB"} \ No newline at end of file diff --git a/dist/modules/turnstile/Turnstile.d.js b/dist/modules/turnstile/Turnstile.d.js new file mode 100644 index 0000000..1cc0c89 --- /dev/null +++ b/dist/modules/turnstile/Turnstile.d.js @@ -0,0 +1,12 @@ +/** + * Cloudflare Turnstile widget — reusable across any form. Renders the challenge + * and reports the token via onToken. The token must then be verified + * server-side (see verifyTurnstile) before a submission is trusted. + * + * siteKey is a prop rather than an env var so all Turnstile config lives in one + * place (SiteIntegrations), consistent with the plugin's panel-managed model. + * The script is injected directly (no next/script dependency) so the widget + * stays framework-agnostic. + */ export { }; + +//# sourceMappingURL=Turnstile.d.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/Turnstile.d.js.map b/dist/modules/turnstile/Turnstile.d.js.map new file mode 100644 index 0000000..13d4934 --- /dev/null +++ b/dist/modules/turnstile/Turnstile.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["Turnstile.d.ts"],"sourcesContent":["declare global {\n interface Window {\n turnstile?: {\n render: (el: HTMLElement, opts: Record) => string;\n reset: (id?: string) => void;\n };\n }\n}\nexport type TurnstileProps = {\n /** Called with the token once solved, or null on expiry/error. */\n onToken: (token: null | string) => void;\n /**\n * Public Turnstile site key. The client project reads it server-side from\n * SiteIntegrations (turnstileSiteKey) and passes it in — the widget is a pure\n * client component and can't read Payload itself.\n */\n siteKey: string;\n theme?: 'auto' | 'dark' | 'light';\n};\n/**\n * Cloudflare Turnstile widget — reusable across any form. Renders the challenge\n * and reports the token via onToken. The token must then be verified\n * server-side (see verifyTurnstile) before a submission is trusted.\n *\n * siteKey is a prop rather than an env var so all Turnstile config lives in one\n * place (SiteIntegrations), consistent with the plugin's panel-managed model.\n * The script is injected directly (no next/script dependency) so the widget\n * stays framework-agnostic.\n */\nexport declare function Turnstile({ onToken, siteKey, theme }: TurnstileProps): import(\"react/jsx-runtime\").JSX.Element | null;\n"],"names":[],"mappings":"AAmBA;;;;;;;;;CASC,GACD,WAA+H"} \ No newline at end of file diff --git a/dist/modules/turnstile/Turnstile.d.ts b/dist/modules/turnstile/Turnstile.d.ts new file mode 100644 index 0000000..1e77d64 --- /dev/null +++ b/dist/modules/turnstile/Turnstile.d.ts @@ -0,0 +1,30 @@ +declare global { + interface Window { + turnstile?: { + render: (el: HTMLElement, opts: Record) => string; + reset: (id?: string) => void; + }; + } +} +export type TurnstileProps = { + /** Called with the token once solved, or null on expiry/error. */ + onToken: (token: null | string) => void; + /** + * Public Turnstile site key. The client project reads it server-side from + * SiteIntegrations (turnstileSiteKey) and passes it in — the widget is a pure + * client component and can't read Payload itself. + */ + siteKey: string; + theme?: 'auto' | 'dark' | 'light'; +}; +/** + * Cloudflare Turnstile widget — reusable across any form. Renders the challenge + * and reports the token via onToken. The token must then be verified + * server-side (see verifyTurnstile) before a submission is trusted. + * + * siteKey is a prop rather than an env var so all Turnstile config lives in one + * place (SiteIntegrations), consistent with the plugin's panel-managed model. + * The script is injected directly (no next/script dependency) so the widget + * stays framework-agnostic. + */ +export declare function Turnstile({ onToken, siteKey, theme }: TurnstileProps): import("react/jsx-runtime").JSX.Element | null; diff --git a/dist/modules/turnstile/Turnstile.js b/dist/modules/turnstile/Turnstile.js new file mode 100644 index 0000000..fc4ed53 --- /dev/null +++ b/dist/modules/turnstile/Turnstile.js @@ -0,0 +1,64 @@ +'use client'; +import { jsx as _jsx } from "react/jsx-runtime"; +import { useEffect, useRef } from 'react'; +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; +/** Loads the Turnstile script once, shared across all widget instances. */ function ensureScript() { + if (typeof document === 'undefined') { + return; + } + if (document.querySelector(`script[src="${SCRIPT_SRC}"]`)) { + return; + } + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + script.async = true; + script.defer = true; + document.head.appendChild(script); +} +/** + * Cloudflare Turnstile widget — reusable across any form. Renders the challenge + * and reports the token via onToken. The token must then be verified + * server-side (see verifyTurnstile) before a submission is trusted. + * + * siteKey is a prop rather than an env var so all Turnstile config lives in one + * place (SiteIntegrations), consistent with the plugin's panel-managed model. + * The script is injected directly (no next/script dependency) so the widget + * stays framework-agnostic. + */ export function Turnstile({ onToken, siteKey, theme = 'auto' }) { + const ref = useRef(null); + const widgetId = useRef(null); + useEffect(()=>{ + if (!siteKey) { + return; + } + ensureScript(); + function render() { + if (!ref.current || !window.turnstile || widgetId.current) { + return; + } + widgetId.current = window.turnstile.render(ref.current, { + callback: (token)=>onToken(token), + 'error-callback': ()=>onToken(null), + 'expired-callback': ()=>onToken(null), + sitekey: siteKey, + theme + }); + } + render(); + // Script may load after mount — retry briefly until ready. + const interval = setInterval(render, 300); + return ()=>clearInterval(interval); + }, [ + siteKey, + theme, + onToken + ]); + if (!siteKey) { + return null; + } + return /*#__PURE__*/ _jsx("div", { + ref: ref + }); +} + +//# sourceMappingURL=Turnstile.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/Turnstile.js.map b/dist/modules/turnstile/Turnstile.js.map new file mode 100644 index 0000000..093dfa4 --- /dev/null +++ b/dist/modules/turnstile/Turnstile.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/turnstile/Turnstile.tsx"],"sourcesContent":["'use client'\nimport { useEffect, useRef } from 'react'\n\ndeclare global {\n interface Window {\n turnstile?: {\n render: (el: HTMLElement, opts: Record) => string\n reset: (id?: string) => void\n }\n }\n}\n\nconst SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'\n\n/** Loads the Turnstile script once, shared across all widget instances. */\nfunction ensureScript(): void {\n if (typeof document === 'undefined') {return}\n if (document.querySelector(`script[src=\"${SCRIPT_SRC}\"]`)) {return}\n const script = document.createElement('script')\n script.src = SCRIPT_SRC\n script.async = true\n script.defer = true\n document.head.appendChild(script)\n}\n\nexport type TurnstileProps = {\n /** Called with the token once solved, or null on expiry/error. */\n onToken: (token: null | string) => void\n /**\n * Public Turnstile site key. The client project reads it server-side from\n * SiteIntegrations (turnstileSiteKey) and passes it in — the widget is a pure\n * client component and can't read Payload itself.\n */\n siteKey: string\n theme?: 'auto' | 'dark' | 'light'\n}\n\n/**\n * Cloudflare Turnstile widget — reusable across any form. Renders the challenge\n * and reports the token via onToken. The token must then be verified\n * server-side (see verifyTurnstile) before a submission is trusted.\n *\n * siteKey is a prop rather than an env var so all Turnstile config lives in one\n * place (SiteIntegrations), consistent with the plugin's panel-managed model.\n * The script is injected directly (no next/script dependency) so the widget\n * stays framework-agnostic.\n */\nexport function Turnstile({ onToken, siteKey, theme = 'auto' }: TurnstileProps) {\n const ref = useRef(null)\n const widgetId = useRef(null)\n\n useEffect(() => {\n if (!siteKey) {return}\n\n ensureScript()\n\n function render() {\n if (!ref.current || !window.turnstile || widgetId.current) {return}\n widgetId.current = window.turnstile.render(ref.current, {\n callback: (token: string) => onToken(token),\n 'error-callback': () => onToken(null),\n 'expired-callback': () => onToken(null),\n sitekey: siteKey,\n theme,\n })\n }\n\n render()\n // Script may load after mount — retry briefly until ready.\n const interval = setInterval(render, 300)\n return () => clearInterval(interval)\n }, [siteKey, theme, onToken])\n\n if (!siteKey) {return null}\n\n return
\n}\n"],"names":["useEffect","useRef","SCRIPT_SRC","ensureScript","document","querySelector","script","createElement","src","async","defer","head","appendChild","Turnstile","onToken","siteKey","theme","ref","widgetId","render","current","window","turnstile","callback","token","sitekey","interval","setInterval","clearInterval","div"],"mappings":"AAAA;;AACA,SAASA,SAAS,EAAEC,MAAM,QAAQ,QAAO;AAWzC,MAAMC,aAAa;AAEnB,yEAAyE,GACzE,SAASC;IACP,IAAI,OAAOC,aAAa,aAAa;QAAC;IAAM;IAC5C,IAAIA,SAASC,aAAa,CAAC,CAAC,YAAY,EAAEH,WAAW,EAAE,CAAC,GAAG;QAAC;IAAM;IAClE,MAAMI,SAASF,SAASG,aAAa,CAAC;IACtCD,OAAOE,GAAG,GAAGN;IACbI,OAAOG,KAAK,GAAG;IACfH,OAAOI,KAAK,GAAG;IACfN,SAASO,IAAI,CAACC,WAAW,CAACN;AAC5B;AAcA;;;;;;;;;CASC,GACD,OAAO,SAASO,UAAU,EAAEC,OAAO,EAAEC,OAAO,EAAEC,QAAQ,MAAM,EAAkB;IAC5E,MAAMC,MAAMhB,OAAuB;IACnC,MAAMiB,WAAWjB,OAAsB;IAEvCD,UAAU;QACR,IAAI,CAACe,SAAS;YAAC;QAAM;QAErBZ;QAEA,SAASgB;YACP,IAAI,CAACF,IAAIG,OAAO,IAAI,CAACC,OAAOC,SAAS,IAAIJ,SAASE,OAAO,EAAE;gBAAC;YAAM;YAClEF,SAASE,OAAO,GAAGC,OAAOC,SAAS,CAACH,MAAM,CAACF,IAAIG,OAAO,EAAE;gBACtDG,UAAU,CAACC,QAAkBV,QAAQU;gBACrC,kBAAkB,IAAMV,QAAQ;gBAChC,oBAAoB,IAAMA,QAAQ;gBAClCW,SAASV;gBACTC;YACF;QACF;QAEAG;QACA,2DAA2D;QAC3D,MAAMO,WAAWC,YAAYR,QAAQ;QACrC,OAAO,IAAMS,cAAcF;IAC7B,GAAG;QAACX;QAASC;QAAOF;KAAQ;IAE5B,IAAI,CAACC,SAAS;QAAC,OAAO;IAAI;IAE1B,qBAAO,KAACc;QAAIZ,KAAKA;;AACnB"} \ No newline at end of file diff --git a/dist/modules/turnstile/client.d.js b/dist/modules/turnstile/client.d.js new file mode 100644 index 0000000..0a1f8b5 --- /dev/null +++ b/dist/modules/turnstile/client.d.js @@ -0,0 +1,3 @@ +export { Turnstile } from './Turnstile.js'; + +//# sourceMappingURL=client.d.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/client.d.js.map b/dist/modules/turnstile/client.d.js.map new file mode 100644 index 0000000..5ade349 --- /dev/null +++ b/dist/modules/turnstile/client.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["client.d.ts"],"sourcesContent":["export { Turnstile } from './Turnstile.js';\nexport type { TurnstileProps } from './Turnstile.js';\n"],"names":["Turnstile"],"mappings":"AAAA,SAASA,SAAS,QAAQ,iBAAiB"} \ No newline at end of file diff --git a/dist/modules/turnstile/client.d.ts b/dist/modules/turnstile/client.d.ts new file mode 100644 index 0000000..7180b99 --- /dev/null +++ b/dist/modules/turnstile/client.d.ts @@ -0,0 +1,2 @@ +export { Turnstile } from './Turnstile.js'; +export type { TurnstileProps } from './Turnstile.js'; diff --git a/dist/modules/turnstile/client.js b/dist/modules/turnstile/client.js new file mode 100644 index 0000000..157876e --- /dev/null +++ b/dist/modules/turnstile/client.js @@ -0,0 +1,6 @@ +'use client'; +// Client-only exports — the Turnstile widget. Kept separate from index.ts so +// the server-only verify never leaks into a browser bundle. +export { Turnstile } from './Turnstile.js'; + +//# sourceMappingURL=client.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/client.js.map b/dist/modules/turnstile/client.js.map new file mode 100644 index 0000000..6beab7c --- /dev/null +++ b/dist/modules/turnstile/client.js.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/modules/turnstile/index.d.js b/dist/modules/turnstile/index.d.js new file mode 100644 index 0000000..1e062a4 --- /dev/null +++ b/dist/modules/turnstile/index.d.js @@ -0,0 +1,3 @@ +export { verifyTurnstile } from './verify.js'; + +//# sourceMappingURL=index.d.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/index.d.js.map b/dist/modules/turnstile/index.d.js.map new file mode 100644 index 0000000..6067c5c --- /dev/null +++ b/dist/modules/turnstile/index.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["index.d.ts"],"sourcesContent":["export { verifyTurnstile } from './verify.js';\n"],"names":["verifyTurnstile"],"mappings":"AAAA,SAASA,eAAe,QAAQ,cAAc"} \ No newline at end of file diff --git a/dist/modules/turnstile/index.d.ts b/dist/modules/turnstile/index.d.ts new file mode 100644 index 0000000..fe1cc66 --- /dev/null +++ b/dist/modules/turnstile/index.d.ts @@ -0,0 +1 @@ +export { verifyTurnstile } from './verify.js'; diff --git a/dist/modules/turnstile/index.js b/dist/modules/turnstile/index.js new file mode 100644 index 0000000..69979b6 --- /dev/null +++ b/dist/modules/turnstile/index.js @@ -0,0 +1,5 @@ +// Server-only exports. verify.ts imports 'server-only', so this must never be +// imported from a client component — use ./client for the widget instead. +export { verifyTurnstile } from './verify.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/index.js.map b/dist/modules/turnstile/index.js.map new file mode 100644 index 0000000..acf77d2 --- /dev/null +++ b/dist/modules/turnstile/index.js.map @@ -0,0 +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"} \ No newline at end of file diff --git a/dist/modules/turnstile/verify.d.js b/dist/modules/turnstile/verify.d.js new file mode 100644 index 0000000..1292653 --- /dev/null +++ b/dist/modules/turnstile/verify.d.js @@ -0,0 +1,3 @@ +import 'server-only'; + +//# sourceMappingURL=verify.d.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/verify.d.js.map b/dist/modules/turnstile/verify.d.js.map new file mode 100644 index 0000000..e9a8b17 --- /dev/null +++ b/dist/modules/turnstile/verify.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["verify.d.ts"],"sourcesContent":["import 'server-only';\nimport type { BasePayload } from 'payload';\ntype VerifyTurnstileArgs = {\n /** Optional client IP for stricter verification. */\n ip?: string;\n /** Payload instance — used to read the secret from SiteIntegrations. */\n payload: BasePayload;\n /** Token produced by the client-side widget. */\n token: string;\n};\n/**\n * Verifies a Turnstile token with Cloudflare, server-side only.\n *\n * The secret comes from the SiteIntegrations global (editor-managed, per the\n * plugin's \"secrets in the panel\" model), read via the Local API which bypasses\n * access control. `server-only` guarantees this never reaches the browser\n * bundle, keeping the secret off the client.\n *\n * Returns false on any failure (missing secret/token, network error, rejected\n * challenge) — callers treat false as \"do not trust this submission\".\n */\nexport declare function verifyTurnstile({ ip, payload, token, }: VerifyTurnstileArgs): Promise;\nexport {};\n"],"names":[],"mappings":"AAAA,OAAO,cAAc"} \ No newline at end of file diff --git a/dist/modules/turnstile/verify.d.ts b/dist/modules/turnstile/verify.d.ts new file mode 100644 index 0000000..36d2dfb --- /dev/null +++ b/dist/modules/turnstile/verify.d.ts @@ -0,0 +1,23 @@ +import 'server-only'; +import type { BasePayload } from 'payload'; +type VerifyTurnstileArgs = { + /** Optional client IP for stricter verification. */ + ip?: string; + /** Payload instance — used to read the secret from SiteIntegrations. */ + payload: BasePayload; + /** Token produced by the client-side widget. */ + token: string; +}; +/** + * Verifies a Turnstile token with Cloudflare, server-side only. + * + * The secret comes from the SiteIntegrations global (editor-managed, per the + * plugin's "secrets in the panel" model), read via the Local API which bypasses + * access control. `server-only` guarantees this never reaches the browser + * bundle, keeping the secret off the client. + * + * Returns false on any failure (missing secret/token, network error, rejected + * challenge) — callers treat false as "do not trust this submission". + */ +export declare function verifyTurnstile({ ip, payload, token, }: VerifyTurnstileArgs): Promise; +export {}; diff --git a/dist/modules/turnstile/verify.js b/dist/modules/turnstile/verify.js new file mode 100644 index 0000000..250d69c --- /dev/null +++ b/dist/modules/turnstile/verify.js @@ -0,0 +1,41 @@ +import 'server-only'; +import { getSiteIntegrations } from '../payload/index.js'; +/** + * Verifies a Turnstile token with Cloudflare, server-side only. + * + * The secret comes from the SiteIntegrations global (editor-managed, per the + * plugin's "secrets in the panel" model), read via the Local API which bypasses + * access control. `server-only` guarantees this never reaches the browser + * bundle, keeping the secret off the client. + * + * Returns false on any failure (missing secret/token, network error, rejected + * challenge) — callers treat false as "do not trust this submission". + */ export async function verifyTurnstile({ ip, payload, token }) { + if (!token) { + return false; + } + const integrations = await getSiteIntegrations(payload); + const secret = integrations.turnstileSecretKey; + if (!secret) { + return false; + } + const body = new URLSearchParams({ + response: token, + secret + }); + if (ip) { + body.append('remoteip', ip); + } + try { + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + body, + method: 'POST' + }); + const data = await res.json(); + return data.success; + } catch { + return false; + } +} + +//# sourceMappingURL=verify.js.map \ No newline at end of file diff --git a/dist/modules/turnstile/verify.js.map b/dist/modules/turnstile/verify.js.map new file mode 100644 index 0000000..91d58b7 --- /dev/null +++ b/dist/modules/turnstile/verify.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/turnstile/verify.ts"],"sourcesContent":["import 'server-only'\n\nimport type { BasePayload } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\n\ntype SiteverifyResponse = {\n challenge_ts?: string\n 'error-codes'?: string[]\n hostname?: string\n success: boolean\n}\n\ntype IntegrationsWithTurnstile = {\n turnstileSecretKey?: null | string\n}\n\ntype VerifyTurnstileArgs = {\n /** Optional client IP for stricter verification. */\n ip?: string\n /** Payload instance — used to read the secret from SiteIntegrations. */\n payload: BasePayload\n /** Token produced by the client-side widget. */\n token: string\n}\n\n/**\n * Verifies a Turnstile token with Cloudflare, server-side only.\n *\n * The secret comes from the SiteIntegrations global (editor-managed, per the\n * plugin's \"secrets in the panel\" model), read via the Local API which bypasses\n * access control. `server-only` guarantees this never reaches the browser\n * bundle, keeping the secret off the client.\n *\n * Returns false on any failure (missing secret/token, network error, rejected\n * challenge) — callers treat false as \"do not trust this submission\".\n */\nexport async function verifyTurnstile({\n ip,\n payload,\n token,\n}: VerifyTurnstileArgs): Promise {\n if (!token) {return false}\n\n const integrations = await getSiteIntegrations(payload)\n const secret = integrations.turnstileSecretKey\n if (!secret) {return false}\n\n const body = new URLSearchParams({ response: token, secret })\n if (ip) {body.append('remoteip', ip)}\n\n try {\n const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {\n body,\n method: 'POST',\n })\n const data = (await res.json()) as SiteverifyResponse\n return data.success\n } catch {\n return false\n }\n}\n"],"names":["getSiteIntegrations","verifyTurnstile","ip","payload","token","integrations","secret","turnstileSecretKey","body","URLSearchParams","response","append","res","fetch","method","data","json","success"],"mappings":"AAAA,OAAO,cAAa;AAIpB,SAASA,mBAAmB,QAAQ,sBAAqB;AAsBzD;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,gBAAgB,EACpCC,EAAE,EACFC,OAAO,EACPC,KAAK,EACe;IACpB,IAAI,CAACA,OAAO;QAAC,OAAO;IAAK;IAEzB,MAAMC,eAAe,MAAML,oBAA+CG;IAC1E,MAAMG,SAASD,aAAaE,kBAAkB;IAC9C,IAAI,CAACD,QAAQ;QAAC,OAAO;IAAK;IAE1B,MAAME,OAAO,IAAIC,gBAAgB;QAAEC,UAAUN;QAAOE;IAAO;IAC3D,IAAIJ,IAAI;QAACM,KAAKG,MAAM,CAAC,YAAYT;IAAG;IAEpC,IAAI;QACF,MAAMU,MAAM,MAAMC,MAAM,6DAA6D;YACnFL;YACAM,QAAQ;QACV;QACA,MAAMC,OAAQ,MAAMH,IAAII,IAAI;QAC5B,OAAOD,KAAKE,OAAO;IACrB,EAAE,OAAM;QACN,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/plugin.d.js b/dist/plugin.d.js new file mode 100644 index 0000000..aa0c91c --- /dev/null +++ b/dist/plugin.d.js @@ -0,0 +1,25 @@ +/** + * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3. + * + * @example + * ```ts + * import { ipalKit } from 'ipal-kit' + * + * export default buildConfig({ + * plugins: [ + * ipalKit({ + * i18n: { + * locales: [ + * { code: 'pl', label: 'Polski' }, + * { code: 'en', label: 'English' }, + * ], + * defaultLocale: 'pl', + * }, + * access: { authCollection: 'users' }, + * }), + * ], + * }) + * ``` + */ export { }; + +//# sourceMappingURL=plugin.d.js.map \ No newline at end of file diff --git a/dist/plugin.d.js.map b/dist/plugin.d.js.map new file mode 100644 index 0000000..99164a6 --- /dev/null +++ b/dist/plugin.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["plugin.d.ts"],"sourcesContent":["import type { Plugin } from 'payload';\nimport type { IpalOptions } from './types.js';\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport declare const ipalKit: (options: IpalOptions) => Plugin;\n"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,WAA+D"} \ No newline at end of file diff --git a/dist/plugin.d.ts b/dist/plugin.d.ts new file mode 100644 index 0000000..6550968 --- /dev/null +++ b/dist/plugin.d.ts @@ -0,0 +1,26 @@ +import type { Plugin } from 'payload'; +import type { IpalOptions } from './types.js'; +/** + * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3. + * + * @example + * ```ts + * import { ipalKit } from 'ipal-kit' + * + * export default buildConfig({ + * plugins: [ + * ipalKit({ + * i18n: { + * locales: [ + * { code: 'pl', label: 'Polski' }, + * { code: 'en', label: 'English' }, + * ], + * defaultLocale: 'pl', + * }, + * access: { authCollection: 'users' }, + * }), + * ], + * }) + * ``` + */ +export declare const ipalKit: (options: IpalOptions) => Plugin; diff --git a/dist/plugin.js b/dist/plugin.js new file mode 100644 index 0000000..fa81ed0 --- /dev/null +++ b/dist/plugin.js @@ -0,0 +1,100 @@ +import { buildCookieSettings } from './globals/CookieSettings/index.js'; +import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'; +import { buildSiteSettings } from './globals/SiteSettings/index.js'; +import { injectRoles } from './modules/access/index.js'; +import { buildArchiveFields } from './modules/content/index.js'; +import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; +import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'; +import { buildSystemPagesFields } from './modules/pages/index.js'; +import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'; +/** + * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3. + * + * @example + * ```ts + * import { ipalKit } from 'ipal-kit' + * + * export default buildConfig({ + * plugins: [ + * ipalKit({ + * i18n: { + * locales: [ + * { code: 'pl', label: 'Polski' }, + * { code: 'en', label: 'English' }, + * ], + * defaultLocale: 'pl', + * }, + * access: { authCollection: 'users' }, + * }), + * ], + * }) + * ``` + */ export const ipalKit = (options)=>{ + // Validate eagerly — fail fast before Payload boots + validateI18nConfig(options.i18n); + return async (incomingConfig)=>{ + // Early return when disabled — schema stays, behavior off + if (options.enabled === false) { + return incomingConfig; + } + let config = { + ...incomingConfig + }; + // --- i18n --- + config.localization = buildLocalizationConfig(options.i18n); + // --- access: inject roles into the client's auth collection --- + if (options.access) { + config = injectRoles(config, options.access); + } + // --- seo: apply @payloadcms/plugin-seo directly --- + // NOTE: apply the plugin function to the config immediately rather than + // pushing it onto config.plugins. Payload has already iterated the plugins + // array by the time IPAL runs, so nested plugins added to that list are + // never executed. Calling the plugin as (config) => config applies its + // transform now. + if (options.seo) { + config = await buildSeoPlugin({ + seo: options.seo + })(config); + // Auto-fill empty meta from document content on save + config = injectAutoFillMeta(config, options.seo); + // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI, + // which breaks when other fields already exist in the collection) + config = injectSeoTabs(config, options.seo); + } + // --- forms: apply @payloadcms/plugin-form-builder directly --- + if (options.forms) { + config = await buildFormsPlugin(options.forms)(config); + } + // --- globals --- + // The System Pages tab collects every "which page plays this role" + // assignment. Composing it here keeps SiteSettings unaware of which modules + // are enabled — it just renders the fields it's given. + const systemPageFields = [ + ...options.pages ? buildSystemPagesFields(options.pages) : [], + ...options.content && options.pages ? buildArchiveFields(options.content, options.pages.slug) : [] + ]; + config.globals = [ + ...config.globals ?? [], + buildSiteSettings({ + additionalFields: options.siteSettingsFields, + systemPageFields + }), + buildSiteIntegrations({ + additionalFields: options.integrationsFields + }), + buildCookieSettings() + ]; + // --- hooks: onInit --- + const incomingOnInit = config.onInit; + config.onInit = async (payload)=>{ + if (incomingOnInit) { + await incomingOnInit(payload); + } + payload.logger.info('[ipal] Plugin initialized.'); + }; + return config; + }; +}; + +//# sourceMappingURL=plugin.js.map \ No newline at end of file diff --git a/dist/plugin.js.map b/dist/plugin.js.map new file mode 100644 index 0000000..915cbe6 --- /dev/null +++ b/dist/plugin.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Plugin } from 'payload'\n\nimport type { IpalOptions } from './types.js'\n\nimport { buildCookieSettings } from './globals/CookieSettings/index.js'\nimport { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'\nimport { buildSiteSettings } from './globals/SiteSettings/index.js'\nimport { injectRoles } from './modules/access/index.js'\nimport { buildArchiveFields } from './modules/content/index.js'\nimport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nimport { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'\nimport { buildSystemPagesFields } from './modules/pages/index.js'\nimport { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'\n\n/**\n * IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.\n *\n * @example\n * ```ts\n * import { ipalKit } from 'ipal-kit'\n *\n * export default buildConfig({\n * plugins: [\n * ipalKit({\n * i18n: {\n * locales: [\n * { code: 'pl', label: 'Polski' },\n * { code: 'en', label: 'English' },\n * ],\n * defaultLocale: 'pl',\n * },\n * access: { authCollection: 'users' },\n * }),\n * ],\n * })\n * ```\n */\nexport const ipalKit = (options: IpalOptions): Plugin => {\n // Validate eagerly — fail fast before Payload boots\n validateI18nConfig(options.i18n)\n\n return async (incomingConfig: Config): Promise => {\n // Early return when disabled — schema stays, behavior off\n if (options.enabled === false) {\n return incomingConfig\n }\n\n let config = { ...incomingConfig }\n\n // --- i18n ---\n config.localization = buildLocalizationConfig(options.i18n)\n\n // --- access: inject roles into the client's auth collection ---\n if (options.access) {\n config = injectRoles(config, options.access)\n }\n\n // --- seo: apply @payloadcms/plugin-seo directly ---\n // NOTE: apply the plugin function to the config immediately rather than\n // pushing it onto config.plugins. Payload has already iterated the plugins\n // array by the time IPAL runs, so nested plugins added to that list are\n // never executed. Calling the plugin as (config) => config applies its\n // transform now.\n if (options.seo) {\n config = await buildSeoPlugin({ seo: options.seo })(config)\n // Auto-fill empty meta from document content on save\n config = injectAutoFillMeta(config, options.seo)\n // Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,\n // which breaks when other fields already exist in the collection)\n config = injectSeoTabs(config, options.seo)\n }\n\n // --- forms: apply @payloadcms/plugin-form-builder directly ---\n if (options.forms) {\n config = await buildFormsPlugin(options.forms)(config)\n }\n\n // --- globals ---\n // The System Pages tab collects every \"which page plays this role\"\n // assignment. Composing it here keeps SiteSettings unaware of which modules\n // are enabled — it just renders the fields it's given.\n const systemPageFields = [\n ...(options.pages ? buildSystemPagesFields(options.pages) : []),\n ...(options.content && options.pages\n ? buildArchiveFields(options.content, options.pages.slug)\n : []),\n ]\n\n config.globals = [\n ...(config.globals ?? []),\n buildSiteSettings({\n additionalFields: options.siteSettingsFields,\n systemPageFields,\n }),\n buildSiteIntegrations({ additionalFields: options.integrationsFields }),\n buildCookieSettings(),\n ]\n\n // --- hooks: onInit ---\n const incomingOnInit = config.onInit\n config.onInit = async (payload) => {\n if (incomingOnInit) {await incomingOnInit(payload)}\n payload.logger.info('[ipal] Plugin initialized.')\n }\n\n return config\n }\n}\n"],"names":["buildCookieSettings","buildSiteIntegrations","buildSiteSettings","injectRoles","buildArchiveFields","buildFormsPlugin","buildLocalizationConfig","validateI18nConfig","buildSystemPagesFields","buildSeoPlugin","injectAutoFillMeta","injectSeoTabs","ipalKit","options","i18n","incomingConfig","enabled","config","localization","access","seo","forms","systemPageFields","pages","content","slug","globals","additionalFields","siteSettingsFields","integrationsFields","incomingOnInit","onInit","payload","logger","info"],"mappings":"AAIA,SAASA,mBAAmB,QAAQ,oCAAmC;AACvE,SAASC,qBAAqB,QAAQ,sCAAqC;AAC3E,SAASC,iBAAiB,QAAQ,kCAAiC;AACnE,SAASC,WAAW,QAAQ,4BAA2B;AACvD,SAASC,kBAAkB,QAAQ,6BAA4B;AAC/D,SAASC,gBAAgB,QAAQ,uCAAsC;AACvE,SAASC,uBAAuB,EAAEC,kBAAkB,QAAQ,0BAAyB;AACrF,SAASC,sBAAsB,QAAQ,2BAA0B;AACjE,SAASC,cAAc,EAAEC,kBAAkB,EAAEC,aAAa,QAAQ,yBAAwB;AAE1F;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,UAAU,CAACC;IACtB,oDAAoD;IACpDN,mBAAmBM,QAAQC,IAAI;IAE/B,OAAO,OAAOC;QACZ,0DAA0D;QAC1D,IAAIF,QAAQG,OAAO,KAAK,OAAO;YAC7B,OAAOD;QACT;QAEA,IAAIE,SAAS;YAAE,GAAGF,cAAc;QAAC;QAEjC,eAAe;QACfE,OAAOC,YAAY,GAAGZ,wBAAwBO,QAAQC,IAAI;QAE1D,iEAAiE;QACjE,IAAID,QAAQM,MAAM,EAAE;YAClBF,SAASd,YAAYc,QAAQJ,QAAQM,MAAM;QAC7C;QAEA,qDAAqD;QACrD,wEAAwE;QACxE,2EAA2E;QAC3E,wEAAwE;QACxE,uEAAuE;QACvE,iBAAiB;QACjB,IAAIN,QAAQO,GAAG,EAAE;YACfH,SAAS,MAAMR,eAAe;gBAAEW,KAAKP,QAAQO,GAAG;YAAC,GAAGH;YACpD,qDAAqD;YACrDA,SAASP,mBAAmBO,QAAQJ,QAAQO,GAAG;YAC/C,uEAAuE;YACvE,kEAAkE;YAClEH,SAASN,cAAcM,QAAQJ,QAAQO,GAAG;QAC5C;QAEA,gEAAgE;QAChE,IAAIP,QAAQQ,KAAK,EAAE;YACjBJ,SAAS,MAAMZ,iBAAiBQ,QAAQQ,KAAK,EAAEJ;QACjD;QAEA,kBAAkB;QAClB,mEAAmE;QACnE,4EAA4E;QAC5E,uDAAuD;QACvD,MAAMK,mBAAmB;eACnBT,QAAQU,KAAK,GAAGf,uBAAuBK,QAAQU,KAAK,IAAI,EAAE;eAC1DV,QAAQW,OAAO,IAAIX,QAAQU,KAAK,GAChCnB,mBAAmBS,QAAQW,OAAO,EAAEX,QAAQU,KAAK,CAACE,IAAI,IACtD,EAAE;SACP;QAEDR,OAAOS,OAAO,GAAG;eACXT,OAAOS,OAAO,IAAI,EAAE;YACxBxB,kBAAkB;gBAChByB,kBAAkBd,QAAQe,kBAAkB;gBAC5CN;YACF;YACArB,sBAAsB;gBAAE0B,kBAAkBd,QAAQgB,kBAAkB;YAAC;YACrE7B;SACD;QAED,wBAAwB;QACxB,MAAM8B,iBAAiBb,OAAOc,MAAM;QACpCd,OAAOc,MAAM,GAAG,OAAOC;YACrB,IAAIF,gBAAgB;gBAAC,MAAMA,eAAeE;YAAQ;YAClDA,QAAQC,MAAM,CAACC,IAAI,CAAC;QACtB;QAEA,OAAOjB;IACT;AACF,EAAC"} \ No newline at end of file diff --git a/dist/types.d.js b/dist/types.d.js new file mode 100644 index 0000000..4d1830e --- /dev/null +++ b/dist/types.d.js @@ -0,0 +1,6 @@ +/** + * Configuration options for the IPAL plugin. + * Passed by the client project in payload.config.ts. + */ export { }; + +//# sourceMappingURL=types.d.js.map \ No newline at end of file diff --git a/dist/types.d.js.map b/dist/types.d.js.map new file mode 100644 index 0000000..42184a7 --- /dev/null +++ b/dist/types.d.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["types.d.ts"],"sourcesContent":["import type { Field } from 'payload';\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 * 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 * 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 /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean;\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 /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig;\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[];\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 * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption;\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[];\n};\n"],"names":[],"mappings":"AAOA;;;CAGC,GACD,WAoCE"} \ No newline at end of file diff --git a/dist/types.d.ts b/dist/types.d.ts new file mode 100644 index 0000000..3287ee5 --- /dev/null +++ b/dist/types.d.ts @@ -0,0 +1,48 @@ +import type { Field } from 'payload'; +import type { AccessOption } from './modules/access/types.js'; +import type { ContentOption } from './modules/content/types.js'; +import type { FormsOption } from './modules/forms/types.js'; +import type { I18nConfig } from './modules/i18n/types.js'; +import type { PagesOption } from './modules/pages/types.js'; +import type { SeoOption } from './modules/seo/types.js'; +/** + * Configuration options for the IPAL plugin. + * Passed by the client project in payload.config.ts. + */ +export type IpalOptions = { + /** + * Role-based access control. Injects a fixed `roles` field + * (admin > editor > user) into the client's auth collection. + */ + access?: AccessOption; + /** + * Collections whose entries live under an archive page — blog posts, case + * studies, anything with a listing. Adds an "archive page" assignment per + * collection in SiteSettings; the assigned page's localized slug becomes the + * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`. + */ + content?: ContentOption; + /** Disable the plugin without uninstalling (keeps DB schema intact) */ + enabled?: boolean; + /** + * Forms — form-builder collections (forms, form-submissions) plus the + * callable submitForm (Turnstile + persistence + SMTP-from-panel email). + */ + forms?: FormsOption; + /** Internationalization — locales, default locale, fallback behavior */ + i18n: I18nConfig; + /** Additional fields injected into SiteIntegrations global */ + integrationsFields?: Field[]; + /** + * System-page assignments (homepage, privacy, cookies) in SiteSettings. + * Provide the slug of the client's Pages collection to enable. + */ + pages?: PagesOption; + /** + * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo) + * and enables locale-aware metadata helpers. + */ + seo?: SeoOption; + /** Additional fields injected into SiteSettings global */ + siteSettingsFields?: Field[]; +}; diff --git a/dist/types.js b/dist/types.js new file mode 100644 index 0000000..3601f21 --- /dev/null +++ b/dist/types.js @@ -0,0 +1,6 @@ +/** + * Configuration options for the IPAL plugin. + * Passed by the client project in payload.config.ts. + */ export { }; + +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/dist/types.js.map b/dist/types.js.map new file mode 100644 index 0000000..7c3b73f --- /dev/null +++ b/dist/types.js.map @@ -0,0 +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"} \ No newline at end of file