Compare commits

..
39 Commits
Author SHA1 Message Date
radoslaw.smolinski fce17654f8 1.2.3 2026-09-08 18:13:16 +02:00
radoslaw.smolinski 419207ac12 Added support for single localization 2026-09-08 18:12:58 +02:00
radoslaw.smolinski e30ac71044 1.2.2 2026-09-08 14:25:26 +02:00
radoslaw.smolinski 781e348ded Page title generator on browser tab rebuilded. 2026-09-08 14:25:15 +02:00
radoslaw.smolinski e16a18e488 1.2.1 2026-09-08 13:14:15 +02:00
radoslaw.smolinski 7cd3cbaae5 1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page, exclude 404 from sitemap, robots param docs 2026-09-08 13:14:05 +02:00
radoslaw.smolinski 60f07fddc9 1.2.0 2026-09-08 12:49:49 +02:00
radoslaw.smolinski 068415849f 1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page 2026-09-08 12:49:49 +02:00
radoslaw.smolinski e39e2a361a 1.1.2 2026-08-28 15:51:02 +02:00
radoslaw.smolinski 9d3e749c38 1.1.1 2026-08-28 15:50:42 +02:00
radoslaw.smolinski b7c6cb4d81 1.1.0 2026-08-28 15:50:29 +02:00
radoslaw.smolinski c41b75e364 1.1.0: R2 storage, media preconnect/normalization, favicon + SEO structured data, x-default hreflang 2026-08-28 15:50:25 +02:00
radoslaw.smolinski 41630bb8c5 1.1.1 2026-08-28 00:09:20 +02:00
radoslaw.smolinski b9430e7ab6 SEO: favicon metadata, Organization JSON-LD, favicon validation; R2 media (preconnect, generateFileURL per-collection), filename normalization 2026-08-28 00:08:35 +02:00
radoslaw.smolinski f1f808d079 1.1.0 2026-08-27 20:21:01 +02:00
radoslaw.smolinski a8a632e1b0 R2 storage, media preconnect, filename normalization, graph email 2026-08-27 20:20:56 +02:00
radoslaw.smolinski a695ec7319 1.0.22 2026-08-27 18:57:18 +02:00
radoslaw.smolinski c6730335ef R2 storage (generateFileURL per-collection) + filename normalization 2026-08-27 18:57:14 +02:00
radoslaw.smolinski 247b849759 1.0.21 2026-08-27 15:41:48 +02:00
radoslaw.smolinski 0dee178ded R2 storage from env + filename normalization 2026-08-27 15:41:47 +02:00
radoslaw.smolinski 9359f26788 1.0.20 2026-08-27 12:45:47 +02:00
radoslaw.smolinski 5630765215 R2 storage from env + filename normalization 2026-08-27 12:45:47 +02:00
radoslaw.smolinski ed4a78b109 1.0.19 2026-08-27 12:31:09 +02:00
radoslaw.smolinski 9acb22e2f9 R2 storage from env + filename normalization 2026-08-27 12:31:09 +02:00
radoslaw.smolinski 6a8361d710 1.0.18 2026-08-26 13:53:44 +02:00
radoslaw.smolinski 502ffee31f i18n: NEXT_LOCALE cookie; graph: sender display name from panel 2026-08-26 13:53:41 +02:00
radoslaw.smolinski 5a322230ae 1.0.17 2026-08-24 19:06:11 +02:00
radoslaw.smolinski f919c288b2 i18n: rename locale cookie to NEXT_LOCALE (Next.js convention) 2026-08-24 19:06:06 +02:00
radoslaw.smolinski 21b948a4f8 1.0.16 2026-08-24 00:29:26 +02:00
radoslaw.smolinski d04271f942 ship docs/ in npm package for agent access 2026-08-24 00:29:25 +02:00
radoslaw.smolinski 4f08e9ea4d 1.0.15 2026-08-22 23:05:06 +02:00
radoslaw.smolinski 67347f1e34 graph: sender display name from panel 2026-08-22 23:05:05 +02:00
radoslaw.smolinski 75f2b6400d 1.0.14 2026-08-22 22:50:34 +02:00
radoslaw.smolinski 26aec1c5af graph: sender display name from panel (address stays sender, no Send-As) 2026-08-22 22:50:31 +02:00
radoslaw.smolinski 0fe7ca0575 1.0.13 2026-08-22 22:35:59 +02:00
radoslaw.smolinski d745a764fe 1.0.12 2026-08-22 22:35:33 +02:00
radoslaw.smolinski 6c273118a2 declare @payloadcms/ui, next, react-dom as peer deps 2026-08-22 22:35:29 +02:00
radoslaw.smolinski a010a7368a 1.0.11 2026-08-22 22:26:50 +02:00
radoslaw.smolinski 08bd00f01f graph: use replyTo instead of from to avoid ErrorSendAsDenied 2026-08-22 22:26:47 +02:00
135 changed files with 4740 additions and 1090 deletions
+2
View File
@@ -12,3 +12,5 @@ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentCont
export type { CookieBannerClassNames } from '../modules/consent/client.js'; export type { CookieBannerClassNames } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js'; export { Turnstile } from '../modules/turnstile/client.js';
export type { TurnstileProps } from '../modules/turnstile/client.js'; export type { TurnstileProps } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
export type { FormNotificationTexts } from '../modules/notifications/types.js';
+1
View File
@@ -10,5 +10,6 @@ export { Analytics } from '../modules/analytics/client.js';
* client-only code. * client-only code.
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js'; */ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js'; export { Turnstile } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
//# sourceMappingURL=client.js.map //# sourceMappingURL=client.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"} {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
+1
View File
@@ -7,3 +7,4 @@
*/ */
export { RenderBlocks } from '../modules/blocks/index.js'; export { RenderBlocks } from '../modules/blocks/index.js';
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js'; export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
+1
View File
@@ -5,5 +5,6 @@
* lives here rather than in the main package entry to keep React out of the * lives here rather than in the main package entry to keep React out of the
* server-config bundle. * server-config bundle.
*/ export { RenderBlocks } from '../modules/blocks/index.js'; */ export { RenderBlocks } from '../modules/blocks/index.js';
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
//# sourceMappingURL=rsc.js.map //# sourceMappingURL=rsc.js.map
+1 -1
View File
@@ -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"}
+17 -8
View File
@@ -1,17 +1,26 @@
'use client'; 'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useField } from '@payloadcms/ui';
import { useState } from 'react'; import { useState } from 'react';
export const MaskedField = ({ field, path })=>{ export const MaskedField = (props)=>{
const { setValue, value } = useField({ const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {};
path const [internalValue, setInternalValue] = useState(propValue ?? '');
});
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path; const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
const handleChange = (e)=>{
const newVal = e.target.value;
setInternalValue(newVal);
if (typeof propSetValue === 'function') {
propSetValue(newVal);
}
if (typeof propOnChange === 'function') {
propOnChange(e);
}
};
const currentValue = propValue !== undefined ? propValue : internalValue;
return /*#__PURE__*/ _jsxs("div", { return /*#__PURE__*/ _jsxs("div", {
className: "field-type text", className: "field-type text",
children: [ children: [
/*#__PURE__*/ _jsx("label", { label && /*#__PURE__*/ _jsx("label", {
className: "field-label", className: "field-label",
children: label children: label
}), }),
@@ -23,12 +32,12 @@ export const MaskedField = ({ field, path })=>{
children: [ children: [
/*#__PURE__*/ _jsx("input", { /*#__PURE__*/ _jsx("input", {
autoComplete: "off", autoComplete: "off",
onChange: (e)=>setValue(e.target.value), onChange: handleChange,
style: { style: {
flex: 1 flex: 1
}, },
type: revealed ? 'text' : 'password', type: revealed ? 'text' : 'password',
value: value ?? '' value: currentValue ?? ''
}), }),
/*#__PURE__*/ _jsx("button", { /*#__PURE__*/ _jsx("button", {
onClick: ()=>setRevealed((r)=>!r), onClick: ()=>setRevealed((r)=>!r),
@@ -1 +1 @@
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { useField } from '@payloadcms/ui'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = ({ field, path }) => {\n const { setValue, value } = useField<string>({ path })\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n return (\n <div className=\"field-type text\">\n <label className=\"field-label\">{label}</label>\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={(e) => setValue(e.target.value)}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={value ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\nexport default MaskedField\n"],"names":["useField","useState","MaskedField","field","path","setValue","value","revealed","setRevealed","label","name","div","className","style","display","gap","input","autoComplete","onChange","e","target","flex","type","button","onClick","r"],"mappings":"AAAA;;AAGA,SAASA,QAAQ,QAAQ,iBAAgB;AACzC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE;IACnE,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGN,SAAiB;QAAEI;IAAK;IACpD,MAAM,CAACG,UAAUC,YAAY,GAAGP,SAAS;IACzC,MAAMQ,QAAQ,OAAON,OAAOM,UAAU,WAAWN,MAAMM,KAAK,GAAIN,OAAOO,QAAQN;IAE/E,qBACE,MAACO;QAAIC,WAAU;;0BACb,KAACH;gBAAMG,WAAU;0BAAeH;;0BAChC,MAACE;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbC,UAAU,CAACC,IAAMd,SAASc,EAAEC,MAAM,CAACd,KAAK;wBACxCO,OAAO;4BAAEQ,MAAM;wBAAE;wBACjBC,MAAMf,WAAW,SAAS;wBAC1BD,OAAOA,SAAS;;kCAElB,KAACiB;wBAAOC,SAAS,IAAMhB,YAAY,CAACiB,IAAM,CAACA;wBAAIH,MAAK;kCACjDf,WAAW,SAAS;;;;;;AAK/B,EAAC;AACD,eAAeL,YAAW"} {"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = (props: any) => {\n const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}\n const [internalValue, setInternalValue] = useState(propValue ?? '')\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const newVal = e.target.value\n setInternalValue(newVal)\n if (typeof propSetValue === 'function') {\n propSetValue(newVal)\n }\n if (typeof propOnChange === 'function') {\n propOnChange(e)\n }\n }\n\n const currentValue = propValue !== undefined ? propValue : internalValue\n\n return (\n <div className=\"field-type text\">\n {label && <label className=\"field-label\">{label}</label>}\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={handleChange}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={currentValue ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\n\nexport default MaskedField\n"],"names":["useState","MaskedField","props","field","path","value","propValue","setValue","propSetValue","onChange","propOnChange","internalValue","setInternalValue","revealed","setRevealed","label","name","handleChange","e","newVal","target","currentValue","undefined","div","className","style","display","gap","input","autoComplete","flex","type","button","onClick","r"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAACC;IACpD,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAEC,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAEC,UAAUC,YAAY,EAAE,GAAGR,SAAS,CAAC;IACpG,MAAM,CAACS,eAAeC,iBAAiB,GAAGZ,SAASM,aAAa;IAChE,MAAM,CAACO,UAAUC,YAAY,GAAGd,SAAS;IACzC,MAAMe,QAAQ,OAAOZ,OAAOY,UAAU,WAAWZ,MAAMY,KAAK,GAAIZ,OAAOa,QAAQZ;IAE/E,MAAMa,eAAe,CAACC;QACpB,MAAMC,SAASD,EAAEE,MAAM,CAACf,KAAK;QAC7BO,iBAAiBO;QACjB,IAAI,OAAOX,iBAAiB,YAAY;YACtCA,aAAaW;QACf;QACA,IAAI,OAAOT,iBAAiB,YAAY;YACtCA,aAAaQ;QACf;IACF;IAEA,MAAMG,eAAef,cAAcgB,YAAYhB,YAAYK;IAE3D,qBACE,MAACY;QAAIC,WAAU;;YACZT,uBAAS,KAACA;gBAAMS,WAAU;0BAAeT;;0BAC1C,MAACQ;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbpB,UAAUQ;wBACVQ,OAAO;4BAAEK,MAAM;wBAAE;wBACjBC,MAAMlB,WAAW,SAAS;wBAC1BR,OAAOgB,gBAAgB;;kCAEzB,KAACW;wBAAOC,SAAS,IAAMnB,YAAY,CAACoB,IAAM,CAACA;wBAAIH,MAAK;kCACjDlB,WAAW,SAAS;;;;;;AAK/B,EAAC;AAED,eAAeZ,YAAW"}
+4
View File
@@ -14,6 +14,10 @@ type BuildSiteIntegrationsArgs = {
* impossible to enter.) * impossible to enter.)
* *
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId). * 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 declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig;
export {}; export {};
+4 -5
View File
@@ -1,7 +1,6 @@
import { isAdmin } from '../../modules/access/index.js'; import { isAdmin } from '../../modules/access/index.js';
import { analyticsFields } from './fields/analytics.js'; import { analyticsFields } from './fields/analytics.js';
import { smtpFields } from './fields/smtp.js'; import { smtpFields } from './fields/smtp.js';
import { storageFields } from './fields/storage.js';
import { turnstileFields } from './fields/turnstile.js'; import { turnstileFields } from './fields/turnstile.js';
/** /**
* Builds the SiteIntegrations global. * Builds the SiteIntegrations global.
@@ -14,6 +13,10 @@ import { turnstileFields } from './fields/turnstile.js';
* impossible to enter.) * impossible to enter.)
* *
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId). * 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 } = {}) { */ export function buildSiteIntegrations({ additionalFields } = {}) {
return { return {
slug: 'site-integrations', slug: 'site-integrations',
@@ -42,10 +45,6 @@ import { turnstileFields } from './fields/turnstile.js';
fields: smtpFields, fields: smtpFields,
label: 'SMTP' label: 'SMTP'
}, },
{
fields: storageFields,
label: 'Storage'
},
...additionalFields?.length ? [ ...additionalFields?.length ? [
{ {
fields: additionalFields, fields: additionalFields,
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import type { Field } from 'payload'; import type { Field } from 'payload';
/** /**
* General site identity fields. * General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo). * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
*/ */
export declare const generalFields: Field[]; export declare const generalFields: Field[];
+11 -3
View File
@@ -1,6 +1,7 @@
import { validateFaviconField } from '../../../modules/seo/index.js';
/** /**
* General site identity fields. * 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 = [ */ export const generalFields = [
{ {
name: 'siteName', name: 'siteName',
@@ -63,7 +64,7 @@
name: 'logo', name: 'logo',
type: 'upload', type: 'upload',
admin: { admin: {
description: 'Primary site logo.' description: 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).'
}, },
relationTo: 'media' relationTo: 'media'
}, },
@@ -79,7 +80,14 @@
name: 'favicon', name: 'favicon',
type: 'upload', type: 'upload',
admin: { 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' relationTo: 'media'
} }
+1 -1
View File
@@ -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"}
+6
View File
@@ -20,16 +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 { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js';
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'; export type { LocaleMiddlewareResult } from './modules/i18n/index.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
export { 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 type { PagesOption, SystemPageRole } from './modules/pages/index.js';
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'; export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
export type { GlobalQueryOptions } from './modules/payload/index.js'; export type { GlobalQueryOptions } from './modules/payload/index.js';
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js'; export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
export { buildSecurityHeaders } from './modules/security/index.js'; export { buildSecurityHeaders } from './modules/security/index.js';
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'; export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'; export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'; export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'; export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js'; export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
export { buildSlugField, toSlug } from './modules/slug/index.js'; export { buildSlugField, toSlug } from './modules/slug/index.js';
export { buildR2Storage } from './modules/storage/index.js';
export { ipalKit } from './plugin.js'; export { ipalKit } from './plugin.js';
export type { IpalOptions } from './types.js'; export type { IpalOptions } from './types.js';
+9
View File
@@ -12,12 +12,21 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
export { createContentHelpers } from './modules/frontend/index.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 { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
// Media — filename normalization hook for upload collections (Media).
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js';
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'; export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js'; export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
export { buildSecurityHeaders } from './modules/security/index.js'; export { buildSecurityHeaders } from './modules/security/index.js';
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'; export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js'; export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
export { buildSlugField, toSlug } from './modules/slug/index.js'; export { buildSlugField, toSlug } from './modules/slug/index.js';
// Storage — Cloudflare R2 media offload, configured from .env.
export { buildR2Storage } from './modules/storage/index.js';
export { ipalKit } from './plugin.js'; export { ipalKit } from './plugin.js';
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+28 -13
View File
@@ -89,10 +89,19 @@ import { getSiteIntegrations } from '../payload/index.js';
sent: false sent: false
}; };
} }
// From-display comes from the panel; falls back to the caller's from. // Display name on the From, WITHOUT triggering Send-As.
//
// 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 panel = await getSiteIntegrations(payload);
const fromAddress = panel.smtpFromAddress || undefined; const replyToAddress = panel.smtpFromAddress || undefined;
const fromName = panel.smtpFromName || undefined; const senderName = panel.smtpFromName || undefined;
const to = toRecipients(message.to); const to = toRecipients(message.to);
if (to.length === 0) { if (to.length === 0) {
payload.logger.error('[ipal] Email not sent: no valid recipient.'); payload.logger.error('[ipal] Email not sent: no valid recipient.');
@@ -104,6 +113,14 @@ import { getSiteIntegrations } from '../payload/index.js';
// Graph accepts either HTML or Text; Payload gives us html and/or text. // Graph accepts either HTML or Text; Payload gives us html and/or text.
const isHtml = typeof message.html === 'string' && message.html.length > 0; const isHtml = typeof message.html === 'string' && message.html.length > 0;
const content = isHtml ? String(message.html) : String(message.text ?? ''); const content = isHtml ? String(message.html) : String(message.text ?? '');
// Reply-To: prefer whatever the caller set; otherwise the panel address.
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
{
emailAddress: {
address: replyToAddress
}
}
] : [];
const graphMessage = { const graphMessage = {
body: { body: {
content, content,
@@ -117,21 +134,19 @@ import { getSiteIntegrations } from '../payload/index.js';
...message.bcc ? { ...message.bcc ? {
bccRecipients: toRecipients(message.bcc) bccRecipients: toRecipients(message.bcc)
} : {}, } : {},
// from is only honoured if the app has Send-As for that address; when // From with the sender's OWN address (no Send-As) plus an optional
// it's the shared mailbox itself, omit it and Graph uses the sender. // display name from the panel. Omit entirely when no name is set —
...fromAddress ? { // Graph then uses the mailbox's default name.
...senderName ? {
from: { from: {
emailAddress: { emailAddress: {
address: fromAddress, name: senderName,
...fromName ? { address: env.sender
name: fromName
} : {}
} }
} }
} : {}, } : {},
// replyTo lets the recipient reply to the real submitter if the caller set it. ...replyTo.length > 0 ? {
...message.replyTo ? { replyTo
replyTo: toRecipients(message.replyTo)
} : {} } : {}
}; };
try { try {
File diff suppressed because one or more lines are too long
+16 -16
View File
@@ -1,14 +1,8 @@
import type { BasePayload, SanitizedConfig } from 'payload'; 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 { I18nConfig } from '../i18n/index.js';
import type { RobotsRules, SitemapEntry } from '../seo/index.js'; import type { SitemapEntry, RobotsRules } from '../seo/index.js';
type CreateContentHelpersArgs = { type CreateContentHelpersArgs = {
/**
* Absolute site origin for sitemap/robots URLs. Falls back to
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
* reject, so set one in production).
*/
baseUrl?: string;
/** /**
* The client's payload config promise (the default export of payload.config). * The client's payload config promise (the default export of payload.config).
* Passed in because the plugin never imports the client's config directly. * Passed in because the plugin never imports the client's config directly.
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
config: Promise<SanitizedConfig> | SanitizedConfig; config: Promise<SanitizedConfig> | SanitizedConfig;
/** Archive-backed collections, same value as the plugin option. */ /** Archive-backed collections, same value as the plugin option. */
content?: ContentOption; content?: ContentOption;
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string;
/** /**
* i18n config. Required only if you want the ready-made `sitemap` / `robots` * i18n config. Required only if you want the ready-made `sitemap` / `robots`
* handlers — they need the locale list to emit hreflang. * handlers — they need the locale list to emit hreflang.
*/ */
i18n?: I18nConfig; i18n?: I18nConfig;
/** Pages collection slug. Defaults to 'pages'. */ /**
pagesSlug?: string; * Absolute site origin for sitemap/robots URLs. Falls back to
/** SiteSettings global slug. Defaults to 'site-settings'. */ * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
settingsSlug?: string; * reject, so set one in production).
*/
baseUrl?: string;
}; };
/** /**
* Bundles the per-request data helpers a frontend needs — the same cached * Bundles the per-request data helpers a frontend needs — the same cached
@@ -52,13 +52,13 @@ type CreateContentHelpersArgs = {
* a URL is, fetching gets the listing. Metadata generation needs the first and * a URL is, fetching gets the listing. Metadata generation needs the first and
* not the second, and a page component composes them in two obvious lines. * not the second, and a page component composes them in two obvious lines.
*/ */
export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): { export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): {
getCachedPayload: () => Promise<BasePayload>; getCachedPayload: () => Promise<BasePayload>;
getConfiguredLocales: () => Promise<string[]>; getConfiguredLocales: () => Promise<string[]>;
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
getSettings: (locale: string) => Promise<import("payload").JsonObject>; getSettings: (locale: string) => Promise<import("payload").JsonObject>;
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>; resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
robots: () => RobotsRules; getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
sitemap: () => Promise<SitemapEntry[]>; sitemap: () => Promise<SitemapEntry[]>;
robots: () => RobotsRules;
}; };
export {}; export {};
+37 -17
View File
@@ -1,7 +1,7 @@
import { getPayload } from 'payload';
import { cache } from 'react'; import { cache } from 'react';
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'; import { getPayload } from 'payload';
import { buildRobots, buildSitemapEntries } from '../seo/index.js'; 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 * Bundles the per-request data helpers a frontend needs — the same cached
* wrappers every project was writing by hand (getPayload, settings, locale * wrappers every project was writing by hand (getPayload, settings, locale
@@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what * `resolveRoute` and `getEntries` are separate on purpose: routing decides what
* a URL is, fetching gets the listing. Metadata generation needs the first and * a URL is, fetching gets the listing. Metadata generation needs the first and
* not the second, and a page component composes them in two obvious lines. * not the second, and a page component composes them in two obvious lines.
*/ export function createContentHelpers({ 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 origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
const getCachedPayload = cache(async ()=>getPayload({ const getCachedPayload = cache(async ()=>getPayload({
config: await config config: await config
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return payload.findGlobal({ return payload.findGlobal({
slug: settingsSlug, 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)=>{ /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return resolveRouteRaw({ return resolveRouteRaw({
content,
locale,
page,
pagesSlug,
payload, payload,
locale,
segments, segments,
page,
content,
pagesSlug,
settingsSlug settingsSlug
}); });
}); });
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{ /** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
const payload = await getCachedPayload(); const payload = await getCachedPayload();
return getArchiveEntries({ return getArchiveEntries({
payload,
collection, collection,
locale, locale,
page, page,
payload,
perPage perPage
}); });
}); });
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
* ```ts * ```ts
* // app/sitemap.ts * // app/sitemap.ts
* export { sitemap as default } from '@/lib/content' * export { sitemap as default } from '@/lib/content'
* export const dynamic = 'force-dynamic' // generate at runtime, not build
* ``` * ```
*
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
* calls into Payload → the database. The build container usually has no access
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
* the build dies. Two defenses:
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
* prerender, generates at runtime when the DB is reachable (recommended).
* 2. This handler also catches DB errors and returns [] so that even without
* (1) the build won't crash — it just ships an empty sitemap until the
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
*/ const sitemap = cache(async ()=>{ */ const sitemap = cache(async ()=>{
if (!i18n) { if (!i18n) {
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.'); throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
} }
return buildSitemapEntries({ try {
baseUrl: origin, return await buildSitemapEntries({
payload: await getCachedPayload(),
config: i18n, config: i18n,
baseUrl: origin,
content, content,
pagesSlug, pagesSlug,
payload: await getCachedPayload(),
settingsSlug settingsSlug
}); });
} catch (error) {
// DB unreachable (typically a container build with no DB network) — return
// an empty sitemap instead of failing the build. Runtime regeneration will
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
return [];
}
}); });
/** /**
* Ready-made handler for Next's `app/robots.ts`. Re-export directly: * Ready-made handler for Next's `app/robots.ts`. Re-export directly:
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
return { return {
getCachedPayload, getCachedPayload,
getConfiguredLocales, getConfiguredLocales,
getEntries,
getSettings, getSettings,
resolveRoute, resolveRoute,
robots, getEntries,
sitemap sitemap,
robots
}; };
} }
File diff suppressed because one or more lines are too long
+22 -22
View File
@@ -1,21 +1,21 @@
import type { I18nConfig } from './types.js'; import type { I18nConfig } from '../i18n/index.js';
/** /**
* Minimal request shape the middleware reads. Kept structural so the plugin * Minimal request shape the middleware reads. Kept structural so the plugin
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it. * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
*/ */
type MiddlewareRequest = { type MiddlewareRequest = {
nextUrl: {
pathname: string;
search: string;
clone: () => URL;
};
cookies: { cookies: {
get: (name: string) => { get: (name: string) => {
value: string; value: string;
} | undefined; } | undefined;
}; };
headers: { headers: {
get: (name: string) => null | string; get: (name: string) => string | null;
};
nextUrl: {
clone: () => URL;
pathname: string;
search: string;
}; };
url: string; url: string;
}; };
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
* `next` means let the request pass through untouched. * `next` means let the request pass through untouched.
*/ */
export type LocaleMiddlewareResult = { export type LocaleMiddlewareResult = {
cookie?: {
name: string;
value: string;
};
location: string;
type: 'redirect';
} | {
cookie?: {
name: string;
value: string;
};
type: 'next'; type: 'next';
cookie?: {
name: string;
value: string;
};
} | {
type: 'redirect';
location: string;
cookie?: {
name: string;
value: string;
};
}; };
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
config: I18nConfig; config: I18nConfig;
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string;
/** /**
* Consent category that gates *persisting* the locale cookie. The locale is * Consent category that gates *persisting* the locale cookie. The locale is
* always detected (routing works regardless), but the choice is only written * always detected (routing works regardless), but the choice is only written
@@ -47,11 +49,9 @@ type CreateLocaleMiddlewareArgs = {
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly * 'functional'. Pass 'necessary' to always persist (treat locale as strictly
* necessary), which restores the pre-consent behaviour. * necessary), which restores the pre-consent behaviour.
*/ */
consentCategory?: 'functional' | 'necessary'; consentCategory?: 'necessary' | 'functional';
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */ /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
consentCookieName?: string; consentCookieName?: string;
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string;
}; };
/** /**
* Builds locale-routing logic for Next.js middleware. * Builds locale-routing logic for Next.js middleware.
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
* return res * return res
* } * }
*/ */
export declare function createLocaleMiddleware({ config, consentCategory, consentCookieName, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult; export declare function createLocaleMiddleware({ config, cookieName, consentCategory, consentCookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
/** /**
* Default Next.js middleware matcher: run on everything except API routes, the * Default Next.js middleware matcher: run on everything except API routes, the
* admin panel, Next internals, and files with an extension (static assets). * admin panel, Next internals, and files with an extension (static assets).
+15 -7
View File
@@ -1,5 +1,5 @@
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js';
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'; import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
/** /**
* First path segment of a URL pathname, or '' for root. * First path segment of a URL pathname, or '' for root.
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''. * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
@@ -32,18 +32,26 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) { */ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE }) {
// Whether the locale cookie may be written: 'necessary' is always granted; // Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented. // 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request) { function mayPersistLocale(request) {
if (consentCategory === 'necessary') { if (consentCategory === 'necessary') return true;
return true;
}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value); const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
return consent?.[consentCategory] === true; return consent?.[consentCategory] === true;
} }
return function localeMiddleware(request) { return function localeMiddleware(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Single-locale sites have no /pl, /en prefix and no negotiation — one
// language, no redirect. The middleware becomes a pass-through: paths stay
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
// single-locale usually don't even mount this middleware, but guarding here
// makes it safe if they do.)
if (config.locales.length === 1) {
return {
type: 'next'
};
}
// Already locale-prefixed (e.g. the visitor switched language by // Already locale-prefixed (e.g. the visitor switched language by
// navigating to /en). Routing is fine — but if the URL's locale differs // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we // from the stored cookie, the visitor is *choosing* a language, and we
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
} }
// Resolve the locale to use // Resolve the locale to use
const locale = negotiateLocale({ const locale = negotiateLocale({
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
acceptLanguage: request.headers.get('accept-language'), acceptLanguage: request.headers.get('accept-language'),
config, config
cookieLocale: request.cookies.get(cookieName)?.value ?? null
}); });
// Redirect to the locale-prefixed path, preserving the rest // Redirect to the locale-prefixed path, preserving the rest
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
File diff suppressed because one or more lines are too long
+10 -3
View File
@@ -28,6 +28,12 @@ import { isValidLocale } from './helpers.js';
if (!slug) { if (!slug) {
return undefined; return undefined;
} }
// Single-locale sites have no /pl, /en prefix — the language segment is
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
// one configured locale means one language, so no prefix is needed. The
// project's folder structure matches (app/[[...slug]] without [locale]).
const singleLocale = config.locales.length === 1;
const localeSegment = singleLocale ? '' : `/${locale}`;
if (prefix) { if (prefix) {
const segment = prefix[locale]; const segment = prefix[locale];
// No archive slug in this locale means the entry is unreachable there — // No archive slug in this locale means the entry is unreachable there —
@@ -36,12 +42,13 @@ import { isValidLocale } from './helpers.js';
if (!segment) { if (!segment) {
return undefined; return undefined;
} }
return `/${locale}/${segment}/${slug}`; return `${localeSegment}/${segment}/${slug}`;
} }
if (slug === homeSlug) { if (slug === homeSlug) {
return `/${locale}`; // Home collapses to the root: '/' for single-locale, '/pl' otherwise.
return localeSegment || '/';
} }
return `/${locale}/${slug}`; return `${localeSegment}/${slug}`;
} }
/** /**
* Resolves the equivalent path for the same document in a different locale — * Resolves the equivalent path for the same document in a different locale —
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1,6 +1,13 @@
import type { I18nConfig } from './types.js'; import type { I18nConfig } from './types.js';
/** Cookie name the template uses to persist a visitor's locale choice. */ /** 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 = { type NegotiateLocaleArgs = {
/** Raw Accept-Language header value */ /** Raw Accept-Language header value */
acceptLanguage?: null | string; acceptLanguage?: null | string;
+7 -1
View File
@@ -1,5 +1,11 @@
import { getLocaleCodes, isValidLocale } from './helpers.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 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: * Resolves which locale to serve, in priority order:
* 1. Cookie (explicit prior choice) * 1. Cookie (explicit prior choice)
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
+3
View File
@@ -0,0 +1,3 @@
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"}
+33
View File
@@ -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 {};
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"}
+13 -1
View File
@@ -19,6 +19,11 @@ export type PageMetadata = {
locale?: string; locale?: string;
title: string; title: string;
}; };
/** robots directives — set to noindex/follow for legal/thin/search pages. */
robots?: {
follow: boolean;
index: boolean;
};
title: string; title: string;
}; };
type BuildMetadataArgs = { type BuildMetadataArgs = {
@@ -35,6 +40,13 @@ type BuildMetadataArgs = {
meta?: null | SeoMeta; meta?: null | SeoMeta;
/** Page title or site name first. Defaults to 'page-first'. */ /** Page title or site name first. Defaults to 'page-first'. */
order?: TitleOrder; order?: TitleOrder;
/**
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
* page-title source when meta.title is empty — the browser tab and search
* result should show the page name, not go blank, when an editor didn't fill
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
*/
pageTitle?: null | string;
/** /**
* Localized segment the document lives under (an archive page's slugs). * Localized segment the document lives under (an archive page's slugs).
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
@@ -65,5 +77,5 @@ type BuildMetadataArgs = {
* pieces (meta group, site name, image URL, localized slugs) and passes them * pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch. * in — the plugin composes, it doesn't fetch.
*/ */
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata; export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
export {}; export {};
+15 -4
View File
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
* Designed for use inside Next.js `generateMetadata`. The caller resolves the * Designed for use inside Next.js `generateMetadata`. The caller resolves the
* pieces (meta group, site name, image URL, localized slugs) and passes them * pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch. * in — the plugin composes, it doesn't fetch.
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) { */ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
// titleOverride wins outright: an editor who filled it in wants that exact // Title source priority: titleOverride (exact, wins outright) > meta.title
// string in the tab, not a composition. // (SEO title an editor set) > pageTitle (the document's own name). This means
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
// composed with the site name, instead of just the site name or a blank.
const override = meta?.titleOverride?.trim(); const override = meta?.titleOverride?.trim();
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
const title = override || composeTitle({ const title = override || composeTitle({
order, order,
pageTitle: meta?.title, pageTitle: resolvedPageTitle,
separator, separator,
siteName siteName
}); });
@@ -69,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
images images
}, },
locale locale
},
// noindex → tell search engines to exclude the page but still follow links
// (authority flows through). For legal/thin/search-result pages.
...meta?.noindex ? {
robots: {
follow: true,
index: false
} }
} : {}
}; };
} }
File diff suppressed because one or more lines are too long
+41
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import type { BasePayload } from 'payload'; import type { BasePayload } from 'payload';
import type { ContentOption } from '../content/index.js';
import type { I18nConfig } from '../i18n/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`. * 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. * is the common, weaker kind.
*/ */
export type SitemapEntry = { export type SitemapEntry = {
url: string;
lastModified?: string | Date;
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
alternates?: { alternates?: {
languages: Record<string, string>; languages: Record<string, string>;
}; };
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
lastModified?: Date | string;
priority?: number;
url: string;
}; };
type BuildSitemapArgs = { type BuildSitemapArgs = {
payload: BasePayload;
config: I18nConfig;
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
baseUrl: string; baseUrl: string;
changeFrequency?: SitemapEntry['changeFrequency']; /** Pages collection slug. Defaults to 'pages'. */
config: I18nConfig; pagesSlug?: string;
/** Archive-backed collections, same value as the plugin option. */ /** Archive-backed collections, same value as the plugin option. */
content?: ContentOption; content?: ContentOption;
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
/** /**
* Slug of the page that is the site root (collapses to /{locale}). * Slug of the page that is the site root (collapses to /{locale}).
* Read from System Pages when omitted. * Read from System Pages when omitted.
*/ */
homeSlug?: string; homeSlug?: string;
/** Pages collection slug. Defaults to 'pages'. */ changeFrequency?: SitemapEntry['changeFrequency'];
pagesSlug?: string;
payload: BasePayload;
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string;
}; };
/** /**
* Collects every public URL — pages and archive entries — as sitemap entries * Collects every public URL — pages and archive entries — as sitemap entries
@@ -57,5 +57,5 @@ type BuildSitemapArgs = {
* } * }
* ``` * ```
*/ */
export declare function buildSitemapEntries({ 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 {}; export {};
+44 -42
View File
@@ -1,13 +1,31 @@
import { archiveFieldName } from '../content/index.js'; import { getLocalizedSlugs } from '../i18n/index.js';
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'; import { buildLocalizedPath } from '../i18n/index.js';
import { buildHreflangAlternates } from './hreflang.js'; import { buildHreflangAlternates } from './hreflang.js';
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) { import { archiveFieldName } from '../content/index.js';
if (doc._status && doc._status !== 'published') { /**
return false; * 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;
}
} }
if (doc.meta?.noindex) {
return false; 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; return true;
} }
/** /**
@@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js';
* every locale (including itself, per Google's guidance). * every locale (including itself, per Google's guidance).
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) { */ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
config, slugField: doc.slug,
slugField: doc.slug config
}) : {}; }) : {};
const path = buildLocalizedPath({ const path = buildLocalizedPath({
slugs,
locale,
config, config,
homeSlug, homeSlug,
locale, prefix
prefix,
slugs
}); });
if (!path) { if (!path) return null;
return null;
}
const origin = baseUrl.replace(/\/$/, ''); const origin = baseUrl.replace(/\/$/, '');
const languages = buildHreflangAlternates({ const languages = buildHreflangAlternates({
baseUrl, slugs,
config, config,
baseUrl,
homeSlug, homeSlug,
prefix, prefix
slugs
}); });
return { return {
url: `${origin}${path}`, 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 locales = config.locales.map((l)=>l.code);
const defaultLocale = config.defaultLocale; const defaultLocale = config.defaultLocale;
// Resolve homeSlug and archive prefixes from System Pages (read once, in all // Resolve homeSlug and archive prefixes from System Pages (read once, in all
// locales so archive prefixes are available per language). // locales so archive prefixes are available per language).
const settings = await payload.findGlobal({ const settings = await payload.findGlobal({
slug: settingsSlug, slug: settingsSlug,
depth: 1, locale: 'all',
locale: 'all' depth: 1
}); });
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'; const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
// Which collections to walk: pages (no prefix) + each content collection with // Which collections to walk: pages (no prefix) + each content collection with
@@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js';
// alternates without re-querying per locale. // alternates without re-querying per locale.
const result = await payload.find({ const result = await payload.find({
collection: collection.slug, collection: collection.slug,
locale: 'all',
depth: 0, depth: 0,
limit: 0, limit: 0,
locale: 'all',
pagination: false pagination: false
}); });
for (const raw of result.docs){ for (const raw of result.docs){
if (!isIndexable(raw)) { if (!isIndexable(raw)) continue;
continue;
}
// Emit the entry under the default locale's URL; alternates cover the rest. // Emit the entry under the default locale's URL; alternates cover the rest.
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency); const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
if (entry) { if (entry) entries.push(entry);
entries.push(entry);
}
} }
} }
return entries; return entries;
} }
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) { /** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
if (!rel || typeof rel !== 'object') { if (!rel || typeof rel !== 'object') return undefined;
return undefined;
}
const slug = rel.slug; const slug = rel.slug;
if (typeof slug === 'string') { if (typeof slug === 'string') return slug;
return slug;
}
if (slug && typeof slug === 'object') { if (slug && typeof slug === 'object') {
const v = slug[locale]; const v = slug[locale];
return typeof v === 'string' ? v : undefined; return typeof v === 'string' ? v : undefined;
@@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js';
return undefined; return undefined;
} }
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) { /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
if (!rel || typeof rel !== 'object') { if (!rel || typeof rel !== 'object') return undefined;
return undefined;
}
const slug = rel.slug; const slug = rel.slug;
if (!slug || typeof slug !== 'object') { if (!slug || typeof slug !== 'object') return undefined;
return undefined;
}
const map = {}; const map = {};
for (const locale of locales){ for (const locale of locales){
const v = slug[locale]; const v = slug[locale];
if (typeof v === 'string') { if (typeof v === 'string') map[locale] = v;
map[locale] = v;
}
} }
return Object.keys(map).length ? map : undefined; return Object.keys(map).length ? map : undefined;
} }
File diff suppressed because one or more lines are too long
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
...base, ...base,
imageUrl: resolveOgImage(doc), imageUrl: resolveOgImage(doc),
meta: doc.meta, meta: doc.meta,
pageTitle: doc.title,
prefix, prefix,
query, query,
slugs slugs
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -1,8 +1,10 @@
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'; import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
type BuildHreflangArgs = { type BuildHreflangArgs = {
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs;
config: I18nConfig;
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */ /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
baseUrl?: string; baseUrl?: string;
config: I18nConfig;
/** Home slug that collapses to the locale root. Defaults to 'home'. */ /** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string; homeSlug?: string;
/** /**
@@ -11,8 +13,6 @@ type BuildHreflangArgs = {
* are omitted — an entry with no archive in that language has no URL there. * are omitted — an entry with no archive in that language has no URL there.
*/ */
prefix?: LocalizedSlugs; prefix?: LocalizedSlugs;
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs;
}; };
/** /**
* Builds a map of locale → URL for hreflang alternate links, suitable for * Builds a map of locale → URL for hreflang alternate links, suitable for
@@ -30,5 +30,5 @@ type BuildHreflangArgs = {
* }) * })
* // → { 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' }
*/ */
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>; export declare function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix, }: BuildHreflangArgs): Record<string, string>;
export {}; export {};
+20 -4
View File
@@ -14,21 +14,37 @@ import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
* baseUrl: 'https://example.com', * baseUrl: 'https://example.com',
* }) * })
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' } * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) { */ export function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug = 'home', prefix }) {
const origin = baseUrl?.replace(/\/$/, '') ?? ''; const origin = baseUrl?.replace(/\/$/, '') ?? '';
const alternates = {}; const alternates = {};
// Single-locale sites have no language alternatives — hreflang describes
// relationships BETWEEN language versions, and there's only one. Emitting
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
// its canonical, but no alternate-language links.
if (config.locales.length === 1) {
return alternates;
}
for (const locale of getLocaleCodes(config)){ for (const locale of getLocaleCodes(config)){
const path = buildLocalizedPath({ const path = buildLocalizedPath({
slugs,
locale,
config, config,
homeSlug, homeSlug,
locale, prefix
prefix,
slugs
}); });
if (path) { if (path) {
alternates[locale] = `${origin}${path}`; alternates[locale] = `${origin}${path}`;
} }
} }
// 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[config.defaultLocale];
if (defaultLocalePath) {
alternates['x-default'] = defaultLocalePath;
}
return alternates; return alternates;
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, 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'\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n config: I18nConfig\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n slugs,\n config,\n baseUrl,\n homeSlug = 'home',\n prefix,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n // Single-locale sites have no language alternatives — hreflang describes\n // relationships BETWEEN language versions, and there's only one. Emitting\n // hreflang (or x-default) here would be wrong, so return empty: the page keeps\n // its canonical, but no alternate-language links.\n if (config.locales.length === 1) {\n return alternates\n }\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[config.defaultLocale]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","slugs","config","baseUrl","homeSlug","prefix","origin","replace","alternates","locales","length","locale","path","defaultLocalePath","defaultLocale"],"mappings":"AACA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,KAAK,EACLC,MAAM,EACNC,OAAO,EACPC,WAAW,MAAM,EACjBC,MAAM,EACY;IAClB,MAAMC,SAASH,SAASI,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,yEAAyE;IACzE,0EAA0E;IAC1E,+EAA+E;IAC/E,kDAAkD;IAClD,IAAIN,OAAOO,OAAO,CAACC,MAAM,KAAK,GAAG;QAC/B,OAAOF;IACT;IAEA,KAAK,MAAMG,UAAUZ,eAAeG,QAAS;QAC3C,MAAMU,OAAOd,mBAAmB;YAAEG;YAAOU;YAAQT;YAAQE;YAAUC;QAAO;QAC1E,IAAIO,MAAM;YACRJ,UAAU,CAACG,OAAO,GAAG,GAAGL,SAASM,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBL,UAAU,CAACN,OAAOY,aAAa,CAAC;IAC1D,IAAID,mBAAmB;QACrBL,UAAU,CAAC,YAAY,GAAGK;IAC5B;IAEA,OAAOL;AACT"}
+9
View File
@@ -1,11 +1,19 @@
export { buildAutoFillMetaHook } from './autoFillMeta.js'; export { buildAutoFillMetaHook } from './autoFillMeta.js';
export type { AutoFillMapping } 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 { buildMetadata } from './buildMetadata.js';
export type { PageMetadata } from './buildMetadata.js'; export type { PageMetadata } from './buildMetadata.js';
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
export { buildRobots } from './buildRobots.js'; export { buildRobots } from './buildRobots.js';
export type { RobotsRules } from './buildRobots.js'; export type { RobotsRules } from './buildRobots.js';
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
export { buildSitemapEntries } from './buildSitemapEntries.js'; export { buildSitemapEntries } from './buildSitemapEntries.js';
export type { SitemapEntry } from './buildSitemapEntries.js'; export type { SitemapEntry } from './buildSitemapEntries.js';
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js'; export { composeTitle } from './composeTitle.js';
export type { TitleOrder } from './composeTitle.js'; export type { TitleOrder } from './composeTitle.js';
export { createMetadataGenerator } from './createMetadataGenerator.js'; export { createMetadataGenerator } from './createMetadataGenerator.js';
@@ -18,3 +26,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js';
export { buildSeoPlugin } from './seoPluginConfig.js'; export { buildSeoPlugin } from './seoPluginConfig.js';
export { slugsAcrossLocales } from './slugsAcrossLocales.js'; export { slugsAcrossLocales } from './slugsAcrossLocales.js';
export type { SeoMeta, SeoOption } from './types.js'; export type { SeoMeta, SeoOption } from './types.js';
export { validateFaviconField } from './validateFavicon.js';
+9
View File
@@ -1,7 +1,15 @@
export { buildAutoFillMetaHook } from './autoFillMeta.js'; 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 { buildMetadata } from './buildMetadata.js';
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
export { buildRobots } from './buildRobots.js'; export { buildRobots } from './buildRobots.js';
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
export { buildSitemapEntries } from './buildSitemapEntries.js'; export { buildSitemapEntries } from './buildSitemapEntries.js';
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
export { composeTitle } from './composeTitle.js'; export { composeTitle } from './composeTitle.js';
export { createMetadataGenerator } from './createMetadataGenerator.js'; export { createMetadataGenerator } from './createMetadataGenerator.js';
export { createPageMetadata } from './createPageMetadata.js'; export { createPageMetadata } from './createPageMetadata.js';
@@ -11,5 +19,6 @@ export { injectSeoTabs } from './injectSeoTabs.js';
export { readSiteMetaConfig } from './readSiteMetaConfig.js'; export { readSiteMetaConfig } from './readSiteMetaConfig.js';
export { buildSeoPlugin } from './seoPluginConfig.js'; export { buildSeoPlugin } from './seoPluginConfig.js';
export { slugsAcrossLocales } from './slugsAcrossLocales.js'; export { slugsAcrossLocales } from './slugsAcrossLocales.js';
export { validateFaviconField } from './validateFavicon.js';
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { 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"}
+1
View File
@@ -34,6 +34,7 @@ export type SeoOption = {
export type SeoMeta = { export type SeoMeta = {
description?: null | string; description?: null | string;
image?: unknown; image?: unknown;
noindex?: boolean | null;
title?: null | string; title?: null | string;
/** When set, used as the whole title — no site name, no separator. */ /** When set, used as the whole title — no site name, no separator. */
titleOverride?: null | string; titleOverride?: null | string;
+1 -1
View File
@@ -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
View File
@@ -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;
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
export { buildR2Storage } from './buildR2Storage.js';
+3
View File
@@ -0,0 +1,3 @@
export { buildR2Storage } from './buildR2Storage.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -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"}
+254
View File
@@ -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
View File
@@ -1,5 +1,7 @@
# IPAL — Dokumentacja modułów # 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 **Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
[README](../README.md). [README](../README.md).
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.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) | | access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) | | payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) | | seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) | | blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) | | consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.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) | | content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
Nowy projekt krok po kroku: [getting-started.md](./getting-started.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) Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.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) Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
+24
View File
@@ -10,6 +10,30 @@ registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący. (pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
## ⚠️ NIE pisz własnego renderera bloków (switch)
**Renderer bloków to `RenderBlocks` z pluginu — NIGDY własny `switch`/`if`.**
Częsty błąd: projekt pisze własny `BlockRenderer` z `switch (block.blockType)`
i 20 case'ami. To łamie A0 — plugin ma silnik, projekt dostarcza tylko MAPĘ
komponentów.
```tsx
// ŹLE — własny switch w projekcie (gadatliwy, bez enhanceProps, rośnie liniowo)
switch (block.blockType) {
case 'hero': return <Hero {...block} />
case 'faq': return <FAQ {...block} />
// ...20 case'ów
}
// DOBRZE — mapa + RenderBlocks (silnik z pluginu)
const registry = { hero: Hero, faq: FAQ, /* ... */ }
<RenderBlocks blocks={page.layout} components={registry} />
```
Dlaczego RenderBlocks, nie switch: enhanceProps (anchory nav, itp.), guardy,
obsługa zagnieżdżeń, spójność między projektami. Switch tego nie ma i rośnie
z każdym blokiem. Mapa jest płaska i deklaratywna.
## Front — RenderBlocks ## Front — RenderBlocks
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy): Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
+36 -1
View File
@@ -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-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków | | `--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. nie wygeneruje tych klas.
### Gdy tokeny nie wystarczą ### Gdy tokeny nie wystarczą
@@ -132,3 +132,38 @@ domyślny (nie dokleja się).
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne 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).
+206
View File
@@ -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
+27
View File
@@ -150,3 +150,30 @@ Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze 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ć.
+25
View File
@@ -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ę dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację
Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem). 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ść ## Zależność
```json ```json
+213 -227
View File
@@ -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 > [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 Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
bazy, importMap, kolejność wpięcia).
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
--- ---
@@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
```bash ```bash
npx create-payload-app@latest moj-projekt npx create-payload-app@latest moj-projekt
# → Blank, SQLite # → Blank, SQLite (dev) / Postgres (prod)
cd moj-projekt cd moj-projekt
``` ```
## 2. Plugin i zależności ## 2. Plugin i zależności
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
współdzielone z Payloadem, których plugin nie zaciąga sam:
```bash ```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 nodemailer lucide-react slugify server-only
``` ```
### Spójność wersji @payloadcms/* (KRYTYCZNE)
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
projekt się wywala. Wymuś w `package.json`: projekt się wywala. Wymuś w `package.json`:
@@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`:
```json ```json
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"payload": "3.84.1", "payload": "3.88.0",
"@payloadcms/ui": "3.84.1", "@payloadcms/ui": "3.88.0",
"@payloadcms/next": "3.84.1", "@payloadcms/next": "3.88.0",
"@payloadcms/db-sqlite": "3.84.1", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/richtext-lexical": "3.84.1", "@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/plugin-seo": "3.84.1", "@payloadcms/plugin-seo": "3.88.0",
"@payloadcms/plugin-form-builder": "3.84.1" "@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 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 ## 3. Konfiguracja locale — jedno źródło
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
miejscach:
```ts ```ts
// src/i18n.config.ts // src/i18n.config.ts
@@ -67,25 +78,24 @@ export const i18nConfig = {
{ code: 'pl', label: 'Polski' }, { code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' }, { 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 ## 4. payload.config.ts
```ts ```ts
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit' import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { Pages } from '@/collections/Pages' import { Pages } from '@/collections/Pages'
export default buildConfig({ export default buildConfig({
// …reszta z template'u
collections: [Users, Media, Pages], 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). // Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
email: panelSmtpAdapter(), email: mailAdapter(),
plugins: [ plugins: [
ipalKit({ ipalKit({
@@ -113,11 +123,11 @@ export const Pages: CollectionConfig = {
access: { read: () => true }, access: { read: () => true },
fields: [ fields: [
{ name: 'title', type: 'text', required: true, localized: true }, { name: 'title', type: 'text', required: true, localized: true },
buildSlugField({ from: 'title' }), buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
{ {
name: 'layout', name: 'layout',
type: 'blocks', 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' import { ContentBlockComponent } from '@/blocks/Content/Component'
export const blockRegistry: BlockComponentMap = { 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 ```bash
pnpm add tailwindcss @tailwindcss/postcss pnpm add tailwindcss @tailwindcss/postcss
@@ -183,16 +209,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } }
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js"; @source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
``` ```
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez **`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły. niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
Ścieżka jest relatywna do pliku CSS. Ś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 Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian: properties (fallbacki wbudowane):
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy: ```css
> `npx @next/codemod@canary middleware-to-proxy .` :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 ```ts
// src/proxy.ts // src/proxy.ts
@@ -205,104 +245,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const result = localeMiddleware(request) const result = localeMiddleware(request)
if (result.type === 'next') return NextResponse.next()
const response = NextResponse.redirect(result.location) // Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined // języka), TYLKO gdy jest zgoda na functional.
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value) 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 return response
} }
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje // Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin // Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
// i /_next, i wszystko zwróci 500. // Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
export const config = { export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'], matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
} }
``` ```
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa ### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
> `middleware.ts` czy `proxy.ts`.
## 9. Warstwa dostępu do danych Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
Slugi są w bazie (pole `slug` localized):
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
sprawia, że nie pytają bazy dwa razy o to samo.
```ts ```ts
// src/lib/payload.ts import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
import { cache } from 'react'
import { getPayload } from 'payload'
import config from '@/payload.config'
export const getCachedPayload = cache(async () => getPayload({ config: await config })) 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'
```
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
```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/payload.ts — funkcje projektu, typowane
import { cache } from 'react'
import { getSiteSettings } from '@intecion/ipal-kit'
import { getCachedPayload } from './content' // z content, nie osobny getPayload
import type { SiteSetting } from '@/payload-types'
export const getSettings = cache(async (locale: string) => export const getSettings = cache(async (locale: string) =>
(await getCachedPayload()).findGlobal({ getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
slug: 'site-settings',
locale: locale as 'pl' | 'en',
depth: 2,
}),
) )
``` ```
```ts > NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
// src/lib/locales.ts > `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
import { cache } from 'react'
import config from '@/payload.config'
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
const payloadConfig = await config
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
})
```
```ts
// src/lib/pages.ts
import { cache } from 'react'
import type { Page, SiteSetting } from '@/payload-types'
import { getCachedPayload, getSettings } from './payload'
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
},
)
```
## 10. Trasy ## 10. Trasy
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad layout locale (bo `<html lang>` musi znać język).
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
``` ```
src/app/(frontend)/ src/app/(frontend)/
styles.css styles.css
[locale]/ [locale]/
layout.tsx layout.tsx # walidacja locale + ConsentProvider + Analytics
[[...slug]]/ [[...slug]]/
page.tsx page.tsx # render bloków
``` ```
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast **`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`. dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
```tsx ```tsx
// src/app/(frontend)/[locale]/layout.tsx // src/app/(frontend)/[locale]/layout.tsx
@@ -310,8 +335,8 @@ import { notFound } from 'next/navigation'
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit' import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client' import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { getCachedPayload, getSettings } from '@/lib/payload' import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
import { getConfiguredLocales } from '@/lib/locales' import { getSettings } from '@/lib/payload'
import '../styles.css' import '../styles.css'
export default async function LocaleLayout({ children, params }) { export default async function LocaleLayout({ children, params }) {
@@ -325,13 +350,9 @@ export default async function LocaleLayout({ children, params }) {
const [texts, analytics] = await Promise.all([ const [texts, analytics] = await Promise.all([
getConsentTexts({ getConsentTexts({
config: i18nConfig, config: i18nConfig, locale, payload,
locale, privacyPolicy: privacyPage && typeof privacyPage === 'object'
payload, ? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
privacyPolicy:
privacyPage && typeof privacyPage === 'object'
? { page: privacyPage, label: 'Polityka prywatności' }
: undefined,
}), }),
getAnalyticsConfig(payload), getAnalyticsConfig(payload),
]) ])
@@ -343,7 +364,7 @@ export default async function LocaleLayout({ children, params }) {
<main>{children}</main> <main>{children}</main>
<CookieBanner /> <CookieBanner />
<CookieButton /> <CookieButton />
<Analytics {...analytics} /> <Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
</ConsentProvider> </ConsentProvider>
</body> </body>
</html> </html>
@@ -364,8 +385,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc'
import { createPageMetadata } from '@intecion/ipal-kit' import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config' import { i18nConfig } from '@/i18n.config'
import { blockRegistry } from '@/blocks/registry' import { blockRegistry } from '@/blocks/registry'
import { getCachedPayload } from '@/lib/payload' import { getCachedPayload, resolveRoute } from '@/lib/content'
import { resolvePage } from '@/lib/pages'
const pageMetadata = createPageMetadata({ const pageMetadata = createPageMetadata({
config: i18nConfig, config: i18nConfig,
@@ -377,104 +397,92 @@ export async function generateMetadata({ params }): Promise<Metadata> {
return pageMetadata({ payload: await getCachedPayload(), locale, slug }) 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 { locale, slug } = await params
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null) const { page } = await searchParams
if (!page) notFound() const route = await resolveRoute(locale, slug ?? [], page) // 3 args
if (!route) notFound()
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} /> 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 ## 11. Metadata / SEO (szczegóły)
# .env
DATABASE_URL=file:./moj-projekt.db `createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
PAYLOAD_SECRET=<losowy-ciąg> dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
NEXT_PUBLIC_SERVER_URL=http://localhost:3000 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. 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 ```bash
pnpm generate:types pnpm generate:types
pnpm payload generate:importmap # pola SEO to komponenty admina pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
pnpm dev pnpm dev
``` ```
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty `generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
admina.
## 13. Konfiguracja w panelu ## 15. Konfiguracja w panelu
`http://localhost:3000/admin` `http://localhost:3000/admin`
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin). 1. **Utwórz pierwszego użytkownika** (rola admin).
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu. 2. **Site Settings → General** — nazwa witryny, tytuł.
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale** 3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza slug EN = 404 na `/en/…`).
404 na `/en/…`. 4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404. 5. **Cookie Settings** — treść bannera per język.
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne). 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 ### 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: ### Blog / archiwum
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe przypisanie strony-archiwum w System Pages.
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:
### Sitemapa i robots
```ts ```ts
// app/sitemap.ts // app/sitemap.ts
export { sitemap as default } from '@/lib/content' 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' 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 ## Kiedy coś nie działa
| Objaw | Przyczyna | | 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` | | `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda | | `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline | | Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` | | `/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 | | `/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 | | `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ć | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` | | Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu | | istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 | | zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |
| `/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 |
+141
View File
@@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en' 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) ### Przełącznik języka (bez 404)
```ts ```ts
@@ -108,3 +123,129 @@ Zachowanie:
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`) - 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.
## Strona jednojęzyczna (bez prefiksu /pl)
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie
`/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb
jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
się same:
- **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/`
- **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych)
- **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji)
- **canonical** → `https://klient.pl/o-nas` (bez prefiksu)
### Config — jeden locale
```ts
// i18n.config.ts
export const i18nConfig = {
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
defaultLocale: 'pl',
}
```
### Struktura katalogów — BEZ [locale]
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`:
```
# JEDNOJĘZYCZNY (bez [locale])
app/(frontend)/
layout.tsx # locale stałe z config, nie z params
not-found.tsx
[[...slug]]/page.tsx # /o-nas, /kontakt
# WIELOJĘZYCZNY (z [locale]) — dla porównania
app/(frontend)/[locale]/
layout.tsx # locale z params
[[...slug]]/page.tsx # /pl/o-nas, /en/about
```
### Layout jednojęzyczny — locale z config
```tsx
// app/(frontend)/layout.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export default async function Layout({ children }: { children: React.ReactNode }) {
const locale = i18nConfig.defaultLocale // stałe, nie z params
const settings = await getSettings(locale)
// ...reszta jak zwykle, ale locale jest stałe
return <html lang={locale}>...</html>
}
```
### Strony jednojęzyczne — locale z config
```tsx
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export async function generateMetadata({ params }) {
const { slug } = await params // TYLKO slug, nie locale
const locale = i18nConfig.defaultLocale // stałe
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
}
export default async function Page({ params }) {
const { slug } = await params
const locale = i18nConfig.defaultLocale // stałe
const route = await resolveRoute(locale, slug ?? [], pageNum)
// ...
}
```
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config.
### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz
go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez
przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
### Przejście jedno- → wielojęzyczny (later)
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
- dodaj locale do config
- przenieś strukturę do `[locale]/`
- layout/strony czytają locale z params
- wróci prefiks `/pl`, `/en` + hreflang
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
`/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.
-64
View File
@@ -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.
+141
View File
@@ -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.
+488
View File
@@ -53,6 +53,31 @@ Per strona (tab SEO):
Domyślnie: `Tytuł | Nazwa witryny`. Domyślnie: `Tytuł | Nazwa witryny`.
### Skąd bierze się „Tytuł" (priorytet źródła)
Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:
1. **titleOverride** — jeśli wypełniony, jest całym tytułem (bez składania).
2. **meta.title** — tytuł SEO wpisany w tab SEO.
3. **page.title** — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.
Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:
- **buildAutoFillMetaHook** (przy ZAPISIE) — wypełnia puste `meta.title` z pola
dokumentu (`title`). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
- **pageTitle w buildMetadata** (przy RENDEROWANIU) — jeśli meta.title mimo to
puste (np. auto-fill niewpięty), używa `page.title`. Druga linia obrony.
Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie
pusty tytuł ani sam siteName.
> **Uwaga — „Strona Główna” w tytule:** jeśli strona główna ma nazwę dokumentu
> „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie
> „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz **titleOverride**
> dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na
> coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest
> sama treść nazwy, nie brak tytułu.
## Front — createPageMetadata ## Front — createPageMetadata
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
@@ -217,6 +242,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({
```ts ```ts
// app/sitemap.ts // app/sitemap.ts
export { sitemap as default } from '@/lib/content' export { sitemap as default } from '@/lib/content'
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
// app/robots.ts // app/robots.ts
export { robots as default } from '@/lib/content' export { robots as default } from '@/lib/content'
@@ -227,6 +253,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 katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała
logika jest w pluginie. 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: Co zawiera sitemapa:
- każdą stronę i wpis bloga, URL w domyślnym locale - każdą stronę i wpis bloga, URL w domyślnym locale
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>` - `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
@@ -248,3 +284,455 @@ const entries = await buildSitemapEntries({
Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na 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">`.

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