Compare commits
33
Commits
v1.0.12
...
e30ac71044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f | ||
|
|
e39e2a361a | ||
|
|
9d3e749c38 | ||
|
|
b7c6cb4d81 | ||
|
|
c41b75e364 | ||
|
|
41630bb8c5 | ||
|
|
b9430e7ab6 | ||
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 | ||
|
|
ed4a78b109 | ||
|
|
9acb22e2f9 | ||
|
|
6a8361d710 | ||
|
|
502ffee31f | ||
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 |
Vendored
+1
@@ -7,3 +7,4 @@
|
||||
*/
|
||||
export { RenderBlocks } from '../modules/blocks/index.js';
|
||||
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
||||
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||
|
||||
Vendored
+1
@@ -5,5 +5,6 @@
|
||||
* 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 { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||
|
||||
//# sourceMappingURL=rsc.js.map
|
||||
Vendored
+1
-1
@@ -1 +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"}
|
||||
{"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'\nexport { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'\n"],"names":["RenderBlocks","MediaPreconnect"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B;AAOzD,SAASC,eAAe,QAAQ,wCAAuC"}
|
||||
+4
@@ -14,6 +14,10 @@ type BuildSiteIntegrationsArgs = {
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*
|
||||
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||
* consumed by buildR2Storage. See docs/storage.md.
|
||||
*/
|
||||
export declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig;
|
||||
export {};
|
||||
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
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.
|
||||
@@ -14,6 +13,10 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*
|
||||
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||
* consumed by buildR2Storage. See docs/storage.md.
|
||||
*/ export function buildSiteIntegrations({ additionalFields } = {}) {
|
||||
return {
|
||||
slug: 'site-integrations',
|
||||
@@ -42,10 +45,6 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
fields: smtpFields,
|
||||
label: 'SMTP'
|
||||
},
|
||||
{
|
||||
fields: storageFields,
|
||||
label: 'Storage'
|
||||
},
|
||||
...additionalFields?.length ? [
|
||||
{
|
||||
fields: additionalFields,
|
||||
|
||||
+1
-1
@@ -1 +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"}
|
||||
{"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 { 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 *\n * Note: R2 storage credentials are NOT here — storage is infrastructure and\n * binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),\n * consumed by buildR2Storage. See docs/storage.md.\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 ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","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,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;;;;;CAeC,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,GAAKV,QAAQU;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQb;wBAAiBgB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQZ;wBAAYe,OAAO;oBAAO;uBAChCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { Field } from 'payload';
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||
*/
|
||||
export declare const generalFields: Field[];
|
||||
|
||||
+11
-3
@@ -1,6 +1,7 @@
|
||||
import { validateFaviconField } from '../../../modules/seo/index.js';
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||
*/ export const generalFields = [
|
||||
{
|
||||
name: 'siteName',
|
||||
@@ -63,7 +64,7 @@
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Primary site logo.'
|
||||
description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).'
|
||||
},
|
||||
relationTo: 'media'
|
||||
},
|
||||
@@ -79,7 +80,14 @@
|
||||
name: 'favicon',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.'
|
||||
description: 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.'
|
||||
},
|
||||
hooks: {
|
||||
// Warns the editor at save time if the favicon is too small (<48×48) or
|
||||
// not square — Google won't display such favicons in search results.
|
||||
beforeValidate: [
|
||||
validateFaviconField
|
||||
]
|
||||
},
|
||||
relationTo: 'media'
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +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"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
||||
Vendored
+6
-2
@@ -20,18 +20,22 @@ export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18
|
||||
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 { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/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 { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||
export { 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 { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage, } from './modules/notifications/index.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
||||
export { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
export type { IpalOptions } from './types.js';
|
||||
|
||||
Vendored
+9
-1
@@ -12,13 +12,21 @@ 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';
|
||||
// Media — filename normalization hook for upload collections (Media).
|
||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/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 { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||
export { 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 { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage } from './modules/notifications/index.js';
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+22
-14
@@ -89,18 +89,19 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
sent: false
|
||||
};
|
||||
}
|
||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
||||
// Display name on the From, WITHOUT triggering Send-As.
|
||||
//
|
||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
||||
// mailbox we legitimately own) — and route replies to the panel address
|
||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
||||
// the real destination. No Send-As needed.
|
||||
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||
// same-address / custom-name From is allowed and gives each project its
|
||||
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||
//
|
||||
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||
// and the panel's from-name becomes the sender display name.
|
||||
const panel = await getSiteIntegrations(payload);
|
||||
const replyToAddress = panel.smtpFromAddress || undefined;
|
||||
const replyToName = panel.smtpFromName || undefined;
|
||||
const senderName = panel.smtpFromName || undefined;
|
||||
const to = toRecipients(message.to);
|
||||
if (to.length === 0) {
|
||||
payload.logger.error('[ipal] Email not sent: no valid recipient.');
|
||||
@@ -116,10 +117,7 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress,
|
||||
...replyToName ? {
|
||||
name: replyToName
|
||||
} : {}
|
||||
address: replyToAddress
|
||||
}
|
||||
}
|
||||
] : [];
|
||||
@@ -136,7 +134,17 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
...message.bcc ? {
|
||||
bccRecipients: toRecipients(message.bcc)
|
||||
} : {},
|
||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
||||
// From with the sender's OWN address (no Send-As) plus an optional
|
||||
// display name from the panel. Omit entirely when no name is set —
|
||||
// Graph then uses the mailbox's default name.
|
||||
...senderName ? {
|
||||
from: {
|
||||
emailAddress: {
|
||||
name: senderName,
|
||||
address: env.sender
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
...replyTo.length > 0 ? {
|
||||
replyTo
|
||||
} : {}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+16
-16
@@ -1,14 +1,8 @@
|
||||
import type { BasePayload, SanitizedConfig } from 'payload';
|
||||
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js';
|
||||
import type { SitemapEntry, RobotsRules } 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.
|
||||
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig;
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
/**
|
||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||
@@ -52,13 +52,13 @@ type CreateContentHelpersArgs = {
|
||||
* 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): {
|
||||
export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): {
|
||||
getCachedPayload: () => Promise<BasePayload>;
|
||||
getConfiguredLocales: () => Promise<string[]>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
|
||||
robots: () => RobotsRules;
|
||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
|
||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||
sitemap: () => Promise<SitemapEntry[]>;
|
||||
robots: () => RobotsRules;
|
||||
};
|
||||
export {};
|
||||
|
||||
+42
-22
@@ -1,7 +1,7 @@
|
||||
import { getPayload } from 'payload';
|
||||
import { cache } from 'react';
|
||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
import { getPayload } from 'payload';
|
||||
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js';
|
||||
import { buildSitemapEntries, buildRobots } 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
|
||||
@@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* `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' }) {
|
||||
*/ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
||||
const getCachedPayload = cache(async ()=>getPayload({
|
||||
config: await config
|
||||
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
const payload = await getCachedPayload();
|
||||
return payload.findGlobal({
|
||||
slug: settingsSlug,
|
||||
depth: 2,
|
||||
locale: locale
|
||||
locale: locale,
|
||||
depth: 2
|
||||
});
|
||||
});
|
||||
/** 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,
|
||||
locale,
|
||||
segments,
|
||||
page,
|
||||
content,
|
||||
pagesSlug,
|
||||
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({
|
||||
payload,
|
||||
collection,
|
||||
locale,
|
||||
page,
|
||||
payload,
|
||||
perPage
|
||||
});
|
||||
});
|
||||
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
* ```ts
|
||||
* // app/sitemap.ts
|
||||
* export { sitemap as default } from '@/lib/content'
|
||||
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||
* calls into Payload → the database. The build container usually has no access
|
||||
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||
* the build dies. Two defenses:
|
||||
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||
* 2. This handler also catches DB errors and returns [] so that even without
|
||||
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||
*/ const sitemap = cache(async ()=>{
|
||||
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
|
||||
});
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug
|
||||
});
|
||||
} catch (error) {
|
||||
// DB unreachable (typically a container build with no DB network) — return
|
||||
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
||||
return {
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
robots,
|
||||
sitemap
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,6 +1,13 @@
|
||||
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";
|
||||
/**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/
|
||||
export declare const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
acceptLanguage?: null | string;
|
||||
|
||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
||||
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';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */ /**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/ export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
|
||||
/**
|
||||
* Resolves which locale to serve, in priority order:
|
||||
* 1. Cookie (explicit prior choice)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/media/index.ts"],"sourcesContent":["export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'\n"],"names":["normalizeFilename","normalizeFilenameHook"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,qBAAqB,QAAQ,yBAAwB"}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { CollectionBeforeOperationHook } from 'payload';
|
||||
/**
|
||||
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||
*
|
||||
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
* "already-clean.webp" → "already-clean.webp"
|
||||
*
|
||||
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||
* the extension, and rejoin.
|
||||
*/
|
||||
export declare function normalizeFilename(filename: string): string;
|
||||
/**
|
||||
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||
* incoming file's name to its normalized form before Payload stores it, so both
|
||||
* the stored file and its DB filename are clean. Works with local disk and with
|
||||
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||
*
|
||||
* Wire into your Media collection:
|
||||
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||
*/
|
||||
export declare const normalizeFilenameHook: CollectionBeforeOperationHook;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import slugify from 'slugify';
|
||||
/**
|
||||
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||
*
|
||||
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
* "already-clean.webp" → "already-clean.webp"
|
||||
*
|
||||
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||
* the extension, and rejoin.
|
||||
*/ export function normalizeFilename(filename) {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
// No extension (or leading-dot dotfile) → slug the whole thing.
|
||||
if (lastDot <= 0) {
|
||||
return slugify(filename, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
}
|
||||
const stem = filename.slice(0, lastDot);
|
||||
const ext = filename.slice(lastDot + 1).toLowerCase();
|
||||
const cleanStem = slugify(stem, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
const cleanExt = slugify(ext, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true
|
||||
});
|
||||
// Stem could slug to empty (e.g. filename was all symbols) — fall back so we
|
||||
// never produce a nameless file.
|
||||
const safeStem = cleanStem || 'plik';
|
||||
return cleanExt ? `${safeStem}.${cleanExt}` : safeStem;
|
||||
}
|
||||
/**
|
||||
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||
* incoming file's name to its normalized form before Payload stores it, so both
|
||||
* the stored file and its DB filename are clean. Works with local disk and with
|
||||
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||
*
|
||||
* Wire into your Media collection:
|
||||
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||
*/ export const normalizeFilenameHook = ({ req, operation })=>{
|
||||
if (operation !== 'create' && operation !== 'update') return;
|
||||
const file = req.file;
|
||||
if (file?.name) {
|
||||
file.name = normalizeFilename(file.name);
|
||||
}
|
||||
};
|
||||
|
||||
//# sourceMappingURL=normalizeFilename.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/media/normalizeFilename.ts"],"sourcesContent":["import type { CollectionBeforeOperationHook } from 'payload'\nimport slugify from 'slugify'\n\n/**\n * Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)\n * while preserving the extension. Keeps uploaded media URLs clean and portable.\n *\n * \"Zdjęcie jeden nad morzem.jpg\" → \"zdjecie-jeden-nad-morzem.jpg\"\n * \"Faktura #12 (2024).PDF\" → \"faktura-12-2024.pdf\"\n * \"already-clean.webp\" → \"already-clean.webp\"\n *\n * Why not toSlug(): toSlug uses strict:true, which would strip the dot and\n * merge name+extension. Here we split on the LAST dot, slug the stem, lowercase\n * the extension, and rejoin.\n */\nexport function normalizeFilename(filename: string): string {\n const lastDot = filename.lastIndexOf('.')\n\n // No extension (or leading-dot dotfile) → slug the whole thing.\n if (lastDot <= 0) {\n return slugify(filename, { lower: true, strict: true, trim: true })\n }\n\n const stem = filename.slice(0, lastDot)\n const ext = filename.slice(lastDot + 1).toLowerCase()\n\n const cleanStem = slugify(stem, { lower: true, strict: true, trim: true })\n const cleanExt = slugify(ext, { lower: true, strict: true, trim: true })\n\n // Stem could slug to empty (e.g. filename was all symbols) — fall back so we\n // never produce a nameless file.\n const safeStem = cleanStem || 'plik'\n\n return cleanExt ? `${safeStem}.${cleanExt}` : safeStem\n}\n\n/**\n * beforeOperation hook for an upload collection (e.g. Media). Rewrites the\n * incoming file's name to its normalized form before Payload stores it, so both\n * the stored file and its DB filename are clean. Works with local disk and with\n * cloud storage adapters (R2/S3) — it runs before the storage layer.\n *\n * Wire into your Media collection:\n * import { normalizeFilenameHook } from '@intecion/ipal-kit'\n * hooks: { beforeOperation: [normalizeFilenameHook] }\n */\nexport const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => {\n if (operation !== 'create' && operation !== 'update') return\n const file = req.file\n if (file?.name) {\n file.name = normalizeFilename(file.name)\n }\n}\n"],"names":["slugify","normalizeFilename","filename","lastDot","lastIndexOf","lower","strict","trim","stem","slice","ext","toLowerCase","cleanStem","cleanExt","safeStem","normalizeFilenameHook","req","operation","file","name"],"mappings":"AACA,OAAOA,aAAa,UAAS;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBC,QAAgB;IAChD,MAAMC,UAAUD,SAASE,WAAW,CAAC;IAErC,gEAAgE;IAChE,IAAID,WAAW,GAAG;QAChB,OAAOH,QAAQE,UAAU;YAAEG,OAAO;YAAMC,QAAQ;YAAMC,MAAM;QAAK;IACnE;IAEA,MAAMC,OAAON,SAASO,KAAK,CAAC,GAAGN;IAC/B,MAAMO,MAAMR,SAASO,KAAK,CAACN,UAAU,GAAGQ,WAAW;IAEnD,MAAMC,YAAYZ,QAAQQ,MAAM;QAAEH,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IACxE,MAAMM,WAAWb,QAAQU,KAAK;QAAEL,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IAEtE,6EAA6E;IAC7E,iCAAiC;IACjC,MAAMO,WAAWF,aAAa;IAE9B,OAAOC,WAAW,GAAGC,SAAS,CAAC,EAAED,UAAU,GAAGC;AAChD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAuD,CAAC,EAAEC,GAAG,EAAEC,SAAS,EAAE;IACrF,IAAIA,cAAc,YAAYA,cAAc,UAAU;IACtD,MAAMC,OAAOF,IAAIE,IAAI;IACrB,IAAIA,MAAMC,MAAM;QACdD,KAAKC,IAAI,GAAGlB,kBAAkBiB,KAAKC,IAAI;IACzC;AACF,EAAC"}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
type Crumb = {
|
||||
/** Visible name of the breadcrumb (e.g. 'Usługi'). */
|
||||
name: string;
|
||||
/** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||
* and uses them to understand structure — a signal that helps navigational
|
||||
* results and sitelinks.
|
||||
*
|
||||
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||
*
|
||||
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildBreadcrumbJsonLd([
|
||||
* { name: 'Strona główna', url: `${base}/pl` },
|
||||
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
* ])
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||
*
|
||||
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||
* meaningful and shouldn't be emitted.
|
||||
*/
|
||||
export declare function buildBreadcrumbJsonLd(crumbs: Crumb[]): {
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
itemListElement: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
item: string;
|
||||
position: number;
|
||||
}[];
|
||||
} | null;
|
||||
export {};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||
* and uses them to understand structure — a signal that helps navigational
|
||||
* results and sitelinks.
|
||||
*
|
||||
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||
*
|
||||
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildBreadcrumbJsonLd([
|
||||
* { name: 'Strona główna', url: `${base}/pl` },
|
||||
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
* ])
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||
*
|
||||
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||
* meaningful and shouldn't be emitted.
|
||||
*/ export function buildBreadcrumbJsonLd(crumbs) {
|
||||
if (!crumbs || crumbs.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((crumb, index)=>({
|
||||
name: crumb.name,
|
||||
'@type': 'ListItem',
|
||||
item: crumb.url,
|
||||
position: index + 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildBreadcrumbJsonLd.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildBreadcrumbJsonLd.ts"],"sourcesContent":["type Crumb = {\n /** Visible name of the breadcrumb (e.g. 'Usługi'). */\n name: string\n /** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site\n * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)\n * and uses them to understand structure — a signal that helps navigational\n * results and sitelinks.\n *\n * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are\n * PER-PAGE — build them from the page's ancestry and emit on that page:\n *\n * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildBreadcrumbJsonLd([\n * { name: 'Strona główna', url: `${base}/pl` },\n * { name: 'Usługi', url: `${base}/pl/uslugi` },\n * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },\n * ])\n * <script type=\"application/ld+json\" ... />\n *\n * The crumb data comes from the page's real position (parent pages / URL path),\n * NOT hardcoded. Derive it from the resolved route, not a static list.\n *\n * Returns null for an empty/single crumb list — a one-item breadcrumb isn't\n * meaningful and shouldn't be emitted.\n */\nexport function buildBreadcrumbJsonLd(crumbs: Crumb[]) {\n if (!crumbs || crumbs.length < 2) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: crumbs.map((crumb, index) => ({\n name: crumb.name,\n '@type': 'ListItem',\n item: crumb.url,\n position: index + 1,\n })),\n }\n}\n"],"names":["buildBreadcrumbJsonLd","crumbs","length","itemListElement","map","crumb","index","name","item","url","position"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASA,sBAAsBC,MAAe;IACnD,IAAI,CAACA,UAAUA,OAAOC,MAAM,GAAG,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,OAAOG,GAAG,CAAC,CAACC,OAAOC,QAAW,CAAA;gBAC7CC,MAAMF,MAAME,IAAI;gBAChB,SAAS;gBACTC,MAAMH,MAAMI,GAAG;gBACfC,UAAUJ,QAAQ;YACpB,CAAA;IACF;AACF"}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
type FaqItem = {
|
||||
answer: string;
|
||||
question: string;
|
||||
};
|
||||
/**
|
||||
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||
* with voice/AI answers. Strong for service landing pages.
|
||||
*
|
||||
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||
* the panel) — the structured data must match visible content, or Google may
|
||||
* flag it. Never invent Q&A that isn't on the page.
|
||||
*
|
||||
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildFaqJsonLd(
|
||||
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
* )
|
||||
*
|
||||
* Returns null for empty list.
|
||||
*/
|
||||
export declare function buildFaqJsonLd(items: FaqItem[]): {
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
mainEntity: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
acceptedAnswer: {
|
||||
'@type': string;
|
||||
text: string;
|
||||
};
|
||||
}[];
|
||||
} | null;
|
||||
export {};
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||
* with voice/AI answers. Strong for service landing pages.
|
||||
*
|
||||
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||
* the panel) — the structured data must match visible content, or Google may
|
||||
* flag it. Never invent Q&A that isn't on the page.
|
||||
*
|
||||
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildFaqJsonLd(
|
||||
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
* )
|
||||
*
|
||||
* Returns null for empty list.
|
||||
*/ export function buildFaqJsonLd(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items.map((item)=>({
|
||||
name: item.question,
|
||||
'@type': 'Question',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildFaqJsonLd.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildFaqJsonLd.ts"],"sourcesContent":["type FaqItem = {\n answer: string\n question: string\n}\n\n/**\n * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as\n * expandable FAQ rich results under the page, taking more SERP space and helping\n * with voice/AI answers. Strong for service landing pages.\n *\n * Feed it the SAME questions/answers rendered on the page (from an FAQ block in\n * the panel) — the structured data must match visible content, or Google may\n * flag it. Never invent Q&A that isn't on the page.\n *\n * import { buildFaqJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildFaqJsonLd(\n * faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))\n * )\n *\n * Returns null for empty list.\n */\nexport function buildFaqJsonLd(items: FaqItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: items.map((item) => ({\n name: item.question,\n '@type': 'Question',\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n }\n}\n"],"names":["buildFaqJsonLd","items","length","mainEntity","map","item","name","question","acceptedAnswer","text","answer"],"mappings":"AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASA,eAAeC,KAAgB;IAC7C,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,YAAYF,MAAMG,GAAG,CAAC,CAACC,OAAU,CAAA;gBAC/BC,MAAMD,KAAKE,QAAQ;gBACnB,SAAS;gBACTC,gBAAgB;oBACd,SAAS;oBACTC,MAAMJ,KAAKK,MAAM;gBACnB;YACF,CAAA;IACF;AACF"}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
type MediaLike = {
|
||||
height?: null | number;
|
||||
mimeType?: null | string;
|
||||
url?: null | string;
|
||||
width?: null | number;
|
||||
} | null | undefined;
|
||||
/**
|
||||
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||
*
|
||||
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||
* in search results. This generates the tags correctly, every time, from the
|
||||
* panel field.
|
||||
*
|
||||
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||
* generateMetadata — not per page:
|
||||
*
|
||||
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||
* export async function generateMetadata(): Promise<Metadata> {
|
||||
* const settings = await getSettings(locale)
|
||||
* return buildIconsMetadata(settings.favicon)
|
||||
* }
|
||||
*
|
||||
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||
* will reject.
|
||||
*/
|
||||
export declare function buildIconsMetadata(favicon: MediaLike): Metadata;
|
||||
export {};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||
*
|
||||
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||
* in search results. This generates the tags correctly, every time, from the
|
||||
* panel field.
|
||||
*
|
||||
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||
* generateMetadata — not per page:
|
||||
*
|
||||
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||
* export async function generateMetadata(): Promise<Metadata> {
|
||||
* const settings = await getSettings(locale)
|
||||
* return buildIconsMetadata(settings.favicon)
|
||||
* }
|
||||
*
|
||||
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||
* will reject.
|
||||
*/ export function buildIconsMetadata(favicon) {
|
||||
const url = favicon?.url;
|
||||
if (!url) {
|
||||
return {};
|
||||
}
|
||||
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg');
|
||||
return {
|
||||
icons: {
|
||||
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||
icon: isSvg ? [
|
||||
{
|
||||
type: 'image/svg+xml',
|
||||
url
|
||||
}
|
||||
] : [
|
||||
{
|
||||
sizes: 'any',
|
||||
url
|
||||
}
|
||||
],
|
||||
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||
apple: [
|
||||
{
|
||||
url
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildIconsMetadata.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildIconsMetadata.ts"],"sourcesContent":["import type { Metadata } from 'next'\n\ntype MediaLike =\n | { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }\n | null\n | undefined\n\n/**\n * Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's\n * favicon upload, so the browser tab AND Google get a proper <link rel=\"icon\">.\n *\n * Why the plugin must do this (not the project): favicon-in-Google has strict\n * rules — a real <link rel=\"icon\"> in <head>, square, ≥48×48, at a stable URL.\n * Leaving it to each project meant inconsistent hand-rolled tags and no favicon\n * in search results. This generates the tags correctly, every time, from the\n * panel field.\n *\n * Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's\n * generateMetadata — not per page:\n *\n * import { buildIconsMetadata } from '@intecion/ipal-kit'\n * export async function generateMetadata(): Promise<Metadata> {\n * const settings = await getSettings(locale)\n * return buildIconsMetadata(settings.favicon)\n * }\n *\n * Google notes: it caches favicons separately and slowly (days/weeks), and only\n * shows them for icons it deems valid. Warn on too-small icons at upload time\n * (see the media validation hook) so editors don't ship a <48px favicon Google\n * will reject.\n */\nexport function buildIconsMetadata(favicon: MediaLike): Metadata {\n const url = favicon?.url\n if (!url) {return {}}\n\n const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')\n\n return {\n icons: {\n // Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).\n icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],\n // Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.\n apple: [{ url }],\n },\n }\n}\n"],"names":["buildIconsMetadata","favicon","url","isSvg","mimeType","endsWith","icons","icon","type","sizes","apple"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,mBAAmBC,OAAkB;IACnD,MAAMC,MAAMD,SAASC;IACrB,IAAI,CAACA,KAAK;QAAC,OAAO,CAAC;IAAC;IAEpB,MAAMC,QAAQF,SAASG,aAAa,mBAAmBF,IAAIG,QAAQ,CAAC;IAEpE,OAAO;QACLC,OAAO;YACL,sEAAsE;YACtEC,MAAMJ,QAAQ;gBAAC;oBAAEK,MAAM;oBAAiBN;gBAAI;aAAE,GAAG;gBAAC;oBAAEO,OAAO;oBAAOP;gBAAI;aAAE;YACxE,yEAAyE;YACzEQ,OAAO;gBAAC;oBAAER;gBAAI;aAAE;QAClB;IACF;AACF"}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
type MediaLike = {
|
||||
url?: null | string;
|
||||
} | null | undefined;
|
||||
type Address = {
|
||||
city?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
region?: string;
|
||||
street?: string;
|
||||
};
|
||||
type LocalBusinessJsonLdArgs = {
|
||||
address?: Address;
|
||||
/** Geo coordinates for maps/local search. */
|
||||
geo?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
image?: MediaLike;
|
||||
logo?: MediaLike;
|
||||
name: string;
|
||||
/** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */
|
||||
openingHours?: string[];
|
||||
priceRange?: string;
|
||||
sameAs?: string[];
|
||||
/** Business phone, e.g. '+48 123 456 789'. */
|
||||
telephone?: string;
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||
* SEO. Helps Google show the business in local results / map pack with address,
|
||||
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||
*
|
||||
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||
* root layout (business is site-wide):
|
||||
*
|
||||
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildLocalBusinessJsonLd({
|
||||
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||
* address: company.address, openingHours: company.hours,
|
||||
* })
|
||||
*
|
||||
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||
* returned object — schema.org has many LocalBusiness subtypes.
|
||||
*/
|
||||
export declare function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url, }: LocalBusinessJsonLdArgs): {
|
||||
sameAs?: string[] | undefined;
|
||||
priceRange?: string | undefined;
|
||||
openingHours?: string[] | undefined;
|
||||
geo?: {
|
||||
'@type': string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
} | undefined;
|
||||
address?: {
|
||||
addressCountry?: string | undefined;
|
||||
addressRegion?: string | undefined;
|
||||
postalCode?: string | undefined;
|
||||
addressLocality?: string | undefined;
|
||||
streetAddress?: string | undefined;
|
||||
'@type': string;
|
||||
} | undefined;
|
||||
logo?: string | undefined;
|
||||
image?: string | undefined;
|
||||
telephone?: string | undefined;
|
||||
name: string;
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
url: string;
|
||||
};
|
||||
export {};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||
* SEO. Helps Google show the business in local results / map pack with address,
|
||||
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||
*
|
||||
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||
* root layout (business is site-wide):
|
||||
*
|
||||
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildLocalBusinessJsonLd({
|
||||
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||
* address: company.address, openingHours: company.hours,
|
||||
* })
|
||||
*
|
||||
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||
* returned object — schema.org has many LocalBusiness subtypes.
|
||||
*/ export function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url }) {
|
||||
const logoUrl = logo?.url;
|
||||
const imageUrl = image?.url ?? logoUrl;
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
url,
|
||||
...telephone ? {
|
||||
telephone
|
||||
} : {},
|
||||
...imageUrl ? {
|
||||
image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}`
|
||||
} : {},
|
||||
...logoUrl ? {
|
||||
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||
} : {},
|
||||
...address ? {
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
...address.street ? {
|
||||
streetAddress: address.street
|
||||
} : {},
|
||||
...address.city ? {
|
||||
addressLocality: address.city
|
||||
} : {},
|
||||
...address.postalCode ? {
|
||||
postalCode: address.postalCode
|
||||
} : {},
|
||||
...address.region ? {
|
||||
addressRegion: address.region
|
||||
} : {},
|
||||
...address.country ? {
|
||||
addressCountry: address.country
|
||||
} : {}
|
||||
}
|
||||
} : {},
|
||||
...geo ? {
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: geo.latitude,
|
||||
longitude: geo.longitude
|
||||
}
|
||||
} : {},
|
||||
...openingHours && openingHours.length > 0 ? {
|
||||
openingHours
|
||||
} : {},
|
||||
...priceRange ? {
|
||||
priceRange
|
||||
} : {},
|
||||
...sameAs && sameAs.length > 0 ? {
|
||||
sameAs
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildLocalBusinessJsonLd.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildLocalBusinessJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype Address = {\n city?: string\n country?: string // ISO code, e.g. 'PL'\n postalCode?: string\n region?: string\n street?: string\n}\n\ntype LocalBusinessJsonLdArgs = {\n address?: Address\n /** Geo coordinates for maps/local search. */\n geo?: { latitude: number; longitude: number }\n image?: MediaLike\n logo?: MediaLike\n name: string\n /** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */\n openingHours?: string[]\n priceRange?: string // e.g. '$$'\n sameAs?: string[]\n /** Business phone, e.g. '+48 123 456 789'. */\n telephone?: string\n url: string\n}\n\n/**\n * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL\n * SEO. Helps Google show the business in local results / map pack with address,\n * hours, phone. Strong signal for \"usługa + miasto\" queries.\n *\n * All data from the panel (company global) — nothing hardcoded. Emit once in the\n * root layout (business is site-wide):\n *\n * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildLocalBusinessJsonLd({\n * name: company.name, url: baseUrl, telephone: company.phone,\n * address: company.address, openingHours: company.hours,\n * })\n *\n * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the\n * returned object — schema.org has many LocalBusiness subtypes.\n */\nexport function buildLocalBusinessJsonLd({\n name,\n address,\n geo,\n image,\n logo,\n openingHours,\n priceRange,\n sameAs,\n telephone,\n url,\n}: LocalBusinessJsonLdArgs) {\n const logoUrl = logo?.url\n const imageUrl = image?.url ?? logoUrl\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'LocalBusiness',\n url,\n ...(telephone ? { telephone } : {}),\n ...(imageUrl ? { image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` } : {}),\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(address\n ? {\n address: {\n '@type': 'PostalAddress',\n ...(address.street ? { streetAddress: address.street } : {}),\n ...(address.city ? { addressLocality: address.city } : {}),\n ...(address.postalCode ? { postalCode: address.postalCode } : {}),\n ...(address.region ? { addressRegion: address.region } : {}),\n ...(address.country ? { addressCountry: address.country } : {}),\n },\n }\n : {}),\n ...(geo\n ? { geo: { '@type': 'GeoCoordinates', latitude: geo.latitude, longitude: geo.longitude } }\n : {}),\n ...(openingHours && openingHours.length > 0 ? { openingHours } : {}),\n ...(priceRange ? { priceRange } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildLocalBusinessJsonLd","name","address","geo","image","logo","openingHours","priceRange","sameAs","telephone","url","logoUrl","imageUrl","startsWith","street","streetAddress","city","addressLocality","postalCode","region","addressRegion","country","addressCountry","latitude","longitude","length"],"mappings":"AA0BA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,yBAAyB,EACvCC,IAAI,EACJC,OAAO,EACPC,GAAG,EACHC,KAAK,EACLC,IAAI,EACJC,YAAY,EACZC,UAAU,EACVC,MAAM,EACNC,SAAS,EACTC,GAAG,EACqB;IACxB,MAAMC,UAAUN,MAAMK;IACtB,MAAME,WAAWR,OAAOM,OAAOC;IAE/B,OAAO;QACLV;QACA,YAAY;QACZ,SAAS;QACTS;QACA,GAAID,YAAY;YAAEA;QAAU,IAAI,CAAC,CAAC;QAClC,GAAIG,WAAW;YAAER,OAAOQ,SAASC,UAAU,CAAC,UAAUD,WAAW,GAAGF,MAAME,UAAU;QAAC,IAAI,CAAC,CAAC;QAC3F,GAAID,UAAU;YAAEN,MAAMM,QAAQE,UAAU,CAAC,UAAUF,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIT,UACA;YACEA,SAAS;gBACP,SAAS;gBACT,GAAIA,QAAQY,MAAM,GAAG;oBAAEC,eAAeb,QAAQY,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIZ,QAAQc,IAAI,GAAG;oBAAEC,iBAAiBf,QAAQc,IAAI;gBAAC,IAAI,CAAC,CAAC;gBACzD,GAAId,QAAQgB,UAAU,GAAG;oBAAEA,YAAYhB,QAAQgB,UAAU;gBAAC,IAAI,CAAC,CAAC;gBAChE,GAAIhB,QAAQiB,MAAM,GAAG;oBAAEC,eAAelB,QAAQiB,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIjB,QAAQmB,OAAO,GAAG;oBAAEC,gBAAgBpB,QAAQmB,OAAO;gBAAC,IAAI,CAAC,CAAC;YAChE;QACF,IACA,CAAC,CAAC;QACN,GAAIlB,MACA;YAAEA,KAAK;gBAAE,SAAS;gBAAkBoB,UAAUpB,IAAIoB,QAAQ;gBAAEC,WAAWrB,IAAIqB,SAAS;YAAC;QAAE,IACvF,CAAC,CAAC;QACN,GAAIlB,gBAAgBA,aAAamB,MAAM,GAAG,IAAI;YAAEnB;QAAa,IAAI,CAAC,CAAC;QACnE,GAAIC,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIC,UAAUA,OAAOiB,MAAM,GAAG,IAAI;YAAEjB;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||
Vendored
+13
-1
@@ -19,6 +19,11 @@ export type PageMetadata = {
|
||||
locale?: string;
|
||||
title: string;
|
||||
};
|
||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||
robots?: {
|
||||
follow: boolean;
|
||||
index: boolean;
|
||||
};
|
||||
title: string;
|
||||
};
|
||||
type BuildMetadataArgs = {
|
||||
@@ -35,6 +40,13 @@ type BuildMetadataArgs = {
|
||||
meta?: null | SeoMeta;
|
||||
/** Page title or site name first. Defaults to 'page-first'. */
|
||||
order?: TitleOrder;
|
||||
/**
|
||||
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||
* page-title source when meta.title is empty — the browser tab and search
|
||||
* result should show the page name, not go blank, when an editor didn't fill
|
||||
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||
*/
|
||||
pageTitle?: null | string;
|
||||
/**
|
||||
* Localized segment the document lives under (an archive page's slugs).
|
||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||
@@ -65,5 +77,5 @@ type BuildMetadataArgs = {
|
||||
* 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 declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||
export {};
|
||||
|
||||
Vendored
+16
-5
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* 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.
|
||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
|
||||
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||
// composed with the site name, instead of just the site name or a blank.
|
||||
const override = meta?.titleOverride?.trim();
|
||||
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
|
||||
const title = override || composeTitle({
|
||||
order,
|
||||
pageTitle: meta?.title,
|
||||
pageTitle: resolvedPageTitle,
|
||||
separator,
|
||||
siteName
|
||||
});
|
||||
@@ -69,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
images
|
||||
},
|
||||
locale
|
||||
}
|
||||
},
|
||||
// noindex → tell search engines to exclude the page but still follow links
|
||||
// (authority flows through). For legal/thin/search-result pages.
|
||||
...meta?.noindex ? {
|
||||
robots: {
|
||||
follow: true,
|
||||
index: false
|
||||
}
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+41
@@ -0,0 +1,41 @@
|
||||
type MediaLike = {
|
||||
url?: null | string;
|
||||
} | null | undefined;
|
||||
type OrganizationJsonLdArgs = {
|
||||
/** Logo media (from panel). Google uses this for brand knowledge panels. */
|
||||
logo?: MediaLike;
|
||||
/** Organization / site name. */
|
||||
name: string;
|
||||
/** Optional social / official profile URLs (sameAs). */
|
||||
sameAs?: string[];
|
||||
/** Absolute site URL (https://…). */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||
* with a brand: name, logo, official links. Improves how the site appears in
|
||||
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||
* alongside favicon for identity.
|
||||
*
|
||||
* Returns a plain object; the project renders it as a <script type="application/
|
||||
* ld+json"> in the root layout:
|
||||
*
|
||||
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildOrganizationJsonLd({
|
||||
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||
* })
|
||||
* <script type="application/ld+json"
|
||||
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
*
|
||||
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||
* the root layout (Organization is site-wide, not per page).
|
||||
*/
|
||||
export declare function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs): {
|
||||
sameAs?: string[] | undefined;
|
||||
logo?: string | undefined;
|
||||
name: string;
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
url: string;
|
||||
};
|
||||
export {};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||
* with a brand: name, logo, official links. Improves how the site appears in
|
||||
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||
* alongside favicon for identity.
|
||||
*
|
||||
* Returns a plain object; the project renders it as a <script type="application/
|
||||
* ld+json"> in the root layout:
|
||||
*
|
||||
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildOrganizationJsonLd({
|
||||
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||
* })
|
||||
* <script type="application/ld+json"
|
||||
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
*
|
||||
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||
* the root layout (Organization is site-wide, not per page).
|
||||
*/ export function buildOrganizationJsonLd({ name, logo, sameAs, url }) {
|
||||
const logoUrl = logo?.url;
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
url,
|
||||
...logoUrl ? {
|
||||
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||
} : {},
|
||||
...sameAs && sameAs.length > 0 ? {
|
||||
sameAs
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildOrganizationJsonLd.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildOrganizationJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype OrganizationJsonLdArgs = {\n /** Logo media (from panel). Google uses this for brand knowledge panels. */\n logo?: MediaLike\n /** Organization / site name. */\n name: string\n /** Optional social / official profile URLs (sameAs). */\n sameAs?: string[]\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds Organization JSON-LD (schema.org) — helps Google associate the site\n * with a brand: name, logo, official links. Improves how the site appears in\n * search (brand recognition, logo in knowledge panels) and is a signal used\n * alongside favicon for identity.\n *\n * Returns a plain object; the project renders it as a <script type=\"application/\n * ld+json\"> in the root layout:\n *\n * import { buildOrganizationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildOrganizationJsonLd({\n * name: settings.siteName, url: baseUrl, logo: settings.logo,\n * })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n *\n * Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in\n * the root layout (Organization is site-wide, not per page).\n */\nexport function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {\n const logoUrl = logo?.url\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Organization',\n url,\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildOrganizationJsonLd","name","logo","sameAs","url","logoUrl","startsWith","length"],"mappings":"AAaA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,wBAAwB,EAAEC,IAAI,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAA0B;IACzF,MAAMC,UAAUH,MAAME;IAEtB,OAAO;QACLH;QACA,YAAY;QACZ,SAAS;QACTG;QACA,GAAIC,UAAU;YAAEH,MAAMG,QAAQC,UAAU,CAAC,UAAUD,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIF,UAAUA,OAAOI,MAAM,GAAG,IAAI;YAAEJ;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
type ServiceJsonLdArgs = {
|
||||
/** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */
|
||||
areaServed?: string | string[];
|
||||
description?: string;
|
||||
/** Service name, e.g. 'Sprzątanie biur'. */
|
||||
name: string;
|
||||
/** Provider (business) name. */
|
||||
providerName: string;
|
||||
/** Service type / category. */
|
||||
serviceType?: string;
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||
* understand "what this page sells" — useful for service landing pages
|
||||
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||
*
|
||||
* Per-page (each service page emits its own), data from the panel:
|
||||
*
|
||||
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildServiceJsonLd({
|
||||
* name: page.serviceName, providerName: company.name,
|
||||
* url: pageUrl, areaServed: 'Wrocław',
|
||||
* })
|
||||
*/
|
||||
export declare function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url, }: ServiceJsonLdArgs): {
|
||||
serviceType?: string | undefined;
|
||||
areaServed?: string | string[] | undefined;
|
||||
description?: string | undefined;
|
||||
name: string;
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
provider: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
export {};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||
* understand "what this page sells" — useful for service landing pages
|
||||
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||
*
|
||||
* Per-page (each service page emits its own), data from the panel:
|
||||
*
|
||||
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildServiceJsonLd({
|
||||
* name: page.serviceName, providerName: company.name,
|
||||
* url: pageUrl, areaServed: 'Wrocław',
|
||||
* })
|
||||
*/ export function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url }) {
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
provider: {
|
||||
name: providerName,
|
||||
'@type': 'LocalBusiness',
|
||||
url
|
||||
},
|
||||
...description ? {
|
||||
description
|
||||
} : {},
|
||||
...areaServed ? {
|
||||
areaServed
|
||||
} : {},
|
||||
...serviceType ? {
|
||||
serviceType
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildServiceJsonLd.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildServiceJsonLd.ts"],"sourcesContent":["type ServiceJsonLdArgs = {\n /** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */\n areaServed?: string | string[]\n description?: string\n /** Service name, e.g. 'Sprzątanie biur'. */\n name: string\n /** Provider (business) name. */\n providerName: string\n /** Service type / category. */\n serviceType?: string\n url: string\n}\n\n/**\n * Builds Service JSON-LD (schema.org) for a service offering. Helps Google\n * understand \"what this page sells\" — useful for service landing pages\n * (\"usługa + miasto\"). Pairs well with LocalBusiness (the provider).\n *\n * Per-page (each service page emits its own), data from the panel:\n *\n * import { buildServiceJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildServiceJsonLd({\n * name: page.serviceName, providerName: company.name,\n * url: pageUrl, areaServed: 'Wrocław',\n * })\n */\nexport function buildServiceJsonLd({\n name,\n areaServed,\n description,\n providerName,\n serviceType,\n url,\n}: ServiceJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Service',\n provider: {\n name: providerName,\n '@type': 'LocalBusiness',\n url,\n },\n ...(description ? { description } : {}),\n ...(areaServed ? { areaServed } : {}),\n ...(serviceType ? { serviceType } : {}),\n }\n}\n"],"names":["buildServiceJsonLd","name","areaServed","description","providerName","serviceType","url","provider"],"mappings":"AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASA,mBAAmB,EACjCC,IAAI,EACJC,UAAU,EACVC,WAAW,EACXC,YAAY,EACZC,WAAW,EACXC,GAAG,EACe;IAClB,OAAO;QACLL;QACA,YAAY;QACZ,SAAS;QACTM,UAAU;YACRN,MAAMG;YACN,SAAS;YACTE;QACF;QACA,GAAIH,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAID,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIG,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;IACxC;AACF"}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
type NavItem = {
|
||||
/** Visible label (e.g. 'Usługi'). */
|
||||
name: string;
|
||||
/** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||
* navigation targets.
|
||||
*
|
||||
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||
*
|
||||
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildSiteNavigationJsonLd(
|
||||
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||
* )
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||
* Returns null for empty nav.
|
||||
*/
|
||||
export declare function buildSiteNavigationJsonLd(items: NavItem[]): {
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
itemListElement: {
|
||||
name: string;
|
||||
'@type': string;
|
||||
position: number;
|
||||
url: string;
|
||||
}[];
|
||||
} | null;
|
||||
export {};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||
* navigation targets.
|
||||
*
|
||||
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||
*
|
||||
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildSiteNavigationJsonLd(
|
||||
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||
* )
|
||||
* <script type="application/ld+json" ... />
|
||||
*
|
||||
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||
* Returns null for empty nav.
|
||||
*/ export function buildSiteNavigationJsonLd(items) {
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: items.map((item, index)=>({
|
||||
name: item.name,
|
||||
'@type': 'SiteNavigationElement',
|
||||
position: index + 1,
|
||||
url: item.url
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildSiteNavigationJsonLd.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildSiteNavigationJsonLd.ts"],"sourcesContent":["type NavItem = {\n /** Visible label (e.g. 'Usługi'). */\n name: string\n /** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —\n * declares the site's primary nav as structured data. A weaker sitelinks signal\n * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main\n * navigation targets.\n *\n * Feed it the SAME nav items the header renders (from the panel/nav global), so\n * the structured data matches the visible menu — not a separate hardcoded list.\n *\n * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildSiteNavigationJsonLd(\n * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))\n * )\n * <script type=\"application/ld+json\" ... />\n *\n * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.\n * Returns null for empty nav.\n */\nexport function buildSiteNavigationJsonLd(items: NavItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'ItemList',\n itemListElement: items.map((item, index) => ({\n name: item.name,\n '@type': 'SiteNavigationElement',\n position: index + 1,\n url: item.url,\n })),\n }\n}\n"],"names":["buildSiteNavigationJsonLd","items","length","itemListElement","map","item","index","name","position","url"],"mappings":"AAOA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,0BAA0BC,KAAgB;IACxD,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,MAAMG,GAAG,CAAC,CAACC,MAAMC,QAAW,CAAA;gBAC3CC,MAAMF,KAAKE,IAAI;gBACf,SAAS;gBACTC,UAAUF,QAAQ;gBAClBG,KAAKJ,KAAKI,GAAG;YACf,CAAA;IACF;AACF"}
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import type { BasePayload } from 'payload';
|
||||
import type { ContentOption } from '../content/index.js';
|
||||
import type { I18nConfig } from '../i18n/index.js';
|
||||
import type { ContentOption } from '../content/index.js';
|
||||
/**
|
||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||
*
|
||||
@@ -10,31 +10,31 @@ import type { I18nConfig } from '../i18n/index.js';
|
||||
* is the common, weaker kind.
|
||||
*/
|
||||
export type SitemapEntry = {
|
||||
url: string;
|
||||
lastModified?: string | Date;
|
||||
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
alternates?: {
|
||||
languages: Record<string, string>;
|
||||
};
|
||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
|
||||
lastModified?: Date | string;
|
||||
priority?: number;
|
||||
url: string;
|
||||
};
|
||||
type BuildSitemapArgs = {
|
||||
payload: BasePayload;
|
||||
config: I18nConfig;
|
||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||
baseUrl: string;
|
||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||
config: I18nConfig;
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string;
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption;
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string;
|
||||
/**
|
||||
* 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;
|
||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||
};
|
||||
/**
|
||||
* Collects every public URL — pages and archive entries — as sitemap entries
|
||||
@@ -57,5 +57,5 @@ type BuildSitemapArgs = {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||
export declare function buildSitemapEntries({ payload, config, baseUrl, pagesSlug, content, settingsSlug, homeSlug, changeFrequency, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||
export {};
|
||||
|
||||
+46
-44
@@ -1,13 +1,31 @@
|
||||
import { archiveFieldName } from '../content/index.js';
|
||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js';
|
||||
import { getLocalizedSlugs } from '../i18n/index.js';
|
||||
import { buildLocalizedPath } 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;
|
||||
import { archiveFieldName } from '../content/index.js';
|
||||
/**
|
||||
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
||||
* documents (e.g. a '404' page in the Pages collection) but should not be
|
||||
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||
* is an audit finding. Matched against the slug in any locale.
|
||||
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||
'404',
|
||||
'500',
|
||||
'not-found',
|
||||
'error'
|
||||
]);
|
||||
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
||||
if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug);
|
||||
if (slug && typeof slug === 'object') {
|
||||
for (const value of Object.values(slug)){
|
||||
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||
if (doc._status && doc._status !== 'published') return false;
|
||||
if (doc.meta?.noindex) return false;
|
||||
if (hasExcludedSlug(doc.slug)) return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
@@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* 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
|
||||
slugField: doc.slug,
|
||||
config
|
||||
}) : {};
|
||||
const path = buildLocalizedPath({
|
||||
slugs,
|
||||
locale,
|
||||
config,
|
||||
homeSlug,
|
||||
locale,
|
||||
prefix,
|
||||
slugs
|
||||
prefix
|
||||
});
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
if (!path) return null;
|
||||
const origin = baseUrl.replace(/\/$/, '');
|
||||
const languages = buildHreflangAlternates({
|
||||
baseUrl,
|
||||
slugs,
|
||||
config,
|
||||
baseUrl,
|
||||
homeSlug,
|
||||
prefix,
|
||||
slugs
|
||||
prefix
|
||||
});
|
||||
return {
|
||||
url: `${origin}${path}`,
|
||||
@@ -73,15 +89,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
|
||||
*/ export async function buildSitemapEntries({ payload, config, baseUrl, pagesSlug = 'pages', content, settingsSlug = 'site-settings', homeSlug, changeFrequency = 'weekly' }) {
|
||||
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'
|
||||
locale: 'all',
|
||||
depth: 1
|
||||
});
|
||||
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
||||
// Which collections to walk: pages (no prefix) + each content collection with
|
||||
@@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
// alternates without re-querying per locale.
|
||||
const result = await payload.find({
|
||||
collection: collection.slug,
|
||||
locale: 'all',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
locale: 'all',
|
||||
pagination: false
|
||||
});
|
||||
for (const raw of result.docs){
|
||||
if (!isIndexable(raw)) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!rel || typeof rel !== 'object') return undefined;
|
||||
const slug = rel.slug;
|
||||
if (typeof slug === 'string') {
|
||||
return slug;
|
||||
}
|
||||
if (typeof slug === 'string') return slug;
|
||||
if (slug && typeof slug === 'object') {
|
||||
const v = slug[locale];
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
@@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
return undefined;
|
||||
}
|
||||
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
||||
if (!rel || typeof rel !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (!rel || typeof rel !== 'object') return undefined;
|
||||
const slug = rel.slug;
|
||||
if (!slug || typeof slug !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (typeof v === 'string') map[locale] = v;
|
||||
}
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+55
@@ -0,0 +1,55 @@
|
||||
type SearchActionConfig = {
|
||||
/**
|
||||
* URL template for site search, with {search_term_string} placeholder.
|
||||
* e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if
|
||||
* the site actually HAS a working search page — a SearchAction pointing at a
|
||||
* non-existent search does more harm than good.
|
||||
*/
|
||||
target: string;
|
||||
};
|
||||
type WebSiteJsonLdArgs = {
|
||||
/** Site name (from panel — siteName). */
|
||||
name: string;
|
||||
/**
|
||||
* Optional site search. Enables the "sitelinks searchbox" — a search field
|
||||
* Google may show under the brand result. Only pass when a real search page
|
||||
* exists. Omit entirely otherwise.
|
||||
*/
|
||||
search?: SearchActionConfig;
|
||||
/** Absolute site URL (https://…). */
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||
* searchbox" (a search field under the brand result in Google).
|
||||
*
|
||||
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||
* schema guarantees them; Google generates them algorithmically from site
|
||||
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||
* expectations accordingly (see docs/seo.md).
|
||||
*
|
||||
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||
*
|
||||
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||
* <script type="application/ld+json"
|
||||
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
*/
|
||||
export declare function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs): {
|
||||
potentialAction?: {
|
||||
'@type': string;
|
||||
'query-input': string;
|
||||
target: {
|
||||
'@type': string;
|
||||
urlTemplate: string;
|
||||
};
|
||||
} | undefined;
|
||||
name: string;
|
||||
'@context': string;
|
||||
'@type': string;
|
||||
url: string;
|
||||
};
|
||||
export {};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||
* searchbox" (a search field under the brand result in Google).
|
||||
*
|
||||
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||
* schema guarantees them; Google generates them algorithmically from site
|
||||
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||
* expectations accordingly (see docs/seo.md).
|
||||
*
|
||||
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||
*
|
||||
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||
* <script type="application/ld+json"
|
||||
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
*/ export function buildWebSiteJsonLd({ name, search, url }) {
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url,
|
||||
...search ? {
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
'query-input': 'required name=search_term_string',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: search.target
|
||||
}
|
||||
}
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildWebSiteJsonLd.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/buildWebSiteJsonLd.ts"],"sourcesContent":["type SearchActionConfig = {\n /**\n * URL template for site search, with {search_term_string} placeholder.\n * e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if\n * the site actually HAS a working search page — a SearchAction pointing at a\n * non-existent search does more harm than good.\n */\n target: string\n}\n\ntype WebSiteJsonLdArgs = {\n /** Site name (from panel — siteName). */\n name: string\n /**\n * Optional site search. Enables the \"sitelinks searchbox\" — a search field\n * Google may show under the brand result. Only pass when a real search page\n * exists. Omit entirely otherwise.\n */\n search?: SearchActionConfig\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds WebSite JSON-LD (schema.org). Two jobs:\n * - Declares the site + name (helps Google associate brand queries with the site).\n * - Optionally declares a SearchAction, which is what can produce the \"sitelinks\n * searchbox\" (a search field under the brand result in Google).\n *\n * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No\n * schema guarantees them; Google generates them algorithmically from site\n * structure, internal links, clear titles, and ranking. This schema is a SIGNAL\n * that improves the odds and can enable the searchbox — not a switch. Manage\n * expectations accordingly (see docs/seo.md).\n *\n * Emit once in the ROOT layout (site-wide), from panel data:\n *\n * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n */\nexport function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'WebSite',\n url,\n ...(search\n ? {\n potentialAction: {\n '@type': 'SearchAction',\n 'query-input': 'required name=search_term_string',\n target: {\n '@type': 'EntryPoint',\n urlTemplate: search.target,\n },\n },\n }\n : {}),\n }\n}\n"],"names":["buildWebSiteJsonLd","name","search","url","potentialAction","target","urlTemplate"],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,mBAAmB,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAAqB;IACzE,OAAO;QACLF;QACA,YAAY;QACZ,SAAS;QACTE;QACA,GAAID,SACA;YACEE,iBAAiB;gBACf,SAAS;gBACT,eAAe;gBACfC,QAAQ;oBACN,SAAS;oBACTC,aAAaJ,OAAOG,MAAM;gBAC5B;YACF;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||
+1
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||
...base,
|
||||
imageUrl: resolveOgImage(doc),
|
||||
meta: doc.meta,
|
||||
pageTitle: doc.title,
|
||||
prefix,
|
||||
query,
|
||||
slugs
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-2
@@ -15,20 +15,28 @@ type BuildHreflangArgs = {
|
||||
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.
|
||||
*
|
||||
* Also emits `x-default` pointing at the default locale — the version Google
|
||||
* serves when the user's language/region matches no hreflang, and the fallback
|
||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||
* Accept-Language).
|
||||
*
|
||||
* @example
|
||||
* 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' }
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
*/
|
||||
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>;
|
||||
export {};
|
||||
|
||||
Vendored
+20
-3
@@ -1,19 +1,27 @@
|
||||
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
||||
import { buildLocalizedPath, getDefaultLocale, 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.
|
||||
*
|
||||
* Also emits `x-default` pointing at the default locale — the version Google
|
||||
* serves when the user's language/region matches no hreflang, and the fallback
|
||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||
* Accept-Language).
|
||||
*
|
||||
* @example
|
||||
* 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' }
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||
const alternates = {};
|
||||
@@ -29,6 +37,15 @@ import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
||||
alternates[locale] = `${origin}${path}`;
|
||||
}
|
||||
}
|
||||
// x-default: the version Google serves when the user's language/region doesn't
|
||||
// match any hreflang — and, crucially here, the fallback when the root ('/')
|
||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||
// at the default locale (the primary market) so search shows that version by
|
||||
// default instead of guessing. Only set when the default locale has a URL.
|
||||
const defaultLocalePath = alternates[getDefaultLocale(config)];
|
||||
if (defaultLocalePath) {
|
||||
alternates['x-default'] = defaultLocalePath;
|
||||
}
|
||||
return alternates;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, 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<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n 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"}
|
||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;AACT"}
|
||||
Vendored
+9
@@ -1,11 +1,19 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||
export { buildMetadata } from './buildMetadata.js';
|
||||
export type { PageMetadata } from './buildMetadata.js';
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||
export { buildRobots } from './buildRobots.js';
|
||||
export type { RobotsRules } from './buildRobots.js';
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||
export type { SitemapEntry } from './buildSitemapEntries.js';
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||
export { composeTitle } from './composeTitle.js';
|
||||
export type { TitleOrder } from './composeTitle.js';
|
||||
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
||||
@@ -18,3 +26,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js';
|
||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||
export type { SeoMeta, SeoOption } from './types.js';
|
||||
export { validateFaviconField } from './validateFavicon.js';
|
||||
|
||||
Vendored
+9
@@ -1,7 +1,15 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||
export { buildMetadata } from './buildMetadata.js';
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||
export { buildRobots } from './buildRobots.js';
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||
export { composeTitle } from './composeTitle.js';
|
||||
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
||||
export { createPageMetadata } from './createPageMetadata.js';
|
||||
@@ -11,5 +19,6 @@ export { injectSeoTabs } from './injectSeoTabs.js';
|
||||
export { readSiteMetaConfig } from './readSiteMetaConfig.js';
|
||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||
export { validateFaviconField } from './validateFavicon.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { 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"}
|
||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||
Vendored
+1
@@ -34,6 +34,7 @@ export type SeoOption = {
|
||||
export type SeoMeta = {
|
||||
description?: null | string;
|
||||
image?: unknown;
|
||||
noindex?: boolean | null;
|
||||
title?: null | string;
|
||||
/** When set, used as the whole title — no site name, no separator. */
|
||||
titleOverride?: null | string;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +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, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => 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"}
|
||||
{"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, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => 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 noindex?: boolean | null\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,WAOC"}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { FieldHook } from 'payload';
|
||||
/**
|
||||
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||
* so warn the editor at save time if the uploaded icon is too small or not
|
||||
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||
* Google won't display.
|
||||
*
|
||||
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||
* Media relationship). Non-blocking by default — returns a warning string that
|
||||
* Payload surfaces; make it throw if you want a hard block.
|
||||
*
|
||||
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||
*/
|
||||
export declare const validateFaviconField: FieldHook;
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||
* so warn the editor at save time if the uploaded icon is too small or not
|
||||
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||
* Google won't display.
|
||||
*
|
||||
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||
* Media relationship). Non-blocking by default — returns a warning string that
|
||||
* Payload surfaces; make it throw if you want a hard block.
|
||||
*
|
||||
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||
*/ export const validateFaviconField = async ({ req, value })=>{
|
||||
if (!value) {
|
||||
return value;
|
||||
} // no favicon set → nothing to validate (optional field)
|
||||
try {
|
||||
const media = await req.payload.findByID({
|
||||
id: typeof value === 'object' ? value.id : value,
|
||||
collection: 'media',
|
||||
depth: 0
|
||||
});
|
||||
const width = media.width;
|
||||
const height = media.height;
|
||||
const mimeType = media.mimeType;
|
||||
// SVG scales infinitely — skip size checks.
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
return value;
|
||||
}
|
||||
if (typeof width === 'number' && typeof height === 'number') {
|
||||
if (width < 48 || height < 48) {
|
||||
req.payload.logger.warn(`[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` + `display it in search results. Upload a larger square icon (96 or 192px).`);
|
||||
}
|
||||
if (width !== height) {
|
||||
req.payload.logger.warn(`[ipal] Favicon is not square (${width}×${height}). Use a square icon ` + `so it isn't cropped in the browser tab or search results.`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Media lookup failed — don't block the save over a validation warning.
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
//# sourceMappingURL=validateFavicon.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/seo/validateFavicon.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\n/**\n * Field validation for the favicon upload: Google rejects favicons under 48×48,\n * so warn the editor at save time if the uploaded icon is too small or not\n * square. This ENFORCES the requirement instead of silently shipping a favicon\n * Google won't display.\n *\n * Attach to the favicon field's validate (or as a beforeValidate hook on the\n * Media relationship). Non-blocking by default — returns a warning string that\n * Payload surfaces; make it throw if you want a hard block.\n *\n * Note: dimensions come from the related Media doc (Payload stores width/height\n * for image uploads), so this checks the resolved upload, not the raw file.\n */\nexport const validateFaviconField: FieldHook = async ({ req, value }) => {\n if (!value) {return value} // no favicon set → nothing to validate (optional field)\n\n try {\n const media = await req.payload.findByID({\n id: typeof value === 'object' ? (value as { id: string }).id : value,\n collection: 'media',\n depth: 0,\n })\n\n const width = (media as { width?: number }).width\n const height = (media as { height?: number }).height\n const mimeType = (media as { mimeType?: string }).mimeType\n\n // SVG scales infinitely — skip size checks.\n if (mimeType === 'image/svg+xml') {return value}\n\n if (typeof width === 'number' && typeof height === 'number') {\n if (width < 48 || height < 48) {\n req.payload.logger.warn(\n `[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` +\n `display it in search results. Upload a larger square icon (96 or 192px).`,\n )\n }\n if (width !== height) {\n req.payload.logger.warn(\n `[ipal] Favicon is not square (${width}×${height}). Use a square icon ` +\n `so it isn't cropped in the browser tab or search results.`,\n )\n }\n }\n } catch {\n // Media lookup failed — don't block the save over a validation warning.\n }\n\n return value\n}\n"],"names":["validateFaviconField","req","value","media","payload","findByID","id","collection","depth","width","height","mimeType","logger","warn"],"mappings":"AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMA,uBAAkC,OAAO,EAAEC,GAAG,EAAEC,KAAK,EAAE;IAClE,IAAI,CAACA,OAAO;QAAC,OAAOA;IAAK,EAAE,wDAAwD;IAEnF,IAAI;QACF,MAAMC,QAAQ,MAAMF,IAAIG,OAAO,CAACC,QAAQ,CAAC;YACvCC,IAAI,OAAOJ,UAAU,WAAW,AAACA,MAAyBI,EAAE,GAAGJ;YAC/DK,YAAY;YACZC,OAAO;QACT;QAEA,MAAMC,QAAQ,AAACN,MAA6BM,KAAK;QACjD,MAAMC,SAAS,AAACP,MAA8BO,MAAM;QACpD,MAAMC,WAAW,AAACR,MAAgCQ,QAAQ;QAE1D,4CAA4C;QAC5C,IAAIA,aAAa,iBAAiB;YAAC,OAAOT;QAAK;QAE/C,IAAI,OAAOO,UAAU,YAAY,OAAOC,WAAW,UAAU;YAC3D,IAAID,QAAQ,MAAMC,SAAS,IAAI;gBAC7BT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,kBAAkB,EAAEJ,MAAM,CAAC,EAAEC,OAAO,8BAA8B,CAAC,GAClE,CAAC,wEAAwE,CAAC;YAEhF;YACA,IAAID,UAAUC,QAAQ;gBACpBT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,8BAA8B,EAAEJ,MAAM,CAAC,EAAEC,OAAO,qBAAqB,CAAC,GACrE,CAAC,yDAAyD,CAAC;YAEjE;QACF;IACF,EAAE,OAAM;IACN,wEAAwE;IAC1E;IAEA,OAAOR;AACT,EAAC"}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||
* first image load.
|
||||
*
|
||||
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||
* preconnect).
|
||||
*
|
||||
* Server Component — drop it in the <head> of your locale layout:
|
||||
*
|
||||
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||
* // in <head> (or top of <body> — Next hoists link tags):
|
||||
* <MediaPreconnect />
|
||||
*/
|
||||
export declare function MediaPreconnect(): import("react/jsx-runtime").JSX.Element | null;
|
||||
export default MediaPreconnect;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
/**
|
||||
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||
* first image load.
|
||||
*
|
||||
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||
* preconnect).
|
||||
*
|
||||
* Server Component — drop it in the <head> of your locale layout:
|
||||
*
|
||||
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||
* // in <head> (or top of <body> — Next hoists link tags):
|
||||
* <MediaPreconnect />
|
||||
*/ export function MediaPreconnect() {
|
||||
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||
if (!publicUrl) {
|
||||
return null;
|
||||
}
|
||||
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(publicUrl).origin;
|
||||
} catch {
|
||||
return null // malformed URL → skip rather than emit a broken tag
|
||||
;
|
||||
}
|
||||
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("link", {
|
||||
crossOrigin: "anonymous",
|
||||
href: origin,
|
||||
rel: "preconnect"
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("link", {
|
||||
href: origin,
|
||||
rel: "dns-prefetch"
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
export default MediaPreconnect;
|
||||
|
||||
//# sourceMappingURL=MediaPreconnect.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/storage/MediaPreconnect.tsx"],"sourcesContent":["/**\n * Emits <link rel=\"preconnect\"> + <link rel=\"dns-prefetch\"> for the media CDN\n * domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the\n * media host early — before it hits the first <img>. Saves ~150–300ms on the\n * first image load.\n *\n * Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),\n * so there's ONE source of truth — no per-project hardcoded domain. Renders\n * nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to\n * preconnect).\n *\n * Server Component — drop it in the <head> of your locale layout:\n *\n * import { MediaPreconnect } from '@intecion/ipal-kit/rsc'\n * // in <head> (or top of <body> — Next hoists link tags):\n * <MediaPreconnect />\n */\nexport function MediaPreconnect() {\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '')\n if (!publicUrl) {return null}\n\n // Origin only (scheme + host) — preconnect targets an origin, not a path.\n let origin: string\n try {\n origin = new URL(publicUrl).origin\n } catch {\n return null // malformed URL → skip rather than emit a broken tag\n }\n\n return (\n <>\n <link crossOrigin=\"anonymous\" href={origin} rel=\"preconnect\" />\n <link href={origin} rel=\"dns-prefetch\" />\n </>\n )\n}\n\nexport default MediaPreconnect\n"],"names":["MediaPreconnect","publicUrl","process","env","R2_PUBLIC_URL","replace","origin","URL","link","crossOrigin","href","rel"],"mappings":";AAAA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA;IACd,MAAMC,YAAYC,QAAQC,GAAG,CAACC,aAAa,EAAEC,QAAQ,OAAO;IAC5D,IAAI,CAACJ,WAAW;QAAC,OAAO;IAAI;IAE5B,0EAA0E;IAC1E,IAAIK;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIN,WAAWK,MAAM;IACpC,EAAE,OAAM;QACN,OAAO,KAAK,qDAAqD;;IACnE;IAEA,qBACE;;0BACE,KAACE;gBAAKC,aAAY;gBAAYC,MAAMJ;gBAAQK,KAAI;;0BAChD,KAACH;gBAAKE,MAAMJ;gBAAQK,KAAI;;;;AAG9B;AAEA,eAAeX,gBAAe"}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type { Plugin } from 'payload';
|
||||
/**
|
||||
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||
*
|
||||
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||
* panel. See docs/storage.md for the required variables.
|
||||
*
|
||||
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||
* no-op passthrough so the project falls back to Payload's default local disk
|
||||
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||
* gracefully when a transport isn't configured.
|
||||
*
|
||||
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||
*/
|
||||
export declare const buildR2Storage: (collections?: string[]) => Plugin;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { s3Storage } from '@payloadcms/storage-s3';
|
||||
/**
|
||||
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||
*
|
||||
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||
* panel. See docs/storage.md for the required variables.
|
||||
*
|
||||
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||
* no-op passthrough so the project falls back to Payload's default local disk
|
||||
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||
* gracefully when a transport isn't configured.
|
||||
*
|
||||
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||
*/ export const buildR2Storage = (collections = [
|
||||
'media'
|
||||
])=>{
|
||||
const bucket = process.env.R2_BUCKET;
|
||||
const endpoint = process.env.R2_ENDPOINT;
|
||||
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||
// Any missing → skip R2, fall back to local disk. Warn so it's not silent.
|
||||
if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
|
||||
return (config)=>{
|
||||
// Only warn when SOME vars are set (partial config = likely a mistake).
|
||||
if (bucket || endpoint || accessKeyId || secretAccessKey) {
|
||||
console.warn('[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' + 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.');
|
||||
}
|
||||
return config;
|
||||
};
|
||||
}
|
||||
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||
// accepts uploads and won't serve files (403). With a custom domain
|
||||
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||
// pointing there. Without it, uploads work but images don't display publicly.
|
||||
// See docs/storage.md.
|
||||
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||
;
|
||||
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||
publicUrl,
|
||||
prefix,
|
||||
filename
|
||||
].filter(Boolean).join('/') : undefined;
|
||||
const collectionsConfig = {};
|
||||
for (const slug of collections){
|
||||
collectionsConfig[slug] = generateFileURL ? {
|
||||
generateFileURL
|
||||
} : true;
|
||||
}
|
||||
return s3Storage({
|
||||
bucket,
|
||||
collections: collectionsConfig,
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey
|
||||
},
|
||||
endpoint,
|
||||
region: 'auto',
|
||||
// R2 requires path-style addressing for S3 compatibility.
|
||||
forcePathStyle: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//# sourceMappingURL=buildR2Storage.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { buildR2Storage } from './buildR2Storage.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export { buildR2Storage } from './buildR2Storage.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/storage/index.ts"],"sourcesContent":["export { buildR2Storage } from './buildR2Storage.js'\n"],"names":["buildR2Storage"],"mappings":"AAAA,SAASA,cAAc,QAAQ,sBAAqB"}
|
||||
@@ -0,0 +1,254 @@
|
||||
# Playbook wdrożenia — ipal-kit
|
||||
|
||||
Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ
|
||||
KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z
|
||||
faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją.
|
||||
|
||||
Powiązane: [standardy-kodu.md](./standardy-kodu.md) (dobre praktyki senior),
|
||||
[publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md)
|
||||
(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI).
|
||||
|
||||
---
|
||||
|
||||
## ZŁOTE ZASADY (łam tylko świadomie)
|
||||
|
||||
1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod.
|
||||
2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co
|
||||
robi już plugin — zatrzymaj się, użyj pluginu.
|
||||
3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń.
|
||||
4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź.
|
||||
5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki.
|
||||
6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.**
|
||||
7. **Zmieniłeś API → zaktualizuj docs w tym samym commicie.** Docs jadą w
|
||||
pakiecie; rozjazd kod↔docs = agent dostaje złą mapę.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze)
|
||||
|
||||
Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie
|
||||
zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez
|
||||
PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie.
|
||||
|
||||
```
|
||||
źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart
|
||||
```
|
||||
|
||||
### Sztywna procedura zmiany w pluginie
|
||||
|
||||
```bash
|
||||
cd ~/payload-cms/ipal-kit
|
||||
|
||||
# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest
|
||||
grep -c "<symbol-zmiany>" src/<ścieżka> # MUSI być >0
|
||||
|
||||
# 1b. DOCS — jeśli zmiana dotyka API/zachowania, ZAKTUALIZUJ docs/
|
||||
# (nowa funkcja, zmiana sygnatury, nowe pole panelu, nowy adapter...).
|
||||
# Docs jadą w pakiecie (files: dist, docs) — nieaktualne docs = agent
|
||||
# dostaje złą mapę. Kod i docs publikuj RAZEM.
|
||||
|
||||
# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę
|
||||
pnpm build
|
||||
grep -c "<symbol-zmiany>" dist/<ścieżka> # MUSI być >0
|
||||
|
||||
# 3. COMMIT (PRZED version — inaczej "working directory not clean")
|
||||
git add -A && git commit -m "opis"
|
||||
|
||||
# 4. VERSION + PUBLISH
|
||||
npm version patch # czyste repo wymagane
|
||||
npm publish
|
||||
|
||||
# 5. PUSH
|
||||
git push && git push --tags
|
||||
|
||||
# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę
|
||||
cd ~/<projekt>
|
||||
pnpm add @intecion/ipal-kit@<nowa-wersja>
|
||||
grep -c "<symbol-zmiany>" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0
|
||||
|
||||
# 7. RESTART dev (Payload buduje adaptery/config przy starcie!)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### TRZY punkty kontrolne grep (nie pomijaj żadnego)
|
||||
|
||||
| Etap | Grep | Jeśli 0 |
|
||||
|---|---|---|
|
||||
| po edycji | `src/...` | zmiana nie zapisana / zły plik |
|
||||
| po build | `dist/...` | build nie złapał / błąd typów |
|
||||
| po pnpm add | `node_modules/...` | projekt ma starą wersję |
|
||||
|
||||
**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`.
|
||||
Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts`
|
||||
→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js`
|
||||
pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa.
|
||||
|
||||
### Pułapki kolejności (realne błędy sesji)
|
||||
|
||||
- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE
|
||||
commit przed version.
|
||||
- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed
|
||||
publish, grep dist po buildzie.
|
||||
- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci.
|
||||
Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu.
|
||||
- **Publikacja bez aktualizacji docs** → agent (Antigravity) po `pnpm add`
|
||||
czyta `node_modules/@intecion/ipal-kit/docs/` z NIEAKTUALNĄ mapą. Jeśli
|
||||
zmieniłeś API — docs w tym samym commicie.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze)
|
||||
|
||||
Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze.
|
||||
|
||||
### Reguła: grepuj TOKENY, nie całe frazy z kolejnością
|
||||
|
||||
**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK —
|
||||
bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`).
|
||||
Obiekt JS ignoruje kolejność, ale grep nie.
|
||||
|
||||
```bash
|
||||
# ŹLE — zależny od kolejności/formatowania:
|
||||
grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu
|
||||
|
||||
# DOBRZE — pojedynczy token, odporny:
|
||||
grep -c "senderName" plik.ts # 3 ✓
|
||||
```
|
||||
|
||||
Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE"
|
||||
|
||||
Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę:
|
||||
|
||||
1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie.
|
||||
Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.)
|
||||
2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie.
|
||||
Kliknij Save.
|
||||
3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie
|
||||
instancje = hooki bez kontekstu. Sprawdź:
|
||||
`ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui`
|
||||
Jest? → peerDependency problem (patrz Część D).
|
||||
4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy
|
||||
mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na
|
||||
inny adres.
|
||||
5. **Import map nieaktualny?** Custom komponenty Payload:
|
||||
`npx payload generate:importmap`.
|
||||
|
||||
**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'".
|
||||
Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) →
|
||||
`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ D — peerDependencies (dublety zależności)
|
||||
|
||||
**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie,
|
||||
NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet
|
||||
→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem).
|
||||
|
||||
Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`,
|
||||
`@payloadcms/plugin-*`, `react`, `react-dom`, `next`.
|
||||
|
||||
**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) →
|
||||
pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner
|
||||
wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies,
|
||||
opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`.
|
||||
|
||||
Weryfikacja braku dubletu:
|
||||
```bash
|
||||
ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \
|
||||
&& echo "DUBLET ✗" || echo "OK ✓"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność)
|
||||
|
||||
Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności.
|
||||
|
||||
1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22
|
||||
2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion`
|
||||
3. **`pnpm add @intecion/ipal-kit`** + zależności peer
|
||||
4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje)
|
||||
5. **i18n.config.ts** — jedno źródło locale
|
||||
6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()`
|
||||
7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno)
|
||||
8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload
|
||||
9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota
|
||||
10. **Bloki** — dane przez enhanceProps, nie import lib (cykl)
|
||||
11. **buildSlugField** zamiast ręcznego slug
|
||||
12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek
|
||||
13. **buildSecurityHeaders** w next.config
|
||||
14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ F — EMAIL (SMTP vs Graph)
|
||||
|
||||
Pełne szczegóły: [email.md](./email.md). Decyzja transportu:
|
||||
|
||||
- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony)
|
||||
- **Klient z własnym SMTP / Gmail** → SMTP
|
||||
- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport
|
||||
- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce
|
||||
|
||||
### Graph — checklist wdrożenia (Wasza strona, jednorazowo)
|
||||
|
||||
1. Azure: App registration → tenantId, clientId, clientSecret
|
||||
2. Azure: Mail.Send APPLICATION permission + **Grant admin consent**
|
||||
3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER
|
||||
4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to)
|
||||
|
||||
### Realne pułapki Graph (wszystkie zdarzyły się w sesji)
|
||||
|
||||
| Błąd | Przyczyna | Naprawa |
|
||||
|---|---|---|
|
||||
| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo |
|
||||
| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki |
|
||||
| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure |
|
||||
| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send |
|
||||
|
||||
**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka,
|
||||
zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta
|
||||
→ replyTo (odpowiedzi trafiają do klienta).
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU
|
||||
|
||||
Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu
|
||||
→ projekt. Jeśli identyczne wszędzie → plugin.**
|
||||
|
||||
| Rzecz | Gdzie | Dlaczego |
|
||||
|---|---|---|
|
||||
| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne |
|
||||
| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu |
|
||||
| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side |
|
||||
| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie |
|
||||
| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env |
|
||||
| **CSP** | **projekt** | zależy od domen projektu |
|
||||
| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy |
|
||||
| **Breadcrumbs** | **projekt** | render z danych routingu projektu |
|
||||
| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy |
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE"
|
||||
|
||||
Nie mów „działa", dopóki:
|
||||
|
||||
- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev)
|
||||
- [ ] root `/` przekierowuje na locale (bez middleware.ts)
|
||||
- [ ] formularz wysyła (test przez panel: Send test)
|
||||
- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno)
|
||||
- [ ] brak dubletu @payloadcms/ui (Część D)
|
||||
- [ ] grep potwierdza wersję pluginu w node_modules
|
||||
- [ ] sekrety w .env (nie w repo), maskowane w panelu
|
||||
- [ ] brak plików middleware.ts, brak zaszytej mapy slugów
|
||||
- [ ] strona 404 (not-found.tsx) — edytowalna, per język, link powrotu
|
||||
- [ ] formularze z buildera w panelu (NIE własne hardkodowane)
|
||||
- [ ] compliance: polityki, baner cookies, zgoda RODO w formularzach
|
||||
(patrz [wymagania-prawne.md](./wymagania-prawne.md))
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
# IPAL — Dokumentacja modułów
|
||||
|
||||
> **Zaczynasz wdrożenie?** Przeczytaj najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) — sztywna procedura, kolejność, realne przykłady błędów.
|
||||
|
||||
**Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
|
||||
[README](../README.md).
|
||||
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md).
|
||||
@@ -106,6 +108,7 @@ export default buildConfig({
|
||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
|
||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||
@@ -118,7 +121,7 @@ export default buildConfig({
|
||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||
|
||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md)
|
||||
Referencja wdrożenia frontu: [getting-started.md](./getting-started.md)
|
||||
Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
|
||||
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md)
|
||||
Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
|
||||
|
||||
+37
-2
@@ -109,7 +109,7 @@ Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
||||
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
||||
|
||||
Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind
|
||||
Wymaga `@source` skanującego pakiet (patrz getting-started.md) — inaczej Tailwind
|
||||
nie wygeneruje tych klas.
|
||||
|
||||
### Gdy tokeny nie wystarczą
|
||||
@@ -131,4 +131,39 @@ Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
||||
domyślny (nie dokleja się).
|
||||
|
||||
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
||||
uchwyty do CSS albo testów e2e.
|
||||
uchwyty do CSS albo testów e2e.
|
||||
|
||||
## Locale jako cookie functional (wbudowane)
|
||||
|
||||
Plugin sam zarządza jedną cookie functional: **`NEXT_LOCALE`** (wybór języka).
|
||||
Nie musisz nic konfigurować — działa out of the box:
|
||||
|
||||
- **Zapis za zgodą.** Middleware zapisuje `NEXT_LOCALE` tylko, gdy użytkownik
|
||||
zaakceptował kategorię **functional**. Bez zgody język działa (negocjacja per
|
||||
żądanie), ale nie jest utrwalany w cookie.
|
||||
- **Sprzątanie po cofnięciu.** Gdy użytkownik cofnie zgodę na functional, hook
|
||||
consent usuwa `NEXT_LOCALE` automatycznie. Odpowiada za to `DEFAULT_COOKIE_MAP`:
|
||||
|
||||
```ts
|
||||
const DEFAULT_COOKIE_MAP = {
|
||||
functional: [LOCALE_COOKIE_NAME], // 'NEXT_LOCALE' — plugin zna własną cookie
|
||||
}
|
||||
```
|
||||
|
||||
### Twoje własne cookie functional/analytics
|
||||
|
||||
Jeśli ustawiasz własne cookie podlegające zgodzie, rozszerz mapę — hook wtedy
|
||||
sprzątnie też Twoje po cofnięciu zgody:
|
||||
|
||||
```ts
|
||||
useConsent({
|
||||
functional: ['NEXT_LOCALE', 'moje-ustawienie'],
|
||||
analytics: ['_ga', '_gid'],
|
||||
})
|
||||
```
|
||||
|
||||
Przekazana mapa zastępuje domyślną — pamiętaj dołączyć `NEXT_LOCALE`, jeśli
|
||||
chcesz zachować sprzątanie locale (albo zaimportuj `LOCALE_COOKIE_NAME` i dodaj).
|
||||
|
||||
> Mechanizm zgody dla locale jest opisany też od strony i18n:
|
||||
> [i18n.md](./i18n.md#cookie-locale-a-zgoda-rodo).
|
||||
@@ -0,0 +1,206 @@
|
||||
# Deployment — zmienne środowiskowe i produkcja
|
||||
|
||||
Jedno źródło prawdy o zmiennych środowiskowych (wszystkie, co znaczą, wymagane
|
||||
czy nie) oraz jak wdrożyć projekt na produkcję spójnie. Env jest częścią
|
||||
deploymentu — te same zmienne w dev (.env) i na produkcji (runtime hostingu).
|
||||
|
||||
Powiązane: [getting-started.md](./getting-started.md), [storage.md](./storage.md)
|
||||
(R2), [email.md](./email.md) (Graph), [security.md](./security.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. ZMIENNE ŚRODOWISKOWE — pełna lista
|
||||
|
||||
### Rdzeń (WYMAGANE — projekt bez nich nie wstanie)
|
||||
|
||||
```bash
|
||||
# Baza danych (Mongo albo Postgres — zależnie od projektu)
|
||||
DATABASE_URI=mongodb://... # albo postgres://... / file:./dev.db (dev)
|
||||
|
||||
# Sekret Payload (podpisywanie sesji/tokenów) — losowy, długi
|
||||
PAYLOAD_SECRET=<losowy-ciąg-min-32-znaki>
|
||||
|
||||
# Publiczny URL serwisu (canonical, hreflang, OG, manifest)
|
||||
NEXT_PUBLIC_SERVER_URL=https://klient.pl # dev: http://localhost:3000
|
||||
```
|
||||
|
||||
### Email — Graph (OPCJONALNE, agencyjne, gdy transport = Graph)
|
||||
|
||||
```bash
|
||||
GRAPH_TENANT_ID=<azure-tenant-id>
|
||||
GRAPH_CLIENT_ID=<azure-app-client-id>
|
||||
GRAPH_CLIENT_SECRET=<azure-app-secret>
|
||||
GRAPH_SENDER=[email protected] # wspólna skrzynka
|
||||
```
|
||||
Bez nich transport Graph nie zadziała (fallback SMTP). Patrz email.md.
|
||||
|
||||
### Storage — R2 (OPCJONALNE, gdy media w R2)
|
||||
|
||||
```bash
|
||||
R2_BUCKET=<nazwa-bucketa>
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=<access-key>
|
||||
R2_SECRET_ACCESS_KEY=<secret-key>
|
||||
R2_PUBLIC_URL=https://media.klient.pl # custom domena (obrazy publiczne)
|
||||
```
|
||||
Brak → fallback na lokalny dysk. Patrz storage.md.
|
||||
|
||||
### Tabela — wszystkie zmienne
|
||||
|
||||
| Zmienna | Wymagana | Warstwa | Opis |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URI` | ✅ | infra | połączenie z bazą |
|
||||
| `PAYLOAD_SECRET` | ✅ | infra | sekret Payload |
|
||||
| `NEXT_PUBLIC_SERVER_URL` | ✅ | infra | publiczny URL (canonical, OG) |
|
||||
| `GRAPH_TENANT_ID` | ⬜ | email | Azure tenant (Graph) |
|
||||
| `GRAPH_CLIENT_ID` | ⬜ | email | Azure app id |
|
||||
| `GRAPH_CLIENT_SECRET` | ⬜ | email | Azure secret |
|
||||
| `GRAPH_SENDER` | ⬜ | email | skrzynka nadawcza |
|
||||
| `R2_BUCKET` | ⬜ | storage | bucket R2 |
|
||||
| `R2_ENDPOINT` | ⬜ | storage | endpoint S3 R2 |
|
||||
| `R2_ACCESS_KEY_ID` | ⬜ | storage | klucz R2 |
|
||||
| `R2_SECRET_ACCESS_KEY` | ⬜ | storage | sekret R2 |
|
||||
| `R2_PUBLIC_URL` | ⬜ | storage | custom domena mediów |
|
||||
|
||||
**Zasada:** wszystkie sekrety to zmienne agencyjne/infrastrukturalne — w `.env`
|
||||
(dev) i runtime hostingu (prod), NIGDY w repo. Dane per-projekt edytowalne przez
|
||||
redaktora idą do PANELU, nie do env (patrz architektura-tresci.md).
|
||||
|
||||
### .env.example — zawsze w repo
|
||||
|
||||
Każdy projekt ma `.env.example` z listą zmiennych (bez wartości/sekretów) —
|
||||
szablon dla następnej osoby. Commituj go; `.env` (z wartościami) NIGDY.
|
||||
|
||||
---
|
||||
|
||||
## 2. PRZED DEPLOYEM — checklist
|
||||
|
||||
- [ ] `pnpm build --webpack` przechodzi LOKALNIE (nie tylko dev)
|
||||
- [ ] Wszystkie wymagane env ustawione na hostingu (runtime)
|
||||
- [ ] `NEXT_PUBLIC_SERVER_URL` = produkcyjny URL (nie localhost)
|
||||
- [ ] `PAYLOAD_SECRET` inny niż w dev (produkcyjny sekret)
|
||||
- [ ] Baza produkcyjna (nie dev/SQLite)
|
||||
- [ ] HSTS włączony (buildSecurityHeaders hsts: production)
|
||||
- [ ] Media: R2 z custom domeną (jeśli używane) — obrazy publiczne
|
||||
- [ ] Migracja mediów lokalne→R2 (jeśli przełączasz)
|
||||
- [ ] Strony polityk + baner cookies (patrz wymagania-prawne.md)
|
||||
|
||||
---
|
||||
|
||||
## 3. BUDOWANIE NA PRODUKCJĘ
|
||||
|
||||
### Build script (Next 16 + Payload)
|
||||
|
||||
```json
|
||||
// package.json — --webpack KONIECZNE (Turbopack konfliktuje z withPayload)
|
||||
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
|
||||
```
|
||||
|
||||
`--max-old-space-size` — Payload + Next bywają pamięciożerne przy buildzie;
|
||||
3072 MB zapobiega OOM na mniejszych maszynach.
|
||||
|
||||
### Kolejność build → migracje → start
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile # dokładnie z lockfile (powtarzalny build)
|
||||
pnpm generate:types # typy z kolekcji
|
||||
pnpm build # --webpack
|
||||
# migracje bazy (jeśli Postgres z migracjami):
|
||||
pnpm payload migrate
|
||||
pnpm start # produkcyjny serwer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3a. Pułapka: metadata w <body> zamiast <head> (htmlLimitedBots)
|
||||
|
||||
Next 16 streamuje metadata dynamicznych stron do `<body>` (przenosi do head
|
||||
skryptem JS). Crawlery bez JS widzą canonical/hreflang/title/favicon poza head →
|
||||
utrata SEO. **Każdy projekt** tego potrzebuje w next.config:
|
||||
|
||||
```ts
|
||||
const nextConfig: NextConfig = {
|
||||
htmlLimitedBots:
|
||||
/Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`.
|
||||
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
|
||||
|
||||
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
||||
|
||||
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
||||
traktuje je jako statyczne. Jeśli taka trasa czyta bazę (sitemap → Payload →
|
||||
Mongo/Postgres), build **próbuje połączyć się z bazą**. A kontener budujący
|
||||
(Coolify/Docker/Railway/CI) zwykle NIE ma dostępu do sieci bazy → połączenie
|
||||
pada (`ENOTFOUND`, `MongooseServerSelectionError`) → **build się wywala**.
|
||||
|
||||
**Rozwiązanie — `force-dynamic` na trasach zależnych od bazy:**
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
|
||||
```
|
||||
|
||||
To mówi Next: nie prerenderuj w buildzie, generuj w runtime (gdy baza jest
|
||||
dostępna). Dotyczy KAŻDEJ trasy czytającej bazę podczas renderowania:
|
||||
- `app/sitemap.ts` → `force-dynamic`
|
||||
- inne trasy/strony czytające bazę w prerenderze → rozważ `force-dynamic` albo
|
||||
obsłuż błąd bazy (try/catch z fallbackiem)
|
||||
|
||||
Plugin dodatkowo zabezpiecza handler sitemap (łapie błąd bazy, zwraca pustą
|
||||
mapę), więc build nie padnie nawet bez `force-dynamic` — ale to siatka
|
||||
bezpieczeństwa, nie właściwe rozwiązanie. Zawsze dodawaj `force-dynamic`.
|
||||
|
||||
**Strona 404** (`not-found.tsx`) czytająca ustawienia z bazy — ten sam problem.
|
||||
Opakuj `getCachedPayload()` w try/catch, żeby brak bazy w buildzie nie wywalił
|
||||
prerenderu 404 (fallback na statyczne teksty).
|
||||
|
||||
**Weryfikacja lokalna** (symuluj brak bazy):
|
||||
```bash
|
||||
DATABASE_URI=mongodb://invalid-host:27017/test pnpm build
|
||||
# build musi przejść (kod 0), mimo niedostępnej bazy
|
||||
```
|
||||
|
||||
## 4. HOSTING (Coolify / Docker)
|
||||
|
||||
### Zmienne runtime, nie build
|
||||
|
||||
Zmienne środowiskowe ustaw w **runtime** hostingu (Coolify → Environment
|
||||
Variables), nie zapiekaj w build. `NEXT_PUBLIC_*` są wyjątkiem — wchodzą w build
|
||||
(bo publiczne, w bundlu klienta), więc muszą być dostępne PODCZAS buildu.
|
||||
|
||||
### Persystencja mediów
|
||||
|
||||
Jeśli media lokalne (nie R2) — potrzebują **wolumenu** (inaczej znikną przy
|
||||
redeployu). Dlatego R2 jest zalecane na produkcji: media poza kontenerem,
|
||||
przetrwają redeploy. Patrz storage.md.
|
||||
|
||||
### Health check
|
||||
|
||||
Payload wystawia panel pod `/admin` — health check może pingować stronę główną
|
||||
albo `/admin`. Nie ustawiaj health check na endpoint wymagający bazy, jeśli
|
||||
baza wstaje wolniej niż app.
|
||||
|
||||
---
|
||||
|
||||
## 5. PO DEPLOYU — weryfikacja
|
||||
|
||||
- [ ] Strona główna `/` przekierowuje na locale (`/pl`)
|
||||
- [ ] Panel `/admin` działa, logowanie OK
|
||||
- [ ] Formularz wysyła (test przez panel: Send test)
|
||||
- [ ] Media się wyświetlają (jeśli R2 — custom domena działa, nie 403)
|
||||
- [ ] Favicon w `<head>` (patrz seo.md — Google cache'uje wolno)
|
||||
- [ ] HTTPS + nagłówki bezpieczeństwa (sprawdź np. securityheaders.com)
|
||||
- [ ] Sitemap `/sitemap.xml` i `/robots.txt` odpowiadają
|
||||
|
||||
---
|
||||
|
||||
## DLACZEGO TO WAŻNE
|
||||
|
||||
- **Jedna lista env** — nikt nie zgaduje, czego brakuje
|
||||
- **Powtarzalny deploy** — frozen-lockfile, ta sama kolejność, każdy projekt tak samo
|
||||
- **Sekrety bezpieczne** — env/runtime, nigdy repo
|
||||
- **Media przetrwają** — R2 albo wolumen, nie znikają przy redeployu
|
||||
+28
-1
@@ -149,4 +149,31 @@ Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
|
||||
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
||||
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
|
||||
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
|
||||
domeny nie jest potrzebny.
|
||||
domeny nie jest potrzebny.
|
||||
|
||||
## Przełącznik transportu — mailAdapter
|
||||
|
||||
`mailAdapter()` to dyspozytor: jeden adapter wpięty w config, wybiera transport
|
||||
(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu
|
||||
przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie
|
||||
da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz).
|
||||
|
||||
```ts
|
||||
// payload.config.ts — JEDEN adapter, wybór wewnątrz
|
||||
import { mailAdapter } from '@intecion/ipal-kit'
|
||||
email: mailAdapter()
|
||||
```
|
||||
|
||||
Panel → Site Integrations → SMTP → **Email Transport** (SMTP / Microsoft Graph).
|
||||
Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak
|
||||
sekretów w .env → log + fallback na SMTP (nie cicha awaria).
|
||||
|
||||
## Test wysyłki — przycisk w panelu
|
||||
|
||||
W tabie SMTP jest przycisk **Send test**: podaj adres, kliknij, wyślij testowy
|
||||
mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint
|
||||
`POST /api/ipal/test-email` (admin-only). Zapisz zmiany przed testem — endpoint
|
||||
czyta z bazy, nie z pola na ekranie.
|
||||
|
||||
> Bezcenne przy diagnozie Graph — od razu widzisz `ErrorSendAsDenied`,
|
||||
> `Insufficient privileges` itp. zamiast zgadywać.
|
||||
@@ -4,6 +4,31 @@ Wpina `@payloadcms/plugin-form-builder` (kolekcje forms + form-submissions) i
|
||||
dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację
|
||||
Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem).
|
||||
|
||||
## ⚠️ ZASADA: formularz POCHODZI z buildera w panelu (obowiązkowe)
|
||||
|
||||
**Formularze buduje redaktor w panelu** (kolekcja Forms), NIE deweloper w kodzie.
|
||||
To jest CMS — klient sam definiuje pola, etykiety, komunikaty, odbiorcę. Front
|
||||
tylko RENDERUJE formularz z panelu i wysyła przez `submitForm`.
|
||||
|
||||
**NIGDY nie twórz własnego, hardkodowanego formularza** — z ręcznie wpisanymi
|
||||
polami, etykietami w JSX, własną walidacją. To łamie „nic na sztywno" (klient nie
|
||||
zmieni pól ani tekstów) i omija cały mechanizm pluginu (Turnstile, rate-limit,
|
||||
consent RODO, powiadomienia).
|
||||
|
||||
| ŹLE (własny formularz) | DOBRZE (builder pluginu) |
|
||||
|---|---|
|
||||
| `<input name="email" placeholder="Email" />` w JSX | pola z kolekcji Forms (panel) |
|
||||
| etykiety/komunikaty w kodzie | etykiety per język w panelu |
|
||||
| własna walidacja/wysyłka | `submitForm` (Turnstile+consent+mail) |
|
||||
| klient nie zmieni formularza | klient edytuje pola w panelu |
|
||||
|
||||
**Jak poprawnie:** redaktor tworzy formularz w kolekcji Forms → front pobiera
|
||||
jego definicję → renderuje pola dynamicznie → wysyła przez `submitForm`. Pola,
|
||||
etykiety, komunikaty, odbiorca — wszystko z panelu.
|
||||
|
||||
Jeśli formularz wymaga pola, którego builder nie ma — dodaj je przez konfigurację
|
||||
`fields` (patrz niżej) albo rozbuduj plugin. NIE hardkoduj własnego formularza.
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
|
||||
+207
-221
@@ -1,14 +1,18 @@
|
||||
# Nowy projekt — krok po kroku
|
||||
# Setup projektu — od zera do wdrożenia
|
||||
|
||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym
|
||||
Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
|
||||
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
|
||||
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
|
||||
|
||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
|
||||
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
||||
> już zainstalowany, i przeprowadza przez **konfigurację** projektu.
|
||||
> zainstalowany, i przeprowadza przez konfigurację.
|
||||
>
|
||||
> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md)
|
||||
> — zasady, procedura, pułapki.
|
||||
|
||||
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i
|
||||
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat
|
||||
bazy, importMap, kolejność wpięcia).
|
||||
|
||||
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
||||
Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
|
||||
importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
||||
|
||||
```bash
|
||||
npx create-payload-app@latest moj-projekt
|
||||
# → Blank, SQLite
|
||||
# → Blank, SQLite (dev) / Postgres (prod)
|
||||
cd moj-projekt
|
||||
```
|
||||
|
||||
## 2. Plugin i zależności
|
||||
|
||||
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea
|
||||
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności
|
||||
współdzielone z Payloadem, których plugin nie zaciąga sam:
|
||||
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
|
||||
zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \
|
||||
pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
|
||||
nodemailer lucide-react slugify server-only
|
||||
```
|
||||
|
||||
### Spójność wersji @payloadcms/* (KRYTYCZNE)
|
||||
|
||||
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
|
||||
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
|
||||
projekt się wywala. Wymuś w `package.json`:
|
||||
@@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`:
|
||||
```json
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"payload": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1"
|
||||
"payload": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/plugin-seo": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -53,11 +58,17 @@ projekt się wywala. Wymuś w `package.json`:
|
||||
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
||||
```
|
||||
|
||||
### Build script z --webpack (Next 16)
|
||||
|
||||
Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`:
|
||||
```json
|
||||
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
|
||||
```
|
||||
|
||||
## 3. Konfiguracja locale — jedno źródło
|
||||
|
||||
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc
|
||||
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu
|
||||
miejscach:
|
||||
Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
|
||||
może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
|
||||
|
||||
```ts
|
||||
// src/i18n.config.ts
|
||||
@@ -67,25 +78,24 @@ export const i18nConfig = {
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
} as const
|
||||
} as const // as const — inaczej TS nie uzna locales za niepustą tuple
|
||||
```
|
||||
|
||||
`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę.
|
||||
Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`.
|
||||
|
||||
## 4. payload.config.ts
|
||||
|
||||
```ts
|
||||
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit'
|
||||
import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { Pages } from '@/collections/Pages'
|
||||
|
||||
export default buildConfig({
|
||||
// …reszta z template'u
|
||||
collections: [Users, Media, Pages],
|
||||
|
||||
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
|
||||
// Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
|
||||
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
||||
email: panelSmtpAdapter(),
|
||||
email: mailAdapter(),
|
||||
|
||||
plugins: [
|
||||
ipalKit({
|
||||
@@ -113,11 +123,11 @@ export const Pages: CollectionConfig = {
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true, localized: true },
|
||||
buildSlugField({ from: 'title' }),
|
||||
buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
|
||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -158,15 +168,31 @@ import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
|
||||
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
||||
|
||||
export const blockRegistry: BlockComponentMap = {
|
||||
content: ContentBlockComponent,
|
||||
content: ContentBlockComponent, // klucz = slug bloku
|
||||
}
|
||||
```
|
||||
|
||||
Klucz w rejestrze = `slug` bloku.
|
||||
> **Jak budować treść, żeby klient mógł wszystko edytować** (filozofia
|
||||
> CMS, kolejność komponent→blok→strona): [architektura-tresci.md](./architektura-tresci.md).
|
||||
|
||||
## 7. Tailwind
|
||||
**Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok.
|
||||
|
||||
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie.
|
||||
### enhanceProps — wstrzykiwanie danych server-side do bloków
|
||||
|
||||
Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey,
|
||||
odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:
|
||||
|
||||
```ts
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Tailwind (WYMÓG)
|
||||
|
||||
Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w
|
||||
czystym Tailwindzie.
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/postcss
|
||||
@@ -183,16 +209,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } }
|
||||
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
|
||||
```
|
||||
|
||||
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez
|
||||
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły.
|
||||
Ścieżka jest relatywna do pliku CSS.
|
||||
**`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
|
||||
niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
|
||||
Ścieżka relatywna do pliku CSS.
|
||||
|
||||
## 8. Proxy (dawniej middleware)
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz
|
||||
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian:
|
||||
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy:
|
||||
> `npx @next/codemod@canary middleware-to-proxy .`
|
||||
Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
|
||||
properties (fallbacki wbudowane):
|
||||
```css
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
Pełna lista tokenów + opcja classNames: [consent.md](./consent.md).
|
||||
|
||||
## 8. Proxy (routing locale) — NIGDY middleware.ts
|
||||
|
||||
> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja
|
||||
> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy
|
||||
> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .`
|
||||
>
|
||||
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
||||
> subpath eksportu, NIE nazwa pliku. Nie myl ich.
|
||||
|
||||
```ts
|
||||
// src/proxy.ts
|
||||
@@ -205,104 +245,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const result = localeMiddleware(request)
|
||||
if (result.type === 'next') return NextResponse.next()
|
||||
|
||||
const response = NextResponse.redirect(result.location)
|
||||
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined
|
||||
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
// Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
|
||||
// języka), TYLKO gdy jest zgoda na functional.
|
||||
const response =
|
||||
result.type === 'next'
|
||||
? NextResponse.next()
|
||||
: NextResponse.redirect(result.location)
|
||||
|
||||
if (result.cookie) {
|
||||
response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
|
||||
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin
|
||||
// i /_next, i wszystko zwróci 500.
|
||||
// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
|
||||
// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
|
||||
// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
|
||||
export const config = {
|
||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
||||
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
|
||||
> `middleware.ts` czy `proxy.ts`.
|
||||
### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
|
||||
|
||||
## 9. Warstwa dostępu do danych
|
||||
|
||||
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
|
||||
sprawia, że nie pytają bazy dwa razy o to samo.
|
||||
Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
|
||||
Slugi są w bazie (pole `slug` localized):
|
||||
|
||||
```ts
|
||||
// src/lib/payload.ts
|
||||
import { cache } from 'react'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
|
||||
|
||||
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
|
||||
|
||||
export const getSettings = cache(async (locale: string) =>
|
||||
(await getCachedPayload()).findGlobal({
|
||||
slug: 'site-settings',
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
}),
|
||||
)
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
|
||||
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/locales.ts
|
||||
import { cache } from 'react'
|
||||
import config from '@/payload.config'
|
||||
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
|
||||
|
||||
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
||||
const payloadConfig = await config
|
||||
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
|
||||
```ts
|
||||
// src/lib/content.ts — JEDYNE źródło helperów pluginu
|
||||
import { createContentHelpers } from '@intecion/ipal-kit'
|
||||
import payloadConfig from '@/payload.config'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
|
||||
export const {
|
||||
getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
|
||||
} = createContentHelpers({
|
||||
config: payloadConfig, // PAYLOAD config (nie i18n!)
|
||||
content: { collections: [] },
|
||||
i18n: i18nConfig, // i18n OSOBNO
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/pages.ts
|
||||
// src/lib/payload.ts — funkcje projektu, typowane
|
||||
import { cache } from 'react'
|
||||
import type { Page, SiteSetting } from '@/payload-types'
|
||||
import { getCachedPayload, getSettings } from './payload'
|
||||
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||
import { getCachedPayload } from './content' // z content, nie osobny getPayload
|
||||
import type { SiteSetting } from '@/payload-types'
|
||||
|
||||
export const resolvePage = cache(
|
||||
async (locale: string, slugPath: string | null): Promise<Page | null> => {
|
||||
if (!slugPath) {
|
||||
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
|
||||
const settings = (await getSettings(locale)) as SiteSetting
|
||||
const homepage = settings.homepage
|
||||
return homepage && typeof homepage === 'object' ? homepage : null
|
||||
}
|
||||
|
||||
const payload = await getCachedPayload()
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slugPath } },
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
})
|
||||
return result.docs[0] ?? null
|
||||
},
|
||||
export const getSettings = cache(async (locale: string) =>
|
||||
getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
|
||||
)
|
||||
```
|
||||
|
||||
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
|
||||
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
|
||||
|
||||
## 10. Trasy
|
||||
|
||||
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
|
||||
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad
|
||||
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
|
||||
layout locale (bo `<html lang>` musi znać język).
|
||||
|
||||
```
|
||||
src/app/(frontend)/
|
||||
styles.css
|
||||
[locale]/
|
||||
layout.tsx
|
||||
layout.tsx # walidacja locale + ConsentProvider + Analytics
|
||||
[[...slug]]/
|
||||
page.tsx
|
||||
page.tsx # render bloków
|
||||
```
|
||||
|
||||
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast
|
||||
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||
**`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
|
||||
dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||
|
||||
```tsx
|
||||
// src/app/(frontend)/[locale]/layout.tsx
|
||||
@@ -310,8 +335,8 @@ import { notFound } from 'next/navigation'
|
||||
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
|
||||
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { getCachedPayload, getSettings } from '@/lib/payload'
|
||||
import { getConfiguredLocales } from '@/lib/locales'
|
||||
import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
|
||||
import { getSettings } from '@/lib/payload'
|
||||
import '../styles.css'
|
||||
|
||||
export default async function LocaleLayout({ children, params }) {
|
||||
@@ -325,13 +350,9 @@ export default async function LocaleLayout({ children, params }) {
|
||||
|
||||
const [texts, analytics] = await Promise.all([
|
||||
getConsentTexts({
|
||||
config: i18nConfig,
|
||||
locale,
|
||||
payload,
|
||||
privacyPolicy:
|
||||
privacyPage && typeof privacyPage === 'object'
|
||||
? { page: privacyPage, label: 'Polityka prywatności' }
|
||||
: undefined,
|
||||
config: i18nConfig, locale, payload,
|
||||
privacyPolicy: privacyPage && typeof privacyPage === 'object'
|
||||
? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
|
||||
}),
|
||||
getAnalyticsConfig(payload),
|
||||
])
|
||||
@@ -343,7 +364,7 @@ export default async function LocaleLayout({ children, params }) {
|
||||
<main>{children}</main>
|
||||
<CookieBanner />
|
||||
<CookieButton />
|
||||
<Analytics {...analytics} />
|
||||
<Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
|
||||
</ConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
@@ -364,8 +385,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc'
|
||||
import { createPageMetadata } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { blockRegistry } from '@/blocks/registry'
|
||||
import { getCachedPayload } from '@/lib/payload'
|
||||
import { resolvePage } from '@/lib/pages'
|
||||
import { getCachedPayload, resolveRoute } from '@/lib/content'
|
||||
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
@@ -377,104 +397,92 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||
}
|
||||
|
||||
export default async function Page({ params }) {
|
||||
export default async function Page({ params, searchParams }) {
|
||||
const { locale, slug } = await params
|
||||
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null)
|
||||
if (!page) notFound()
|
||||
|
||||
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
|
||||
const { page } = await searchParams
|
||||
const route = await resolveRoute(locale, slug ?? [], page) // 3 args
|
||||
if (!route) notFound()
|
||||
return <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Środowisko
|
||||
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
||||
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
||||
- `depth: 2` → relacje w blokach (form) się populują
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DATABASE_URL=file:./moj-projekt.db
|
||||
PAYLOAD_SECRET=<losowy-ciąg>
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
```
|
||||
## 11. Metadata / SEO (szczegóły)
|
||||
|
||||
`createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
|
||||
dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
|
||||
budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string,
|
||||
hreflang nie powstanie.
|
||||
|
||||
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
|
||||
|
||||
## 12. Generowanie i start
|
||||
## 12. Nagłówki bezpieczeństwa
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production', // off w dev (http)
|
||||
additional: [ /* CSP projektu — zna swoje domeny */ ],
|
||||
})
|
||||
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }
|
||||
```
|
||||
Szczegóły: [security.md](./security.md).
|
||||
|
||||
## 13. Środowisko
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DATABASE_URI=<postgres albo file:./dev.db>
|
||||
PAYLOAD_SECRET=<losowy-ciąg>
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
# Email przez Graph (opcjonalnie — sekrety agencyjne):
|
||||
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...
|
||||
```
|
||||
|
||||
## 14. Generowanie i start
|
||||
|
||||
```bash
|
||||
pnpm generate:types
|
||||
pnpm payload generate:importmap # pola SEO to komponenty admina
|
||||
pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty
|
||||
admina.
|
||||
`generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
|
||||
|
||||
## 13. Konfiguracja w panelu
|
||||
## 15. Konfiguracja w panelu
|
||||
|
||||
`http://localhost:3000/admin`
|
||||
|
||||
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin).
|
||||
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu.
|
||||
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale**
|
||||
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza
|
||||
404 na `/en/…`.
|
||||
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404.
|
||||
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne).
|
||||
1. **Utwórz pierwszego użytkownika** (rola admin).
|
||||
2. **Site Settings → General** — nazwa witryny, tytuł.
|
||||
3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
|
||||
slug EN = 404 na `/en/…`).
|
||||
4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
|
||||
5. **Cookie Settings** — treść bannera per język.
|
||||
6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback).
|
||||
7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address.
|
||||
|
||||
Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę.
|
||||
Wejdź na `/` — powinno przekierować na `/pl`.
|
||||
|
||||
---
|
||||
|
||||
## Rzeczy opcjonalne
|
||||
## Opcjonalne
|
||||
|
||||
### Formularz z Turnstile
|
||||
Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations →
|
||||
Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret
|
||||
`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez
|
||||
mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`.
|
||||
|
||||
Wymaga bloku formularza w projekcie (patrz forms.md) oraz:
|
||||
|
||||
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe
|
||||
Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret
|
||||
`1x0000000000000000000000000000000AA`.
|
||||
- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy.
|
||||
- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}`
|
||||
wypisze wszystkie pola tabelką). Maile wysyła form-builder przez
|
||||
`panelSmtpAdapter` — nie pisze się ich w kodzie.
|
||||
|
||||
|
||||
### Blog / archiwum (kolekcja pod stroną-archiwum)
|
||||
|
||||
Pełny opis: content.md. W skrócie:
|
||||
|
||||
1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`,
|
||||
pola, bloki. Dodaj ją do `collections` w payload.config.
|
||||
|
||||
2. **content.config.ts** obok i18n.config.ts:
|
||||
```ts
|
||||
import type { ContentOption } from '@intecion/ipal-kit'
|
||||
export const contentConfig: ContentOption = {
|
||||
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
|
||||
}
|
||||
```
|
||||
|
||||
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
|
||||
|
||||
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
|
||||
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
|
||||
|
||||
5. **Baza + typy** — nowa kolekcja to nowy schemat:
|
||||
```bash
|
||||
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
|
||||
```
|
||||
|
||||
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
|
||||
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
|
||||
|
||||
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
|
||||
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
|
||||
|
||||
### Sitemapa i robots.txt
|
||||
|
||||
`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego
|
||||
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
|
||||
### Blog / archiwum
|
||||
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
|
||||
przypisanie strony-archiwum w System Pages.
|
||||
|
||||
### Sitemapa i robots
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
@@ -482,44 +490,22 @@ export { sitemap as default } from '@/lib/content'
|
||||
export { robots as default } from '@/lib/content'
|
||||
```
|
||||
|
||||
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
|
||||
|
||||
### Analytics
|
||||
|
||||
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
|
||||
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
|
||||
zaakceptuje kategorii Analytics.
|
||||
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
```css
|
||||
/* styles.css */
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Pełna lista tokenów: consent.md.
|
||||
|
||||
---
|
||||
|
||||
## Kiedy coś nie działa
|
||||
|
||||
| Objaw | Przyczyna |
|
||||
|---|---|
|
||||
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||
| Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
||||
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda |
|
||||
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline |
|
||||
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` |
|
||||
| `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
|
||||
| Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
|
||||
| `/admin` i `/_next` → 500 | matcher w proxy nie jest inline |
|
||||
| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` |
|
||||
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
||||
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN |
|
||||
| `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN |
|
||||
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
|
||||
| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` |
|
||||
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` |
|
||||
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu |
|
||||
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 |
|
||||
| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages |
|
||||
| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu |
|
||||
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |
|
||||
| Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
|
||||
| Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
|
||||
| istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
|
||||
| zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |
|
||||
+48
-1
@@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
||||
```
|
||||
|
||||
> **Pułapka typu (TypeScript):** przy `locale: 'all'` Payload w RUNTIME zwraca
|
||||
> zlokalizowane pole jako obiekt `{ pl, en }`, ale wygenerowane typy Payloada
|
||||
> deklarują `doc.slug` jako `string` (typ nie odróżnia trybu `all`). `tsc`
|
||||
> zgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez
|
||||
> helper typ:
|
||||
> ```ts
|
||||
> const slugs = getLocalizedSlugs({
|
||||
> slugField: doc.slug as unknown as Record<string, unknown>,
|
||||
> config,
|
||||
> })
|
||||
> ```
|
||||
> To nie hack — to pomost między statycznym typem (string) a rzeczywistym
|
||||
> kształtem runtime (obiekt), którego generator typów Payloada nie modeluje.
|
||||
> `as unknown as` jest tu poprawne, bo TS nie zna trybu `all`.
|
||||
|
||||
### Przełącznik języka (bez 404)
|
||||
|
||||
```ts
|
||||
@@ -107,4 +122,36 @@ Zachowanie:
|
||||
locale z: cookie → Accept-Language → default
|
||||
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
||||
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
|
||||
## Cookie locale a zgoda (RODO)
|
||||
|
||||
Wybór języka zapisywany jest w cookie **`NEXT_LOCALE`** (konwencja Next.js —
|
||||
kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale
|
||||
zapis podlega zgodzie: to cookie kategorii **functional**, więc:
|
||||
|
||||
- **Zapis TYLKO za zgodą.** Middleware zapisuje `NEXT_LOCALE` jedynie, gdy
|
||||
użytkownik zgodził się na kategorię functional (`mayPersistLocale` sprawdza
|
||||
zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language),
|
||||
ale nie jest utrwalany.
|
||||
- **Sprzątanie po cofnięciu zgody.** Gdy użytkownik cofnie zgodę na functional,
|
||||
cookie `NEXT_LOCALE` jest usuwane automatycznie (consent zna tę cookie przez
|
||||
`DEFAULT_COOKIE_MAP` — patrz [consent.md](./consent.md)).
|
||||
|
||||
Nazwa cookie to jedna stała `LOCALE_COOKIE_NAME` (`modules/i18n/negotiateLocale`),
|
||||
propagująca do middleware i sprzątania consent. Można nadpisać w
|
||||
`createLocaleMiddleware({ cookieName })`, ale domyślnie `NEXT_LOCALE` jest
|
||||
zalecane (interop).
|
||||
|
||||
### Kolejność negocjacji locale
|
||||
|
||||
1. Cookie `NEXT_LOCALE` (jeśli jest — czyli był wybór za zgodą)
|
||||
2. Nagłówek `Accept-Language` (preferencje przeglądarki)
|
||||
3. `defaultLocale` z konfiguracji
|
||||
|
||||
Wejście na `/` → negocjacja → redirect na `/pl` (albo wynik negocjacji).
|
||||
Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą).
|
||||
|
||||
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
||||
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
||||
@@ -1,64 +0,0 @@
|
||||
# security
|
||||
|
||||
Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff,
|
||||
Referrer-Policy, Permissions-Policy) jako funkcja do `next.config`. CSP CELOWO
|
||||
pominięte — zależy od domen projektu, zostaje w projekcie.
|
||||
|
||||
## Zasada
|
||||
|
||||
Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP
|
||||
(Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd
|
||||
ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne —
|
||||
zostaje w projekcie, dodawane przez `additional`.
|
||||
|
||||
## Użycie — next.config.ts
|
||||
|
||||
Nagłówki wpina się w `next.config`, NIE w proxy — bo muszą pokryć CAŁĄ
|
||||
aplikację (też `/admin`, statyki), a proxy pomija te trasy.
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import type { NextConfig } from 'next'
|
||||
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production', // WAŻNE: off w dev (http)
|
||||
additional: [
|
||||
// CSP projektu — zna swoje domeny:
|
||||
// { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
],
|
||||
})
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: securityHeaders }]
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
```
|
||||
|
||||
## Opcje
|
||||
|
||||
| Opcja | Domyślnie | Rola |
|
||||
|---|---|---|
|
||||
| `hsts` | `true` | Strict-Transport-Security (wymuś HTTPS) |
|
||||
| `hstsMaxAge` | `63072000` (2 lata) | max-age HSTS w sekundach |
|
||||
| `hstsIncludeSubDomains` | `true` | HSTS na subdomeny |
|
||||
| `hstsPreload` | `false` | preload (tylko jeśli zgłaszasz do listy) |
|
||||
| `frameOptions` | `'DENY'` | X-Frame-Options (anty-clickjacking) |
|
||||
| `referrerPolicy` | `'strict-origin-when-cross-origin'` | Referrer-Policy |
|
||||
| `permissionsPolicy` | blokuje camera/mic/geolocation | Permissions-Policy |
|
||||
| `additional` | `[]` | dodatkowe nagłówki (np. CSP); same-key nadpisuje |
|
||||
|
||||
## PUŁAPKA — HSTS w dev
|
||||
|
||||
HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost.
|
||||
ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||
|
||||
## Nadpisywanie i CSP
|
||||
|
||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||
plugin go nie generuje, bo zależy od projektu.
|
||||
@@ -0,0 +1,141 @@
|
||||
# security
|
||||
|
||||
Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff,
|
||||
Referrer-Policy, Permissions-Policy) jako funkcja do `next.config`. CSP CELOWO
|
||||
pominięte — zależy od domen projektu, zostaje w projekcie.
|
||||
|
||||
## Zasada
|
||||
|
||||
Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP
|
||||
(Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd
|
||||
ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne —
|
||||
zostaje w projekcie, dodawane przez `additional`.
|
||||
|
||||
## Użycie — next.config.ts
|
||||
|
||||
Nagłówki wpina się w `next.config`, NIE w proxy — bo muszą pokryć CAŁĄ
|
||||
aplikację (też `/admin`, statyki), a proxy pomija te trasy.
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import type { NextConfig } from 'next'
|
||||
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production', // WAŻNE: off w dev (http)
|
||||
additional: [
|
||||
// CSP projektu — zna swoje domeny:
|
||||
// { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
],
|
||||
})
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: securityHeaders }]
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
```
|
||||
|
||||
## Opcje
|
||||
|
||||
| Opcja | Domyślnie | Rola |
|
||||
|---|---|---|
|
||||
| `hsts` | `true` | Strict-Transport-Security (wymuś HTTPS) |
|
||||
| `hstsMaxAge` | `63072000` (2 lata) | max-age HSTS w sekundach |
|
||||
| `hstsIncludeSubDomains` | `true` | HSTS na subdomeny |
|
||||
| `hstsPreload` | `false` | preload (tylko jeśli zgłaszasz do listy) |
|
||||
| `frameOptions` | `'DENY'` | X-Frame-Options (anty-clickjacking) |
|
||||
| `referrerPolicy` | `'strict-origin-when-cross-origin'` | Referrer-Policy |
|
||||
| `permissionsPolicy` | blokuje camera/mic/geolocation | Permissions-Policy |
|
||||
| `additional` | `[]` | dodatkowe nagłówki (np. CSP); same-key nadpisuje |
|
||||
|
||||
## PUŁAPKA — HSTS w dev
|
||||
|
||||
HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost.
|
||||
ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||
|
||||
## Nadpisywanie i CSP
|
||||
|
||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||
plugin go nie generuje, bo zależy od projektu.
|
||||
|
||||
### Dlaczego CSP zostaje w projekcie (nie plugin)
|
||||
|
||||
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
|
||||
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
|
||||
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
||||
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||
projekt dostarcza CSP dopasowany do siebie.
|
||||
|
||||
### Budowa CSP — domeny z env, nie hardkod
|
||||
|
||||
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||
dopasuj do tego, co projekt faktycznie ładuje:
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const r2Url = process.env.R2_PUBLIC_URL || ''
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
|
||||
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
|
||||
// style: self + inline (Tailwind) + Google Fonts
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
// obrazy: self + media R2 (z env!) + data:
|
||||
`img-src 'self' data: ${r2Url}`.trim(),
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"connect-src 'self' https://www.google-analytics.com",
|
||||
// ramki: Turnstile (widget captcha)
|
||||
"frame-src https://challenges.cloudflare.com",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
|
||||
].join('; ')
|
||||
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production',
|
||||
additional: [{ key: 'Content-Security-Policy', value: csp }],
|
||||
})
|
||||
```
|
||||
|
||||
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
|
||||
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
|
||||
zostanie zablokowane.
|
||||
|
||||
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
|
||||
|
||||
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
|
||||
enforcing CSP na ślepo. Metoda bezpieczna:
|
||||
|
||||
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
|
||||
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
|
||||
ale NIE blokuje — strona działa normalnie.
|
||||
```ts
|
||||
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
|
||||
```
|
||||
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
|
||||
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
|
||||
dyrektywy CSP.
|
||||
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
|
||||
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
|
||||
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
|
||||
(enforcing). Teraz CSP chroni, nie psując.
|
||||
|
||||
### Weryfikacja nagłówków na produkcji
|
||||
|
||||
```bash
|
||||
# sprawdź, które nagłówki faktycznie wychodzą:
|
||||
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
|
||||
```
|
||||
|
||||
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
|
||||
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
|
||||
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
||||
|
||||
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||
+464
-1
@@ -217,6 +217,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
|
||||
|
||||
// app/robots.ts
|
||||
export { robots as default } from '@/lib/content'
|
||||
@@ -227,6 +228,16 @@ całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, ska
|
||||
katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała
|
||||
logika jest w pluginie.
|
||||
|
||||
> **Deploy kontenerowy (Coolify/Docker/Railway/CI) — WAŻNE:** `export const
|
||||
> dynamic = 'force-dynamic'` w `app/sitemap.ts` jest KONIECZNE. Bez niego Next
|
||||
> traktuje sitemap jako statyczny i prerenderuje go w `next build` — a to
|
||||
> wywołuje Payload → bazę. Kontener budujący zwykle nie ma dostępu do sieci
|
||||
> Docker, więc połączenie z bazą pada (`ENOTFOUND`) i build się wywala. Z
|
||||
> `force-dynamic` sitemap generuje się w runtime, gdy baza jest dostępna.
|
||||
> (Plugin dodatkowo łapie błąd bazy i zwraca pusty sitemap zamiast wywalić build
|
||||
> — ale `force-dynamic` to właściwe rozwiązanie, nie poleganie na fallbacku.)
|
||||
> Opcjonalnie `export const revalidate = 3600` — cache sitemap na godzinę.
|
||||
|
||||
Co zawiera sitemapa:
|
||||
- każdą stronę i wpis bloga, URL w domyślnym locale
|
||||
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
|
||||
@@ -247,4 +258,456 @@ const entries = await buildSitemapEntries({
|
||||
```
|
||||
|
||||
Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na
|
||||
części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k.
|
||||
części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k.
|
||||
|
||||
## Favicon w Google + Organization (branding w wyszukiwarce)
|
||||
|
||||
Favicon i structured data wpływają na to, jak strona wygląda w wynikach Google.
|
||||
Plugin generuje jedno i drugie z panelu — projekt tylko wpina w root layout.
|
||||
|
||||
### Favicon — format: PNG, nie SVG (ważne)
|
||||
|
||||
**Dla Google użyj PNG (≥48×48), nie SVG.** Zweryfikowane: Google niezawodnie
|
||||
wspiera PNG i ICO, ale **SVG w wynikach Google jest zawodny** — często pokazuje
|
||||
glob mimo że w karcie przeglądarki favicon renderuje się dobrze. Oficjalna
|
||||
dokumentacja Google nie wymienia SVG. Jeśli zależy Ci na faviconie w wyszukiwarce
|
||||
— wgraj PNG.
|
||||
|
||||
- **PNG ≥48×48** (idealnie 96 lub 192), kwadratowy → działa w Google ✓
|
||||
- **SVG** → działa w przeglądarce, ale w Google glob (zawodne) ✗
|
||||
- Walidacja pola favicon OSTRZEGA, gdy wgrasz SVG (żebyś wiedział, że dla search
|
||||
potrzebny PNG).
|
||||
|
||||
### Favicon — dlaczego się nie pokazywał
|
||||
|
||||
Google ma twarde wymogi: `<link rel="icon">` w `<head>`, kwadratowy, **≥48×48px**,
|
||||
stały URL. Gdy projekt renderował favicon „po swojemu", często był za mały, źle
|
||||
otagowany albo nieobecny w head → Google go nie pokazywał. Plugin robi to teraz
|
||||
poprawnie.
|
||||
|
||||
### Wpięcie favicon (root layout)
|
||||
|
||||
Favicon jest GLOBALNY (ten sam wszędzie) — wpina się RAZ w root layout, nie per strona:
|
||||
|
||||
```tsx
|
||||
// app/(frontend)/[locale]/layout.tsx
|
||||
import type { Metadata } from 'next'
|
||||
import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||
import { getSettings } from '@/lib/payload'
|
||||
|
||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const settings = await getSettings(locale)
|
||||
return buildIconsMetadata(settings.favicon) // z pola favicon (panel)
|
||||
}
|
||||
```
|
||||
|
||||
`buildIconsMetadata` generuje poprawne `icons` (favicon + apple-touch) z pola
|
||||
favicon. SVG → skaluje się; PNG → powinien być ≥48×48 (walidacja ostrzega, patrz niżej).
|
||||
|
||||
### Wymuszenie rozmiaru (walidacja)
|
||||
|
||||
Pole favicon w SiteSettings ma walidację `validateFaviconField` — ostrzega
|
||||
redaktora przy zapisie, jeśli favicon jest <48×48 albo nie kwadratowy. Redaktor
|
||||
widzi ostrzeżenie, zamiast po cichu wgrać favicon, którego Google nie pokaże.
|
||||
|
||||
### Organization JSON-LD (branding)
|
||||
|
||||
Pomaga Google powiązać stronę z marką (nazwa, logo) — lepsze wyświetlanie w
|
||||
wynikach, logo w knowledge panel.
|
||||
|
||||
```tsx
|
||||
// root layout — RAZ (Organization jest globalny)
|
||||
import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||
|
||||
const jsonLd = buildOrganizationJsonLd({
|
||||
name: settings.siteName,
|
||||
url: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||
logo: settings.logo,
|
||||
sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe
|
||||
})
|
||||
|
||||
// w JSX layoutu:
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
```
|
||||
|
||||
Dane z panelu (siteName, logo) — nic na sztywno.
|
||||
|
||||
### Po wdrożeniu — cierpliwość z Google
|
||||
|
||||
Google **cache'uje favicon osobno i wolno** (dni, czasem tygodnie). Po poprawnym
|
||||
wpięciu favicon nie pojawi się natychmiast — Googlebot musi ponownie odwiedzić
|
||||
stronę główną. Przyspieszenie: Search Console → prośba o ponowne indeksowanie
|
||||
strony głównej. Sprawdź też, czy `/` nie blokuje Googlebota (robots) i czy
|
||||
favicon URL jest publiczny (nie za auth).
|
||||
|
||||
### Weryfikacja
|
||||
|
||||
1. Otwórz stronę → DevTools → Elements → `<head>` → sprawdź `<link rel="icon">`
|
||||
z poprawnym URL.
|
||||
2. Otwórz sam URL favicon w przeglądarce — obraz się pokazuje, ≥48×48.
|
||||
3. Rich Results Test (Google) — wklej URL strony, sprawdź Organization.
|
||||
4. Search Console → poproś o ponowne indeksowanie strony głównej.
|
||||
|
||||
## Ręczne rozszerzenia SEO/PWA (manifest itp.) — z panelu, NIE hardkod
|
||||
|
||||
Niektóre rzeczy SEO/PWA są na tyle projekt-specyficzne i jednorazowe, że plugin
|
||||
ich nie dostarcza (byłoby przeinżynierowaniem). Robisz je w projekcie — ALE
|
||||
poprawnie: czytając z panelu/env, nie zaszywając wartości klienta.
|
||||
|
||||
> **Zasada:** nawet gdy coś robisz ręcznie w projekcie, dane (nazwa, kolory,
|
||||
> opis, logo) czytaj z panelu (SiteSettings) albo env. Zaszyta nazwa/kolor
|
||||
> klienta = antywzorzec (patrz standardy-kodu.md). Manifest „R Custom Cars" z
|
||||
> hardkodem zadziała tylko dla jednego klienta.
|
||||
|
||||
### Web App Manifest (PWA) — jak zrobić DOBRZE
|
||||
|
||||
Zasada nadrzędna: **brak danych → POMIŃ pole, NIE zaszywaj wartości.** Manifest
|
||||
jest ważny bez `name`? Nie — ale lepszy manifest bez nazwy niż z cudzą nazwą
|
||||
klienta w fallbacku. Fallback z nazwą/kolorem klienta to ukryty hardkod.
|
||||
|
||||
```ts
|
||||
// app/manifest.ts
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { getCachedPayload } from '@/lib/content'
|
||||
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import type { SiteSetting } from '@/payload-types'
|
||||
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const payload = await getCachedPayload()
|
||||
const settings = await getSiteSettings<SiteSetting>(payload, {
|
||||
locale: i18nConfig.defaultLocale as never,
|
||||
})
|
||||
|
||||
const siteName = settings?.siteName?.trim()
|
||||
|
||||
// Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
|
||||
// (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
|
||||
const icon = settings?.favicon ?? settings?.logo
|
||||
const iconEntry =
|
||||
typeof icon === 'object' && icon?.url
|
||||
? (() => {
|
||||
const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
|
||||
const size =
|
||||
typeof icon.width === 'number' && typeof icon.height === 'number'
|
||||
? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
|
||||
: '512x512'
|
||||
return {
|
||||
src: icon.url,
|
||||
type: icon.mimeType ?? 'image/png',
|
||||
sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
// Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
|
||||
// zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
|
||||
return {
|
||||
...(siteName ? { name: siteName, short_name: siteName } : {}),
|
||||
start_url: `/${i18nConfig.defaultLocale}`,
|
||||
display: 'standalone',
|
||||
...(iconEntry ? { icons: [iconEntry] } : {}),
|
||||
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
|
||||
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Kluczowe różnice od częstego błędu agenta:**
|
||||
- **Brak fallbacku z nazwą klienta** — `siteName` puste → pomijamy `name`, nie
|
||||
wstawiamy „Kancelaria X" na sztywno. Cudza nazwa w fallbacku = hardkod.
|
||||
- **PNG dostaje konkretny `sizes`** z wymiarów media (nie `sizes: 'any'` — to
|
||||
ten sam błąd co przy favicon; `any` tylko dla SVG).
|
||||
- **Brak bloku `catch` z hardkodami** — jeśli boisz się błędu, opakuj samo
|
||||
`getSiteSettings` i przy błędzie zwróć minimalny manifest (start_url + display),
|
||||
BEZ zaszytej nazwy/kolorów.
|
||||
- **start_url z i18nConfig**, nie zaszyte `/pl`.
|
||||
|
||||
**Kontrast — czego NIE robić** (realne błędy z projektów):
|
||||
|
||||
```ts
|
||||
// ŹLE — hardkod jawny (rcustomcars)
|
||||
let name = 'R Custom Cars'; short_name: 'RCC'
|
||||
background_color: '#08080a', theme_color: '#d4af37'
|
||||
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka
|
||||
|
||||
// ŹLE — hardkod UKRYTY w fallbacku (kancelaria)
|
||||
siteName || 'Kancelaria Adwokacka Adwokat Romuald Kędzierski' // cudza nazwa w ||
|
||||
sizes: 'any', type: mimeType // 'any' na PNG = źle
|
||||
catch { return { name: 'Kancelaria...', theme_color: '#0e1e24' } } // hardkod w catch
|
||||
```
|
||||
|
||||
Fallback `|| 'Nazwa Klienta'` wygląda niewinnie, ale to hardkod — inny projekt
|
||||
skopiuje i pokaże cudzą nazwę, gdy panel zawiedzie. Brak danych → pomiń pole.
|
||||
|
||||
Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — **dodaj pola**
|
||||
`themeColor`, `manifestDescription` w SiteSettings (przez opcje pluginu
|
||||
SiteSettingsFields) i czytaj z panelu. Wtedy redaktor je zmienia, nie są zaszyte.
|
||||
|
||||
### Inne ręczne rozszerzenia — ta sama zasada
|
||||
|
||||
Cokolwiek dodajesz ręcznie (dodatkowe meta tagi, structured data konkretnego
|
||||
typu, itp.):
|
||||
- dane z panelu (SiteSettings / pola strony) albo env
|
||||
- nic zaszytego per klient (nazwa, kolor, adres, domena)
|
||||
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
||||
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
||||
|
||||
## KRYTYCZNE: metadata w <head> dla Google (htmlLimitedBots)
|
||||
|
||||
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
|
||||
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
||||
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
|
||||
lądują w body w surowym HTML. Crawlery, które nie wykonują JS (Screaming Frog,
|
||||
część botów), widzą je poza head → ignorują → utrata SEO.
|
||||
|
||||
Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka
|
||||
(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić
|
||||
metadata do head dla crawlerów.
|
||||
|
||||
### Rozwiązanie — htmlLimitedBots w next.config
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig: NextConfig = {
|
||||
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
|
||||
// dla crawlerów SEO — zamiast streamingu do <body>.
|
||||
htmlLimitedBots:
|
||||
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||
// ...reszta
|
||||
}
|
||||
```
|
||||
|
||||
`htmlLimitedBots` mówi Next: dla tych User-Agentów wyłącz streaming, wstaw
|
||||
metadata do `<head>` w surowym HTML (blocking). Użytkownicy dalej dostają
|
||||
streaming (szybkie ładowanie); crawlery dostają poprawny head.
|
||||
|
||||
### Objawy (że masz ten problem)
|
||||
|
||||
- Screaming Frog: „canonical/hreflang/title outside <head>"
|
||||
- Search Console: „brak canonical", favicon nie pokazuje się (glob)
|
||||
- W surowym HTML canonical/title są PO `</head>`, na końcu body, ze skryptem
|
||||
`document.querySelectorAll('body link[rel=icon]')...appendChild`
|
||||
|
||||
### Weryfikacja
|
||||
|
||||
```bash
|
||||
# jako Googlebot — metadata MUSI być w <head>
|
||||
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical
|
||||
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS)
|
||||
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona
|
||||
```
|
||||
|
||||
Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical
|
||||
(„User-declared canonical: None"), hreflang i title. Jedna linia w config
|
||||
naprawia wszystko naraz.
|
||||
|
||||
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
||||
|
||||
Przekierowanie `/` → `/pl` (negocjacja locale) może wpływać na SEO. Kluczowe:
|
||||
plugin generuje hreflang, więc Google rozumie, że `/pl` i `/en` to wersje
|
||||
językowe (nie duplikaty). Ale są niuanse.
|
||||
|
||||
### hreflang + x-default (generowane przez plugin)
|
||||
|
||||
`buildHreflangAlternates` generuje `alternates.languages` z wpisami per locale
|
||||
ORAZ **`x-default`** wskazujący na defaultLocale. x-default mówi Google: „gdy
|
||||
język/region użytkownika nie pasuje do żadnej wersji, użyj TEJ" — co pokrywa
|
||||
sytuację roota (Googlebot bez preferencji językowej). Bez x-default Google
|
||||
zgadywałby; z nim dostaje jasną wskazówkę (domyślnie pl).
|
||||
|
||||
Działa automatycznie przez createPageMetadata (canonical + hreflang + x-default).
|
||||
|
||||
### Redirect roota — na co uważać
|
||||
|
||||
- **307 (temporary)** na `/` → `/pl` — plugin tak robi. Dla warunkowego redirectu
|
||||
(zależnego od negocjacji) to obronne. Google i tak podąża.
|
||||
- **Negocjacja Accept-Language** — Googlebot bywa z `Accept-Language: en` albo
|
||||
bez. Może trafić na `/en`. x-default (→ pl) łagodzi to: Google wie, że
|
||||
domyślna wersja to polska.
|
||||
- **Root nie ma własnej treści** — cała moc idzie przez redirect na locale. To
|
||||
normalne dla i18n stron, hreflang to obsługuje.
|
||||
|
||||
### Weryfikacja SEO wielojęzycznego
|
||||
|
||||
1. Search Console → Inspekcja URL dla `/` — zobacz, na co Google przekierowuje
|
||||
i co indeksuje.
|
||||
2. Sprawdź, czy `/pl` i `/en` są indeksowane osobno (nie jako duplikaty).
|
||||
3. Rich Results / źródło strony → potwierdź `<link rel="alternate" hreflang="...">`
|
||||
z wpisami per locale + `hreflang="x-default"`.
|
||||
4. Search Console → raport Międzynarodowe targetowanie (jeśli dostępny) — błędy
|
||||
hreflang.
|
||||
|
||||
### Częste błędy (nie rób tak)
|
||||
|
||||
- Brak hreflang → Google traktuje wersje jako duplikaty (plugin to ma, nie usuwaj).
|
||||
- Zaszyta mapa ścieżek zamiast getLocalizedSlugs → hreflang się rozjedzie z bazą.
|
||||
- `noindex` na `/pl` przez pomyłkę → wypada z indeksu. Sprawdź robots meta.
|
||||
- Redirect roota na twardo 301 do jednego języka → tracisz negocjację i drugą
|
||||
wersję. Zostaw negocjację + hreflang.
|
||||
|
||||
## Sitelinks i structured data (branding w wynikach Google)
|
||||
|
||||
Cel: żeby wyszukanie marki („rcustomcars") pokazało stronę główną + podlinki
|
||||
(sitelinks) z opisami. Ważne — **sitelinków NIE DA SIĘ wymusić.** Google
|
||||
generuje je algorytmicznie ze struktury strony, linkowania wewnętrznego, jasnych
|
||||
tytułów i rankingu. Żaden kod ich nie włączy. Plugin dostarcza SYGNAŁY, które
|
||||
zwiększają szansę — nie gwarancję.
|
||||
|
||||
### Co realnie wpływa na sitelinki (kolejność wg wagi)
|
||||
|
||||
1. **Ranking na 1. stronie Google** — bez tego sitelinków nie ma. To robota SEO
|
||||
(treść, linki), nie kodu.
|
||||
2. **Czysta struktura + jasne tytuły** — logiczna hierarchia stron, opisowe title
|
||||
(nie „Strona 1"). Patrz fundamenty-projektu.md.
|
||||
3. **Linkowanie wewnętrzne** — ważne strony podlinkowane z głównej.
|
||||
4. **Structured data** (poniżej) — sygnał pomocniczy, nie przełącznik.
|
||||
5. **Sitemap + robots** — żeby Google w ogóle widział wszystkie strony (patrz
|
||||
niżej — to fundament, sprawdź czy działa!).
|
||||
|
||||
### Structured data z pluginu — 3 helpery
|
||||
|
||||
Wszystkie emitowane jako `<script type="application/ld+json">`, dane z panelu.
|
||||
|
||||
**1. WebSite + SearchAction (największy realny efekt)** — może dać sitelinks
|
||||
searchbox (pole wyszukiwania pod wynikiem marki). RAZ w root layout:
|
||||
```tsx
|
||||
import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildWebSiteJsonLd({
|
||||
name: settings.siteName,
|
||||
url: baseUrl,
|
||||
// TYLKO jeśli masz działającą stronę wyszukiwania:
|
||||
search: { target: `${baseUrl}/szukaj?q={search_term_string}` },
|
||||
})
|
||||
```
|
||||
Pomiń `search`, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na
|
||||
nieistniejącą stronę szkodzi.
|
||||
|
||||
**2. BreadcrumbList (realny efekt)** — okruszki w wynikach (Dom › Usługi ›
|
||||
Detailing) + Google rozumie hierarchię. PER STRONA, z pozycji strony:
|
||||
```tsx
|
||||
import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildBreadcrumbJsonLd([
|
||||
{ name: 'Strona główna', url: `${base}/pl` },
|
||||
{ name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||
{ name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||
])
|
||||
```
|
||||
Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z
|
||||
zaszytej listy.
|
||||
|
||||
**3. SiteNavigationElement (słabszy, tani)** — nawigacja jako dane. RAZ, z tych
|
||||
samych pozycji co menu w headerze:
|
||||
```tsx
|
||||
import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildSiteNavigationJsonLd(
|
||||
navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||
)
|
||||
```
|
||||
Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista.
|
||||
|
||||
### Realne oczekiwania (ważne)
|
||||
|
||||
- Structured data **nie gwarantuje** sitelinków — to sygnał wśród wielu.
|
||||
- Efekt (jeśli będzie) pojawia się **po tygodniach**, gdy Google przecrawluje i
|
||||
strona rankuje.
|
||||
- Największy wpływ ma **ranking + struktura + linkowanie**, nie schema. Schema
|
||||
pomaga Google zrozumieć, ale nie zastąpi bycia na 1. stronie.
|
||||
- Weryfikuj: Google Rich Results Test (czy schema poprawna) + Search Console
|
||||
(co Google pokazuje dla marki).
|
||||
|
||||
### To, co ZALEŻY OD PROJEKTU (obowiązki wpięcia)
|
||||
|
||||
Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu:
|
||||
|
||||
- [ ] `buildWebSiteJsonLd` w root layout (search tylko jeśli jest wyszukiwarka)
|
||||
- [ ] `buildOrganizationJsonLd` w root layout (logo, nazwa)
|
||||
- [ ] `buildBreadcrumbJsonLd` na podstronach (z realnej ścieżki)
|
||||
- [ ] `buildSiteNavigationJsonLd` z pozycji menu (jeśli jest header nav)
|
||||
- [ ] `app/robots.ts` i `app/sitemap.ts` wystawione (patrz niżej — bez tego
|
||||
Google nie widzi stron!)
|
||||
- [ ] Jasne, opisowe tytuły stron (nie generyczne)
|
||||
- [ ] Logiczna hierarchia + linkowanie wewnętrzne z głównej
|
||||
|
||||
Dane WSZĘDZIE z panelu (siteName, nav, logo), nigdy zaszyte.
|
||||
|
||||
## noindex per strona (strony prawne, cienkie, wyniki wyszukiwania)
|
||||
|
||||
Niektóre strony NIE powinny być w indeksie Google: polityki/regulamin (kanibalizują
|
||||
frazy), strony z parametrami, wyniki wyszukiwania. Plugin wspiera to przez pole
|
||||
`noindex` w meta SEO.
|
||||
|
||||
```ts
|
||||
// w danych strony (meta): noindex: true
|
||||
// buildMetadata automatycznie doda robots: { index: false, follow: true }
|
||||
```
|
||||
|
||||
`noindex, follow` — strona wypada z indeksu, ale linki dalej przekazują moc
|
||||
(follow). Ustaw dla:
|
||||
- polityka prywatności, regulamin, polityka cookies
|
||||
- strony z parametrami kalkulatorów, filtrów
|
||||
- wyniki wewnętrznej wyszukiwarki
|
||||
|
||||
Redaktor zaznacza `noindex` w panelu (pole SEO strony), plugin generuje tag.
|
||||
Alternatywnie: dodaj `noindex` do System Pages o rolach prawnych automatycznie.
|
||||
|
||||
## robots.txt — blokada parametrów (crawl budget)
|
||||
|
||||
URL-e z parametrami (`?meter=101-120m2`, `?s=fraza`) marnują budżet indeksowania —
|
||||
Google skanuje dziesiątki pustych wariantów. Zablokuj je w robots:
|
||||
|
||||
```ts
|
||||
// app/robots.ts
|
||||
import { buildRobots } from '@intecion/ipal-kit'
|
||||
export default function robots() {
|
||||
return buildRobots({
|
||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
|
||||
disallow: ['/admin', '/api', '/*?meter=*', '/*?s=*'], // + parametry
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Wzorce `/*?param=*` odcinają parametryzowane URL-e. Realne z audytu: 55
|
||||
niezindeksowanych stron kalkulatora — blokada w robots by temu zapobiegła.
|
||||
|
||||
## Local SEO — LocalBusiness, Service, FAQPage (structured data)
|
||||
|
||||
Dla firm lokalnych (usługi + miasto) — trzy schematy zwiększające widoczność
|
||||
w wynikach lokalnych i rich results.
|
||||
|
||||
**LocalBusiness (map pack, wyniki lokalne)** — RAZ w root layout, z globala company:
|
||||
```ts
|
||||
import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildLocalBusinessJsonLd({
|
||||
name: company.name, url: baseUrl, telephone: company.phone,
|
||||
address: company.address, openingHours: company.hours,
|
||||
geo: company.geo, priceRange: '$$',
|
||||
})
|
||||
```
|
||||
Najważniejsze dla „usługa + miasto". Dla konkretnego typu (Dentist, Plumber)
|
||||
nadpisz `@type`.
|
||||
|
||||
**Service (co strona oferuje)** — per strona usługowa:
|
||||
```ts
|
||||
import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildServiceJsonLd({
|
||||
name: 'Sprzątanie biur', providerName: company.name,
|
||||
url: pageUrl, areaServed: 'Wrocław',
|
||||
})
|
||||
```
|
||||
|
||||
**FAQPage (rich results FAQ)** — per strona z FAQ, z bloku FAQ w panelu:
|
||||
```ts
|
||||
import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
const jsonLd = buildFaqJsonLd(
|
||||
faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
)
|
||||
```
|
||||
WAŻNE: Q&A musi odpowiadać widocznej treści strony (Google flaguje rozbieżność).
|
||||
Nie wymyślaj pytań, których nie ma na stronie.
|
||||
|
||||
Wszystkie: dane z panelu (company, bloki), jako `<script type="application/ld+json">`.
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
# storage — media na Cloudflare R2
|
||||
|
||||
Offload mediów (obrazy, pliki) do Cloudflare R2 zamiast lokalnego dysku. R2 jest
|
||||
S3-kompatybilny; plugin dostarcza `buildR2Storage`, który czyta dane z `.env`
|
||||
i konfiguruje adapter.
|
||||
|
||||
> **Storage to infrastruktura, nie treść.** Dane R2 (klucze, bucket) idą do
|
||||
> `.env` — jak DATABASE_URI, PAYLOAD_SECRET, GRAPH_*. NIE do panelu (to sekrety
|
||||
> agencyjne, wiążą się przy starcie, nie zmienia ich redaktor).
|
||||
|
||||
## Zależność
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/storage-s3
|
||||
```
|
||||
|
||||
## Zmienne .env
|
||||
|
||||
Patrz [R2-ENV-przyklad](../R2-ENV-przyklad.md) po pełną instrukcję skąd wziąć wartości.
|
||||
|
||||
```bash
|
||||
R2_BUCKET=nazwa-bucketa
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=<access-key-id>
|
||||
R2_SECRET_ACCESS_KEY=<secret-access-key>
|
||||
```
|
||||
|
||||
## Wpięcie (payload.config.ts)
|
||||
|
||||
```ts
|
||||
import { buildR2Storage } from '@intecion/ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
plugins: [
|
||||
ipalKit({ /* ... */ }),
|
||||
buildR2Storage(['media']), // slugi kolekcji upload do offloadu
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
`buildR2Storage` przyjmuje listę kolekcji upload (domyślnie `['media']`). Jeśli
|
||||
masz więcej kolekcji plików: `buildR2Storage(['media', 'documents'])`.
|
||||
|
||||
## Zachowanie (fallback)
|
||||
|
||||
- **Wszystkie 4 zmienne** → media w R2.
|
||||
- **Brak zmiennych** → fallback na lokalny dysk (dev działa bez R2, zero konfiguracji).
|
||||
- **Część zmiennych** → ostrzeżenie w logu + fallback (częściowa konfiguracja =
|
||||
pewnie pomyłka).
|
||||
|
||||
To wzorzec „degrade gracefully" — jak mailAdapter, który wraca do SMTP, gdy brak
|
||||
Graph. Projekt działa niezależnie od tego, czy R2 jest skonfigurowany.
|
||||
|
||||
## Publiczny dostęp + custom domena (WAŻNE — krok po kroku)
|
||||
|
||||
R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403),
|
||||
dopóki nie skonfigurujesz publicznego odczytu przez custom domenę. To proces
|
||||
w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie.
|
||||
|
||||
### Dlaczego custom domena, nie „r2.dev"
|
||||
|
||||
R2 oferuje szybki publiczny URL `*.r2.dev`, ALE:
|
||||
- jest rate-limitowany (nie do produkcji)
|
||||
- nie przechodzi przez cache Cloudflare (brak CDN, wolniej, drożej)
|
||||
- brzydki URL (nie Twoja domena)
|
||||
|
||||
Dla produkcji ZAWSZE custom domena (np. `media.klient.pl`) — daje CDN, cache,
|
||||
własny URL. r2.dev tylko do szybkiego testu.
|
||||
|
||||
### Warunek wstępny: domena w Cloudflare
|
||||
|
||||
Custom domena dla R2 wymaga, żeby domena (albo subdomena) była zarządzana przez
|
||||
Cloudflare (nameservery klienta wskazują na Cloudflare). Jeśli domena klienta
|
||||
jest u innego rejestratora — trzeba ją najpierw dodać do Cloudflare (Add Site)
|
||||
i przełączyć nameservery. Sama subdomena `media.klient.pl` wystarczy, jeśli
|
||||
główna domena jest już w Cloudflare.
|
||||
|
||||
### Krok po kroku — podpięcie custom domeny
|
||||
|
||||
1. **Cloudflare Dashboard → R2 → wybierz bucket**
|
||||
2. Zakładka **Settings** → sekcja **Public access** → **Custom Domains**
|
||||
3. **Connect Domain** → wpisz subdomenę, np. `media.klient.pl`
|
||||
4. Cloudflare automatycznie doda rekord CNAME (bo domena jest w Cloudflare) i
|
||||
wystawi certyfikat SSL. Poczekaj, aż status = **Active** (kilka minut).
|
||||
5. Od tej chwili pliki są publiczne pod `https://media.klient.pl/<klucz-pliku>`.
|
||||
|
||||
### Krok: ustaw publiczny URL w projekcie
|
||||
|
||||
Payload musi generować URL-e mediów wskazujące na custom domenę, nie na endpoint
|
||||
S3. Dodaj zmienną i przekaż ją do adaptera:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
R2_PUBLIC_URL=https://media.klient.pl
|
||||
```
|
||||
|
||||
Adapter `buildR2Storage` czyta ją i ustawia jako bazowy URL mediów (jeśli
|
||||
ustawiona). Bez niej Payload zwróci URL wskazujący na prywatny endpoint S3 →
|
||||
403 na froncie. (Patrz aktualizacja buildR2Storage niżej.)
|
||||
|
||||
### Weryfikacja
|
||||
|
||||
1. Wgraj obraz w panelu (Media).
|
||||
2. Sprawdź URL obrazu w panelu — powinien być `https://media.klient.pl/...`,
|
||||
NIE `https://<account>.r2.cloudflarestorage.com/...`.
|
||||
3. Otwórz URL w przeglądarce — obraz się pokazuje (nie 403).
|
||||
4. Na froncie `<img src>` działa.
|
||||
|
||||
### Częsty błąd: 403 mimo custom domeny
|
||||
|
||||
- **URL wskazuje na endpoint S3, nie custom domenę** → brakuje `R2_PUBLIC_URL`
|
||||
albo adapter jej nie używa. Sprawdź URL w panelu.
|
||||
- **Custom domena nie Active** → poczekaj na SSL/CNAME w Cloudflare.
|
||||
- **Public access wyłączony** → w bucket Settings sprawdź, czy custom domena jest
|
||||
podpięta (nie tylko utworzona).
|
||||
|
||||
Bez tego media wgrają się do R2, ale front pokaże 403. Konfiguracja domeny jest
|
||||
po stronie Cloudflare, publiczny URL po stronie projektu (.env).
|
||||
|
||||
## Migracja istniejących mediów
|
||||
|
||||
Jeśli projekt miał media lokalnie i przełączasz na R2 — nowe uploady idą do R2,
|
||||
ale STARE zostają na dysku (i znikną przy redeployu bez wolumenu). Przed
|
||||
przełączeniem na produkcji przenieś istniejące pliki do bucketa (np. `rclone`
|
||||
albo ręcznie przez R2 dashboard), inaczej stare obrazy znikną.
|
||||
|
||||
## Weryfikacja
|
||||
|
||||
```bash
|
||||
# po wpięciu i ustawieniu .env:
|
||||
pnpm dev
|
||||
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
||||
```
|
||||
|
||||
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
||||
|
||||
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||
|
||||
- **Dev bez R2 w .env** → media na lokalnym dysku. Szybki start, zero konfiguracji.
|
||||
- **Dev z R2 w .env** → media w R2 już podczas developmentu. Przydatne, gdy
|
||||
seedujesz treść w devie i chcesz, żeby od razu lądowała w buckecie (np. wspólny
|
||||
bucket dev, albo test realnego flow przed produkcją).
|
||||
|
||||
Przełączasz trybem po prostu obecnością zmiennych R2 w `.env`. Ten sam kod,
|
||||
`buildR2Storage` sam wykrywa. Nie musisz nic zmieniać w configu między trybami.
|
||||
|
||||
> Jeśli seedujesz w devie do R2 — pamiętaj, że to realny bucket. Używaj osobnego
|
||||
> bucketa dev (nie produkcyjnego), żeby nie mieszać danych testowych z realnymi.
|
||||
|
||||
## Normalizacja nazw plików (automatyczna)
|
||||
|
||||
Plik `normalizeFilenameHook` czyści nazwy wgrywanych plików — slugifikuje nazwę,
|
||||
zachowuje rozszerzenie:
|
||||
|
||||
```
|
||||
"Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
"Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
```
|
||||
|
||||
Wpięcie w kolekcję Media (projekt):
|
||||
|
||||
```ts
|
||||
import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: { staticDir: 'media' /* ... */ },
|
||||
hooks: {
|
||||
beforeOperation: [normalizeFilenameHook], // czyści nazwę przed zapisem
|
||||
},
|
||||
fields: [ /* alt itd. */ ],
|
||||
}
|
||||
```
|
||||
|
||||
Działa z lokalnym dyskiem i z R2/S3 (hook biegnie PRZED warstwą storage, więc
|
||||
czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
|
||||
|
||||
- **URL-e mediów są czyste** — `/media/zdjecie-nad-morzem.jpg`, nie
|
||||
`/media/Zdjęcie%20jeden%20nad%20morzem.jpg` (spacje/diakrytyki w URL = problemy).
|
||||
- **Przenośność** — nazwa bez polskich znaków/spacji działa wszędzie (CDN, S3, systemy plików).
|
||||
- **Bez kolizji kodowania** — spacje i `#`, `()` w nazwach plików potrafią psuć
|
||||
ścieżki i cache.
|
||||
|
||||
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
||||
jej poza hookiem.
|
||||
|
||||
## Preconnect do domeny mediów (wydajność)
|
||||
|
||||
Komponent `MediaPreconnect` generuje `<link rel="preconnect">` + `dns-prefetch`
|
||||
dla domeny mediów (R2_PUBLIC_URL) — przeglądarka nawiązuje połączenie TLS/DNS
|
||||
z CDN zawczasu, zanim napotka pierwszy `<img>`. Zysk ~150–300 ms na pierwszym
|
||||
obrazie.
|
||||
|
||||
```tsx
|
||||
// layout.tsx — w <head> (albo górze <body>, Next hoistuje link tagi)
|
||||
import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||
|
||||
<head>
|
||||
<MediaPreconnect />
|
||||
</head>
|
||||
```
|
||||
|
||||
Czyta domenę z **R2_PUBLIC_URL** (to samo źródło co buildR2Storage) — zero
|
||||
hardkodu, jedno źródło prawdy. Gdy R2_PUBLIC_URL nie ustawione (lokalny dysk,
|
||||
brak CDN) → nie renderuje nic. Zmiana domeny mediów = zmiana jednej zmiennej
|
||||
env, komponent podąża automatycznie.
|
||||
|
||||
> NIE hardkoduj `<link rel="preconnect" href="https://media.klient.pl">` ręcznie
|
||||
> w layoutcie — to zaszywa domenę klienta w kodzie. Użyj MediaPreconnect, który
|
||||
> bierze ją z env.
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.0.12",
|
||||
"version": "1.2.2",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -38,7 +38,8 @@
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"docs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
@@ -59,6 +60,7 @@
|
||||
"test:int": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/storage-s3": "^3.88.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
|
||||
Generated
+509
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@payloadcms/storage-s3':
|
||||
specifier: ^3.88.0
|
||||
version: 3.88.0(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
lucide-react:
|
||||
specifier: ^0.400.0
|
||||
version: 0.400.0([email protected])
|
||||
@@ -133,6 +136,88 @@ packages:
|
||||
resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-8+UH1uBWwjbRu0ugTBhJ/cu3rL/vzf1tm9/3+xZGf1uybIO3HTF2xLJJaTKkRPvzxz8/n/w+YrwoHGZ2Q/a2Zg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-4dxJX2pueKHtFyxdtT/IH385NpAWjbNjpbbIN6L9YK58dbae6ldyiLSom1EV5nPoOxKtWLXiqch+yo3NjXfleQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@aws-sdk/client-s3': ^3.1119.0
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-yEqb0ispm9uXZ7nk5stErMB7yn43j3XCAaHM15MsdWaCk7RvEmGjYdbyHZcH86JpUg7mk1v1m2xwzLj2o+3uCg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/[email protected]':
|
||||
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1646,6 +1731,13 @@ packages:
|
||||
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.6 <17.0.0'
|
||||
payload: 3.88.0
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-wIcRVedtBZIQMu7dpSE1TOJKJd0HuVSUNPrV2KxIbtqQFm+ly34NCJYY0m1+K0YS6my8BKdcB4jj2exg8454Ig==}
|
||||
peerDependencies:
|
||||
payload: 3.88.0
|
||||
react: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-C915jEHADVN9xW7fSq/bYIZig1zjfQo+t4wrgiqjOCmB81oMgyqLRB3RlL2e6y4PzBUtgss2VS40vn/tVD/61Q==}
|
||||
peerDependencies:
|
||||
@@ -1671,6 +1763,12 @@ packages:
|
||||
react: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-+35JRD4y7ImnIJuPSBhECHGGml0A+xju9BT/zW+lRUycDvvA0cqZNP+t95OVoZrxIB6V75Vg8GKPvAaRAmgOEw==}
|
||||
engines: {node: ^18.20.2 || >=20.9.0}
|
||||
peerDependencies:
|
||||
payload: 3.88.0
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
resolution: {integrity: sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA==}
|
||||
|
||||
@@ -1839,6 +1937,30 @@ packages:
|
||||
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -2563,6 +2685,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
|
||||
@@ -2596,6 +2721,9 @@ packages:
|
||||
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
@@ -2866,6 +2994,10 @@ packages:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==}
|
||||
engines: {node: '>=12.20'}
|
||||
@@ -3363,10 +3495,18 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -3455,6 +3595,9 @@ packages:
|
||||
resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
|
||||
|
||||
@@ -3470,6 +3613,10 @@ packages:
|
||||
resolution: {integrity: sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3577,6 +3724,14 @@ packages:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3672,6 +3827,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -3725,6 +3884,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==}
|
||||
|
||||
@@ -3894,6 +4056,10 @@ packages:
|
||||
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -4098,6 +4264,9 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
@@ -4430,6 +4599,10 @@ packages:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4682,6 +4855,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
@@ -4721,6 +4898,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4966,6 +5147,9 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -5393,6 +5577,10 @@ packages:
|
||||
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -5490,6 +5678,190 @@ snapshots:
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.3.0
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/checksums': 3.1000.29
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/credential-provider-node': 3.972.81
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.75
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.7.2
|
||||
'@smithy/node-http-handler': 4.11.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@aws-sdk/xml-builder': 3.972.40
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/signature-v4': 5.7.3
|
||||
'@smithy/types': 4.17.2
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.7.2
|
||||
'@smithy/node-http-handler': 4.11.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/credential-provider-env': 3.972.70
|
||||
'@aws-sdk/credential-provider-http': 3.972.72
|
||||
'@aws-sdk/credential-provider-login': 3.972.77
|
||||
'@aws-sdk/credential-provider-process': 3.972.70
|
||||
'@aws-sdk/credential-provider-sso': 3.973.14
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.76
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.70
|
||||
'@aws-sdk/credential-provider-http': 3.972.72
|
||||
'@aws-sdk/credential-provider-ini': 3.973.15
|
||||
'@aws-sdk/credential-provider-process': 3.972.70
|
||||
'@aws-sdk/credential-provider-sso': 3.973.14
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.76
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/token-providers': 3.1116.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected](@aws-sdk/[email protected])':
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3': 3.1119.0
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
buffer: 5.6.0
|
||||
events: 3.3.0
|
||||
stream-browserify: 3.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.7.2
|
||||
'@smithy/node-http-handler': 4.11.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/signature-v4': 5.7.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@@ -7020,6 +7392,21 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected](@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@payloadcms/ui': 3.88.0(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
find-node-modules: 2.1.3
|
||||
payload: 3.88.0([email protected])([email protected])
|
||||
range-parser: 1.2.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- monaco-editor
|
||||
- next
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected](@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@payloadcms/translations': 3.88.0
|
||||
@@ -7094,6 +7481,22 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- yjs
|
||||
|
||||
'@payloadcms/[email protected](@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3': 3.1119.0
|
||||
'@aws-sdk/lib-storage': 3.1119.0(@aws-sdk/[email protected])
|
||||
'@aws-sdk/s3-request-presigner': 3.1119.0
|
||||
'@payloadcms/plugin-cloud-storage': 3.88.0(@types/[email protected])([email protected])([email protected](@babel/[email protected])(@playwright/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
payload: 3.88.0([email protected])([email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- monaco-editor
|
||||
- next
|
||||
- react
|
||||
- react-dom
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@payloadcms/[email protected]':
|
||||
dependencies:
|
||||
date-fns: 4.1.0
|
||||
@@ -7220,6 +7623,39 @@ snapshots:
|
||||
|
||||
'@sindresorhus/[email protected]': {}
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/[email protected]':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@swc-node/[email protected](@swc/[email protected])(@swc/[email protected])':
|
||||
@@ -8043,6 +8479,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -8078,6 +8516,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.9
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -8330,6 +8773,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -9098,6 +9543,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -9110,6 +9557,10 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
homedir-polyfill: 1.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -9204,6 +9655,11 @@ snapshots:
|
||||
make-dir: 3.1.0
|
||||
pkg-dir: 4.2.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
findup-sync: 4.0.0
|
||||
merge: 2.1.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -9220,6 +9676,13 @@ snapshots:
|
||||
dependencies:
|
||||
semver-regex: 4.0.5
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
detect-file: 1.0.0
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
resolve-dir: 1.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
@@ -9330,6 +9793,20 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
global-prefix: 1.0.2
|
||||
is-windows: 1.0.2
|
||||
resolve-dir: 1.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
expand-tilde: 2.0.2
|
||||
homedir-polyfill: 1.0.3
|
||||
ini: 1.3.8
|
||||
is-windows: 1.0.2
|
||||
which: 1.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -9425,6 +9902,10 @@ snapshots:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
parse-passwd: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -9467,6 +9948,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
kind-of: 6.0.3
|
||||
@@ -9630,6 +10113,8 @@ snapshots:
|
||||
call-bound: 1.0.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
@@ -9855,6 +10340,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.3.0
|
||||
@@ -10320,6 +10807,8 @@ snapshots:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -10610,6 +11099,12 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
@@ -10653,6 +11148,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
expand-tilde: 2.0.2
|
||||
global-modules: 1.0.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -10979,6 +11479,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -11479,6 +11984,10 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -12,3 +12,4 @@ export type {
|
||||
EnhanceProps,
|
||||
RenderBlocksProps,
|
||||
} from '../modules/blocks/index.js'
|
||||
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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 const storageFields: Field[] = [
|
||||
{
|
||||
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.',
|
||||
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#MaskedField',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -3,7 +3,6 @@ import type { Field, GlobalConfig } from 'payload'
|
||||
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'
|
||||
|
||||
type BuildSiteIntegrationsArgs = {
|
||||
@@ -22,6 +21,10 @@ type BuildSiteIntegrationsArgs = {
|
||||
* impossible to enter.)
|
||||
*
|
||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||
*
|
||||
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||
* consumed by buildR2Storage. See docs/storage.md.
|
||||
*/
|
||||
export function buildSiteIntegrations({
|
||||
additionalFields,
|
||||
@@ -44,7 +47,6 @@ export function buildSiteIntegrations({
|
||||
{ fields: analyticsFields, label: 'Analytics' },
|
||||
{ fields: turnstileFields, label: 'Turnstile' },
|
||||
{ fields: smtpFields, label: 'SMTP' },
|
||||
{ fields: storageFields, label: 'Storage' },
|
||||
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import { validateFaviconField } from '../../../modules/seo/index.js'
|
||||
|
||||
/**
|
||||
* General site identity fields.
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
|
||||
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
|
||||
*/
|
||||
export const generalFields: Field[] = [
|
||||
{
|
||||
@@ -45,7 +47,8 @@ export const generalFields: Field[] = [
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Primary site logo.',
|
||||
description:
|
||||
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
@@ -61,7 +64,13 @@ export const generalFields: Field[] = [
|
||||
name: 'favicon',
|
||||
type: 'upload',
|
||||
admin: {
|
||||
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',
|
||||
description:
|
||||
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
|
||||
},
|
||||
hooks: {
|
||||
// Warns the editor at save time if the favicon is too small (<48×48) or
|
||||
// not square — Google won't display such favicons in search results.
|
||||
beforeValidate: [validateFaviconField],
|
||||
},
|
||||
relationTo: 'media',
|
||||
},
|
||||
|
||||
+29
-12
@@ -75,6 +75,18 @@ export {
|
||||
} from './modules/i18n/index.js'
|
||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
||||
// Media — filename normalization hook for upload collections (Media).
|
||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'
|
||||
export {
|
||||
getNotificationTexts,
|
||||
NOTIFICATION_FALLBACK,
|
||||
resolveFormMessage,
|
||||
} from './modules/notifications/index.js'
|
||||
export type {
|
||||
FormNotificationTexts,
|
||||
NotificationsData,
|
||||
NotificationTexts,
|
||||
} from './modules/notifications/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'
|
||||
@@ -87,6 +99,21 @@ export {
|
||||
} from './modules/payload/index.js'
|
||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||
export {
|
||||
buildFaqJsonLd,
|
||||
buildIconsMetadata,
|
||||
buildLocalBusinessJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildServiceJsonLd,
|
||||
validateFaviconField,
|
||||
} from './modules/seo/index.js'
|
||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||
export {
|
||||
buildBreadcrumbJsonLd,
|
||||
buildSiteNavigationJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
} from './modules/seo/index.js'
|
||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||
@@ -99,18 +126,8 @@ export {
|
||||
injectAutoFillMeta,
|
||||
} from './modules/seo/index.js'
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||
export {
|
||||
NOTIFICATION_FALLBACK,
|
||||
getNotificationTexts,
|
||||
resolveFormMessage,
|
||||
} from './modules/notifications/index.js'
|
||||
export type {
|
||||
FormNotificationTexts,
|
||||
NotificationsData,
|
||||
NotificationTexts,
|
||||
} from './modules/notifications/index.js'
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js'
|
||||
|
||||
export { ipalKit } from './plugin.js'
|
||||
export type { IpalOptions } from './types.js'
|
||||
|
||||
|
||||
|
||||
@@ -115,18 +115,19 @@ export const graphAdapter =
|
||||
return { error: 'Graph is not configured (missing env vars).', sent: false }
|
||||
}
|
||||
|
||||
// The panel's from-address is used as Reply-To, NOT as the message From.
|
||||
// Display name on the From, WITHOUT triggering Send-As.
|
||||
//
|
||||
// Why: app-only Graph sends from GRAPH_SENDER's mailbox. If we also set a
|
||||
// `from` that differs from that mailbox, Exchange demands "Send As"
|
||||
// permission on it and rejects with ErrorSendAsDenied otherwise. So we
|
||||
// never override `from` — Graph stamps the mail as GRAPH_SENDER (the
|
||||
// mailbox we legitimately own) — and route replies to the panel address
|
||||
// via Reply-To. Recipients see the mail from forms@… but replying reaches
|
||||
// the real destination. No Send-As needed.
|
||||
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||
// same-address / custom-name From is allowed and gives each project its
|
||||
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||
//
|
||||
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||
// and the panel's from-name becomes the sender display name.
|
||||
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
|
||||
const replyToAddress = panel.smtpFromAddress || undefined
|
||||
const replyToName = panel.smtpFromName || undefined
|
||||
const senderName = panel.smtpFromName || undefined
|
||||
|
||||
const to = toRecipients(message.to)
|
||||
if (to.length === 0) {
|
||||
@@ -142,14 +143,7 @@ export const graphAdapter =
|
||||
const replyTo = message.replyTo
|
||||
? toRecipients(message.replyTo as SendEmailOptions['to'])
|
||||
: replyToAddress
|
||||
? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress,
|
||||
...(replyToName ? { name: replyToName } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
? [{ emailAddress: { address: replyToAddress } }]
|
||||
: []
|
||||
|
||||
const graphMessage: Record<string, unknown> = {
|
||||
@@ -158,7 +152,12 @@ export const graphAdapter =
|
||||
toRecipients: to,
|
||||
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
|
||||
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
|
||||
// NO `from` — Graph uses GRAPH_SENDER's own mailbox, so no Send-As.
|
||||
// From with the sender's OWN address (no Send-As) plus an optional
|
||||
// display name from the panel. Omit entirely when no name is set —
|
||||
// Graph then uses the mailbox's default name.
|
||||
...(senderName
|
||||
? { from: { emailAddress: { name: senderName, address: env.sender } } }
|
||||
: {}),
|
||||
...(replyTo.length > 0 ? { replyTo } : {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import { cache } from 'react'
|
||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'
|
||||
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'
|
||||
import type { I18nConfig } from '../i18n/index.js'
|
||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js'
|
||||
|
||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'
|
||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js'
|
||||
import type { SitemapEntry, RobotsRules } from '../seo/index.js'
|
||||
import { buildSitemapEntries, buildRobots } 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.
|
||||
@@ -24,15 +16,21 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* 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
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,12 +60,12 @@ type CreateContentHelpersArgs = {
|
||||
* 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',
|
||||
pagesSlug = 'pages',
|
||||
i18n,
|
||||
baseUrl,
|
||||
}: CreateContentHelpersArgs) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
||||
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
||||
@@ -81,7 +79,7 @@ export function createContentHelpers({
|
||||
|
||||
const getSettings = cache(async (locale: string) => {
|
||||
const payload = await getCachedPayload()
|
||||
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
|
||||
return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 })
|
||||
})
|
||||
|
||||
/** What does this URL point at? Routing only — no listing data. */
|
||||
@@ -90,9 +88,9 @@ export function createContentHelpers({
|
||||
locale: string,
|
||||
segments: string[] | undefined,
|
||||
page: number,
|
||||
): Promise<null | ResolvedRoute> => {
|
||||
): Promise<ResolvedRoute | null> => {
|
||||
const payload = await getCachedPayload()
|
||||
return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
|
||||
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -105,7 +103,7 @@ export function createContentHelpers({
|
||||
perPage: number,
|
||||
): Promise<ArchiveEntries> => {
|
||||
const payload = await getCachedPayload()
|
||||
return getArchiveEntries({ collection, locale, page, payload, perPage })
|
||||
return getArchiveEntries({ payload, collection, locale, page, perPage })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -116,20 +114,45 @@ export function createContentHelpers({
|
||||
* ```ts
|
||||
* // app/sitemap.ts
|
||||
* export { sitemap as default } from '@/lib/content'
|
||||
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||
* ```
|
||||
*
|
||||
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||
* calls into Payload → the database. The build container usually has no access
|
||||
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||
* the build dies. Two defenses:
|
||||
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||
* 2. This handler also catches DB errors and returns [] so that even without
|
||||
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||
*/
|
||||
const sitemap = cache(async (): Promise<SitemapEntry[]> => {
|
||||
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,
|
||||
})
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
content,
|
||||
pagesSlug,
|
||||
settingsSlug,
|
||||
})
|
||||
} catch (error) {
|
||||
// DB unreachable (typically a container build with no DB network) — return
|
||||
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||
console.warn(
|
||||
'[ipal] sitemap: could not reach the database, returning empty entries ' +
|
||||
"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " +
|
||||
'generate at runtime and avoid build-time DB access):',
|
||||
error,
|
||||
)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -145,10 +168,10 @@ export function createContentHelpers({
|
||||
return {
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
robots,
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { I18nConfig } from './types.js'
|
||||
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'
|
||||
/**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/
|
||||
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'
|
||||
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user