Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39e2a361a | ||
|
|
9d3e749c38 | ||
|
|
b7c6cb4d81 | ||
|
|
c41b75e364 | ||
|
|
41630bb8c5 | ||
|
|
b9430e7ab6 | ||
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 | ||
|
|
ed4a78b109 | ||
|
|
9acb22e2f9 | ||
|
|
6a8361d710 | ||
|
|
502ffee31f | ||
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 | ||
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f |
Vendored
+2
@@ -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';
|
||||||
|
|||||||
Vendored
+1
@@ -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
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"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"}
|
||||||
Vendored
+1
@@ -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';
|
||||||
|
|||||||
Vendored
+1
@@ -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
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B"}
|
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\nexport { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'\n"],"names":["RenderBlocks","MediaPreconnect"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B;AAOzD,SAASC,eAAe,QAAQ,wCAAuC"}
|
||||||
+17
-8
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { storageFields } from './fields/storage.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n { fields: storageFields, label: 'Storage' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","storageFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,aAAa,QAAQ,sBAAqB;AACnD,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQd;wBAAiBiB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQb;wBAAYgB,OAAO;oBAAO;oBACpC;wBAAEH,QAAQZ;wBAAee,OAAO;oBAAU;uBACtCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n *\n * Note: R2 storage credentials are NOT here — storage is infrastructure and\n * binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),\n * consumed by buildR2Storage. See docs/storage.md.\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQb;wBAAiBgB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQZ;wBAAYe,OAAO;oBAAO;uBAChCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Field } from 'payload';
|
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
@@ -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
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description: 'Primary site logo.',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',\n },\n relationTo: 'media',\n },\n]\n"],"names":["generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo"],"mappings":"AAEA;;;CAGC,GACD,OAAO,MAAMA,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;CACD,CAAA"}
|
{"version":3,"sources":["../../../../src/globals/SiteSettings/fields/general.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport { validateFaviconField } from '../../../modules/seo/index.js'\n\n/**\n * General site identity fields.\n * Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).\n */\nexport const generalFields: Field[] = [\n {\n name: 'siteName',\n type: 'text',\n admin: {\n description: 'Used in page titles and Open Graph metadata.',\n },\n localized: true,\n required: true,\n },\n {\n name: 'titleOrder',\n type: 'select',\n admin: {\n description: 'Which comes first in browser tabs.',\n },\n defaultValue: 'page-first',\n options: [\n { label: 'Page first — About Us | Acme', value: 'page-first' },\n { label: 'Site first — Acme | About Us', value: 'site-first' },\n ],\n },\n {\n name: 'titleSeparator',\n type: 'select',\n admin: {\n description: 'Separates the page title from the site name in browser tabs.',\n },\n defaultValue: '|',\n options: [\n { label: 'Pipe — Page | Site', value: '|' },\n { label: 'Dash — Page – Site', value: '–' },\n { label: 'Hyphen — Page - Site', value: '-' },\n { label: 'Bullet — Page · Site', value: '·' },\n { label: 'Slash — Page / Site', value: '/' },\n ],\n },\n {\n name: 'logo',\n type: 'upload',\n admin: {\n description:\n 'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',\n },\n relationTo: 'media',\n },\n {\n name: 'defaultShareImage',\n type: 'upload',\n admin: {\n description: 'Fallback Open Graph image when a page has none.',\n },\n relationTo: 'media',\n },\n {\n name: 'favicon',\n type: 'upload',\n admin: {\n description:\n 'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',\n },\n hooks: {\n // Warns the editor at save time if the favicon is too small (<48×48) or\n // not square — Google won't display such favicons in search results.\n beforeValidate: [validateFaviconField],\n },\n relationTo: 'media',\n },\n]\n"],"names":["validateFaviconField","generalFields","name","type","admin","description","localized","required","defaultValue","options","label","value","relationTo","hooks","beforeValidate"],"mappings":"AAEA,SAASA,oBAAoB,QAAQ,gCAA+B;AAEpE;;;CAGC,GACD,OAAO,MAAMC,gBAAyB;IACpC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAC,WAAW;QACXC,UAAU;IACZ;IACA;QACEL,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAkCC,OAAO;YAAa;YAC/D;gBAAED,OAAO;gBAAkCC,OAAO;YAAa;SAChE;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAG,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAAwBC,OAAO;YAAI;YAC5C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAA0BC,OAAO;YAAI;YAC9C;gBAAED,OAAO;gBAAyBC,OAAO;YAAI;SAC9C;IACH;IACA;QACET,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;QACAO,YAAY;IACd;IACA;QACEV,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QACJ;QACAQ,OAAO;YACL,wEAAwE;YACxE,qEAAqE;YACrEC,gBAAgB;gBAACd;aAAqB;QACxC;QACAY,YAAY;IACd;CACD,CAAA"}
|
||||||
Vendored
+6
@@ -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 { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||||
export { 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';
|
||||||
|
|||||||
Vendored
+9
@@ -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 { buildIconsMetadata, buildOrganizationJsonLd, 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
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+28
-13
@@ -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 {
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,6 +1,13 @@
|
|||||||
import type { I18nConfig } from './types.js';
|
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;
|
||||||
|
|||||||
Vendored
+7
-1
@@ -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)
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/media/index.ts"],"sourcesContent":["export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'\n"],"names":["normalizeFilename","normalizeFilenameHook"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,qBAAqB,QAAQ,yBAAwB"}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import type { CollectionBeforeOperationHook } from 'payload';
|
||||||
|
/**
|
||||||
|
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||||
|
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||||
|
*
|
||||||
|
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||||
|
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||||
|
* "already-clean.webp" → "already-clean.webp"
|
||||||
|
*
|
||||||
|
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||||
|
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||||
|
* the extension, and rejoin.
|
||||||
|
*/
|
||||||
|
export declare function normalizeFilename(filename: string): string;
|
||||||
|
/**
|
||||||
|
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||||
|
* incoming file's name to its normalized form before Payload stores it, so both
|
||||||
|
* the stored file and its DB filename are clean. Works with local disk and with
|
||||||
|
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||||
|
*
|
||||||
|
* Wire into your Media collection:
|
||||||
|
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||||
|
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||||
|
*/
|
||||||
|
export declare const normalizeFilenameHook: CollectionBeforeOperationHook;
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
import slugify from 'slugify';
|
||||||
|
/**
|
||||||
|
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||||
|
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||||
|
*
|
||||||
|
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||||
|
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||||
|
* "already-clean.webp" → "already-clean.webp"
|
||||||
|
*
|
||||||
|
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||||
|
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||||
|
* the extension, and rejoin.
|
||||||
|
*/ export function normalizeFilename(filename) {
|
||||||
|
const lastDot = filename.lastIndexOf('.');
|
||||||
|
// No extension (or leading-dot dotfile) → slug the whole thing.
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return slugify(filename, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stem = filename.slice(0, lastDot);
|
||||||
|
const ext = filename.slice(lastDot + 1).toLowerCase();
|
||||||
|
const cleanStem = slugify(stem, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
const cleanExt = slugify(ext, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
// Stem could slug to empty (e.g. filename was all symbols) — fall back so we
|
||||||
|
// never produce a nameless file.
|
||||||
|
const safeStem = cleanStem || 'plik';
|
||||||
|
return cleanExt ? `${safeStem}.${cleanExt}` : safeStem;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||||
|
* incoming file's name to its normalized form before Payload stores it, so both
|
||||||
|
* the stored file and its DB filename are clean. Works with local disk and with
|
||||||
|
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||||
|
*
|
||||||
|
* Wire into your Media collection:
|
||||||
|
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||||
|
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||||
|
*/ export const normalizeFilenameHook = ({ req, operation })=>{
|
||||||
|
if (operation !== 'create' && operation !== 'update') return;
|
||||||
|
const file = req.file;
|
||||||
|
if (file?.name) {
|
||||||
|
file.name = normalizeFilename(file.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=normalizeFilename.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/media/normalizeFilename.ts"],"sourcesContent":["import type { CollectionBeforeOperationHook } from 'payload'\nimport slugify from 'slugify'\n\n/**\n * Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)\n * while preserving the extension. Keeps uploaded media URLs clean and portable.\n *\n * \"Zdjęcie jeden nad morzem.jpg\" → \"zdjecie-jeden-nad-morzem.jpg\"\n * \"Faktura #12 (2024).PDF\" → \"faktura-12-2024.pdf\"\n * \"already-clean.webp\" → \"already-clean.webp\"\n *\n * Why not toSlug(): toSlug uses strict:true, which would strip the dot and\n * merge name+extension. Here we split on the LAST dot, slug the stem, lowercase\n * the extension, and rejoin.\n */\nexport function normalizeFilename(filename: string): string {\n const lastDot = filename.lastIndexOf('.')\n\n // No extension (or leading-dot dotfile) → slug the whole thing.\n if (lastDot <= 0) {\n return slugify(filename, { lower: true, strict: true, trim: true })\n }\n\n const stem = filename.slice(0, lastDot)\n const ext = filename.slice(lastDot + 1).toLowerCase()\n\n const cleanStem = slugify(stem, { lower: true, strict: true, trim: true })\n const cleanExt = slugify(ext, { lower: true, strict: true, trim: true })\n\n // Stem could slug to empty (e.g. filename was all symbols) — fall back so we\n // never produce a nameless file.\n const safeStem = cleanStem || 'plik'\n\n return cleanExt ? `${safeStem}.${cleanExt}` : safeStem\n}\n\n/**\n * beforeOperation hook for an upload collection (e.g. Media). Rewrites the\n * incoming file's name to its normalized form before Payload stores it, so both\n * the stored file and its DB filename are clean. Works with local disk and with\n * cloud storage adapters (R2/S3) — it runs before the storage layer.\n *\n * Wire into your Media collection:\n * import { normalizeFilenameHook } from '@intecion/ipal-kit'\n * hooks: { beforeOperation: [normalizeFilenameHook] }\n */\nexport const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => {\n if (operation !== 'create' && operation !== 'update') return\n const file = req.file\n if (file?.name) {\n file.name = normalizeFilename(file.name)\n }\n}\n"],"names":["slugify","normalizeFilename","filename","lastDot","lastIndexOf","lower","strict","trim","stem","slice","ext","toLowerCase","cleanStem","cleanExt","safeStem","normalizeFilenameHook","req","operation","file","name"],"mappings":"AACA,OAAOA,aAAa,UAAS;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBC,QAAgB;IAChD,MAAMC,UAAUD,SAASE,WAAW,CAAC;IAErC,gEAAgE;IAChE,IAAID,WAAW,GAAG;QAChB,OAAOH,QAAQE,UAAU;YAAEG,OAAO;YAAMC,QAAQ;YAAMC,MAAM;QAAK;IACnE;IAEA,MAAMC,OAAON,SAASO,KAAK,CAAC,GAAGN;IAC/B,MAAMO,MAAMR,SAASO,KAAK,CAACN,UAAU,GAAGQ,WAAW;IAEnD,MAAMC,YAAYZ,QAAQQ,MAAM;QAAEH,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IACxE,MAAMM,WAAWb,QAAQU,KAAK;QAAEL,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IAEtE,6EAA6E;IAC7E,iCAAiC;IACjC,MAAMO,WAAWF,aAAa;IAE9B,OAAOC,WAAW,GAAGC,SAAS,CAAC,EAAED,UAAU,GAAGC;AAChD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAuD,CAAC,EAAEC,GAAG,EAAEC,SAAS,EAAE;IACrF,IAAIA,cAAc,YAAYA,cAAc,UAAU;IACtD,MAAMC,OAAOF,IAAIE,IAAI;IACrB,IAAIA,MAAMC,MAAM;QACdD,KAAKC,IAAI,GAAGlB,kBAAkBiB,KAAKC,IAAI;IACzC;AACF,EAAC"}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
type Crumb = {
|
||||||
|
/** Visible name of the breadcrumb (e.g. 'Usługi'). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||||
|
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||||
|
* and uses them to understand structure — a signal that helps navigational
|
||||||
|
* results and sitelinks.
|
||||||
|
*
|
||||||
|
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||||
|
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||||
|
*
|
||||||
|
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildBreadcrumbJsonLd([
|
||||||
|
* { name: 'Strona główna', url: `${base}/pl` },
|
||||||
|
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||||
|
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||||
|
* ])
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||||
|
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||||
|
*
|
||||||
|
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||||
|
* meaningful and shouldn't be emitted.
|
||||||
|
*/
|
||||||
|
export declare function buildBreadcrumbJsonLd(crumbs: Crumb[]): {
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
itemListElement: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
item: string;
|
||||||
|
position: number;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
export {};
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site
|
||||||
|
* hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)
|
||||||
|
* and uses them to understand structure — a signal that helps navigational
|
||||||
|
* results and sitelinks.
|
||||||
|
*
|
||||||
|
* Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are
|
||||||
|
* PER-PAGE — build them from the page's ancestry and emit on that page:
|
||||||
|
*
|
||||||
|
* import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildBreadcrumbJsonLd([
|
||||||
|
* { name: 'Strona główna', url: `${base}/pl` },
|
||||||
|
* { name: 'Usługi', url: `${base}/pl/uslugi` },
|
||||||
|
* { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
|
||||||
|
* ])
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* The crumb data comes from the page's real position (parent pages / URL path),
|
||||||
|
* NOT hardcoded. Derive it from the resolved route, not a static list.
|
||||||
|
*
|
||||||
|
* Returns null for an empty/single crumb list — a one-item breadcrumb isn't
|
||||||
|
* meaningful and shouldn't be emitted.
|
||||||
|
*/ export function buildBreadcrumbJsonLd(crumbs) {
|
||||||
|
if (!crumbs || crumbs.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: crumbs.map((crumb, index)=>({
|
||||||
|
name: crumb.name,
|
||||||
|
'@type': 'ListItem',
|
||||||
|
item: crumb.url,
|
||||||
|
position: index + 1
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildBreadcrumbJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildBreadcrumbJsonLd.ts"],"sourcesContent":["type Crumb = {\n /** Visible name of the breadcrumb (e.g. 'Usługi'). */\n name: string\n /** Absolute URL of this crumb (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds BreadcrumbList JSON-LD (schema.org) for a page's position in the site\n * hierarchy. Google can show breadcrumbs in the result (Dom › Usługi › Detailing)\n * and uses them to understand structure — a signal that helps navigational\n * results and sitelinks.\n *\n * Unlike Organization/WebSite (site-wide, root layout), breadcrumbs are\n * PER-PAGE — build them from the page's ancestry and emit on that page:\n *\n * import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildBreadcrumbJsonLd([\n * { name: 'Strona główna', url: `${base}/pl` },\n * { name: 'Usługi', url: `${base}/pl/uslugi` },\n * { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },\n * ])\n * <script type=\"application/ld+json\" ... />\n *\n * The crumb data comes from the page's real position (parent pages / URL path),\n * NOT hardcoded. Derive it from the resolved route, not a static list.\n *\n * Returns null for an empty/single crumb list — a one-item breadcrumb isn't\n * meaningful and shouldn't be emitted.\n */\nexport function buildBreadcrumbJsonLd(crumbs: Crumb[]) {\n if (!crumbs || crumbs.length < 2) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: crumbs.map((crumb, index) => ({\n name: crumb.name,\n '@type': 'ListItem',\n item: crumb.url,\n position: index + 1,\n })),\n }\n}\n"],"names":["buildBreadcrumbJsonLd","crumbs","length","itemListElement","map","crumb","index","name","item","url","position"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASA,sBAAsBC,MAAe;IACnD,IAAI,CAACA,UAAUA,OAAOC,MAAM,GAAG,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,OAAOG,GAAG,CAAC,CAACC,OAAOC,QAAW,CAAA;gBAC7CC,MAAMF,MAAME,IAAI;gBAChB,SAAS;gBACTC,MAAMH,MAAMI,GAAG;gBACfC,UAAUJ,QAAQ;YACpB,CAAA;IACF;AACF"}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
type MediaLike = {
|
||||||
|
height?: null | number;
|
||||||
|
mimeType?: null | string;
|
||||||
|
url?: null | string;
|
||||||
|
width?: null | number;
|
||||||
|
} | null | undefined;
|
||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/
|
||||||
|
export declare function buildIconsMetadata(favicon: MediaLike): Metadata;
|
||||||
|
export {};
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/ export function buildIconsMetadata(favicon) {
|
||||||
|
const url = favicon?.url;
|
||||||
|
if (!url) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg');
|
||||||
|
return {
|
||||||
|
icons: {
|
||||||
|
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||||
|
icon: isSvg ? [
|
||||||
|
{
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
] : [
|
||||||
|
{
|
||||||
|
sizes: 'any',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||||
|
apple: [
|
||||||
|
{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildIconsMetadata.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildIconsMetadata.ts"],"sourcesContent":["import type { Metadata } from 'next'\n\ntype MediaLike =\n | { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }\n | null\n | undefined\n\n/**\n * Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's\n * favicon upload, so the browser tab AND Google get a proper <link rel=\"icon\">.\n *\n * Why the plugin must do this (not the project): favicon-in-Google has strict\n * rules — a real <link rel=\"icon\"> in <head>, square, ≥48×48, at a stable URL.\n * Leaving it to each project meant inconsistent hand-rolled tags and no favicon\n * in search results. This generates the tags correctly, every time, from the\n * panel field.\n *\n * Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's\n * generateMetadata — not per page:\n *\n * import { buildIconsMetadata } from '@intecion/ipal-kit'\n * export async function generateMetadata(): Promise<Metadata> {\n * const settings = await getSettings(locale)\n * return buildIconsMetadata(settings.favicon)\n * }\n *\n * Google notes: it caches favicons separately and slowly (days/weeks), and only\n * shows them for icons it deems valid. Warn on too-small icons at upload time\n * (see the media validation hook) so editors don't ship a <48px favicon Google\n * will reject.\n */\nexport function buildIconsMetadata(favicon: MediaLike): Metadata {\n const url = favicon?.url\n if (!url) {return {}}\n\n const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')\n\n return {\n icons: {\n // Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).\n icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],\n // Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.\n apple: [{ url }],\n },\n }\n}\n"],"names":["buildIconsMetadata","favicon","url","isSvg","mimeType","endsWith","icons","icon","type","sizes","apple"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,mBAAmBC,OAAkB;IACnD,MAAMC,MAAMD,SAASC;IACrB,IAAI,CAACA,KAAK;QAAC,OAAO,CAAC;IAAC;IAEpB,MAAMC,QAAQF,SAASG,aAAa,mBAAmBF,IAAIG,QAAQ,CAAC;IAEpE,OAAO;QACLC,OAAO;YACL,sEAAsE;YACtEC,MAAMJ,QAAQ;gBAAC;oBAAEK,MAAM;oBAAiBN;gBAAI;aAAE,GAAG;gBAAC;oBAAEO,OAAO;oBAAOP;gBAAI;aAAE;YACxE,yEAAyE;YACzEQ,OAAO;gBAAC;oBAAER;gBAAI;aAAE;QAClB;IACF;AACF"}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: null | string;
|
||||||
|
} | null | undefined;
|
||||||
|
type OrganizationJsonLdArgs = {
|
||||||
|
/** Logo media (from panel). Google uses this for brand knowledge panels. */
|
||||||
|
logo?: MediaLike;
|
||||||
|
/** Organization / site name. */
|
||||||
|
name: string;
|
||||||
|
/** Optional social / official profile URLs (sameAs). */
|
||||||
|
sameAs?: string[];
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/
|
||||||
|
export declare function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs): {
|
||||||
|
sameAs?: string[] | undefined;
|
||||||
|
logo?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/ export function buildOrganizationJsonLd({ name, logo, sameAs, url }) {
|
||||||
|
const logoUrl = logo?.url;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
url,
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||||
|
} : {},
|
||||||
|
...sameAs && sameAs.length > 0 ? {
|
||||||
|
sameAs
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildOrganizationJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildOrganizationJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype OrganizationJsonLdArgs = {\n /** Logo media (from panel). Google uses this for brand knowledge panels. */\n logo?: MediaLike\n /** Organization / site name. */\n name: string\n /** Optional social / official profile URLs (sameAs). */\n sameAs?: string[]\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds Organization JSON-LD (schema.org) — helps Google associate the site\n * with a brand: name, logo, official links. Improves how the site appears in\n * search (brand recognition, logo in knowledge panels) and is a signal used\n * alongside favicon for identity.\n *\n * Returns a plain object; the project renders it as a <script type=\"application/\n * ld+json\"> in the root layout:\n *\n * import { buildOrganizationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildOrganizationJsonLd({\n * name: settings.siteName, url: baseUrl, logo: settings.logo,\n * })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n *\n * Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in\n * the root layout (Organization is site-wide, not per page).\n */\nexport function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {\n const logoUrl = logo?.url\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Organization',\n url,\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildOrganizationJsonLd","name","logo","sameAs","url","logoUrl","startsWith","length"],"mappings":"AAaA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,wBAAwB,EAAEC,IAAI,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAA0B;IACzF,MAAMC,UAAUH,MAAME;IAEtB,OAAO;QACLH;QACA,YAAY;QACZ,SAAS;QACTG;QACA,GAAIC,UAAU;YAAEH,MAAMG,QAAQC,UAAU,CAAC,UAAUD,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIF,UAAUA,OAAOI,MAAM,GAAG,IAAI;YAAEJ;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
type NavItem = {
|
||||||
|
/** Visible label (e.g. 'Usługi'). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||||
|
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||||
|
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||||
|
* navigation targets.
|
||||||
|
*
|
||||||
|
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||||
|
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||||
|
*
|
||||||
|
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildSiteNavigationJsonLd(
|
||||||
|
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||||
|
* )
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||||
|
* Returns null for empty nav.
|
||||||
|
*/
|
||||||
|
export declare function buildSiteNavigationJsonLd(items: NavItem[]): {
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
itemListElement: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
position: number;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
export {};
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||||
|
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||||
|
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||||
|
* navigation targets.
|
||||||
|
*
|
||||||
|
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||||
|
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||||
|
*
|
||||||
|
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildSiteNavigationJsonLd(
|
||||||
|
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||||
|
* )
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||||
|
* Returns null for empty nav.
|
||||||
|
*/ export function buildSiteNavigationJsonLd(items) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
itemListElement: items.map((item, index)=>({
|
||||||
|
name: item.name,
|
||||||
|
'@type': 'SiteNavigationElement',
|
||||||
|
position: index + 1,
|
||||||
|
url: item.url
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildSiteNavigationJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildSiteNavigationJsonLd.ts"],"sourcesContent":["type NavItem = {\n /** Visible label (e.g. 'Usługi'). */\n name: string\n /** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —\n * declares the site's primary nav as structured data. A weaker sitelinks signal\n * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main\n * navigation targets.\n *\n * Feed it the SAME nav items the header renders (from the panel/nav global), so\n * the structured data matches the visible menu — not a separate hardcoded list.\n *\n * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildSiteNavigationJsonLd(\n * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))\n * )\n * <script type=\"application/ld+json\" ... />\n *\n * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.\n * Returns null for empty nav.\n */\nexport function buildSiteNavigationJsonLd(items: NavItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'ItemList',\n itemListElement: items.map((item, index) => ({\n name: item.name,\n '@type': 'SiteNavigationElement',\n position: index + 1,\n url: item.url,\n })),\n }\n}\n"],"names":["buildSiteNavigationJsonLd","items","length","itemListElement","map","item","index","name","position","url"],"mappings":"AAOA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,0BAA0BC,KAAgB;IACxD,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,MAAMG,GAAG,CAAC,CAACC,MAAMC,QAAW,CAAA;gBAC3CC,MAAMF,KAAKE,IAAI;gBACf,SAAS;gBACTC,UAAUF,QAAQ;gBAClBG,KAAKJ,KAAKI,GAAG;YACf,CAAA;IACF;AACF"}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
type SearchActionConfig = {
|
||||||
|
/**
|
||||||
|
* URL template for site search, with {search_term_string} placeholder.
|
||||||
|
* e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if
|
||||||
|
* the site actually HAS a working search page — a SearchAction pointing at a
|
||||||
|
* non-existent search does more harm than good.
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
type WebSiteJsonLdArgs = {
|
||||||
|
/** Site name (from panel — siteName). */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Optional site search. Enables the "sitelinks searchbox" — a search field
|
||||||
|
* Google may show under the brand result. Only pass when a real search page
|
||||||
|
* exists. Omit entirely otherwise.
|
||||||
|
*/
|
||||||
|
search?: SearchActionConfig;
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||||
|
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||||
|
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||||
|
* searchbox" (a search field under the brand result in Google).
|
||||||
|
*
|
||||||
|
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||||
|
* schema guarantees them; Google generates them algorithmically from site
|
||||||
|
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||||
|
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||||
|
* expectations accordingly (see docs/seo.md).
|
||||||
|
*
|
||||||
|
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||||
|
*
|
||||||
|
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*/
|
||||||
|
export declare function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs): {
|
||||||
|
potentialAction?: {
|
||||||
|
'@type': string;
|
||||||
|
'query-input': string;
|
||||||
|
target: {
|
||||||
|
'@type': string;
|
||||||
|
urlTemplate: string;
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||||
|
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||||
|
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||||
|
* searchbox" (a search field under the brand result in Google).
|
||||||
|
*
|
||||||
|
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||||
|
* schema guarantees them; Google generates them algorithmically from site
|
||||||
|
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||||
|
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||||
|
* expectations accordingly (see docs/seo.md).
|
||||||
|
*
|
||||||
|
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||||
|
*
|
||||||
|
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*/ export function buildWebSiteJsonLd({ name, search, url }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
url,
|
||||||
|
...search ? {
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
target: {
|
||||||
|
'@type': 'EntryPoint',
|
||||||
|
urlTemplate: search.target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildWebSiteJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildWebSiteJsonLd.ts"],"sourcesContent":["type SearchActionConfig = {\n /**\n * URL template for site search, with {search_term_string} placeholder.\n * e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if\n * the site actually HAS a working search page — a SearchAction pointing at a\n * non-existent search does more harm than good.\n */\n target: string\n}\n\ntype WebSiteJsonLdArgs = {\n /** Site name (from panel — siteName). */\n name: string\n /**\n * Optional site search. Enables the \"sitelinks searchbox\" — a search field\n * Google may show under the brand result. Only pass when a real search page\n * exists. Omit entirely otherwise.\n */\n search?: SearchActionConfig\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds WebSite JSON-LD (schema.org). Two jobs:\n * - Declares the site + name (helps Google associate brand queries with the site).\n * - Optionally declares a SearchAction, which is what can produce the \"sitelinks\n * searchbox\" (a search field under the brand result in Google).\n *\n * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No\n * schema guarantees them; Google generates them algorithmically from site\n * structure, internal links, clear titles, and ranking. This schema is a SIGNAL\n * that improves the odds and can enable the searchbox — not a switch. Manage\n * expectations accordingly (see docs/seo.md).\n *\n * Emit once in the ROOT layout (site-wide), from panel data:\n *\n * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n */\nexport function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'WebSite',\n url,\n ...(search\n ? {\n potentialAction: {\n '@type': 'SearchAction',\n 'query-input': 'required name=search_term_string',\n target: {\n '@type': 'EntryPoint',\n urlTemplate: search.target,\n },\n },\n }\n : {}),\n }\n}\n"],"names":["buildWebSiteJsonLd","name","search","url","potentialAction","target","urlTemplate"],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,mBAAmB,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAAqB;IACzE,OAAO;QACLF;QACA,YAAY;QACZ,SAAS;QACTE;QACA,GAAID,SACA;YACEE,iBAAiB;gBACf,SAAS;gBACT,eAAe;gBACfC,QAAQ;oBACN,SAAS;oBACTC,aAAaJ,OAAOG,MAAM;gBAC5B;YACF;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||||
Vendored
+10
-2
@@ -15,20 +15,28 @@ type BuildHreflangArgs = {
|
|||||||
slugs: LocalizedSlugs;
|
slugs: LocalizedSlugs;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
|
* Also emits `x-default` pointing at the default locale — the version Google
|
||||||
|
* serves when the user's language/region matches no hreflang, and the fallback
|
||||||
|
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||||
|
* Accept-Language).
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
* // → {
|
||||||
|
* // pl: 'https://example.com/pl/o-nas',
|
||||||
|
* // en: 'https://example.com/en/about',
|
||||||
|
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||||
|
* // }
|
||||||
*/
|
*/
|
||||||
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>;
|
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+20
-3
@@ -1,19 +1,27 @@
|
|||||||
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
|
* Also emits `x-default` pointing at the default locale — the version Google
|
||||||
|
* serves when the user's language/region matches no hreflang, and the fallback
|
||||||
|
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||||
|
* Accept-Language).
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
* // → {
|
||||||
|
* // pl: 'https://example.com/pl/o-nas',
|
||||||
|
* // en: 'https://example.com/en/about',
|
||||||
|
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||||
|
* // }
|
||||||
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||||
const alternates = {};
|
const alternates = {};
|
||||||
@@ -29,6 +37,15 @@ import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
|||||||
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[getDefaultLocale(config)];
|
||||||
|
if (defaultLocalePath) {
|
||||||
|
alternates['x-default'] = defaultLocalePath;
|
||||||
|
}
|
||||||
return alternates;
|
return alternates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOZ,mBAAmB;YAAEI;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,OAAOF;AACT"}
|
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;AACT"}
|
||||||
Vendored
+6
@@ -1,11 +1,16 @@
|
|||||||
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 { buildIconsMetadata } from './buildIconsMetadata.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 { 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 +23,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';
|
||||||
|
|||||||
Vendored
+6
@@ -1,7 +1,12 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.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 { 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 +16,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
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\n"],"names":["buildAutoFillMetaHook","buildMetadata","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.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 { 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","buildIconsMetadata","buildMetadata","buildOrganizationJsonLd","buildRobots","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,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,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"}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FieldHook } from 'payload';
|
||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/
|
||||||
|
export declare const validateFaviconField: FieldHook;
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/ export const validateFaviconField = async ({ req, value })=>{
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
} // no favicon set → nothing to validate (optional field)
|
||||||
|
try {
|
||||||
|
const media = await req.payload.findByID({
|
||||||
|
id: typeof value === 'object' ? value.id : value,
|
||||||
|
collection: 'media',
|
||||||
|
depth: 0
|
||||||
|
});
|
||||||
|
const width = media.width;
|
||||||
|
const height = media.height;
|
||||||
|
const mimeType = media.mimeType;
|
||||||
|
// SVG scales infinitely — skip size checks.
|
||||||
|
if (mimeType === 'image/svg+xml') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
|
if (width < 48 || height < 48) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` + `display it in search results. Upload a larger square icon (96 or 192px).`);
|
||||||
|
}
|
||||||
|
if (width !== height) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is not square (${width}×${height}). Use a square icon ` + `so it isn't cropped in the browser tab or search results.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Media lookup failed — don't block the save over a validation warning.
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=validateFavicon.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/validateFavicon.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\n/**\n * Field validation for the favicon upload: Google rejects favicons under 48×48,\n * so warn the editor at save time if the uploaded icon is too small or not\n * square. This ENFORCES the requirement instead of silently shipping a favicon\n * Google won't display.\n *\n * Attach to the favicon field's validate (or as a beforeValidate hook on the\n * Media relationship). Non-blocking by default — returns a warning string that\n * Payload surfaces; make it throw if you want a hard block.\n *\n * Note: dimensions come from the related Media doc (Payload stores width/height\n * for image uploads), so this checks the resolved upload, not the raw file.\n */\nexport const validateFaviconField: FieldHook = async ({ req, value }) => {\n if (!value) {return value} // no favicon set → nothing to validate (optional field)\n\n try {\n const media = await req.payload.findByID({\n id: typeof value === 'object' ? (value as { id: string }).id : value,\n collection: 'media',\n depth: 0,\n })\n\n const width = (media as { width?: number }).width\n const height = (media as { height?: number }).height\n const mimeType = (media as { mimeType?: string }).mimeType\n\n // SVG scales infinitely — skip size checks.\n if (mimeType === 'image/svg+xml') {return value}\n\n if (typeof width === 'number' && typeof height === 'number') {\n if (width < 48 || height < 48) {\n req.payload.logger.warn(\n `[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` +\n `display it in search results. Upload a larger square icon (96 or 192px).`,\n )\n }\n if (width !== height) {\n req.payload.logger.warn(\n `[ipal] Favicon is not square (${width}×${height}). Use a square icon ` +\n `so it isn't cropped in the browser tab or search results.`,\n )\n }\n }\n } catch {\n // Media lookup failed — don't block the save over a validation warning.\n }\n\n return value\n}\n"],"names":["validateFaviconField","req","value","media","payload","findByID","id","collection","depth","width","height","mimeType","logger","warn"],"mappings":"AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMA,uBAAkC,OAAO,EAAEC,GAAG,EAAEC,KAAK,EAAE;IAClE,IAAI,CAACA,OAAO;QAAC,OAAOA;IAAK,EAAE,wDAAwD;IAEnF,IAAI;QACF,MAAMC,QAAQ,MAAMF,IAAIG,OAAO,CAACC,QAAQ,CAAC;YACvCC,IAAI,OAAOJ,UAAU,WAAW,AAACA,MAAyBI,EAAE,GAAGJ;YAC/DK,YAAY;YACZC,OAAO;QACT;QAEA,MAAMC,QAAQ,AAACN,MAA6BM,KAAK;QACjD,MAAMC,SAAS,AAACP,MAA8BO,MAAM;QACpD,MAAMC,WAAW,AAACR,MAAgCQ,QAAQ;QAE1D,4CAA4C;QAC5C,IAAIA,aAAa,iBAAiB;YAAC,OAAOT;QAAK;QAE/C,IAAI,OAAOO,UAAU,YAAY,OAAOC,WAAW,UAAU;YAC3D,IAAID,QAAQ,MAAMC,SAAS,IAAI;gBAC7BT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,kBAAkB,EAAEJ,MAAM,CAAC,EAAEC,OAAO,8BAA8B,CAAC,GAClE,CAAC,wEAAwE,CAAC;YAEhF;YACA,IAAID,UAAUC,QAAQ;gBACpBT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,8BAA8B,EAAEJ,MAAM,CAAC,EAAEC,OAAO,qBAAqB,CAAC,GACrE,CAAC,yDAAyD,CAAC;YAEjE;QACF;IACF,EAAE,OAAM;IACN,wEAAwE;IAC1E;IAEA,OAAOR;AACT,EAAC"}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export declare function MediaPreconnect(): import("react/jsx-runtime").JSX.Element | null;
|
||||||
|
export default MediaPreconnect;
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/ export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||||
|
if (!publicUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin;
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
;
|
||||||
|
}
|
||||||
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
href: origin,
|
||||||
|
rel: "preconnect"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
href: origin,
|
||||||
|
rel: "dns-prefetch"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export default MediaPreconnect;
|
||||||
|
|
||||||
|
//# sourceMappingURL=MediaPreconnect.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/MediaPreconnect.tsx"],"sourcesContent":["/**\n * Emits <link rel=\"preconnect\"> + <link rel=\"dns-prefetch\"> for the media CDN\n * domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the\n * media host early — before it hits the first <img>. Saves ~150–300ms on the\n * first image load.\n *\n * Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),\n * so there's ONE source of truth — no per-project hardcoded domain. Renders\n * nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to\n * preconnect).\n *\n * Server Component — drop it in the <head> of your locale layout:\n *\n * import { MediaPreconnect } from '@intecion/ipal-kit/rsc'\n * // in <head> (or top of <body> — Next hoists link tags):\n * <MediaPreconnect />\n */\nexport function MediaPreconnect() {\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '')\n if (!publicUrl) {return null}\n\n // Origin only (scheme + host) — preconnect targets an origin, not a path.\n let origin: string\n try {\n origin = new URL(publicUrl).origin\n } catch {\n return null // malformed URL → skip rather than emit a broken tag\n }\n\n return (\n <>\n <link crossOrigin=\"anonymous\" href={origin} rel=\"preconnect\" />\n <link href={origin} rel=\"dns-prefetch\" />\n </>\n )\n}\n\nexport default MediaPreconnect\n"],"names":["MediaPreconnect","publicUrl","process","env","R2_PUBLIC_URL","replace","origin","URL","link","crossOrigin","href","rel"],"mappings":";AAAA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA;IACd,MAAMC,YAAYC,QAAQC,GAAG,CAACC,aAAa,EAAEC,QAAQ,OAAO;IAC5D,IAAI,CAACJ,WAAW;QAAC,OAAO;IAAI;IAE5B,0EAA0E;IAC1E,IAAIK;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIN,WAAWK,MAAM;IACpC,EAAE,OAAM;QACN,OAAO,KAAK,qDAAqD;;IACnE;IAEA,qBACE;;0BACE,KAACE;gBAAKC,aAAY;gBAAYC,MAAMJ;gBAAQK,KAAI;;0BAChD,KAACH;gBAAKE,MAAMJ;gBAAQK,KAAI;;;;AAG9B;AAEA,eAAeX,gBAAe"}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Plugin } from 'payload';
|
||||||
|
/**
|
||||||
|
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||||
|
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||||
|
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||||
|
*
|
||||||
|
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||||
|
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||||
|
* panel. See docs/storage.md for the required variables.
|
||||||
|
*
|
||||||
|
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||||
|
* no-op passthrough so the project falls back to Payload's default local disk
|
||||||
|
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||||
|
* gracefully when a transport isn't configured.
|
||||||
|
*
|
||||||
|
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||||
|
*/
|
||||||
|
export declare const buildR2Storage: (collections?: string[]) => Plugin;
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { s3Storage } from '@payloadcms/storage-s3';
|
||||||
|
/**
|
||||||
|
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||||
|
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||||
|
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||||
|
*
|
||||||
|
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||||
|
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||||
|
* panel. See docs/storage.md for the required variables.
|
||||||
|
*
|
||||||
|
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||||
|
* no-op passthrough so the project falls back to Payload's default local disk
|
||||||
|
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||||
|
* gracefully when a transport isn't configured.
|
||||||
|
*
|
||||||
|
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||||
|
*/ export const buildR2Storage = (collections = [
|
||||||
|
'media'
|
||||||
|
])=>{
|
||||||
|
const bucket = process.env.R2_BUCKET;
|
||||||
|
const endpoint = process.env.R2_ENDPOINT;
|
||||||
|
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||||
|
// Any missing → skip R2, fall back to local disk. Warn so it's not silent.
|
||||||
|
if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
return (config)=>{
|
||||||
|
// Only warn when SOME vars are set (partial config = likely a mistake).
|
||||||
|
if (bucket || endpoint || accessKeyId || secretAccessKey) {
|
||||||
|
console.warn('[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' + 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
|
// See docs/storage.md.
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
|
;
|
||||||
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
|
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||||
|
publicUrl,
|
||||||
|
prefix,
|
||||||
|
filename
|
||||||
|
].filter(Boolean).join('/') : undefined;
|
||||||
|
const collectionsConfig = {};
|
||||||
|
for (const slug of collections){
|
||||||
|
collectionsConfig[slug] = generateFileURL ? {
|
||||||
|
generateFileURL
|
||||||
|
} : true;
|
||||||
|
}
|
||||||
|
return s3Storage({
|
||||||
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
|
config: {
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey
|
||||||
|
},
|
||||||
|
endpoint,
|
||||||
|
region: 'auto',
|
||||||
|
// R2 requires path-style addressing for S3 compatibility.
|
||||||
|
forcePathStyle: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildR2Storage.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js';
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/index.ts"],"sourcesContent":["export { buildR2Storage } from './buildR2Storage.js'\n"],"names":["buildR2Storage"],"mappings":"AAAA,SAASA,cAAc,QAAQ,sBAAqB"}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Playbook wdrożenia — ipal-kit
|
||||||
|
|
||||||
|
Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ
|
||||||
|
KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z
|
||||||
|
faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją.
|
||||||
|
|
||||||
|
Powiązane: [standardy-kodu.md](./standardy-kodu.md) (dobre praktyki senior),
|
||||||
|
[publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md)
|
||||||
|
(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ZŁOTE ZASADY (łam tylko świadomie)
|
||||||
|
|
||||||
|
1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod.
|
||||||
|
2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co
|
||||||
|
robi już plugin — zatrzymaj się, użyj pluginu.
|
||||||
|
3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń.
|
||||||
|
4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź.
|
||||||
|
5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki.
|
||||||
|
6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.**
|
||||||
|
7. **Zmieniłeś API → zaktualizuj docs w tym samym commicie.** Docs jadą w
|
||||||
|
pakiecie; rozjazd kod↔docs = agent dostaje złą mapę.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze)
|
||||||
|
|
||||||
|
Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie
|
||||||
|
zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez
|
||||||
|
PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie.
|
||||||
|
|
||||||
|
```
|
||||||
|
źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sztywna procedura zmiany w pluginie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/payload-cms/ipal-kit
|
||||||
|
|
||||||
|
# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest
|
||||||
|
grep -c "<symbol-zmiany>" src/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 1b. DOCS — jeśli zmiana dotyka API/zachowania, ZAKTUALIZUJ docs/
|
||||||
|
# (nowa funkcja, zmiana sygnatury, nowe pole panelu, nowy adapter...).
|
||||||
|
# Docs jadą w pakiecie (files: dist, docs) — nieaktualne docs = agent
|
||||||
|
# dostaje złą mapę. Kod i docs publikuj RAZEM.
|
||||||
|
|
||||||
|
# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę
|
||||||
|
pnpm build
|
||||||
|
grep -c "<symbol-zmiany>" dist/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 3. COMMIT (PRZED version — inaczej "working directory not clean")
|
||||||
|
git add -A && git commit -m "opis"
|
||||||
|
|
||||||
|
# 4. VERSION + PUBLISH
|
||||||
|
npm version patch # czyste repo wymagane
|
||||||
|
npm publish
|
||||||
|
|
||||||
|
# 5. PUSH
|
||||||
|
git push && git push --tags
|
||||||
|
|
||||||
|
# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę
|
||||||
|
cd ~/<projekt>
|
||||||
|
pnpm add @intecion/ipal-kit@<nowa-wersja>
|
||||||
|
grep -c "<symbol-zmiany>" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0
|
||||||
|
|
||||||
|
# 7. RESTART dev (Payload buduje adaptery/config przy starcie!)
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### TRZY punkty kontrolne grep (nie pomijaj żadnego)
|
||||||
|
|
||||||
|
| Etap | Grep | Jeśli 0 |
|
||||||
|
|---|---|---|
|
||||||
|
| po edycji | `src/...` | zmiana nie zapisana / zły plik |
|
||||||
|
| po build | `dist/...` | build nie złapał / błąd typów |
|
||||||
|
| po pnpm add | `node_modules/...` | projekt ma starą wersję |
|
||||||
|
|
||||||
|
**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`.
|
||||||
|
Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts`
|
||||||
|
→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js`
|
||||||
|
pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa.
|
||||||
|
|
||||||
|
### Pułapki kolejności (realne błędy sesji)
|
||||||
|
|
||||||
|
- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE
|
||||||
|
commit przed version.
|
||||||
|
- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed
|
||||||
|
publish, grep dist po buildzie.
|
||||||
|
- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci.
|
||||||
|
Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu.
|
||||||
|
- **Publikacja bez aktualizacji docs** → agent (Antigravity) po `pnpm add`
|
||||||
|
czyta `node_modules/@intecion/ipal-kit/docs/` z NIEAKTUALNĄ mapą. Jeśli
|
||||||
|
zmieniłeś API — docs w tym samym commicie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze)
|
||||||
|
|
||||||
|
Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze.
|
||||||
|
|
||||||
|
### Reguła: grepuj TOKENY, nie całe frazy z kolejnością
|
||||||
|
|
||||||
|
**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK —
|
||||||
|
bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`).
|
||||||
|
Obiekt JS ignoruje kolejność, ale grep nie.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ŹLE — zależny od kolejności/formatowania:
|
||||||
|
grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu
|
||||||
|
|
||||||
|
# DOBRZE — pojedynczy token, odporny:
|
||||||
|
grep -c "senderName" plik.ts # 3 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE"
|
||||||
|
|
||||||
|
Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę:
|
||||||
|
|
||||||
|
1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie.
|
||||||
|
Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.)
|
||||||
|
2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie.
|
||||||
|
Kliknij Save.
|
||||||
|
3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie
|
||||||
|
instancje = hooki bez kontekstu. Sprawdź:
|
||||||
|
`ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui`
|
||||||
|
Jest? → peerDependency problem (patrz Część D).
|
||||||
|
4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy
|
||||||
|
mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na
|
||||||
|
inny adres.
|
||||||
|
5. **Import map nieaktualny?** Custom komponenty Payload:
|
||||||
|
`npx payload generate:importmap`.
|
||||||
|
|
||||||
|
**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'".
|
||||||
|
Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) →
|
||||||
|
`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ D — peerDependencies (dublety zależności)
|
||||||
|
|
||||||
|
**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie,
|
||||||
|
NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet
|
||||||
|
→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem).
|
||||||
|
|
||||||
|
Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`,
|
||||||
|
`@payloadcms/plugin-*`, `react`, `react-dom`, `next`.
|
||||||
|
|
||||||
|
**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) →
|
||||||
|
pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner
|
||||||
|
wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies,
|
||||||
|
opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`.
|
||||||
|
|
||||||
|
Weryfikacja braku dubletu:
|
||||||
|
```bash
|
||||||
|
ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \
|
||||||
|
&& echo "DUBLET ✗" || echo "OK ✓"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność)
|
||||||
|
|
||||||
|
Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności.
|
||||||
|
|
||||||
|
1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22
|
||||||
|
2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion`
|
||||||
|
3. **`pnpm add @intecion/ipal-kit`** + zależności peer
|
||||||
|
4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje)
|
||||||
|
5. **i18n.config.ts** — jedno źródło locale
|
||||||
|
6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()`
|
||||||
|
7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno)
|
||||||
|
8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload
|
||||||
|
9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota
|
||||||
|
10. **Bloki** — dane przez enhanceProps, nie import lib (cykl)
|
||||||
|
11. **buildSlugField** zamiast ręcznego slug
|
||||||
|
12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek
|
||||||
|
13. **buildSecurityHeaders** w next.config
|
||||||
|
14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ F — EMAIL (SMTP vs Graph)
|
||||||
|
|
||||||
|
Pełne szczegóły: [email.md](./email.md). Decyzja transportu:
|
||||||
|
|
||||||
|
- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony)
|
||||||
|
- **Klient z własnym SMTP / Gmail** → SMTP
|
||||||
|
- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport
|
||||||
|
- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce
|
||||||
|
|
||||||
|
### Graph — checklist wdrożenia (Wasza strona, jednorazowo)
|
||||||
|
|
||||||
|
1. Azure: App registration → tenantId, clientId, clientSecret
|
||||||
|
2. Azure: Mail.Send APPLICATION permission + **Grant admin consent**
|
||||||
|
3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER
|
||||||
|
4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to)
|
||||||
|
|
||||||
|
### Realne pułapki Graph (wszystkie zdarzyły się w sesji)
|
||||||
|
|
||||||
|
| Błąd | Przyczyna | Naprawa |
|
||||||
|
|---|---|---|
|
||||||
|
| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo |
|
||||||
|
| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki |
|
||||||
|
| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure |
|
||||||
|
| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send |
|
||||||
|
|
||||||
|
**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka,
|
||||||
|
zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta
|
||||||
|
→ replyTo (odpowiedzi trafiają do klienta).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU
|
||||||
|
|
||||||
|
Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu
|
||||||
|
→ projekt. Jeśli identyczne wszędzie → plugin.**
|
||||||
|
|
||||||
|
| Rzecz | Gdzie | Dlaczego |
|
||||||
|
|---|---|---|
|
||||||
|
| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne |
|
||||||
|
| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu |
|
||||||
|
| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side |
|
||||||
|
| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie |
|
||||||
|
| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env |
|
||||||
|
| **CSP** | **projekt** | zależy od domen projektu |
|
||||||
|
| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy |
|
||||||
|
| **Breadcrumbs** | **projekt** | render z danych routingu projektu |
|
||||||
|
| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE"
|
||||||
|
|
||||||
|
Nie mów „działa", dopóki:
|
||||||
|
|
||||||
|
- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev)
|
||||||
|
- [ ] root `/` przekierowuje na locale (bez middleware.ts)
|
||||||
|
- [ ] formularz wysyła (test przez panel: Send test)
|
||||||
|
- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno)
|
||||||
|
- [ ] brak dubletu @payloadcms/ui (Część D)
|
||||||
|
- [ ] grep potwierdza wersję pluginu w node_modules
|
||||||
|
- [ ] sekrety w .env (nie w repo), maskowane w panelu
|
||||||
|
- [ ] brak plików middleware.ts, brak zaszytej mapy slugów
|
||||||
|
- [ ] strona 404 (not-found.tsx) — edytowalna, per język, link powrotu
|
||||||
|
- [ ] formularze z buildera w panelu (NIE własne hardkodowane)
|
||||||
|
- [ ] compliance: polityki, baner cookies, zgoda RODO w formularzach
|
||||||
|
(patrz [wymagania-prawne.md](./wymagania-prawne.md))
|
||||||
+4
-1
@@ -1,5 +1,7 @@
|
|||||||
# IPAL — Dokumentacja modułów
|
# 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)
|
||||||
|
|||||||
+37
-2
@@ -109,7 +109,7 @@ Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
|||||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
| `--ipal-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ą
|
||||||
@@ -131,4 +131,39 @@ Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
|||||||
domyślny (nie dokleja się).
|
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).
|
||||||
+28
-1
@@ -149,4 +149,31 @@ Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
|
|||||||
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
||||||
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ć.
|
||||||
@@ -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
|
||||||
|
|||||||
+207
-221
@@ -1,14 +1,18 @@
|
|||||||
# Nowy projekt — krok po kroku
|
# Setup projektu — od zera do wdrożenia
|
||||||
|
|
||||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym
|
Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
|
||||||
|
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
|
||||||
|
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
|
||||||
|
|
||||||
|
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
|
||||||
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
> [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 })
|
||||||
export const getSettings = cache(async (locale: string) =>
|
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
|
||||||
(await getCachedPayload()).findGlobal({
|
|
||||||
slug: 'site-settings',
|
|
||||||
locale: locale as 'pl' | 'en',
|
|
||||||
depth: 2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
|
||||||
// src/lib/locales.ts
|
|
||||||
import { cache } from 'react'
|
|
||||||
import config from '@/payload.config'
|
|
||||||
|
|
||||||
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
```ts
|
||||||
const payloadConfig = await config
|
// src/lib/content.ts — JEDYNE źródło helperów pluginu
|
||||||
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
|
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
|
```ts
|
||||||
// src/lib/pages.ts
|
// src/lib/payload.ts — funkcje projektu, typowane
|
||||||
import { cache } from 'react'
|
import { cache } from 'react'
|
||||||
import type { Page, SiteSetting } from '@/payload-types'
|
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||||
import { getCachedPayload, getSettings } from './payload'
|
import { getCachedPayload } from './content' // z content, nie osobny getPayload
|
||||||
|
import type { SiteSetting } from '@/payload-types'
|
||||||
|
|
||||||
export const resolvePage = cache(
|
export const getSettings = cache(async (locale: string) =>
|
||||||
async (locale: string, slugPath: string | null): Promise<Page | null> => {
|
getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
|
||||||
|
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
|
||||||
|
|
||||||
## 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 |
|
|
||||||
+33
-1
@@ -107,4 +107,36 @@ Zachowanie:
|
|||||||
locale z: cookie → Accept-Language → default
|
locale z: cookie → Accept-Language → default
|
||||||
- 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.
|
||||||
+290
-1
@@ -247,4 +247,293 @@ 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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/manifest.ts
|
||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { getCachedPayload } from '@/lib/content'
|
||||||
|
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||||
|
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: 'pl' as never })
|
||||||
|
|
||||||
|
// Wszystko z panelu — zero hardkodu. Ikona z pola logo/favicon (upload),
|
||||||
|
// nie ze statycznej ścieżki.
|
||||||
|
const iconUrl =
|
||||||
|
typeof settings.logo === 'object' && settings.logo?.url ? settings.logo.url : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: settings.siteName ?? '',
|
||||||
|
short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
...(iconUrl
|
||||||
|
? { icons: [{ src: iconUrl, sizes: 'any', type: 'image/svg+xml' }] }
|
||||||
|
: {}),
|
||||||
|
// description / theme_color / background_color:
|
||||||
|
// jeśli klient ich potrzebuje, DODAJ POLA w SiteSettings i czytaj stąd —
|
||||||
|
// NIE wpisuj '#d4af37' na sztywno. Bez pól — pomiń (manifest działa bez nich).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kontrast — czego NIE robić** (realny błąd z sesji):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ŹLE — wszystko zaszyte, zadziała tylko dla jednego klienta
|
||||||
|
let name = 'R Custom Cars' // hardkod nazwy
|
||||||
|
short_name: 'RCC', // hardkod
|
||||||
|
description: 'Custom car styling...', // hardkod
|
||||||
|
background_color: '#08080a', theme_color: '#d4af37', // hardkod kolorów
|
||||||
|
icons: [{ src: '/logo/rcc-logo.svg' }] // statyczna ścieżka, nie panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — **dodaj pola**
|
||||||
|
`themeColor`, `manifestDescription` w SiteSettings (SiteSettingsFields przez
|
||||||
|
opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i 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)
|
||||||
|
|
||||||
|
## 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.
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
# storage — media na Cloudflare R2
|
||||||
|
|
||||||
|
Offload mediów (obrazy, pliki) do Cloudflare R2 zamiast lokalnego dysku. R2 jest
|
||||||
|
S3-kompatybilny; plugin dostarcza `buildR2Storage`, który czyta dane z `.env`
|
||||||
|
i konfiguruje adapter.
|
||||||
|
|
||||||
|
> **Storage to infrastruktura, nie treść.** Dane R2 (klucze, bucket) idą do
|
||||||
|
> `.env` — jak DATABASE_URI, PAYLOAD_SECRET, GRAPH_*. NIE do panelu (to sekrety
|
||||||
|
> agencyjne, wiążą się przy starcie, nie zmienia ich redaktor).
|
||||||
|
|
||||||
|
## Zależność
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @payloadcms/storage-s3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zmienne .env
|
||||||
|
|
||||||
|
Patrz [R2-ENV-przyklad](../R2-ENV-przyklad.md) po pełną instrukcję skąd wziąć wartości.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
R2_BUCKET=nazwa-bucketa
|
||||||
|
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||||
|
R2_ACCESS_KEY_ID=<access-key-id>
|
||||||
|
R2_SECRET_ACCESS_KEY=<secret-access-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wpięcie (payload.config.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { buildR2Storage } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
// ...
|
||||||
|
plugins: [
|
||||||
|
ipalKit({ /* ... */ }),
|
||||||
|
buildR2Storage(['media']), // slugi kolekcji upload do offloadu
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`buildR2Storage` przyjmuje listę kolekcji upload (domyślnie `['media']`). Jeśli
|
||||||
|
masz więcej kolekcji plików: `buildR2Storage(['media', 'documents'])`.
|
||||||
|
|
||||||
|
## Zachowanie (fallback)
|
||||||
|
|
||||||
|
- **Wszystkie 4 zmienne** → media w R2.
|
||||||
|
- **Brak zmiennych** → fallback na lokalny dysk (dev działa bez R2, zero konfiguracji).
|
||||||
|
- **Część zmiennych** → ostrzeżenie w logu + fallback (częściowa konfiguracja =
|
||||||
|
pewnie pomyłka).
|
||||||
|
|
||||||
|
To wzorzec „degrade gracefully" — jak mailAdapter, który wraca do SMTP, gdy brak
|
||||||
|
Graph. Projekt działa niezależnie od tego, czy R2 jest skonfigurowany.
|
||||||
|
|
||||||
|
## Publiczny dostęp + custom domena (WAŻNE — krok po kroku)
|
||||||
|
|
||||||
|
R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403),
|
||||||
|
dopóki nie skonfigurujesz publicznego odczytu przez custom domenę. To proces
|
||||||
|
w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie.
|
||||||
|
|
||||||
|
### Dlaczego custom domena, nie „r2.dev"
|
||||||
|
|
||||||
|
R2 oferuje szybki publiczny URL `*.r2.dev`, ALE:
|
||||||
|
- jest rate-limitowany (nie do produkcji)
|
||||||
|
- nie przechodzi przez cache Cloudflare (brak CDN, wolniej, drożej)
|
||||||
|
- brzydki URL (nie Twoja domena)
|
||||||
|
|
||||||
|
Dla produkcji ZAWSZE custom domena (np. `media.klient.pl`) — daje CDN, cache,
|
||||||
|
własny URL. r2.dev tylko do szybkiego testu.
|
||||||
|
|
||||||
|
### Warunek wstępny: domena w Cloudflare
|
||||||
|
|
||||||
|
Custom domena dla R2 wymaga, żeby domena (albo subdomena) była zarządzana przez
|
||||||
|
Cloudflare (nameservery klienta wskazują na Cloudflare). Jeśli domena klienta
|
||||||
|
jest u innego rejestratora — trzeba ją najpierw dodać do Cloudflare (Add Site)
|
||||||
|
i przełączyć nameservery. Sama subdomena `media.klient.pl` wystarczy, jeśli
|
||||||
|
główna domena jest już w Cloudflare.
|
||||||
|
|
||||||
|
### Krok po kroku — podpięcie custom domeny
|
||||||
|
|
||||||
|
1. **Cloudflare Dashboard → R2 → wybierz bucket**
|
||||||
|
2. Zakładka **Settings** → sekcja **Public access** → **Custom Domains**
|
||||||
|
3. **Connect Domain** → wpisz subdomenę, np. `media.klient.pl`
|
||||||
|
4. Cloudflare automatycznie doda rekord CNAME (bo domena jest w Cloudflare) i
|
||||||
|
wystawi certyfikat SSL. Poczekaj, aż status = **Active** (kilka minut).
|
||||||
|
5. Od tej chwili pliki są publiczne pod `https://media.klient.pl/<klucz-pliku>`.
|
||||||
|
|
||||||
|
### Krok: ustaw publiczny URL w projekcie
|
||||||
|
|
||||||
|
Payload musi generować URL-e mediów wskazujące na custom domenę, nie na endpoint
|
||||||
|
S3. Dodaj zmienną i przekaż ją do adaptera:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
R2_PUBLIC_URL=https://media.klient.pl
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter `buildR2Storage` czyta ją i ustawia jako bazowy URL mediów (jeśli
|
||||||
|
ustawiona). Bez niej Payload zwróci URL wskazujący na prywatny endpoint S3 →
|
||||||
|
403 na froncie. (Patrz aktualizacja buildR2Storage niżej.)
|
||||||
|
|
||||||
|
### Weryfikacja
|
||||||
|
|
||||||
|
1. Wgraj obraz w panelu (Media).
|
||||||
|
2. Sprawdź URL obrazu w panelu — powinien być `https://media.klient.pl/...`,
|
||||||
|
NIE `https://<account>.r2.cloudflarestorage.com/...`.
|
||||||
|
3. Otwórz URL w przeglądarce — obraz się pokazuje (nie 403).
|
||||||
|
4. Na froncie `<img src>` działa.
|
||||||
|
|
||||||
|
### Częsty błąd: 403 mimo custom domeny
|
||||||
|
|
||||||
|
- **URL wskazuje na endpoint S3, nie custom domenę** → brakuje `R2_PUBLIC_URL`
|
||||||
|
albo adapter jej nie używa. Sprawdź URL w panelu.
|
||||||
|
- **Custom domena nie Active** → poczekaj na SSL/CNAME w Cloudflare.
|
||||||
|
- **Public access wyłączony** → w bucket Settings sprawdź, czy custom domena jest
|
||||||
|
podpięta (nie tylko utworzona).
|
||||||
|
|
||||||
|
Bez tego media wgrają się do R2, ale front pokaże 403. Konfiguracja domeny jest
|
||||||
|
po stronie Cloudflare, publiczny URL po stronie projektu (.env).
|
||||||
|
|
||||||
|
## Migracja istniejących mediów
|
||||||
|
|
||||||
|
Jeśli projekt miał media lokalnie i przełączasz na R2 — nowe uploady idą do R2,
|
||||||
|
ale STARE zostają na dysku (i znikną przy redeployu bez wolumenu). Przed
|
||||||
|
przełączeniem na produkcji przenieś istniejące pliki do bucketa (np. `rclone`
|
||||||
|
albo ręcznie przez R2 dashboard), inaczej stare obrazy znikną.
|
||||||
|
|
||||||
|
## Weryfikacja
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# po wpięciu i ustawieniu .env:
|
||||||
|
pnpm dev
|
||||||
|
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
||||||
|
|
||||||
|
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||||
|
|
||||||
|
- **Dev bez R2 w .env** → media na lokalnym dysku. Szybki start, zero konfiguracji.
|
||||||
|
- **Dev z R2 w .env** → media w R2 już podczas developmentu. Przydatne, gdy
|
||||||
|
seedujesz treść w devie i chcesz, żeby od razu lądowała w buckecie (np. wspólny
|
||||||
|
bucket dev, albo test realnego flow przed produkcją).
|
||||||
|
|
||||||
|
Przełączasz trybem po prostu obecnością zmiennych R2 w `.env`. Ten sam kod,
|
||||||
|
`buildR2Storage` sam wykrywa. Nie musisz nic zmieniać w configu między trybami.
|
||||||
|
|
||||||
|
> Jeśli seedujesz w devie do R2 — pamiętaj, że to realny bucket. Używaj osobnego
|
||||||
|
> bucketa dev (nie produkcyjnego), żeby nie mieszać danych testowych z realnymi.
|
||||||
|
|
||||||
|
## Normalizacja nazw plików (automatyczna)
|
||||||
|
|
||||||
|
Plik `normalizeFilenameHook` czyści nazwy wgrywanych plików — slugifikuje nazwę,
|
||||||
|
zachowuje rozszerzenie:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||||
|
"Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wpięcie w kolekcję Media (projekt):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
export const Media: CollectionConfig = {
|
||||||
|
slug: 'media',
|
||||||
|
upload: { staticDir: 'media' /* ... */ },
|
||||||
|
hooks: {
|
||||||
|
beforeOperation: [normalizeFilenameHook], // czyści nazwę przed zapisem
|
||||||
|
},
|
||||||
|
fields: [ /* alt itd. */ ],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Działa z lokalnym dyskiem i z R2/S3 (hook biegnie PRZED warstwą storage, więc
|
||||||
|
czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
|
||||||
|
|
||||||
|
- **URL-e mediów są czyste** — `/media/zdjecie-nad-morzem.jpg`, nie
|
||||||
|
`/media/Zdjęcie%20jeden%20nad%20morzem.jpg` (spacje/diakrytyki w URL = problemy).
|
||||||
|
- **Przenośność** — nazwa bez polskich znaków/spacji działa wszędzie (CDN, S3, systemy plików).
|
||||||
|
- **Bez kolizji kodowania** — spacje i `#`, `()` w nazwach plików potrafią psuć
|
||||||
|
ścieżki i cache.
|
||||||
|
|
||||||
|
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
||||||
|
jej poza hookiem.
|
||||||
|
|
||||||
|
## Preconnect do domeny mediów (wydajność)
|
||||||
|
|
||||||
|
Komponent `MediaPreconnect` generuje `<link rel="preconnect">` + `dns-prefetch`
|
||||||
|
dla domeny mediów (R2_PUBLIC_URL) — przeglądarka nawiązuje połączenie TLS/DNS
|
||||||
|
z CDN zawczasu, zanim napotka pierwszy `<img>`. Zysk ~150–300 ms na pierwszym
|
||||||
|
obrazie.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// layout.tsx — w <head> (albo górze <body>, Next hoistuje link tagi)
|
||||||
|
import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<MediaPreconnect />
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
Czyta domenę z **R2_PUBLIC_URL** (to samo źródło co buildR2Storage) — zero
|
||||||
|
hardkodu, jedno źródło prawdy. Gdy R2_PUBLIC_URL nie ustawione (lokalny dysk,
|
||||||
|
brak CDN) → nie renderuje nic. Zmiana domeny mediów = zmiana jednej zmiennej
|
||||||
|
env, komponent podąża automatycznie.
|
||||||
|
|
||||||
|
> NIE hardkoduj `<link rel="preconnect" href="https://media.klient.pl">` ręcznie
|
||||||
|
> w layoutcie — to zaszywa domenę klienta w kodzie. Użyj MediaPreconnect, który
|
||||||
|
> bierze ją z env.
|
||||||
+19
-14
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.0.10",
|
"version": "1.1.2",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist",
|
||||||
|
"docs"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||||
@@ -59,28 +60,32 @@
|
|||||||
"test:int": "vitest"
|
"test:int": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@payloadcms/storage-s3": "^3.88.0",
|
||||||
"lucide-react": "^0.400.0",
|
"lucide-react": "^0.400.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"slugify": "^1.6.6"
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@payloadcms/plugin-form-builder": "^3.84.1",
|
"@payloadcms/next": "^3.88.0",
|
||||||
"@payloadcms/plugin-seo": "^3.84.1",
|
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||||
|
"@payloadcms/plugin-seo": "^3.88.0",
|
||||||
|
"@payloadcms/ui": "^3.88.0",
|
||||||
"next": ">=15",
|
"next": ">=15",
|
||||||
"payload": "^3.84.1",
|
"payload": "^3.88.0",
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@payloadcms/db-postgres": "3.84.1",
|
"@payloadcms/db-postgres": "3.88.0",
|
||||||
"@payloadcms/db-sqlite": "3.84.1",
|
"@payloadcms/db-sqlite": "3.88.0",
|
||||||
"@payloadcms/eslint-config": "3.28.0",
|
"@payloadcms/eslint-config": "3.28.0",
|
||||||
"@payloadcms/next": "3.84.1",
|
"@payloadcms/next": "3.88.0",
|
||||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||||
"@payloadcms/plugin-seo": "3.84.1",
|
"@payloadcms/plugin-seo": "3.88.0",
|
||||||
"@payloadcms/richtext-lexical": "3.84.1",
|
"@payloadcms/richtext-lexical": "3.88.0",
|
||||||
"@payloadcms/ui": "3.84.1",
|
"@payloadcms/ui": "3.88.0",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@swc-node/register": "1.10.9",
|
"@swc-node/register": "1.10.9",
|
||||||
"@swc/cli": "0.6.0",
|
"@swc/cli": "0.6.0",
|
||||||
@@ -96,7 +101,7 @@
|
|||||||
"mongodb-memory-server": "10.1.4",
|
"mongodb-memory-server": "10.1.4",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"payload": "3.84.1",
|
"payload": "3.88.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"qs-esm": "8.0.1",
|
"qs-esm": "8.0.1",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
|
|||||||
Generated
+632
-417
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,6 @@ export {
|
|||||||
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'
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export type {
|
|||||||
EnhanceProps,
|
EnhanceProps,
|
||||||
RenderBlocksProps,
|
RenderBlocksProps,
|
||||||
} from '../modules/blocks/index.js'
|
} from '../modules/blocks/index.js'
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { TextFieldClientComponent } from 'payload'
|
import type { TextFieldClientComponent } from 'payload'
|
||||||
|
|
||||||
import { useField } from '@payloadcms/ui'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
export const MaskedField: TextFieldClientComponent = (props: any) => {
|
||||||
const { setValue, value } = useField<string>({ path })
|
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
|
||||||
|
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: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className="field-type text">
|
<div className="field-type text">
|
||||||
<label className="field-label">{label}</label>
|
{label && <label className="field-label">{label}</label>}
|
||||||
<div style={{ display: 'flex', gap: '.5rem' }}>
|
<div style={{ display: 'flex', gap: '.5rem' }}>
|
||||||
<input
|
<input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={handleChange}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
type={revealed ? 'text' : 'password'}
|
type={revealed ? 'text' : 'password'}
|
||||||
value={value ?? ''}
|
value={currentValue ?? ''}
|
||||||
/>
|
/>
|
||||||
<button onClick={() => setRevealed((r) => !r)} type="button">
|
<button onClick={() => setRevealed((r) => !r)} type="button">
|
||||||
{revealed ? 'Hide' : 'Reveal'}
|
{revealed ? 'Hide' : 'Reveal'}
|
||||||
@@ -27,4 +39,5 @@ export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MaskedField
|
export default MaskedField
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Field } from 'payload'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cloudflare R2 storage credentials.
|
|
||||||
* Reserved for future use — media offloading to R2.
|
|
||||||
*
|
|
||||||
* Protected at the global level (SiteIntegrations requires an authenticated
|
|
||||||
* user), so the access keys stay editable in the admin panel while remaining
|
|
||||||
* inaccessible to anonymous API requests.
|
|
||||||
*/
|
|
||||||
export const storageFields: Field[] = [
|
|
||||||
{
|
|
||||||
name: 'r2Bucket',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'R2 bucket name.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'r2Endpoint',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'R2 S3-compatible endpoint URL.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'r2AccessKeyId',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'R2 access key ID.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'r2SecretAccessKey',
|
|
||||||
type: 'text',
|
|
||||||
admin: {
|
|
||||||
description: 'R2 secret access key.',
|
|
||||||
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
|
|
||||||
components: {
|
|
||||||
Field: '@intecion/ipal-kit/client#MaskedField',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -3,7 +3,6 @@ import type { Field, GlobalConfig } from 'payload'
|
|||||||
import { isAdmin } from '../../modules/access/index.js'
|
import { 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'
|
||||||
|
|
||||||
type BuildSiteIntegrationsArgs = {
|
type BuildSiteIntegrationsArgs = {
|
||||||
@@ -22,6 +21,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 function buildSiteIntegrations({
|
export function buildSiteIntegrations({
|
||||||
additionalFields,
|
additionalFields,
|
||||||
@@ -44,7 +47,6 @@ export function buildSiteIntegrations({
|
|||||||
{ fields: analyticsFields, label: 'Analytics' },
|
{ fields: analyticsFields, label: 'Analytics' },
|
||||||
{ fields: turnstileFields, label: 'Turnstile' },
|
{ fields: turnstileFields, label: 'Turnstile' },
|
||||||
{ fields: smtpFields, label: 'SMTP' },
|
{ fields: smtpFields, label: 'SMTP' },
|
||||||
{ fields: storageFields, label: 'Storage' },
|
|
||||||
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Field } from 'payload'
|
import type { Field } from 'payload'
|
||||||
|
|
||||||
|
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: Field[] = [
|
export const generalFields: Field[] = [
|
||||||
{
|
{
|
||||||
@@ -45,7 +47,8 @@ export const generalFields: Field[] = [
|
|||||||
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',
|
||||||
},
|
},
|
||||||
@@ -61,7 +64,13 @@ export const generalFields: Field[] = [
|
|||||||
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',
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-2
@@ -75,6 +75,18 @@ export {
|
|||||||
} from './modules/i18n/index.js'
|
} from './modules/i18n/index.js'
|
||||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
||||||
|
// Media — filename normalization hook for upload collections (Media).
|
||||||
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'
|
||||||
|
export {
|
||||||
|
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'
|
||||||
@@ -87,6 +99,18 @@ export {
|
|||||||
} from './modules/payload/index.js'
|
} from './modules/payload/index.js'
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
|
export {
|
||||||
|
buildIconsMetadata,
|
||||||
|
buildOrganizationJsonLd,
|
||||||
|
validateFaviconField,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
|
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||||
|
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||||
|
export {
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
buildSiteNavigationJsonLd,
|
||||||
|
buildWebSiteJsonLd,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||||
@@ -99,8 +123,8 @@ export {
|
|||||||
injectAutoFillMeta,
|
injectAutoFillMeta,
|
||||||
} from './modules/seo/index.js'
|
} 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'
|
||||||
export type { IpalOptions } from './types.js'
|
export type { IpalOptions } from './types.js'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,10 +115,19 @@ export const graphAdapter =
|
|||||||
return { error: 'Graph is not configured (missing env vars).', sent: false }
|
return { error: 'Graph is not configured (missing env vars).', 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<GraphIntegrations>(payload)
|
const panel = await getSiteIntegrations<GraphIntegrations>(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) {
|
||||||
@@ -130,25 +139,26 @@ export const graphAdapter =
|
|||||||
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 as SendEmailOptions['to'])
|
||||||
|
: replyToAddress
|
||||||
|
? [{ emailAddress: { address: replyToAddress } }]
|
||||||
|
: []
|
||||||
|
|
||||||
const graphMessage: Record<string, unknown> = {
|
const graphMessage: Record<string, unknown> = {
|
||||||
body: { content, contentType: isHtml ? 'HTML' : 'Text' },
|
body: { content, contentType: isHtml ? 'HTML' : 'Text' },
|
||||||
subject: message.subject ?? '',
|
subject: message.subject ?? '',
|
||||||
toRecipients: to,
|
toRecipients: to,
|
||||||
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
|
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
|
||||||
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
|
...(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: { name: senderName, address: env.sender } } }
|
||||||
emailAddress: { address: fromAddress, ...(fromName ? { name: fromName } : {}) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
// replyTo lets the recipient reply to the real submitter if the caller set it.
|
|
||||||
...(message.replyTo
|
|
||||||
? { replyTo: toRecipients(message.replyTo as SendEmailOptions['to']) }
|
|
||||||
: {}),
|
: {}),
|
||||||
|
...(replyTo.length > 0 ? { replyTo } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import type { I18nConfig } from './types.js'
|
|||||||
import { getLocaleCodes, isValidLocale } from './helpers.js'
|
import { getLocaleCodes, isValidLocale } from './helpers.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 const LOCALE_COOKIE_NAME = 'ipal-locale'
|
/**
|
||||||
|
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||||
|
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||||
|
* other libraries that read the active locale (instead of a plugin-specific
|
||||||
|
* name). Written only under functional consent; cleared when that consent is
|
||||||
|
* withdrawn (see consent cookieMap).
|
||||||
|
*/
|
||||||
|
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'
|
||||||
|
|
||||||
type NegotiateLocaleArgs = {
|
type NegotiateLocaleArgs = {
|
||||||
/** Raw Accept-Language header value */
|
/** Raw Accept-Language header value */
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CollectionBeforeOperationHook } from 'payload'
|
||||||
|
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: string): string {
|
||||||
|
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: CollectionBeforeOperationHook = ({ req, operation }) => {
|
||||||
|
if (operation !== 'create' && operation !== 'update') return
|
||||||
|
const file = req.file
|
||||||
|
if (file?.name) {
|
||||||
|
file.name = normalizeFilename(file.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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 function buildBreadcrumbJsonLd(crumbs: Crumb[]) {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 function buildIconsMetadata(favicon: MediaLike): Metadata {
|
||||||
|
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 }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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 function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {
|
||||||
|
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 } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 function buildSiteNavigationJsonLd(items: NavItem[]) {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
||||||
|
|
||||||
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
|
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
|
||||||
|
|
||||||
type BuildHreflangArgs = {
|
type BuildHreflangArgs = {
|
||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
@@ -19,20 +19,28 @@ type BuildHreflangArgs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
|
* Also emits `x-default` pointing at the default locale — the version Google
|
||||||
|
* serves when the user's language/region matches no hreflang, and the fallback
|
||||||
|
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
||||||
|
* Accept-Language).
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
* // → {
|
||||||
|
* // pl: 'https://example.com/pl/o-nas',
|
||||||
|
* // en: 'https://example.com/en/about',
|
||||||
|
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||||
|
* // }
|
||||||
*/
|
*/
|
||||||
export function buildHreflangAlternates({
|
export function buildHreflangAlternates({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -51,5 +59,15 @@ export function buildHreflangAlternates({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// x-default: the version Google serves when the user's language/region doesn't
|
||||||
|
// match any hreflang — and, crucially here, the fallback when the root ('/')
|
||||||
|
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||||
|
// at the default locale (the primary market) so search shows that version by
|
||||||
|
// default instead of guessing. Only set when the default locale has a URL.
|
||||||
|
const defaultLocalePath = alternates[getDefaultLocale(config)]
|
||||||
|
if (defaultLocalePath) {
|
||||||
|
alternates['x-default'] = defaultLocalePath
|
||||||
|
}
|
||||||
|
|
||||||
return alternates
|
return alternates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
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 { buildIconsMetadata } from './buildIconsMetadata.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 { 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 +23,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'
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 const validateFaviconField: FieldHook = 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 as { id: string }).id : value,
|
||||||
|
collection: 'media',
|
||||||
|
depth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const width = (media as { width?: number }).width
|
||||||
|
const height = (media as { height?: number }).height
|
||||||
|
const mimeType = (media as { mimeType?: string }).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
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 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: string
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link crossOrigin="anonymous" href={origin} rel="preconnect" />
|
||||||
|
<link href={origin} rel="dns-prefetch" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaPreconnect
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Plugin } from 'payload'
|
||||||
|
|
||||||
|
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: string[] = ['media']): Plugin => {
|
||||||
|
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 }: { filename: string; prefix?: string }) =>
|
||||||
|
[publicUrl, prefix, filename].filter(Boolean).join('/')
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}
|
||||||
|
for (const slug of collections) {
|
||||||
|
collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true
|
||||||
|
}
|
||||||
|
|
||||||
|
return s3Storage({
|
||||||
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
|
config: {
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
endpoint,
|
||||||
|
region: 'auto', // R2 uses 'auto'
|
||||||
|
// R2 requires path-style addressing for S3 compatibility.
|
||||||
|
forcePathStyle: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js'
|
||||||
Reference in New Issue
Block a user