Compare commits
42
Commits
v1.0.7
...
e30ac71044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 | ||
|
|
60f07fddc9 | ||
|
|
068415849f | ||
|
|
e39e2a361a | ||
|
|
9d3e749c38 | ||
|
|
b7c6cb4d81 | ||
|
|
c41b75e364 | ||
|
|
41630bb8c5 | ||
|
|
b9430e7ab6 | ||
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 | ||
|
|
ed4a78b109 | ||
|
|
9acb22e2f9 | ||
|
|
6a8361d710 | ||
|
|
502ffee31f | ||
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 | ||
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f | ||
|
|
cef7f46718 | ||
|
|
ac48f1e9d1 | ||
|
|
3d8bc9019f | ||
|
|
cd65c2799f | ||
|
|
282be290cd |
Vendored
+3
@@ -1,4 +1,5 @@
|
|||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||||
export { Analytics } from '../modules/analytics/client.js';
|
export { Analytics } from '../modules/analytics/client.js';
|
||||||
/**
|
/**
|
||||||
* Entry point: ipal-kit/client
|
* Entry point: ipal-kit/client
|
||||||
@@ -11,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
+2
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
export { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js';
|
||||||
|
export { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js';
|
||||||
export { Analytics } from '../modules/analytics/client.js';
|
export { Analytics } from '../modules/analytics/client.js';
|
||||||
/**
|
/**
|
||||||
* Entry point: ipal-kit/client
|
* Entry point: ipal-kit/client
|
||||||
@@ -9,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 { 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","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,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"}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||||
|
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||||
|
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||||
|
* result inline so you can confirm delivery — or read the exact error — without
|
||||||
|
* leaving the panel.
|
||||||
|
*
|
||||||
|
* Assigned via a `ui` field's admin.components.Field.
|
||||||
|
*/
|
||||||
|
export declare const TestEmailButton: () => import("react/jsx-runtime").JSX.Element;
|
||||||
|
export default TestEmailButton;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { useState } from 'react';
|
||||||
|
/**
|
||||||
|
* Admin UI: a small "send test email" tool for the SiteIntegrations email tab.
|
||||||
|
* Enter an address, click Send, and it POSTs to /api/ipal/test-email, which
|
||||||
|
* sends through the currently-selected transport (SMTP or Graph). Shows the
|
||||||
|
* result inline so you can confirm delivery — or read the exact error — without
|
||||||
|
* leaving the panel.
|
||||||
|
*
|
||||||
|
* Assigned via a `ui` field's admin.components.Field.
|
||||||
|
*/ export const TestEmailButton = ()=>{
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [status, setStatus] = useState({
|
||||||
|
kind: 'idle'
|
||||||
|
});
|
||||||
|
const send = async ()=>{
|
||||||
|
if (!to.trim()) {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: 'Enter a recipient address.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({
|
||||||
|
kind: 'sending'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ipal/test-email', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: to.trim()
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
setStatus({
|
||||||
|
kind: 'ok',
|
||||||
|
msg: data.message ?? 'Test email sent.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: data.error ?? 'Send failed.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus({
|
||||||
|
kind: 'error',
|
||||||
|
msg: 'Request failed. Is the server running?'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return /*#__PURE__*/ _jsxs("div", {
|
||||||
|
className: "field-type",
|
||||||
|
style: {
|
||||||
|
marginTop: '1rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("label", {
|
||||||
|
className: "field-label",
|
||||||
|
children: "Send a test email"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("p", {
|
||||||
|
style: {
|
||||||
|
fontSize: '.85rem',
|
||||||
|
marginTop: 0,
|
||||||
|
opacity: 0.7
|
||||||
|
},
|
||||||
|
children: "Sends through the transport selected above. Save your changes first."
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsxs("div", {
|
||||||
|
style: {
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("input", {
|
||||||
|
onChange: (e)=>setTo(e.target.value),
|
||||||
|
placeholder: "[email protected]",
|
||||||
|
style: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '220px'
|
||||||
|
},
|
||||||
|
type: "email",
|
||||||
|
value: to
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("button", {
|
||||||
|
className: "btn btn--style-secondary",
|
||||||
|
disabled: status.kind === 'sending',
|
||||||
|
onClick: send,
|
||||||
|
style: {
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
children: status.kind === 'sending' ? 'Sending…' : 'Send test'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
status.kind === 'ok' && /*#__PURE__*/ _jsxs("p", {
|
||||||
|
style: {
|
||||||
|
color: 'var(--theme-success-500, green)',
|
||||||
|
marginTop: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"✓ ",
|
||||||
|
status.msg
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
status.kind === 'error' && /*#__PURE__*/ _jsxs("p", {
|
||||||
|
style: {
|
||||||
|
color: 'var(--theme-error-500, crimson)',
|
||||||
|
marginTop: '.5rem'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"✗ ",
|
||||||
|
status.msg
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export default TestEmailButton;
|
||||||
|
|
||||||
|
//# sourceMappingURL=TestEmailButton.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/TestEmailButton.tsx"],"sourcesContent":["'use client'\n\nimport { useState } from 'react'\n\n/**\n * Admin UI: a small \"send test email\" tool for the SiteIntegrations email tab.\n * Enter an address, click Send, and it POSTs to /api/ipal/test-email, which\n * sends through the currently-selected transport (SMTP or Graph). Shows the\n * result inline so you can confirm delivery — or read the exact error — without\n * leaving the panel.\n *\n * Assigned via a `ui` field's admin.components.Field.\n */\nexport const TestEmailButton = () => {\n const [to, setTo] = useState('')\n const [status, setStatus] = useState<\n | { kind: 'error'; msg: string }\n | { kind: 'idle' }\n | { kind: 'ok'; msg: string }\n | { kind: 'sending' }\n >({ kind: 'idle' })\n\n const send = async () => {\n if (!to.trim()) {\n setStatus({ kind: 'error', msg: 'Enter a recipient address.' })\n return\n }\n setStatus({ kind: 'sending' })\n try {\n const res = await fetch('/api/ipal/test-email', {\n body: JSON.stringify({ to: to.trim() }),\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n })\n const data = await res.json()\n if (data.ok) {\n setStatus({ kind: 'ok', msg: data.message ?? 'Test email sent.' })\n } else {\n setStatus({ kind: 'error', msg: data.error ?? 'Send failed.' })\n }\n } catch {\n setStatus({ kind: 'error', msg: 'Request failed. Is the server running?' })\n }\n }\n\n return (\n <div className=\"field-type\" style={{ marginTop: '1rem' }}>\n <label className=\"field-label\">Send a test email</label>\n <p style={{ fontSize: '.85rem', marginTop: 0, opacity: 0.7 }}>\n Sends through the transport selected above. Save your changes first.\n </p>\n <div style={{ alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: '.5rem' }}>\n <input\n onChange={(e) => setTo(e.target.value)}\n placeholder=\"[email protected]\"\n style={{ flex: 1, minWidth: '220px' }}\n type=\"email\"\n value={to}\n />\n <button\n className=\"btn btn--style-secondary\"\n disabled={status.kind === 'sending'}\n onClick={send}\n style={{ whiteSpace: 'nowrap' }}\n type=\"button\"\n >\n {status.kind === 'sending' ? 'Sending…' : 'Send test'}\n </button>\n </div>\n {status.kind === 'ok' && (\n <p style={{ color: 'var(--theme-success-500, green)', marginTop: '.5rem' }}>\n ✓ {status.msg}\n </p>\n )}\n {status.kind === 'error' && (\n <p style={{ color: 'var(--theme-error-500, crimson)', marginTop: '.5rem' }}>\n ✗ {status.msg}\n </p>\n )}\n </div>\n )\n}\n\nexport default TestEmailButton\n"],"names":["useState","TestEmailButton","to","setTo","status","setStatus","kind","send","trim","msg","res","fetch","body","JSON","stringify","credentials","headers","method","data","json","ok","message","error","div","className","style","marginTop","label","p","fontSize","opacity","alignItems","display","flexWrap","gap","input","onChange","e","target","value","placeholder","flex","minWidth","type","button","disabled","onClick","whiteSpace","color"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC;;;;;;;;CAQC,GACD,OAAO,MAAMC,kBAAkB;IAC7B,MAAM,CAACC,IAAIC,MAAM,GAAGH,SAAS;IAC7B,MAAM,CAACI,QAAQC,UAAU,GAAGL,SAK1B;QAAEM,MAAM;IAAO;IAEjB,MAAMC,OAAO;QACX,IAAI,CAACL,GAAGM,IAAI,IAAI;YACdH,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAA6B;YAC7D;QACF;QACAJ,UAAU;YAAEC,MAAM;QAAU;QAC5B,IAAI;YACF,MAAMI,MAAM,MAAMC,MAAM,wBAAwB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAEZ,IAAIA,GAAGM,IAAI;gBAAG;gBACrCO,aAAa;gBACbC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,QAAQ;YACV;YACA,MAAMC,OAAO,MAAMR,IAAIS,IAAI;YAC3B,IAAID,KAAKE,EAAE,EAAE;gBACXf,UAAU;oBAAEC,MAAM;oBAAMG,KAAKS,KAAKG,OAAO,IAAI;gBAAmB;YAClE,OAAO;gBACLhB,UAAU;oBAAEC,MAAM;oBAASG,KAAKS,KAAKI,KAAK,IAAI;gBAAe;YAC/D;QACF,EAAE,OAAM;YACNjB,UAAU;gBAAEC,MAAM;gBAASG,KAAK;YAAyC;QAC3E;IACF;IAEA,qBACE,MAACc;QAAIC,WAAU;QAAaC,OAAO;YAAEC,WAAW;QAAO;;0BACrD,KAACC;gBAAMH,WAAU;0BAAc;;0BAC/B,KAACI;gBAAEH,OAAO;oBAAEI,UAAU;oBAAUH,WAAW;oBAAGI,SAAS;gBAAI;0BAAG;;0BAG9D,MAACP;gBAAIE,OAAO;oBAAEM,YAAY;oBAAUC,SAAS;oBAAQC,UAAU;oBAAQC,KAAK;gBAAQ;;kCAClF,KAACC;wBACCC,UAAU,CAACC,IAAMlC,MAAMkC,EAAEC,MAAM,CAACC,KAAK;wBACrCC,aAAY;wBACZf,OAAO;4BAAEgB,MAAM;4BAAGC,UAAU;wBAAQ;wBACpCC,MAAK;wBACLJ,OAAOrC;;kCAET,KAAC0C;wBACCpB,WAAU;wBACVqB,UAAUzC,OAAOE,IAAI,KAAK;wBAC1BwC,SAASvC;wBACTkB,OAAO;4BAAEsB,YAAY;wBAAS;wBAC9BJ,MAAK;kCAEJvC,OAAOE,IAAI,KAAK,YAAY,aAAa;;;;YAG7CF,OAAOE,IAAI,KAAK,sBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;YAGhBL,OAAOE,IAAI,KAAK,yBACf,MAACsB;gBAAEH,OAAO;oBAAEuB,OAAO;oBAAmCtB,WAAW;gBAAQ;;oBAAG;oBACvEtB,OAAOK,GAAG;;;;;AAKvB,EAAC;AAED,eAAeR,gBAAe"}
|
||||||
+31
@@ -5,8 +5,30 @@
|
|||||||
* user), so all fields — including the password — stay editable in the admin
|
* user), so all fields — including the password — stay editable in the admin
|
||||||
* panel while remaining inaccessible to anonymous API requests.
|
* panel while remaining inaccessible to anonymous API requests.
|
||||||
*/ export const smtpFields = [
|
*/ export const smtpFields = [
|
||||||
|
{
|
||||||
|
name: 'emailTransport',
|
||||||
|
type: 'select',
|
||||||
|
admin: {
|
||||||
|
description: 'How outbound email is sent. "Microsoft Graph" is only available when configured by the administrator (Intecion).'
|
||||||
|
},
|
||||||
|
defaultValue: 'smtp',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'SMTP',
|
||||||
|
value: 'smtp'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Microsoft Graph (Exchange)',
|
||||||
|
value: 'graph'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'row',
|
type: 'row',
|
||||||
|
admin: {
|
||||||
|
// Hide SMTP fields when Graph is selected — they're not used then.
|
||||||
|
condition: (_, siblingData)=>siblingData?.emailTransport !== 'graph'
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'smtpHost',
|
name: 'smtpHost',
|
||||||
@@ -57,6 +79,15 @@
|
|||||||
admin: {
|
admin: {
|
||||||
description: 'Default "from" display name.'
|
description: 'Default "from" display name.'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'emailTest',
|
||||||
|
type: 'ui',
|
||||||
|
admin: {
|
||||||
|
components: {
|
||||||
|
Field: '@intecion/ipal-kit/client#TestEmailButton'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n type: 'row',\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n]\n"],"names":["smtpFields","type","fields","name","admin","placeholder","width","defaultValue","description","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,QAAQ;YACN;gBACEC,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEC,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEH,MAAM;gBACNF,MAAM;gBACNG,OAAO;oBAAEE,OAAO;gBAAM;gBACtBC,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;YACb,sEAAsE;YACtEC,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEP,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;IACA;QACEL,MAAM;QACNF,MAAM;QACNG,OAAO;YACLI,aAAa;QACf;IACF;CACD,CAAA"}
|
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/fields/smtp.ts"],"sourcesContent":["import type { Field } from 'payload'\n\n/**\n * SMTP transport settings for outbound email.\n *\n * Protected at the global level (SiteIntegrations requires an authenticated\n * user), so all fields — including the password — stay editable in the admin\n * panel while remaining inaccessible to anonymous API requests.\n */\nexport const smtpFields: Field[] = [\n {\n name: 'emailTransport',\n type: 'select',\n admin: {\n description:\n 'How outbound email is sent. \"Microsoft Graph\" is only available when configured by the administrator (Intecion).',\n // The Graph option only makes sense when agency credentials exist in env.\n // We can't read process.env in the admin UI directly, so a client project\n // that hasn't set up Graph should filter this option via integrationsFields\n // override, or simply leave it on 'smtp'. The adapter enforces the real\n // availability at send time regardless of what's selected here.\n },\n defaultValue: 'smtp',\n options: [\n { label: 'SMTP', value: 'smtp' },\n { label: 'Microsoft Graph (Exchange)', value: 'graph' },\n ],\n },\n {\n type: 'row',\n admin: {\n // Hide SMTP fields when Graph is selected — they're not used then.\n condition: (_, siblingData) => siblingData?.emailTransport !== 'graph',\n },\n fields: [\n {\n name: 'smtpHost',\n type: 'text',\n admin: { placeholder: 'smtp.example.com', width: '70%' },\n },\n {\n name: 'smtpPort',\n type: 'number',\n admin: { width: '30%' },\n defaultValue: 587,\n },\n ],\n },\n {\n name: 'smtpUser',\n type: 'text',\n admin: {\n description: 'SMTP account username.',\n },\n },\n {\n name: 'smtpPassword',\n type: 'text',\n admin: {\n description: 'SMTP account password.',\n // Masked in the UI (••••) — stored plaintext, readable for SMTP auth.\n components: {\n Field: '@intecion/ipal-kit/client#MaskedField',\n },\n },\n },\n {\n name: 'smtpFromAddress',\n type: 'email',\n admin: {\n description: 'Default \"from\" address for outgoing mail.',\n },\n },\n {\n name: 'smtpFromName',\n type: 'text',\n admin: {\n description: 'Default \"from\" display name.',\n },\n },\n {\n name: 'emailTest',\n type: 'ui',\n admin: {\n components: {\n Field: '@intecion/ipal-kit/client#TestEmailButton',\n },\n },\n },\n]\n"],"names":["smtpFields","name","type","admin","description","defaultValue","options","label","value","condition","_","siblingData","emailTransport","fields","placeholder","width","components","Field"],"mappings":"AAEA;;;;;;CAMC,GACD,OAAO,MAAMA,aAAsB;IACjC;QACEC,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aACE;QAMJ;QACAC,cAAc;QACdC,SAAS;YACP;gBAAEC,OAAO;gBAAQC,OAAO;YAAO;YAC/B;gBAAED,OAAO;gBAA8BC,OAAO;YAAQ;SACvD;IACH;IACA;QACEN,MAAM;QACNC,OAAO;YACL,mEAAmE;YACnEM,WAAW,CAACC,GAAGC,cAAgBA,aAAaC,mBAAmB;QACjE;QACAC,QAAQ;YACN;gBACEZ,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEW,aAAa;oBAAoBC,OAAO;gBAAM;YACzD;YACA;gBACEd,MAAM;gBACNC,MAAM;gBACNC,OAAO;oBAAEY,OAAO;gBAAM;gBACtBV,cAAc;YAChB;SACD;IACH;IACA;QACEJ,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;YACb,sEAAsE;YACtEY,YAAY;gBACVC,OAAO;YACT;QACF;IACF;IACA;QACEhB,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLC,aAAa;QACf;IACF;IACA;QACEH,MAAM;QACNC,MAAM;QACNC,OAAO;YACLa,YAAY;gBACVC,OAAO;YACT;QACF;IACF;CACD,CAAA"}
|
||||||
+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
+12
@@ -7,6 +7,10 @@ export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/cons
|
|||||||
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';
|
export type { ContentCollectionOption, ContentOption, ResolvedRoute, } from './modules/content/index.js';
|
||||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js';
|
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute, } from './modules/content/index.js';
|
||||||
export type { ArchiveEntries } from './modules/content/index.js';
|
export type { ArchiveEntries } from './modules/content/index.js';
|
||||||
|
export { graphAdapter } from './modules/email/graphAdapter.js';
|
||||||
|
export type { GraphAdapterArgs } from './modules/email/graphAdapter.js';
|
||||||
|
export { mailAdapter } from './modules/email/mailAdapter.js';
|
||||||
|
export type { MailAdapterArgs } from './modules/email/mailAdapter.js';
|
||||||
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
||||||
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
||||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
@@ -16,14 +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 type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
|
export { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
export type { IpalOptions } from './types.js';
|
export type { IpalOptions } from './types.js';
|
||||||
|
|||||||
Vendored
+12
@@ -2,6 +2,8 @@ export { adminOnly, adminOnlyField, adminOrEditor, adminOrEditorField, adminOrSe
|
|||||||
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
export { getAnalyticsConfig } from './modules/analytics/index.js';
|
||||||
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
export { ACCEPT_ALL_CONSENT, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_MAX_AGE, CONSENT_VERSION, DEFAULT_CONSENT, getConsentTexts, parseConsent, REJECT_ALL_CONSENT, serializeConsent, setDefaultConsent, updateConsent } from './modules/consent/index.js';
|
||||||
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
export { archiveFieldName, buildArchivePath, buildEntryPath, getArchiveEntries, parsePageParam, resolveRoute } from './modules/content/index.js';
|
||||||
|
export { graphAdapter } from './modules/email/graphAdapter.js';
|
||||||
|
export { mailAdapter } from './modules/email/mailAdapter.js';
|
||||||
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
// Imported straight from the file, NOT from ./modules/email/index.js — that
|
||||||
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
|
||||||
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
// Payload loads the config (or runs generate:importmap) as a plain Node script.
|
||||||
@@ -10,11 +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 { buildFaqJsonLd, buildIconsMetadata, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
|
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
||||||
|
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
|
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||||
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PayloadEmailAdapter } from 'payload';
|
||||||
|
export type GraphAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string;
|
||||||
|
fallbackFromName?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||||
|
* using app-only client-credentials auth. Drop-in alternative to
|
||||||
|
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||||
|
* and the form-builder's submission emails work unchanged.
|
||||||
|
*
|
||||||
|
* Split of configuration (deliberate):
|
||||||
|
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||||
|
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||||
|
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||||
|
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||||
|
* so an editor controls how the mail is labelled and where it lands.
|
||||||
|
*
|
||||||
|
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||||
|
*
|
||||||
|
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||||
|
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||||
|
* shared mailbox GRAPH_SENDER.
|
||||||
|
*/
|
||||||
|
export declare const graphAdapter: (args?: GraphAdapterArgs) => PayloadEmailAdapter;
|
||||||
Vendored
+189
@@ -0,0 +1,189 @@
|
|||||||
|
import { getSiteIntegrations } from '../payload/index.js';
|
||||||
|
/** Reads + validates the agency Graph credentials from env. */ function readGraphEnv() {
|
||||||
|
const tenantId = process.env.GRAPH_TENANT_ID;
|
||||||
|
const clientId = process.env.GRAPH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GRAPH_CLIENT_SECRET;
|
||||||
|
const sender = process.env.GRAPH_SENDER;
|
||||||
|
if (!tenantId || !clientId || !clientSecret || !sender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
sender,
|
||||||
|
tenantId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fetches an app-only access token via the OAuth2 client-credentials flow.
|
||||||
|
* Scope MUST be '.../.default' — passing 'Mail.Send' directly is rejected
|
||||||
|
* (AADSTS1002012). Tokens last ~1h; we fetch per send for simplicity and to
|
||||||
|
* avoid holding state in a possibly multi-instance deployment. If you send at
|
||||||
|
* high volume, cache by expiry.
|
||||||
|
*/ async function getAccessToken(env) {
|
||||||
|
const url = `https://login.microsoftonline.com/${env.tenantId}/oauth2/v2.0/token`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: env.clientId,
|
||||||
|
client_secret: env.clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'https://graph.microsoft.com/.default'
|
||||||
|
});
|
||||||
|
const res = await fetch(url, {
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text();
|
||||||
|
throw new Error(`Graph token request failed (${res.status}): ${detail}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('Graph token response had no access_token');
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
/** Normalizes Payload's to/cc (string | string[] | Address[]) into Graph recipients. */ function toRecipients(value) {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = Array.isArray(value) ? value : [
|
||||||
|
value
|
||||||
|
];
|
||||||
|
return list.map((v)=>typeof v === 'string' ? v : v.address).filter((a)=>typeof a === 'string' && a.length > 0).map((address)=>({
|
||||||
|
emailAddress: {
|
||||||
|
address
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Payload email adapter that sends through Microsoft Graph (our Exchange),
|
||||||
|
* using app-only client-credentials auth. Drop-in alternative to
|
||||||
|
* panelSmtpAdapter — same PayloadEmailAdapter contract, so payload.sendEmail
|
||||||
|
* and the form-builder's submission emails work unchanged.
|
||||||
|
*
|
||||||
|
* Split of configuration (deliberate):
|
||||||
|
* - Graph credentials (tenant/client/secret/sender) = AGENCY secrets, from env.
|
||||||
|
* The client never sees or sets them — it's our Exchange, one mailbox
|
||||||
|
* (GRAPH_SENDER, e.g. [email protected]) for every project.
|
||||||
|
* - From-display + recipient = per-project, from the panel (SiteIntegrations),
|
||||||
|
* so an editor controls how the mail is labelled and where it lands.
|
||||||
|
*
|
||||||
|
* Wiring: email: process.env.GRAPH_CLIENT_ID ? graphAdapter() : panelSmtpAdapter()
|
||||||
|
*
|
||||||
|
* Azure setup (one-time, our side): App registration → Mail.Send APPLICATION
|
||||||
|
* permission → admin consent → in Exchange, grant the app "Send As" on the
|
||||||
|
* shared mailbox GRAPH_SENDER.
|
||||||
|
*/ export const graphAdapter = (args = {})=>({ payload })=>({
|
||||||
|
name: 'ipal-graph',
|
||||||
|
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
|
||||||
|
defaultFromName: args.fallbackFromName ?? 'Website',
|
||||||
|
sendEmail: async (message)=>{
|
||||||
|
const env = readGraphEnv();
|
||||||
|
if (!env) {
|
||||||
|
payload.logger.error('[ipal] Email not sent: Graph is not configured. Set GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER.');
|
||||||
|
return {
|
||||||
|
error: 'Graph is not configured (missing env vars).',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 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 replyToAddress = panel.smtpFromAddress || undefined;
|
||||||
|
const senderName = panel.smtpFromName || undefined;
|
||||||
|
const to = toRecipients(message.to);
|
||||||
|
if (to.length === 0) {
|
||||||
|
payload.logger.error('[ipal] Email not sent: no valid recipient.');
|
||||||
|
return {
|
||||||
|
error: 'No valid recipient.',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Graph accepts either HTML or Text; Payload gives us html and/or text.
|
||||||
|
const isHtml = typeof message.html === 'string' && message.html.length > 0;
|
||||||
|
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 = {
|
||||||
|
body: {
|
||||||
|
content,
|
||||||
|
contentType: isHtml ? 'HTML' : 'Text'
|
||||||
|
},
|
||||||
|
subject: message.subject ?? '',
|
||||||
|
toRecipients: to,
|
||||||
|
...message.cc ? {
|
||||||
|
ccRecipients: toRecipients(message.cc)
|
||||||
|
} : {},
|
||||||
|
...message.bcc ? {
|
||||||
|
bccRecipients: toRecipients(message.bcc)
|
||||||
|
} : {},
|
||||||
|
// From with the sender's OWN address (no Send-As) plus an optional
|
||||||
|
// display name from the panel. Omit entirely when no name is set —
|
||||||
|
// Graph then uses the mailbox's default name.
|
||||||
|
...senderName ? {
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: senderName,
|
||||||
|
address: env.sender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...replyTo.length > 0 ? {
|
||||||
|
replyTo
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken(env);
|
||||||
|
// App-only: MUST target /users/{sender}, never /me.
|
||||||
|
const res = await fetch(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(env.sender)}/sendMail`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: graphMessage,
|
||||||
|
saveToSentItems: false
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
// sendMail returns 202 Accepted with an empty body on success.
|
||||||
|
if (res.status === 202) {
|
||||||
|
return {
|
||||||
|
sent: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const detail = await res.text();
|
||||||
|
payload.logger.error(`[ipal] Graph sendMail failed (${res.status}): ${detail}`);
|
||||||
|
return {
|
||||||
|
error: `Graph sendMail failed (${res.status}).`,
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
payload.logger.error(`[ipal] Graph send error: ${msg}`);
|
||||||
|
return {
|
||||||
|
error: 'Graph send error.',
|
||||||
|
sent: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//# sourceMappingURL=graphAdapter.js.map
|
||||||
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -1,2 +1,6 @@
|
|||||||
|
export { graphAdapter } from './graphAdapter.js';
|
||||||
|
export type { GraphAdapterArgs } from './graphAdapter.js';
|
||||||
|
export { mailAdapter } from './mailAdapter.js';
|
||||||
|
export type { MailAdapterArgs } from './mailAdapter.js';
|
||||||
export { sendEmail } from './sendEmail.js';
|
export { sendEmail } from './sendEmail.js';
|
||||||
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js';
|
||||||
|
|||||||
Vendored
+2
@@ -1,3 +1,5 @@
|
|||||||
|
export { graphAdapter } from './graphAdapter.js';
|
||||||
|
export { mailAdapter } from './mailAdapter.js';
|
||||||
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
|
||||||
// so this must never be imported from a client component.
|
// so this must never be imported from a client component.
|
||||||
export { sendEmail } from './sendEmail.js';
|
export { sendEmail } from './sendEmail.js';
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["sendEmail"],"mappings":"AAAA,mFAAmF;AACnF,0DAA0D;AAC1D,SAASA,SAAS,QAAQ,iBAAgB"}
|
{"version":3,"sources":["../../../src/modules/email/index.ts"],"sourcesContent":["export { graphAdapter } from './graphAdapter.js'\nexport type { GraphAdapterArgs } from './graphAdapter.js'\nexport { mailAdapter } from './mailAdapter.js'\nexport type { MailAdapterArgs } from './mailAdapter.js'\n// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)\n// so this must never be imported from a client component.\nexport { sendEmail } from './sendEmail.js'\nexport type { SendEmailArgs, SendEmailResult } from './sendEmail.js'\n"],"names":["graphAdapter","mailAdapter","sendEmail"],"mappings":"AAAA,SAASA,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,mFAAmF;AACnF,0DAA0D;AAC1D,SAASC,SAAS,QAAQ,iBAAgB"}
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PayloadEmailAdapter } from 'payload';
|
||||||
|
import { type GraphAdapterArgs } from './graphAdapter.js';
|
||||||
|
import { type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js';
|
||||||
|
export type MailAdapterArgs = {
|
||||||
|
fallbackFromAddress?: string;
|
||||||
|
fallbackFromName?: string;
|
||||||
|
graph?: GraphAdapterArgs;
|
||||||
|
smtp?: PanelSmtpAdapterArgs;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||||
|
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||||
|
* This is what makes the choice switchable in the panel — Payload builds the
|
||||||
|
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||||
|
* between two adapters at boot we install one that delegates on every send.
|
||||||
|
*
|
||||||
|
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||||
|
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||||
|
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||||
|
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||||
|
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // payload.config.ts
|
||||||
|
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
* email: mailAdapter()
|
||||||
|
*/
|
||||||
|
export declare const mailAdapter: (args?: MailAdapterArgs) => PayloadEmailAdapter;
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
import { getSiteIntegrations } from '../payload/index.js';
|
||||||
|
import { graphAdapter } from './graphAdapter.js';
|
||||||
|
import { panelSmtpAdapter } from './panelSmtpAdapter.js';
|
||||||
|
/** True when the agency Graph credentials are present in the environment. */ function graphAvailable() {
|
||||||
|
return Boolean(process.env.GRAPH_TENANT_ID && process.env.GRAPH_CLIENT_ID && process.env.GRAPH_CLIENT_SECRET && process.env.GRAPH_SENDER);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Dispatcher email adapter: wired into the config ONCE, but picks the transport
|
||||||
|
* (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.
|
||||||
|
* This is what makes the choice switchable in the panel — Payload builds the
|
||||||
|
* email adapter at boot and can't swap it at runtime, so instead of choosing
|
||||||
|
* between two adapters at boot we install one that delegates on every send.
|
||||||
|
*
|
||||||
|
* Availability guard: Graph only runs if its agency credentials exist in env
|
||||||
|
* (this is *our* Exchange). If the panel says 'graph' but env isn't set up,
|
||||||
|
* we DON'T silently fail — we log clearly and fall back to SMTP, so a client
|
||||||
|
* flipping the switch without the backing config still gets mail out (over SMTP)
|
||||||
|
* rather than silent nothing. If neither is usable, the send reports an error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // payload.config.ts
|
||||||
|
* import { mailAdapter } from '@intecion/ipal-kit'
|
||||||
|
* email: mailAdapter()
|
||||||
|
*/ export const mailAdapter = (args = {})=>(deps)=>{
|
||||||
|
// Build both delegates once; each still resolves its own config per send.
|
||||||
|
const smtp = panelSmtpAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.smtp
|
||||||
|
})(deps);
|
||||||
|
const graph = graphAdapter({
|
||||||
|
fallbackFromAddress: args.fallbackFromAddress,
|
||||||
|
fallbackFromName: args.fallbackFromName,
|
||||||
|
...args.graph
|
||||||
|
})(deps);
|
||||||
|
const { payload } = deps;
|
||||||
|
return {
|
||||||
|
name: 'ipal-mail-dispatcher',
|
||||||
|
defaultFromAddress: smtp.defaultFromAddress,
|
||||||
|
defaultFromName: smtp.defaultFromName,
|
||||||
|
sendEmail: async (message)=>{
|
||||||
|
const settings = await getSiteIntegrations(payload);
|
||||||
|
const choice = settings.emailTransport ?? 'smtp';
|
||||||
|
if (choice === 'graph') {
|
||||||
|
if (graphAvailable()) {
|
||||||
|
return graph.sendEmail(message);
|
||||||
|
}
|
||||||
|
// Panel asked for Graph but the agency creds aren't configured for
|
||||||
|
// this project. Fall back to SMTP rather than silently dropping mail.
|
||||||
|
payload.logger.warn('[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.');
|
||||||
|
return smtp.sendEmail(message);
|
||||||
|
}
|
||||||
|
return smtp.sendEmail(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=mailAdapter.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/email/mailAdapter.ts"],"sourcesContent":["import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'\n\nimport { getSiteIntegrations } from '../payload/index.js'\nimport { graphAdapter, type GraphAdapterArgs } from './graphAdapter.js'\nimport { panelSmtpAdapter, type PanelSmtpAdapterArgs } from './panelSmtpAdapter.js'\n\ntype TransportIntegrations = {\n /** 'smtp' | 'graph' — chosen by the editor in SiteIntegrations. */\n emailTransport?: 'graph' | 'smtp' | null\n}\n\nexport type MailAdapterArgs = {\n fallbackFromAddress?: string\n fallbackFromName?: string\n graph?: GraphAdapterArgs\n smtp?: PanelSmtpAdapterArgs\n}\n\n/** True when the agency Graph credentials are present in the environment. */\nfunction graphAvailable(): boolean {\n return Boolean(\n process.env.GRAPH_TENANT_ID &&\n process.env.GRAPH_CLIENT_ID &&\n process.env.GRAPH_CLIENT_SECRET &&\n process.env.GRAPH_SENDER,\n )\n}\n\n/**\n * Dispatcher email adapter: wired into the config ONCE, but picks the transport\n * (SMTP or Graph) per send by reading `emailTransport` from SiteIntegrations.\n * This is what makes the choice switchable in the panel — Payload builds the\n * email adapter at boot and can't swap it at runtime, so instead of choosing\n * between two adapters at boot we install one that delegates on every send.\n *\n * Availability guard: Graph only runs if its agency credentials exist in env\n * (this is *our* Exchange). If the panel says 'graph' but env isn't set up,\n * we DON'T silently fail — we log clearly and fall back to SMTP, so a client\n * flipping the switch without the backing config still gets mail out (over SMTP)\n * rather than silent nothing. If neither is usable, the send reports an error.\n *\n * @example\n * // payload.config.ts\n * import { mailAdapter } from '@intecion/ipal-kit'\n * email: mailAdapter()\n */\nexport const mailAdapter =\n (args: MailAdapterArgs = {}): PayloadEmailAdapter =>\n (deps) => {\n // Build both delegates once; each still resolves its own config per send.\n const smtp = panelSmtpAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.smtp,\n })(deps)\n const graph = graphAdapter({\n fallbackFromAddress: args.fallbackFromAddress,\n fallbackFromName: args.fallbackFromName,\n ...args.graph,\n })(deps)\n\n const { payload } = deps\n\n return {\n name: 'ipal-mail-dispatcher',\n defaultFromAddress: smtp.defaultFromAddress,\n defaultFromName: smtp.defaultFromName,\n\n sendEmail: async (message: SendEmailOptions) => {\n const settings = await getSiteIntegrations<TransportIntegrations>(payload)\n const choice = settings.emailTransport ?? 'smtp'\n\n if (choice === 'graph') {\n if (graphAvailable()) {\n return graph.sendEmail(message)\n }\n // Panel asked for Graph but the agency creds aren't configured for\n // this project. Fall back to SMTP rather than silently dropping mail.\n payload.logger.warn(\n '[ipal] Transport set to Graph but GRAPH_* env vars are missing; falling back to SMTP.',\n )\n return smtp.sendEmail(message)\n }\n\n return smtp.sendEmail(message)\n },\n }\n }\n"],"names":["getSiteIntegrations","graphAdapter","panelSmtpAdapter","graphAvailable","Boolean","process","env","GRAPH_TENANT_ID","GRAPH_CLIENT_ID","GRAPH_CLIENT_SECRET","GRAPH_SENDER","mailAdapter","args","deps","smtp","fallbackFromAddress","fallbackFromName","graph","payload","name","defaultFromAddress","defaultFromName","sendEmail","message","settings","choice","emailTransport","logger","warn"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,sBAAqB;AACzD,SAASC,YAAY,QAA+B,oBAAmB;AACvE,SAASC,gBAAgB,QAAmC,wBAAuB;AAcnF,2EAA2E,GAC3E,SAASC;IACP,OAAOC,QACLC,QAAQC,GAAG,CAACC,eAAe,IAC3BF,QAAQC,GAAG,CAACE,eAAe,IAC3BH,QAAQC,GAAG,CAACG,mBAAmB,IAC/BJ,QAAQC,GAAG,CAACI,YAAY;AAE5B;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,cACX,CAACC,OAAwB,CAAC,CAAC,GAC3B,CAACC;QACC,0EAA0E;QAC1E,MAAMC,OAAOZ,iBAAiB;YAC5Ba,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKE,IAAI;QACd,GAAGD;QACH,MAAMI,QAAQhB,aAAa;YACzBc,qBAAqBH,KAAKG,mBAAmB;YAC7CC,kBAAkBJ,KAAKI,gBAAgB;YACvC,GAAGJ,KAAKK,KAAK;QACf,GAAGJ;QAEH,MAAM,EAAEK,OAAO,EAAE,GAAGL;QAEpB,OAAO;YACLM,MAAM;YACNC,oBAAoBN,KAAKM,kBAAkB;YAC3CC,iBAAiBP,KAAKO,eAAe;YAErCC,WAAW,OAAOC;gBAChB,MAAMC,WAAW,MAAMxB,oBAA2CkB;gBAClE,MAAMO,SAASD,SAASE,cAAc,IAAI;gBAE1C,IAAID,WAAW,SAAS;oBACtB,IAAItB,kBAAkB;wBACpB,OAAOc,MAAMK,SAAS,CAACC;oBACzB;oBACA,mEAAmE;oBACnE,sEAAsE;oBACtEL,QAAQS,MAAM,CAACC,IAAI,CACjB;oBAEF,OAAOd,KAAKQ,SAAS,CAACC;gBACxB;gBAEA,OAAOT,KAAKQ,SAAS,CAACC;YACxB;QACF;IACF,EAAC"}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Endpoint } from 'payload';
|
||||||
|
/**
|
||||||
|
* Custom endpoint: send a test email to a given address through whatever
|
||||||
|
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||||
|
* setting per send, so the test exercises the REAL path a form email would
|
||||||
|
* take). Mounted at POST /api/ipal/test-email.
|
||||||
|
*
|
||||||
|
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||||
|
* authenticated admin user — a test-send button must never be open to the
|
||||||
|
* public (it would be an open relay / spam vector).
|
||||||
|
*
|
||||||
|
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||||
|
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||||
|
*/
|
||||||
|
export declare const testEmailEndpoint: Endpoint;
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import { addDataAndFileToRequest } from 'payload';
|
||||||
|
/**
|
||||||
|
* Custom endpoint: send a test email to a given address through whatever
|
||||||
|
* transport is currently active (SMTP or Graph — mailAdapter reads the panel
|
||||||
|
* setting per send, so the test exercises the REAL path a form email would
|
||||||
|
* take). Mounted at POST /api/ipal/test-email.
|
||||||
|
*
|
||||||
|
* Admin-only: uses payload.sendEmail (server-side), and requires an
|
||||||
|
* authenticated admin user — a test-send button must never be open to the
|
||||||
|
* public (it would be an open relay / spam vector).
|
||||||
|
*
|
||||||
|
* Returns the adapter's own result so the panel can show exactly what happened,
|
||||||
|
* including the transport-specific error (SMTP auth failure, Graph 401, etc.).
|
||||||
|
*/ export const testEmailEndpoint = {
|
||||||
|
handler: async (req)=>{
|
||||||
|
// Auth: only signed-in admins may trigger a send.
|
||||||
|
if (!req.user) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 401
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await addDataAndFileToRequest(req);
|
||||||
|
const to = req.data?.to?.trim();
|
||||||
|
if (!to || !/^[^@\s]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(to)) {
|
||||||
|
return Response.json({
|
||||||
|
error: 'Provide a valid recipient address.',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await req.payload.sendEmail({
|
||||||
|
html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',
|
||||||
|
subject: 'ipal-kit — test email',
|
||||||
|
text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',
|
||||||
|
to
|
||||||
|
});
|
||||||
|
// Payload's sendEmail resolves with the adapter's result. Our adapters
|
||||||
|
// return { sent: boolean, error?: string }; nodemailer returns info with
|
||||||
|
// messageId. Normalize to a simple ok/message for the panel.
|
||||||
|
const sent = info && typeof info === 'object' && 'sent' in info ? info.sent !== false : true;
|
||||||
|
if (!sent) {
|
||||||
|
const error = info?.error ?? 'Send failed (see server logs).';
|
||||||
|
return Response.json({
|
||||||
|
error,
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 502
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
message: `Test email sent to ${to}.`,
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
req.payload.logger.error(`[ipal] Test email failed: ${message}`);
|
||||||
|
return Response.json({
|
||||||
|
error: 'Send failed. Check transport settings and server logs.',
|
||||||
|
ok: false
|
||||||
|
}, {
|
||||||
|
status: 502
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
method: 'post',
|
||||||
|
path: '/ipal/test-email'
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=testEmailEndpoint.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../../src/modules/email/test/testEmailEndpoint.ts"],"sourcesContent":["import type { Endpoint, PayloadRequest } from 'payload'\n\nimport { addDataAndFileToRequest } from 'payload'\n\n/**\n * Custom endpoint: send a test email to a given address through whatever\n * transport is currently active (SMTP or Graph — mailAdapter reads the panel\n * setting per send, so the test exercises the REAL path a form email would\n * take). Mounted at POST /api/ipal/test-email.\n *\n * Admin-only: uses payload.sendEmail (server-side), and requires an\n * authenticated admin user — a test-send button must never be open to the\n * public (it would be an open relay / spam vector).\n *\n * Returns the adapter's own result so the panel can show exactly what happened,\n * including the transport-specific error (SMTP auth failure, Graph 401, etc.).\n */\nexport const testEmailEndpoint: Endpoint = {\n handler: async (req: PayloadRequest) => {\n // Auth: only signed-in admins may trigger a send.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized', ok: false }, { status: 401 })\n }\n\n await addDataAndFileToRequest(req)\n const to = (req.data?.to as string | undefined)?.trim()\n\n if (!to || !/^[^@\\s]+@[^\\s@][^\\s.@]*\\.[^\\s@]+$/.test(to)) {\n return Response.json(\n { error: 'Provide a valid recipient address.', ok: false },\n { status: 400 },\n )\n }\n\n try {\n const info = await req.payload.sendEmail({\n html: '<p>This is a test message from <strong>ipal-kit</strong>. If you received it, outbound email is configured correctly.</p>',\n subject: 'ipal-kit — test email',\n text: 'This is a test message from ipal-kit. If you received it, outbound email is configured correctly.',\n to,\n })\n\n // Payload's sendEmail resolves with the adapter's result. Our adapters\n // return { sent: boolean, error?: string }; nodemailer returns info with\n // messageId. Normalize to a simple ok/message for the panel.\n const sent =\n info && typeof info === 'object' && 'sent' in info\n ? (info as { sent?: boolean }).sent !== false\n : true\n\n if (!sent) {\n const error = (info as { error?: string })?.error ?? 'Send failed (see server logs).'\n return Response.json({ error, ok: false }, { status: 502 })\n }\n\n return Response.json({ message: `Test email sent to ${to}.`, ok: true })\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n req.payload.logger.error(`[ipal] Test email failed: ${message}`)\n return Response.json(\n { error: 'Send failed. Check transport settings and server logs.', ok: false },\n { status: 502 },\n )\n }\n },\n method: 'post',\n path: '/ipal/test-email',\n}\n"],"names":["addDataAndFileToRequest","testEmailEndpoint","handler","req","user","Response","json","error","ok","status","to","data","trim","test","info","payload","sendEmail","html","subject","text","sent","message","err","Error","String","logger","method","path"],"mappings":"AAEA,SAASA,uBAAuB,QAAQ,UAAS;AAEjD;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,oBAA8B;IACzCC,SAAS,OAAOC;QACd,kDAAkD;QAClD,IAAI,CAACA,IAAIC,IAAI,EAAE;YACb,OAAOC,SAASC,IAAI,CAAC;gBAAEC,OAAO;gBAAgBC,IAAI;YAAM,GAAG;gBAAEC,QAAQ;YAAI;QAC3E;QAEA,MAAMT,wBAAwBG;QAC9B,MAAMO,KAAMP,IAAIQ,IAAI,EAAED,IAA2BE;QAEjD,IAAI,CAACF,MAAM,CAAC,oCAAoCG,IAAI,CAACH,KAAK;YACxD,OAAOL,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAAsCC,IAAI;YAAM,GACzD;gBAAEC,QAAQ;YAAI;QAElB;QAEA,IAAI;YACF,MAAMK,OAAO,MAAMX,IAAIY,OAAO,CAACC,SAAS,CAAC;gBACvCC,MAAM;gBACNC,SAAS;gBACTC,MAAM;gBACNT;YACF;YAEA,uEAAuE;YACvE,yEAAyE;YACzE,6DAA6D;YAC7D,MAAMU,OACJN,QAAQ,OAAOA,SAAS,YAAY,UAAUA,OAC1C,AAACA,KAA4BM,IAAI,KAAK,QACtC;YAEN,IAAI,CAACA,MAAM;gBACT,MAAMb,QAAQ,AAACO,MAA6BP,SAAS;gBACrD,OAAOF,SAASC,IAAI,CAAC;oBAAEC;oBAAOC,IAAI;gBAAM,GAAG;oBAAEC,QAAQ;gBAAI;YAC3D;YAEA,OAAOJ,SAASC,IAAI,CAAC;gBAAEe,SAAS,CAAC,mBAAmB,EAAEX,GAAG,CAAC,CAAC;gBAAEF,IAAI;YAAK;QACxE,EAAE,OAAOc,KAAK;YACZ,MAAMD,UAAUC,eAAeC,QAAQD,IAAID,OAAO,GAAGG,OAAOF;YAC5DnB,IAAIY,OAAO,CAACU,MAAM,CAAClB,KAAK,CAAC,CAAC,0BAA0B,EAAEc,SAAS;YAC/D,OAAOhB,SAASC,IAAI,CAClB;gBAAEC,OAAO;gBAA0DC,IAAI;YAAM,GAC7E;gBAAEC,QAAQ;YAAI;QAElB;IACF;IACAiB,QAAQ;IACRC,MAAM;AACR,EAAC"}
|
||||||
+16
-16
@@ -1,14 +1,8 @@
|
|||||||
import type { BasePayload, SanitizedConfig } from 'payload';
|
import type { BasePayload, SanitizedConfig } from 'payload';
|
||||||
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js';
|
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js';
|
||||||
import type { I18nConfig } from '../i18n/index.js';
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
import type { RobotsRules, SitemapEntry } from '../seo/index.js';
|
import type { SitemapEntry, RobotsRules } from '../seo/index.js';
|
||||||
type CreateContentHelpersArgs = {
|
type CreateContentHelpersArgs = {
|
||||||
/**
|
|
||||||
* Absolute site origin for sitemap/robots URLs. Falls back to
|
|
||||||
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
|
||||||
* reject, so set one in production).
|
|
||||||
*/
|
|
||||||
baseUrl?: string;
|
|
||||||
/**
|
/**
|
||||||
* The client's payload config promise (the default export of payload.config).
|
* The client's payload config promise (the default export of payload.config).
|
||||||
* Passed in because the plugin never imports the client's config directly.
|
* Passed in because the plugin never imports the client's config directly.
|
||||||
@@ -16,15 +10,21 @@ type CreateContentHelpersArgs = {
|
|||||||
config: Promise<SanitizedConfig> | SanitizedConfig;
|
config: Promise<SanitizedConfig> | SanitizedConfig;
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption;
|
content?: ContentOption;
|
||||||
|
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string;
|
||||||
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
|
pagesSlug?: string;
|
||||||
/**
|
/**
|
||||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||||
* handlers — they need the locale list to emit hreflang.
|
* handlers — they need the locale list to emit hreflang.
|
||||||
*/
|
*/
|
||||||
i18n?: I18nConfig;
|
i18n?: I18nConfig;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
/**
|
||||||
pagesSlug?: string;
|
* Absolute site origin for sitemap/robots URLs. Falls back to
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
* NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers
|
||||||
settingsSlug?: string;
|
* reject, so set one in production).
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||||
@@ -52,13 +52,13 @@ type CreateContentHelpersArgs = {
|
|||||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||||
* not the second, and a page component composes them in two obvious lines.
|
* not the second, and a page component composes them in two obvious lines.
|
||||||
*/
|
*/
|
||||||
export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): {
|
export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): {
|
||||||
getCachedPayload: () => Promise<BasePayload>;
|
getCachedPayload: () => Promise<BasePayload>;
|
||||||
getConfiguredLocales: () => Promise<string[]>;
|
getConfiguredLocales: () => Promise<string[]>;
|
||||||
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
|
||||||
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
getSettings: (locale: string) => Promise<import("payload").JsonObject>;
|
||||||
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<null | ResolvedRoute>;
|
resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise<ResolvedRoute | null>;
|
||||||
robots: () => RobotsRules;
|
getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise<ArchiveEntries>;
|
||||||
sitemap: () => Promise<SitemapEntry[]>;
|
sitemap: () => Promise<SitemapEntry[]>;
|
||||||
|
robots: () => RobotsRules;
|
||||||
};
|
};
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+37
-17
@@ -1,7 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
import { getPayload } from 'payload';
|
||||||
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js';
|
||||||
|
import { buildSitemapEntries, buildRobots } from '../seo/index.js';
|
||||||
/**
|
/**
|
||||||
* Bundles the per-request data helpers a frontend needs — the same cached
|
* Bundles the per-request data helpers a frontend needs — the same cached
|
||||||
* wrappers every project was writing by hand (getPayload, settings, locale
|
* wrappers every project was writing by hand (getPayload, settings, locale
|
||||||
@@ -27,7 +27,7 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
|
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
|
||||||
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
* a URL is, fetching gets the listing. Metadata generation needs the first and
|
||||||
* not the second, and a page component composes them in two obvious lines.
|
* not the second, and a page component composes them in two obvious lines.
|
||||||
*/ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) {
|
*/ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) {
|
||||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
||||||
const getCachedPayload = cache(async ()=>getPayload({
|
const getCachedPayload = cache(async ()=>getPayload({
|
||||||
config: await config
|
config: await config
|
||||||
@@ -40,29 +40,29 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return payload.findGlobal({
|
return payload.findGlobal({
|
||||||
slug: settingsSlug,
|
slug: settingsSlug,
|
||||||
depth: 2,
|
locale: locale,
|
||||||
locale: locale
|
depth: 2
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return resolveRouteRaw({
|
return resolveRouteRaw({
|
||||||
content,
|
|
||||||
locale,
|
|
||||||
page,
|
|
||||||
pagesSlug,
|
|
||||||
payload,
|
payload,
|
||||||
|
locale,
|
||||||
segments,
|
segments,
|
||||||
|
page,
|
||||||
|
content,
|
||||||
|
pagesSlug,
|
||||||
settingsSlug
|
settingsSlug
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
/** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{
|
||||||
const payload = await getCachedPayload();
|
const payload = await getCachedPayload();
|
||||||
return getArchiveEntries({
|
return getArchiveEntries({
|
||||||
|
payload,
|
||||||
collection,
|
collection,
|
||||||
locale,
|
locale,
|
||||||
page,
|
page,
|
||||||
payload,
|
|
||||||
perPage
|
perPage
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,19 +73,39 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
* ```ts
|
* ```ts
|
||||||
* // app/sitemap.ts
|
* // app/sitemap.ts
|
||||||
* export { sitemap as default } from '@/lib/content'
|
* export { sitemap as default } from '@/lib/content'
|
||||||
|
* export const dynamic = 'force-dynamic' // generate at runtime, not build
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
|
||||||
|
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
|
||||||
|
* calls into Payload → the database. The build container usually has no access
|
||||||
|
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
|
||||||
|
* the build dies. Two defenses:
|
||||||
|
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
|
||||||
|
* prerender, generates at runtime when the DB is reachable (recommended).
|
||||||
|
* 2. This handler also catches DB errors and returns [] so that even without
|
||||||
|
* (1) the build won't crash — it just ships an empty sitemap until the
|
||||||
|
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
|
||||||
*/ const sitemap = cache(async ()=>{
|
*/ const sitemap = cache(async ()=>{
|
||||||
if (!i18n) {
|
if (!i18n) {
|
||||||
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.');
|
||||||
}
|
}
|
||||||
return buildSitemapEntries({
|
try {
|
||||||
baseUrl: origin,
|
return await buildSitemapEntries({
|
||||||
|
payload: await getCachedPayload(),
|
||||||
config: i18n,
|
config: i18n,
|
||||||
|
baseUrl: origin,
|
||||||
content,
|
content,
|
||||||
pagesSlug,
|
pagesSlug,
|
||||||
payload: await getCachedPayload(),
|
|
||||||
settingsSlug
|
settingsSlug
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// DB unreachable (typically a container build with no DB network) — return
|
||||||
|
// an empty sitemap instead of failing the build. Runtime regeneration will
|
||||||
|
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
|
||||||
|
console.warn('[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
* Ready-made handler for Next's `app/robots.ts`. Re-export directly:
|
||||||
@@ -100,11 +120,11 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|||||||
return {
|
return {
|
||||||
getCachedPayload,
|
getCachedPayload,
|
||||||
getConfiguredLocales,
|
getConfiguredLocales,
|
||||||
getEntries,
|
|
||||||
getSettings,
|
getSettings,
|
||||||
resolveRoute,
|
resolveRoute,
|
||||||
robots,
|
getEntries,
|
||||||
sitemap
|
sitemap,
|
||||||
|
robots
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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"}
|
||||||
Vendored
+33
@@ -0,0 +1,33 @@
|
|||||||
|
type FaqItem = {
|
||||||
|
answer: string;
|
||||||
|
question: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||||
|
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||||
|
* with voice/AI answers. Strong for service landing pages.
|
||||||
|
*
|
||||||
|
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||||
|
* the panel) — the structured data must match visible content, or Google may
|
||||||
|
* flag it. Never invent Q&A that isn't on the page.
|
||||||
|
*
|
||||||
|
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildFaqJsonLd(
|
||||||
|
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns null for empty list.
|
||||||
|
*/
|
||||||
|
export declare function buildFaqJsonLd(items: FaqItem[]): {
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
mainEntity: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
export {};
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||||
|
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||||
|
* with voice/AI answers. Strong for service landing pages.
|
||||||
|
*
|
||||||
|
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||||
|
* the panel) — the structured data must match visible content, or Google may
|
||||||
|
* flag it. Never invent Q&A that isn't on the page.
|
||||||
|
*
|
||||||
|
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildFaqJsonLd(
|
||||||
|
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* Returns null for empty list.
|
||||||
|
*/ export function buildFaqJsonLd(items) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: items.map((item)=>({
|
||||||
|
name: item.question,
|
||||||
|
'@type': 'Question',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: item.answer
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildFaqJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildFaqJsonLd.ts"],"sourcesContent":["type FaqItem = {\n answer: string\n question: string\n}\n\n/**\n * Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as\n * expandable FAQ rich results under the page, taking more SERP space and helping\n * with voice/AI answers. Strong for service landing pages.\n *\n * Feed it the SAME questions/answers rendered on the page (from an FAQ block in\n * the panel) — the structured data must match visible content, or Google may\n * flag it. Never invent Q&A that isn't on the page.\n *\n * import { buildFaqJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildFaqJsonLd(\n * faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))\n * )\n *\n * Returns null for empty list.\n */\nexport function buildFaqJsonLd(items: FaqItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: items.map((item) => ({\n name: item.question,\n '@type': 'Question',\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n }\n}\n"],"names":["buildFaqJsonLd","items","length","mainEntity","map","item","name","question","acceptedAnswer","text","answer"],"mappings":"AAKA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASA,eAAeC,KAAgB;IAC7C,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,YAAYF,MAAMG,GAAG,CAAC,CAACC,OAAU,CAAA;gBAC/BC,MAAMD,KAAKE,QAAQ;gBACnB,SAAS;gBACTC,gBAAgB;oBACd,SAAS;oBACTC,MAAMJ,KAAKK,MAAM;gBACnB;YACF,CAAA;IACF;AACF"}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
type MediaLike = {
|
||||||
|
height?: null | number;
|
||||||
|
mimeType?: null | string;
|
||||||
|
url?: null | string;
|
||||||
|
width?: null | number;
|
||||||
|
} | null | undefined;
|
||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/
|
||||||
|
export declare function buildIconsMetadata(favicon: MediaLike): Metadata;
|
||||||
|
export {};
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's
|
||||||
|
* favicon upload, so the browser tab AND Google get a proper <link rel="icon">.
|
||||||
|
*
|
||||||
|
* Why the plugin must do this (not the project): favicon-in-Google has strict
|
||||||
|
* rules — a real <link rel="icon"> in <head>, square, ≥48×48, at a stable URL.
|
||||||
|
* Leaving it to each project meant inconsistent hand-rolled tags and no favicon
|
||||||
|
* in search results. This generates the tags correctly, every time, from the
|
||||||
|
* panel field.
|
||||||
|
*
|
||||||
|
* Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's
|
||||||
|
* generateMetadata — not per page:
|
||||||
|
*
|
||||||
|
* import { buildIconsMetadata } from '@intecion/ipal-kit'
|
||||||
|
* export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
* const settings = await getSettings(locale)
|
||||||
|
* return buildIconsMetadata(settings.favicon)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Google notes: it caches favicons separately and slowly (days/weeks), and only
|
||||||
|
* shows them for icons it deems valid. Warn on too-small icons at upload time
|
||||||
|
* (see the media validation hook) so editors don't ship a <48px favicon Google
|
||||||
|
* will reject.
|
||||||
|
*/ export function buildIconsMetadata(favicon) {
|
||||||
|
const url = favicon?.url;
|
||||||
|
if (!url) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg');
|
||||||
|
return {
|
||||||
|
icons: {
|
||||||
|
// Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).
|
||||||
|
icon: isSvg ? [
|
||||||
|
{
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
] : [
|
||||||
|
{
|
||||||
|
sizes: 'any',
|
||||||
|
url
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.
|
||||||
|
apple: [
|
||||||
|
{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildIconsMetadata.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildIconsMetadata.ts"],"sourcesContent":["import type { Metadata } from 'next'\n\ntype MediaLike =\n | { height?: null | number; mimeType?: null | string; url?: null | string; width?: null | number }\n | null\n | undefined\n\n/**\n * Builds Next.js `icons` metadata (favicon / apple-touch-icon) from the panel's\n * favicon upload, so the browser tab AND Google get a proper <link rel=\"icon\">.\n *\n * Why the plugin must do this (not the project): favicon-in-Google has strict\n * rules — a real <link rel=\"icon\"> in <head>, square, ≥48×48, at a stable URL.\n * Leaving it to each project meant inconsistent hand-rolled tags and no favicon\n * in search results. This generates the tags correctly, every time, from the\n * panel field.\n *\n * Favicon is GLOBAL (same across pages), so call this once in the ROOT layout's\n * generateMetadata — not per page:\n *\n * import { buildIconsMetadata } from '@intecion/ipal-kit'\n * export async function generateMetadata(): Promise<Metadata> {\n * const settings = await getSettings(locale)\n * return buildIconsMetadata(settings.favicon)\n * }\n *\n * Google notes: it caches favicons separately and slowly (days/weeks), and only\n * shows them for icons it deems valid. Warn on too-small icons at upload time\n * (see the media validation hook) so editors don't ship a <48px favicon Google\n * will reject.\n */\nexport function buildIconsMetadata(favicon: MediaLike): Metadata {\n const url = favicon?.url\n if (!url) {return {}}\n\n const isSvg = favicon?.mimeType === 'image/svg+xml' || url.endsWith('.svg')\n\n return {\n icons: {\n // Main favicon. SVG scales; PNG should be ≥48×48 (ideally 96 or 192).\n icon: isSvg ? [{ type: 'image/svg+xml', url }] : [{ sizes: 'any', url }],\n // Apple touch icon (home-screen bookmark on iOS). Reuses the same asset.\n apple: [{ url }],\n },\n }\n}\n"],"names":["buildIconsMetadata","favicon","url","isSvg","mimeType","endsWith","icons","icon","type","sizes","apple"],"mappings":"AAOA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,mBAAmBC,OAAkB;IACnD,MAAMC,MAAMD,SAASC;IACrB,IAAI,CAACA,KAAK;QAAC,OAAO,CAAC;IAAC;IAEpB,MAAMC,QAAQF,SAASG,aAAa,mBAAmBF,IAAIG,QAAQ,CAAC;IAEpE,OAAO;QACLC,OAAO;YACL,sEAAsE;YACtEC,MAAMJ,QAAQ;gBAAC;oBAAEK,MAAM;oBAAiBN;gBAAI;aAAE,GAAG;gBAAC;oBAAEO,OAAO;oBAAOP;gBAAI;aAAE;YACxE,yEAAyE;YACzEQ,OAAO;gBAAC;oBAAER;gBAAI;aAAE;QAClB;IACF;AACF"}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: null | string;
|
||||||
|
} | null | undefined;
|
||||||
|
type Address = {
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
region?: string;
|
||||||
|
street?: string;
|
||||||
|
};
|
||||||
|
type LocalBusinessJsonLdArgs = {
|
||||||
|
address?: Address;
|
||||||
|
/** Geo coordinates for maps/local search. */
|
||||||
|
geo?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
image?: MediaLike;
|
||||||
|
logo?: MediaLike;
|
||||||
|
name: string;
|
||||||
|
/** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */
|
||||||
|
openingHours?: string[];
|
||||||
|
priceRange?: string;
|
||||||
|
sameAs?: string[];
|
||||||
|
/** Business phone, e.g. '+48 123 456 789'. */
|
||||||
|
telephone?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||||
|
* SEO. Helps Google show the business in local results / map pack with address,
|
||||||
|
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||||
|
*
|
||||||
|
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||||
|
* root layout (business is site-wide):
|
||||||
|
*
|
||||||
|
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildLocalBusinessJsonLd({
|
||||||
|
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||||
|
* address: company.address, openingHours: company.hours,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||||
|
* returned object — schema.org has many LocalBusiness subtypes.
|
||||||
|
*/
|
||||||
|
export declare function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url, }: LocalBusinessJsonLdArgs): {
|
||||||
|
sameAs?: string[] | undefined;
|
||||||
|
priceRange?: string | undefined;
|
||||||
|
openingHours?: string[] | undefined;
|
||||||
|
geo?: {
|
||||||
|
'@type': string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
} | undefined;
|
||||||
|
address?: {
|
||||||
|
addressCountry?: string | undefined;
|
||||||
|
addressRegion?: string | undefined;
|
||||||
|
postalCode?: string | undefined;
|
||||||
|
addressLocality?: string | undefined;
|
||||||
|
streetAddress?: string | undefined;
|
||||||
|
'@type': string;
|
||||||
|
} | undefined;
|
||||||
|
logo?: string | undefined;
|
||||||
|
image?: string | undefined;
|
||||||
|
telephone?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||||
|
* SEO. Helps Google show the business in local results / map pack with address,
|
||||||
|
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||||
|
*
|
||||||
|
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||||
|
* root layout (business is site-wide):
|
||||||
|
*
|
||||||
|
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildLocalBusinessJsonLd({
|
||||||
|
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||||
|
* address: company.address, openingHours: company.hours,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||||
|
* returned object — schema.org has many LocalBusiness subtypes.
|
||||||
|
*/ export function buildLocalBusinessJsonLd({ name, address, geo, image, logo, openingHours, priceRange, sameAs, telephone, url }) {
|
||||||
|
const logoUrl = logo?.url;
|
||||||
|
const imageUrl = image?.url ?? logoUrl;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
url,
|
||||||
|
...telephone ? {
|
||||||
|
telephone
|
||||||
|
} : {},
|
||||||
|
...imageUrl ? {
|
||||||
|
image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}`
|
||||||
|
} : {},
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||||
|
} : {},
|
||||||
|
...address ? {
|
||||||
|
address: {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
...address.street ? {
|
||||||
|
streetAddress: address.street
|
||||||
|
} : {},
|
||||||
|
...address.city ? {
|
||||||
|
addressLocality: address.city
|
||||||
|
} : {},
|
||||||
|
...address.postalCode ? {
|
||||||
|
postalCode: address.postalCode
|
||||||
|
} : {},
|
||||||
|
...address.region ? {
|
||||||
|
addressRegion: address.region
|
||||||
|
} : {},
|
||||||
|
...address.country ? {
|
||||||
|
addressCountry: address.country
|
||||||
|
} : {}
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...geo ? {
|
||||||
|
geo: {
|
||||||
|
'@type': 'GeoCoordinates',
|
||||||
|
latitude: geo.latitude,
|
||||||
|
longitude: geo.longitude
|
||||||
|
}
|
||||||
|
} : {},
|
||||||
|
...openingHours && openingHours.length > 0 ? {
|
||||||
|
openingHours
|
||||||
|
} : {},
|
||||||
|
...priceRange ? {
|
||||||
|
priceRange
|
||||||
|
} : {},
|
||||||
|
...sameAs && sameAs.length > 0 ? {
|
||||||
|
sameAs
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildLocalBusinessJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildLocalBusinessJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype Address = {\n city?: string\n country?: string // ISO code, e.g. 'PL'\n postalCode?: string\n region?: string\n street?: string\n}\n\ntype LocalBusinessJsonLdArgs = {\n address?: Address\n /** Geo coordinates for maps/local search. */\n geo?: { latitude: number; longitude: number }\n image?: MediaLike\n logo?: MediaLike\n name: string\n /** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */\n openingHours?: string[]\n priceRange?: string // e.g. '$$'\n sameAs?: string[]\n /** Business phone, e.g. '+48 123 456 789'. */\n telephone?: string\n url: string\n}\n\n/**\n * Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL\n * SEO. Helps Google show the business in local results / map pack with address,\n * hours, phone. Strong signal for \"usługa + miasto\" queries.\n *\n * All data from the panel (company global) — nothing hardcoded. Emit once in the\n * root layout (business is site-wide):\n *\n * import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildLocalBusinessJsonLd({\n * name: company.name, url: baseUrl, telephone: company.phone,\n * address: company.address, openingHours: company.hours,\n * })\n *\n * For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the\n * returned object — schema.org has many LocalBusiness subtypes.\n */\nexport function buildLocalBusinessJsonLd({\n name,\n address,\n geo,\n image,\n logo,\n openingHours,\n priceRange,\n sameAs,\n telephone,\n url,\n}: LocalBusinessJsonLdArgs) {\n const logoUrl = logo?.url\n const imageUrl = image?.url ?? logoUrl\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'LocalBusiness',\n url,\n ...(telephone ? { telephone } : {}),\n ...(imageUrl ? { image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` } : {}),\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(address\n ? {\n address: {\n '@type': 'PostalAddress',\n ...(address.street ? { streetAddress: address.street } : {}),\n ...(address.city ? { addressLocality: address.city } : {}),\n ...(address.postalCode ? { postalCode: address.postalCode } : {}),\n ...(address.region ? { addressRegion: address.region } : {}),\n ...(address.country ? { addressCountry: address.country } : {}),\n },\n }\n : {}),\n ...(geo\n ? { geo: { '@type': 'GeoCoordinates', latitude: geo.latitude, longitude: geo.longitude } }\n : {}),\n ...(openingHours && openingHours.length > 0 ? { openingHours } : {}),\n ...(priceRange ? { priceRange } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildLocalBusinessJsonLd","name","address","geo","image","logo","openingHours","priceRange","sameAs","telephone","url","logoUrl","imageUrl","startsWith","street","streetAddress","city","addressLocality","postalCode","region","addressRegion","country","addressCountry","latitude","longitude","length"],"mappings":"AA0BA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,yBAAyB,EACvCC,IAAI,EACJC,OAAO,EACPC,GAAG,EACHC,KAAK,EACLC,IAAI,EACJC,YAAY,EACZC,UAAU,EACVC,MAAM,EACNC,SAAS,EACTC,GAAG,EACqB;IACxB,MAAMC,UAAUN,MAAMK;IACtB,MAAME,WAAWR,OAAOM,OAAOC;IAE/B,OAAO;QACLV;QACA,YAAY;QACZ,SAAS;QACTS;QACA,GAAID,YAAY;YAAEA;QAAU,IAAI,CAAC,CAAC;QAClC,GAAIG,WAAW;YAAER,OAAOQ,SAASC,UAAU,CAAC,UAAUD,WAAW,GAAGF,MAAME,UAAU;QAAC,IAAI,CAAC,CAAC;QAC3F,GAAID,UAAU;YAAEN,MAAMM,QAAQE,UAAU,CAAC,UAAUF,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIT,UACA;YACEA,SAAS;gBACP,SAAS;gBACT,GAAIA,QAAQY,MAAM,GAAG;oBAAEC,eAAeb,QAAQY,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIZ,QAAQc,IAAI,GAAG;oBAAEC,iBAAiBf,QAAQc,IAAI;gBAAC,IAAI,CAAC,CAAC;gBACzD,GAAId,QAAQgB,UAAU,GAAG;oBAAEA,YAAYhB,QAAQgB,UAAU;gBAAC,IAAI,CAAC,CAAC;gBAChE,GAAIhB,QAAQiB,MAAM,GAAG;oBAAEC,eAAelB,QAAQiB,MAAM;gBAAC,IAAI,CAAC,CAAC;gBAC3D,GAAIjB,QAAQmB,OAAO,GAAG;oBAAEC,gBAAgBpB,QAAQmB,OAAO;gBAAC,IAAI,CAAC,CAAC;YAChE;QACF,IACA,CAAC,CAAC;QACN,GAAIlB,MACA;YAAEA,KAAK;gBAAE,SAAS;gBAAkBoB,UAAUpB,IAAIoB,QAAQ;gBAAEC,WAAWrB,IAAIqB,SAAS;YAAC;QAAE,IACvF,CAAC,CAAC;QACN,GAAIlB,gBAAgBA,aAAamB,MAAM,GAAG,IAAI;YAAEnB;QAAa,IAAI,CAAC,CAAC;QACnE,GAAIC,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIC,UAAUA,OAAOiB,MAAM,GAAG,IAAI;YAAEjB;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||||
Vendored
+13
-1
@@ -19,6 +19,11 @@ export type PageMetadata = {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
|
robots?: {
|
||||||
|
follow: boolean;
|
||||||
|
index: boolean;
|
||||||
|
};
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
type BuildMetadataArgs = {
|
type BuildMetadataArgs = {
|
||||||
@@ -35,6 +40,13 @@ type BuildMetadataArgs = {
|
|||||||
meta?: null | SeoMeta;
|
meta?: null | SeoMeta;
|
||||||
/** Page title or site name first. Defaults to 'page-first'. */
|
/** Page title or site name first. Defaults to 'page-first'. */
|
||||||
order?: TitleOrder;
|
order?: TitleOrder;
|
||||||
|
/**
|
||||||
|
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||||
|
* page-title source when meta.title is empty — the browser tab and search
|
||||||
|
* result should show the page name, not go blank, when an editor didn't fill
|
||||||
|
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||||
|
*/
|
||||||
|
pageTitle?: null | string;
|
||||||
/**
|
/**
|
||||||
* Localized segment the document lives under (an archive page's slugs).
|
* Localized segment the document lives under (an archive page's slugs).
|
||||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||||
@@ -65,5 +77,5 @@ type BuildMetadataArgs = {
|
|||||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||||
* in — the plugin composes, it doesn't fetch.
|
* in — the plugin composes, it doesn't fetch.
|
||||||
*/
|
*/
|
||||||
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+15
-4
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
||||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||||
* in — the plugin composes, it doesn't fetch.
|
* in — the plugin composes, it doesn't fetch.
|
||||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) {
|
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
|
||||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||||
// string in the tab, not a composition.
|
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||||
|
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||||
|
// composed with the site name, instead of just the site name or a blank.
|
||||||
const override = meta?.titleOverride?.trim();
|
const override = meta?.titleOverride?.trim();
|
||||||
|
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
|
||||||
const title = override || composeTitle({
|
const title = override || composeTitle({
|
||||||
order,
|
order,
|
||||||
pageTitle: meta?.title,
|
pageTitle: resolvedPageTitle,
|
||||||
separator,
|
separator,
|
||||||
siteName
|
siteName
|
||||||
});
|
});
|
||||||
@@ -69,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
images
|
images
|
||||||
},
|
},
|
||||||
locale
|
locale
|
||||||
|
},
|
||||||
|
// noindex → tell search engines to exclude the page but still follow links
|
||||||
|
// (authority flows through). For legal/thin/search-result pages.
|
||||||
|
...meta?.noindex ? {
|
||||||
|
robots: {
|
||||||
|
follow: true,
|
||||||
|
index: false
|
||||||
}
|
}
|
||||||
|
} : {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+41
@@ -0,0 +1,41 @@
|
|||||||
|
type MediaLike = {
|
||||||
|
url?: null | string;
|
||||||
|
} | null | undefined;
|
||||||
|
type OrganizationJsonLdArgs = {
|
||||||
|
/** Logo media (from panel). Google uses this for brand knowledge panels. */
|
||||||
|
logo?: MediaLike;
|
||||||
|
/** Organization / site name. */
|
||||||
|
name: string;
|
||||||
|
/** Optional social / official profile URLs (sameAs). */
|
||||||
|
sameAs?: string[];
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/
|
||||||
|
export declare function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs): {
|
||||||
|
sameAs?: string[] | undefined;
|
||||||
|
logo?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Builds Organization JSON-LD (schema.org) — helps Google associate the site
|
||||||
|
* with a brand: name, logo, official links. Improves how the site appears in
|
||||||
|
* search (brand recognition, logo in knowledge panels) and is a signal used
|
||||||
|
* alongside favicon for identity.
|
||||||
|
*
|
||||||
|
* Returns a plain object; the project renders it as a <script type="application/
|
||||||
|
* ld+json"> in the root layout:
|
||||||
|
*
|
||||||
|
* import { buildOrganizationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildOrganizationJsonLd({
|
||||||
|
* name: settings.siteName, url: baseUrl, logo: settings.logo,
|
||||||
|
* })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*
|
||||||
|
* Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in
|
||||||
|
* the root layout (Organization is site-wide, not per page).
|
||||||
|
*/ export function buildOrganizationJsonLd({ name, logo, sameAs, url }) {
|
||||||
|
const logoUrl = logo?.url;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
url,
|
||||||
|
...logoUrl ? {
|
||||||
|
logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}`
|
||||||
|
} : {},
|
||||||
|
...sameAs && sameAs.length > 0 ? {
|
||||||
|
sameAs
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildOrganizationJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildOrganizationJsonLd.ts"],"sourcesContent":["type MediaLike = { url?: null | string } | null | undefined\n\ntype OrganizationJsonLdArgs = {\n /** Logo media (from panel). Google uses this for brand knowledge panels. */\n logo?: MediaLike\n /** Organization / site name. */\n name: string\n /** Optional social / official profile URLs (sameAs). */\n sameAs?: string[]\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds Organization JSON-LD (schema.org) — helps Google associate the site\n * with a brand: name, logo, official links. Improves how the site appears in\n * search (brand recognition, logo in knowledge panels) and is a signal used\n * alongside favicon for identity.\n *\n * Returns a plain object; the project renders it as a <script type=\"application/\n * ld+json\"> in the root layout:\n *\n * import { buildOrganizationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildOrganizationJsonLd({\n * name: settings.siteName, url: baseUrl, logo: settings.logo,\n * })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n *\n * Data comes from the panel (siteName, logo) — nothing hardcoded. Emit once in\n * the root layout (Organization is site-wide, not per page).\n */\nexport function buildOrganizationJsonLd({ name, logo, sameAs, url }: OrganizationJsonLdArgs) {\n const logoUrl = logo?.url\n\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Organization',\n url,\n ...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),\n ...(sameAs && sameAs.length > 0 ? { sameAs } : {}),\n }\n}\n"],"names":["buildOrganizationJsonLd","name","logo","sameAs","url","logoUrl","startsWith","length"],"mappings":"AAaA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,wBAAwB,EAAEC,IAAI,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAA0B;IACzF,MAAMC,UAAUH,MAAME;IAEtB,OAAO;QACLH;QACA,YAAY;QACZ,SAAS;QACTG;QACA,GAAIC,UAAU;YAAEH,MAAMG,QAAQC,UAAU,CAAC,UAAUD,UAAU,GAAGD,MAAMC,SAAS;QAAC,IAAI,CAAC,CAAC;QACtF,GAAIF,UAAUA,OAAOI,MAAM,GAAG,IAAI;YAAEJ;QAAO,IAAI,CAAC,CAAC;IACnD;AACF"}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
type ServiceJsonLdArgs = {
|
||||||
|
/** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */
|
||||||
|
areaServed?: string | string[];
|
||||||
|
description?: string;
|
||||||
|
/** Service name, e.g. 'Sprzątanie biur'. */
|
||||||
|
name: string;
|
||||||
|
/** Provider (business) name. */
|
||||||
|
providerName: string;
|
||||||
|
/** Service type / category. */
|
||||||
|
serviceType?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||||
|
* understand "what this page sells" — useful for service landing pages
|
||||||
|
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||||
|
*
|
||||||
|
* Per-page (each service page emits its own), data from the panel:
|
||||||
|
*
|
||||||
|
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildServiceJsonLd({
|
||||||
|
* name: page.serviceName, providerName: company.name,
|
||||||
|
* url: pageUrl, areaServed: 'Wrocław',
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
export declare function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url, }: ServiceJsonLdArgs): {
|
||||||
|
serviceType?: string | undefined;
|
||||||
|
areaServed?: string | string[] | undefined;
|
||||||
|
description?: string | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
provider: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||||
|
* understand "what this page sells" — useful for service landing pages
|
||||||
|
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||||
|
*
|
||||||
|
* Per-page (each service page emits its own), data from the panel:
|
||||||
|
*
|
||||||
|
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildServiceJsonLd({
|
||||||
|
* name: page.serviceName, providerName: company.name,
|
||||||
|
* url: pageUrl, areaServed: 'Wrocław',
|
||||||
|
* })
|
||||||
|
*/ export function buildServiceJsonLd({ name, areaServed, description, providerName, serviceType, url }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
provider: {
|
||||||
|
name: providerName,
|
||||||
|
'@type': 'LocalBusiness',
|
||||||
|
url
|
||||||
|
},
|
||||||
|
...description ? {
|
||||||
|
description
|
||||||
|
} : {},
|
||||||
|
...areaServed ? {
|
||||||
|
areaServed
|
||||||
|
} : {},
|
||||||
|
...serviceType ? {
|
||||||
|
serviceType
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildServiceJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildServiceJsonLd.ts"],"sourcesContent":["type ServiceJsonLdArgs = {\n /** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */\n areaServed?: string | string[]\n description?: string\n /** Service name, e.g. 'Sprzątanie biur'. */\n name: string\n /** Provider (business) name. */\n providerName: string\n /** Service type / category. */\n serviceType?: string\n url: string\n}\n\n/**\n * Builds Service JSON-LD (schema.org) for a service offering. Helps Google\n * understand \"what this page sells\" — useful for service landing pages\n * (\"usługa + miasto\"). Pairs well with LocalBusiness (the provider).\n *\n * Per-page (each service page emits its own), data from the panel:\n *\n * import { buildServiceJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildServiceJsonLd({\n * name: page.serviceName, providerName: company.name,\n * url: pageUrl, areaServed: 'Wrocław',\n * })\n */\nexport function buildServiceJsonLd({\n name,\n areaServed,\n description,\n providerName,\n serviceType,\n url,\n}: ServiceJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'Service',\n provider: {\n name: providerName,\n '@type': 'LocalBusiness',\n url,\n },\n ...(description ? { description } : {}),\n ...(areaServed ? { areaServed } : {}),\n ...(serviceType ? { serviceType } : {}),\n }\n}\n"],"names":["buildServiceJsonLd","name","areaServed","description","providerName","serviceType","url","provider"],"mappings":"AAaA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASA,mBAAmB,EACjCC,IAAI,EACJC,UAAU,EACVC,WAAW,EACXC,YAAY,EACZC,WAAW,EACXC,GAAG,EACe;IAClB,OAAO;QACLL;QACA,YAAY;QACZ,SAAS;QACTM,UAAU;YACRN,MAAMG;YACN,SAAS;YACTE;QACF;QACA,GAAIH,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;QACtC,GAAID,aAAa;YAAEA;QAAW,IAAI,CAAC,CAAC;QACpC,GAAIG,cAAc;YAAEA;QAAY,IAAI,CAAC,CAAC;IACxC;AACF"}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
type NavItem = {
|
||||||
|
/** Visible label (e.g. 'Usługi'). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||||
|
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||||
|
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||||
|
* navigation targets.
|
||||||
|
*
|
||||||
|
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||||
|
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||||
|
*
|
||||||
|
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildSiteNavigationJsonLd(
|
||||||
|
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||||
|
* )
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||||
|
* Returns null for empty nav.
|
||||||
|
*/
|
||||||
|
export declare function buildSiteNavigationJsonLd(items: NavItem[]): {
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
itemListElement: {
|
||||||
|
name: string;
|
||||||
|
'@type': string;
|
||||||
|
position: number;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
export {};
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —
|
||||||
|
* declares the site's primary nav as structured data. A weaker sitelinks signal
|
||||||
|
* than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main
|
||||||
|
* navigation targets.
|
||||||
|
*
|
||||||
|
* Feed it the SAME nav items the header renders (from the panel/nav global), so
|
||||||
|
* the structured data matches the visible menu — not a separate hardcoded list.
|
||||||
|
*
|
||||||
|
* import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildSiteNavigationJsonLd(
|
||||||
|
* navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
|
||||||
|
* )
|
||||||
|
* <script type="application/ld+json" ... />
|
||||||
|
*
|
||||||
|
* Emit once (site-wide, root layout). Data from the nav source, never hardcoded.
|
||||||
|
* Returns null for empty nav.
|
||||||
|
*/ export function buildSiteNavigationJsonLd(items) {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'ItemList',
|
||||||
|
itemListElement: items.map((item, index)=>({
|
||||||
|
name: item.name,
|
||||||
|
'@type': 'SiteNavigationElement',
|
||||||
|
position: index + 1,
|
||||||
|
url: item.url
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildSiteNavigationJsonLd.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildSiteNavigationJsonLd.ts"],"sourcesContent":["type NavItem = {\n /** Visible label (e.g. 'Usługi'). */\n name: string\n /** Absolute URL (e.g. 'https://example.com/pl/uslugi'). */\n url: string\n}\n\n/**\n * Builds SiteNavigationElement JSON-LD (schema.org) from the main navigation —\n * declares the site's primary nav as structured data. A weaker sitelinks signal\n * than WebSite/breadcrumbs, but cheap: it tells Google which pages are the main\n * navigation targets.\n *\n * Feed it the SAME nav items the header renders (from the panel/nav global), so\n * the structured data matches the visible menu — not a separate hardcoded list.\n *\n * import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildSiteNavigationJsonLd(\n * navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))\n * )\n * <script type=\"application/ld+json\" ... />\n *\n * Emit once (site-wide, root layout). Data from the nav source, never hardcoded.\n * Returns null for empty nav.\n */\nexport function buildSiteNavigationJsonLd(items: NavItem[]) {\n if (!items || items.length === 0) {return null}\n\n return {\n '@context': 'https://schema.org',\n '@type': 'ItemList',\n itemListElement: items.map((item, index) => ({\n name: item.name,\n '@type': 'SiteNavigationElement',\n position: index + 1,\n url: item.url,\n })),\n }\n}\n"],"names":["buildSiteNavigationJsonLd","items","length","itemListElement","map","item","index","name","position","url"],"mappings":"AAOA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASA,0BAA0BC,KAAgB;IACxD,IAAI,CAACA,SAASA,MAAMC,MAAM,KAAK,GAAG;QAAC,OAAO;IAAI;IAE9C,OAAO;QACL,YAAY;QACZ,SAAS;QACTC,iBAAiBF,MAAMG,GAAG,CAAC,CAACC,MAAMC,QAAW,CAAA;gBAC3CC,MAAMF,KAAKE,IAAI;gBACf,SAAS;gBACTC,UAAUF,QAAQ;gBAClBG,KAAKJ,KAAKI,GAAG;YACf,CAAA;IACF;AACF"}
|
||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
import type { BasePayload } from 'payload';
|
import type { BasePayload } from 'payload';
|
||||||
import type { ContentOption } from '../content/index.js';
|
|
||||||
import type { I18nConfig } from '../i18n/index.js';
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
|
import type { ContentOption } from '../content/index.js';
|
||||||
/**
|
/**
|
||||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||||
*
|
*
|
||||||
@@ -10,31 +10,31 @@ import type { I18nConfig } from '../i18n/index.js';
|
|||||||
* is the common, weaker kind.
|
* is the common, weaker kind.
|
||||||
*/
|
*/
|
||||||
export type SitemapEntry = {
|
export type SitemapEntry = {
|
||||||
|
url: string;
|
||||||
|
lastModified?: string | Date;
|
||||||
|
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
|
priority?: number;
|
||||||
alternates?: {
|
alternates?: {
|
||||||
languages: Record<string, string>;
|
languages: Record<string, string>;
|
||||||
};
|
};
|
||||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
|
|
||||||
lastModified?: Date | string;
|
|
||||||
priority?: number;
|
|
||||||
url: string;
|
|
||||||
};
|
};
|
||||||
type BuildSitemapArgs = {
|
type BuildSitemapArgs = {
|
||||||
|
payload: BasePayload;
|
||||||
|
config: I18nConfig;
|
||||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
changeFrequency?: SitemapEntry['changeFrequency'];
|
/** Pages collection slug. Defaults to 'pages'. */
|
||||||
config: I18nConfig;
|
pagesSlug?: string;
|
||||||
/** Archive-backed collections, same value as the plugin option. */
|
/** Archive-backed collections, same value as the plugin option. */
|
||||||
content?: ContentOption;
|
content?: ContentOption;
|
||||||
|
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||||
|
settingsSlug?: string;
|
||||||
/**
|
/**
|
||||||
* Slug of the page that is the site root (collapses to /{locale}).
|
* Slug of the page that is the site root (collapses to /{locale}).
|
||||||
* Read from System Pages when omitted.
|
* Read from System Pages when omitted.
|
||||||
*/
|
*/
|
||||||
homeSlug?: string;
|
homeSlug?: string;
|
||||||
/** Pages collection slug. Defaults to 'pages'. */
|
changeFrequency?: SitemapEntry['changeFrequency'];
|
||||||
pagesSlug?: string;
|
|
||||||
payload: BasePayload;
|
|
||||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
|
||||||
settingsSlug?: string;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Collects every public URL — pages and archive entries — as sitemap entries
|
* Collects every public URL — pages and archive entries — as sitemap entries
|
||||||
@@ -57,5 +57,5 @@ type BuildSitemapArgs = {
|
|||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
export declare function buildSitemapEntries({ payload, config, baseUrl, pagesSlug, content, settingsSlug, homeSlug, changeFrequency, }: BuildSitemapArgs): Promise<SitemapEntry[]>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+45
-43
@@ -1,13 +1,31 @@
|
|||||||
import { archiveFieldName } from '../content/index.js';
|
import { getLocalizedSlugs } from '../i18n/index.js';
|
||||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js';
|
import { buildLocalizedPath } from '../i18n/index.js';
|
||||||
import { buildHreflangAlternates } from './hreflang.js';
|
import { buildHreflangAlternates } from './hreflang.js';
|
||||||
/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) {
|
import { archiveFieldName } from '../content/index.js';
|
||||||
if (doc._status && doc._status !== 'published') {
|
/**
|
||||||
return false;
|
* Slugs that must never appear in the sitemap — error/system pages that exist as
|
||||||
|
* documents (e.g. a '404' page in the Pages collection) but should not be
|
||||||
|
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||||
|
* is an audit finding. Matched against the slug in any locale.
|
||||||
|
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||||
|
'404',
|
||||||
|
'500',
|
||||||
|
'not-found',
|
||||||
|
'error'
|
||||||
|
]);
|
||||||
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
||||||
|
if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug);
|
||||||
|
if (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug)){
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true;
|
||||||
}
|
}
|
||||||
if (doc.meta?.noindex) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||||
|
if (doc._status && doc._status !== 'published') return false;
|
||||||
|
if (doc.meta?.noindex) return false;
|
||||||
|
if (hasExcludedSlug(doc.slug)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
* every locale (including itself, per Google's guidance).
|
* every locale (including itself, per Google's guidance).
|
||||||
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
|
*/ function entryFor(doc, locale, config, baseUrl, homeSlug, prefix, changeFrequency) {
|
||||||
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
|
const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({
|
||||||
config,
|
slugField: doc.slug,
|
||||||
slugField: doc.slug
|
config
|
||||||
}) : {};
|
}) : {};
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale,
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
locale,
|
prefix
|
||||||
prefix,
|
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
if (!path) {
|
if (!path) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const origin = baseUrl.replace(/\/$/, '');
|
const origin = baseUrl.replace(/\/$/, '');
|
||||||
const languages = buildHreflangAlternates({
|
const languages = buildHreflangAlternates({
|
||||||
baseUrl,
|
slugs,
|
||||||
config,
|
config,
|
||||||
|
baseUrl,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
prefix,
|
prefix
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
url: `${origin}${path}`,
|
url: `${origin}${path}`,
|
||||||
@@ -73,15 +89,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
* })
|
* })
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) {
|
*/ export async function buildSitemapEntries({ payload, config, baseUrl, pagesSlug = 'pages', content, settingsSlug = 'site-settings', homeSlug, changeFrequency = 'weekly' }) {
|
||||||
const locales = config.locales.map((l)=>l.code);
|
const locales = config.locales.map((l)=>l.code);
|
||||||
const defaultLocale = config.defaultLocale;
|
const defaultLocale = config.defaultLocale;
|
||||||
// Resolve homeSlug and archive prefixes from System Pages (read once, in all
|
// Resolve homeSlug and archive prefixes from System Pages (read once, in all
|
||||||
// locales so archive prefixes are available per language).
|
// locales so archive prefixes are available per language).
|
||||||
const settings = await payload.findGlobal({
|
const settings = await payload.findGlobal({
|
||||||
slug: settingsSlug,
|
slug: settingsSlug,
|
||||||
depth: 1,
|
locale: 'all',
|
||||||
locale: 'all'
|
depth: 1
|
||||||
});
|
});
|
||||||
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home';
|
||||||
// Which collections to walk: pages (no prefix) + each content collection with
|
// Which collections to walk: pages (no prefix) + each content collection with
|
||||||
@@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
// alternates without re-querying per locale.
|
// alternates without re-querying per locale.
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: collection.slug,
|
collection: collection.slug,
|
||||||
|
locale: 'all',
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
locale: 'all',
|
|
||||||
pagination: false
|
pagination: false
|
||||||
});
|
});
|
||||||
for (const raw of result.docs){
|
for (const raw of result.docs){
|
||||||
if (!isIndexable(raw)) {
|
if (!isIndexable(raw)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Emit the entry under the default locale's URL; alternates cover the rest.
|
// Emit the entry under the default locale's URL; alternates cover the rest.
|
||||||
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
const entry = entryFor(raw, defaultLocale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency);
|
||||||
if (entry) {
|
if (entry) entries.push(entry);
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
|
/** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel, locale) {
|
||||||
if (!rel || typeof rel !== 'object') {
|
if (!rel || typeof rel !== 'object') return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const slug = rel.slug;
|
const slug = rel.slug;
|
||||||
if (typeof slug === 'string') {
|
if (typeof slug === 'string') return slug;
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
if (slug && typeof slug === 'object') {
|
if (slug && typeof slug === 'object') {
|
||||||
const v = slug[locale];
|
const v = slug[locale];
|
||||||
return typeof v === 'string' ? v : undefined;
|
return typeof v === 'string' ? v : undefined;
|
||||||
@@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
/** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) {
|
||||||
if (!rel || typeof rel !== 'object') {
|
if (!rel || typeof rel !== 'object') return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const slug = rel.slug;
|
const slug = rel.slug;
|
||||||
if (!slug || typeof slug !== 'object') {
|
if (!slug || typeof slug !== 'object') return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const locale of locales){
|
for (const locale of locales){
|
||||||
const v = slug[locale];
|
const v = slug[locale];
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') map[locale] = v;
|
||||||
map[locale] = v;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Object.keys(map).length ? map : undefined;
|
return Object.keys(map).length ? map : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+55
@@ -0,0 +1,55 @@
|
|||||||
|
type SearchActionConfig = {
|
||||||
|
/**
|
||||||
|
* URL template for site search, with {search_term_string} placeholder.
|
||||||
|
* e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if
|
||||||
|
* the site actually HAS a working search page — a SearchAction pointing at a
|
||||||
|
* non-existent search does more harm than good.
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
type WebSiteJsonLdArgs = {
|
||||||
|
/** Site name (from panel — siteName). */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Optional site search. Enables the "sitelinks searchbox" — a search field
|
||||||
|
* Google may show under the brand result. Only pass when a real search page
|
||||||
|
* exists. Omit entirely otherwise.
|
||||||
|
*/
|
||||||
|
search?: SearchActionConfig;
|
||||||
|
/** Absolute site URL (https://…). */
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||||
|
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||||
|
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||||
|
* searchbox" (a search field under the brand result in Google).
|
||||||
|
*
|
||||||
|
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||||
|
* schema guarantees them; Google generates them algorithmically from site
|
||||||
|
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||||
|
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||||
|
* expectations accordingly (see docs/seo.md).
|
||||||
|
*
|
||||||
|
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||||
|
*
|
||||||
|
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*/
|
||||||
|
export declare function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs): {
|
||||||
|
potentialAction?: {
|
||||||
|
'@type': string;
|
||||||
|
'query-input': string;
|
||||||
|
target: {
|
||||||
|
'@type': string;
|
||||||
|
urlTemplate: string;
|
||||||
|
};
|
||||||
|
} | undefined;
|
||||||
|
name: string;
|
||||||
|
'@context': string;
|
||||||
|
'@type': string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export {};
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Builds WebSite JSON-LD (schema.org). Two jobs:
|
||||||
|
* - Declares the site + name (helps Google associate brand queries with the site).
|
||||||
|
* - Optionally declares a SearchAction, which is what can produce the "sitelinks
|
||||||
|
* searchbox" (a search field under the brand result in Google).
|
||||||
|
*
|
||||||
|
* IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No
|
||||||
|
* schema guarantees them; Google generates them algorithmically from site
|
||||||
|
* structure, internal links, clear titles, and ranking. This schema is a SIGNAL
|
||||||
|
* that improves the odds and can enable the searchbox — not a switch. Manage
|
||||||
|
* expectations accordingly (see docs/seo.md).
|
||||||
|
*
|
||||||
|
* Emit once in the ROOT layout (site-wide), from panel data:
|
||||||
|
*
|
||||||
|
* import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
|
||||||
|
* const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })
|
||||||
|
* <script type="application/ld+json"
|
||||||
|
* dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
*/ export function buildWebSiteJsonLd({ name, search, url }) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
url,
|
||||||
|
...search ? {
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
target: {
|
||||||
|
'@type': 'EntryPoint',
|
||||||
|
urlTemplate: search.target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildWebSiteJsonLd.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/buildWebSiteJsonLd.ts"],"sourcesContent":["type SearchActionConfig = {\n /**\n * URL template for site search, with {search_term_string} placeholder.\n * e.g. 'https://example.com/szukaj?q={search_term_string}'. Only include if\n * the site actually HAS a working search page — a SearchAction pointing at a\n * non-existent search does more harm than good.\n */\n target: string\n}\n\ntype WebSiteJsonLdArgs = {\n /** Site name (from panel — siteName). */\n name: string\n /**\n * Optional site search. Enables the \"sitelinks searchbox\" — a search field\n * Google may show under the brand result. Only pass when a real search page\n * exists. Omit entirely otherwise.\n */\n search?: SearchActionConfig\n /** Absolute site URL (https://…). */\n url: string\n}\n\n/**\n * Builds WebSite JSON-LD (schema.org). Two jobs:\n * - Declares the site + name (helps Google associate brand queries with the site).\n * - Optionally declares a SearchAction, which is what can produce the \"sitelinks\n * searchbox\" (a search field under the brand result in Google).\n *\n * IMPORTANT — sitelinks (the sub-links under a result) CANNOT be forced. No\n * schema guarantees them; Google generates them algorithmically from site\n * structure, internal links, clear titles, and ranking. This schema is a SIGNAL\n * that improves the odds and can enable the searchbox — not a switch. Manage\n * expectations accordingly (see docs/seo.md).\n *\n * Emit once in the ROOT layout (site-wide), from panel data:\n *\n * import { buildWebSiteJsonLd } from '@intecion/ipal-kit'\n * const jsonLd = buildWebSiteJsonLd({ name: settings.siteName, url: baseUrl })\n * <script type=\"application/ld+json\"\n * dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n */\nexport function buildWebSiteJsonLd({ name, search, url }: WebSiteJsonLdArgs) {\n return {\n name,\n '@context': 'https://schema.org',\n '@type': 'WebSite',\n url,\n ...(search\n ? {\n potentialAction: {\n '@type': 'SearchAction',\n 'query-input': 'required name=search_term_string',\n target: {\n '@type': 'EntryPoint',\n urlTemplate: search.target,\n },\n },\n }\n : {}),\n }\n}\n"],"names":["buildWebSiteJsonLd","name","search","url","potentialAction","target","urlTemplate"],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASA,mBAAmB,EAAEC,IAAI,EAAEC,MAAM,EAAEC,GAAG,EAAqB;IACzE,OAAO;QACLF;QACA,YAAY;QACZ,SAAS;QACTE;QACA,GAAID,SACA;YACEE,iBAAiB;gBACf,SAAS;gBACT,eAAe;gBACfC,QAAQ;oBACN,SAAS;oBACTC,aAAaJ,OAAOG,MAAM;gBAC5B;YACF;QACF,IACA,CAAC,CAAC;IACR;AACF"}
|
||||||
+1
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
|||||||
...base,
|
...base,
|
||||||
imageUrl: resolveOgImage(doc),
|
imageUrl: resolveOgImage(doc),
|
||||||
meta: doc.meta,
|
meta: doc.meta,
|
||||||
|
pageTitle: doc.title,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
slugs
|
slugs
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-2
@@ -15,20 +15,28 @@ type BuildHreflangArgs = {
|
|||||||
slugs: LocalizedSlugs;
|
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
+9
@@ -1,11 +1,19 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
export type { AutoFillMapping } from './autoFillMeta.js';
|
export type { AutoFillMapping } from './autoFillMeta.js';
|
||||||
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
export type { PageMetadata } from './buildMetadata.js';
|
export type { PageMetadata } from './buildMetadata.js';
|
||||||
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
export type { RobotsRules } from './buildRobots.js';
|
export type { RobotsRules } from './buildRobots.js';
|
||||||
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
export type { SitemapEntry } from './buildSitemapEntries.js';
|
export type { SitemapEntry } from './buildSitemapEntries.js';
|
||||||
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
|
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||||
export { composeTitle } from './composeTitle.js';
|
export { composeTitle } from './composeTitle.js';
|
||||||
export type { TitleOrder } from './composeTitle.js';
|
export type { TitleOrder } from './composeTitle.js';
|
||||||
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
||||||
@@ -18,3 +26,4 @@ export type { SiteMetaConfig } from './readSiteMetaConfig.js';
|
|||||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||||
export type { SeoMeta, SeoOption } from './types.js';
|
export type { SeoMeta, SeoOption } from './types.js';
|
||||||
|
export { validateFaviconField } from './validateFavicon.js';
|
||||||
|
|||||||
Vendored
+9
@@ -1,7 +1,15 @@
|
|||||||
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
export { buildAutoFillMetaHook } from './autoFillMeta.js';
|
||||||
|
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js';
|
||||||
|
export { buildFaqJsonLd } from './buildFaqJsonLd.js';
|
||||||
|
export { buildIconsMetadata } from './buildIconsMetadata.js';
|
||||||
|
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js';
|
||||||
export { buildMetadata } from './buildMetadata.js';
|
export { buildMetadata } from './buildMetadata.js';
|
||||||
|
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js';
|
||||||
export { buildRobots } from './buildRobots.js';
|
export { buildRobots } from './buildRobots.js';
|
||||||
|
export { buildServiceJsonLd } from './buildServiceJsonLd.js';
|
||||||
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
export { buildSitemapEntries } from './buildSitemapEntries.js';
|
||||||
|
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js';
|
||||||
|
export { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js';
|
||||||
export { composeTitle } from './composeTitle.js';
|
export { composeTitle } from './composeTitle.js';
|
||||||
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
export { createMetadataGenerator } from './createMetadataGenerator.js';
|
||||||
export { createPageMetadata } from './createPageMetadata.js';
|
export { createPageMetadata } from './createPageMetadata.js';
|
||||||
@@ -11,5 +19,6 @@ export { injectSeoTabs } from './injectSeoTabs.js';
|
|||||||
export { readSiteMetaConfig } from './readSiteMetaConfig.js';
|
export { readSiteMetaConfig } from './readSiteMetaConfig.js';
|
||||||
export { buildSeoPlugin } from './seoPluginConfig.js';
|
export { buildSeoPlugin } from './seoPluginConfig.js';
|
||||||
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
export { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||||
|
export { validateFaviconField } from './validateFavicon.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\n"],"names":["buildAutoFillMetaHook","buildMetadata","buildRobots","buildSitemapEntries","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB"}
|
{"version":3,"sources":["../../../src/modules/seo/index.ts"],"sourcesContent":["export { buildAutoFillMetaHook } from './autoFillMeta.js'\nexport type { AutoFillMapping } from './autoFillMeta.js'\nexport { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'\nexport { buildFaqJsonLd } from './buildFaqJsonLd.js'\nexport { buildIconsMetadata } from './buildIconsMetadata.js'\nexport { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'\nexport { buildMetadata } from './buildMetadata.js'\nexport type { PageMetadata } from './buildMetadata.js'\nexport { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'\nexport { buildRobots } from './buildRobots.js'\nexport type { RobotsRules } from './buildRobots.js'\nexport { buildServiceJsonLd } from './buildServiceJsonLd.js'\nexport { buildSitemapEntries } from './buildSitemapEntries.js'\nexport type { SitemapEntry } from './buildSitemapEntries.js'\nexport { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'\nexport { buildWebSiteJsonLd } from './buildWebSiteJsonLd.js'\nexport { composeTitle } from './composeTitle.js'\nexport type { TitleOrder } from './composeTitle.js'\nexport { createMetadataGenerator } from './createMetadataGenerator.js'\nexport { createPageMetadata } from './createPageMetadata.js'\nexport { buildHreflangAlternates } from './hreflang.js'\nexport { injectAutoFillMeta } from './injectAutoFillMeta.js'\nexport { injectSeoTabs } from './injectSeoTabs.js'\nexport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nexport type { SiteMetaConfig } from './readSiteMetaConfig.js'\nexport { buildSeoPlugin } from './seoPluginConfig.js'\nexport { slugsAcrossLocales } from './slugsAcrossLocales.js'\nexport type { SeoMeta, SeoOption } from './types.js'\nexport { validateFaviconField } from './validateFavicon.js'\n"],"names":["buildAutoFillMetaHook","buildBreadcrumbJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLocalBusinessJsonLd","buildMetadata","buildOrganizationJsonLd","buildRobots","buildServiceJsonLd","buildSitemapEntries","buildSiteNavigationJsonLd","buildWebSiteJsonLd","composeTitle","createMetadataGenerator","createPageMetadata","buildHreflangAlternates","injectAutoFillMeta","injectSeoTabs","readSiteMetaConfig","buildSeoPlugin","slugsAcrossLocales","validateFaviconField"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,oBAAmB;AAEzD,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,cAAc,QAAQ,sBAAqB;AACpD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,wBAAwB,QAAQ,gCAA+B;AACxE,SAASC,aAAa,QAAQ,qBAAoB;AAElD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,WAAW,QAAQ,mBAAkB;AAE9C,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,mBAAmB,QAAQ,2BAA0B;AAE9D,SAASC,yBAAyB,QAAQ,iCAAgC;AAC1E,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,YAAY,QAAQ,oBAAmB;AAEhD,SAASC,uBAAuB,QAAQ,+BAA8B;AACtE,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,uBAAuB,QAAQ,gBAAe;AACvD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,cAAc,QAAQ,uBAAsB;AACrD,SAASC,kBAAkB,QAAQ,0BAAyB;AAE5D,SAASC,oBAAoB,QAAQ,uBAAsB"}
|
||||||
Vendored
+1
@@ -34,6 +34,7 @@ export type SeoOption = {
|
|||||||
export type SeoMeta = {
|
export type SeoMeta = {
|
||||||
description?: null | string;
|
description?: null | string;
|
||||||
image?: unknown;
|
image?: unknown;
|
||||||
|
noindex?: boolean | null;
|
||||||
title?: null | string;
|
title?: null | string;
|
||||||
/** When set, used as the whole title — no site name, no separator. */
|
/** When set, used as the whole title — no site name, no separator. */
|
||||||
titleOverride?: null | string;
|
titleOverride?: null | string;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AutoFillMapping } from './autoFillMeta.js'\n\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[]\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[]\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: { doc: Record<string, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => string\n}\n\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string\n image?: unknown\n title?: null | string\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string\n}\n"],"names":[],"mappings":"AA2BA;;;;CAIC,GACD,WAMC"}
|
{"version":3,"sources":["../../../src/modules/seo/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AutoFillMapping } from './autoFillMeta.js'\n\n/**\n * SEO configuration.\n *\n * The plugin wires @payloadcms/plugin-seo into the client's config and adds\n * locale-aware metadata helpers on top. Collections come from options because\n * the plugin doesn't own the client's content collections (e.g. Pages).\n */\nexport type SeoOption = {\n /**\n * Auto-fill empty meta from document fields on save. Defaults to the website\n * template mapping (title → meta.title). Set to false to disable.\n */\n autoFill?: AutoFillMapping | false\n /** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */\n collections: string[]\n /** Extra fields appended to the SEO group in the admin. */\n fields?: Field[]\n /** Optional: customize how meta descriptions are generated. */\n generateDescription?: (args: { doc: Record<string, unknown> }) => string\n /** Optional: customize how meta titles are generated in the admin preview. */\n generateTitle?: (args: { doc: Record<string, unknown> }) => string\n}\n\n/**\n * Minimal shape of the SEO meta group as stored on a document by\n * @payloadcms/plugin-seo. The client's generated types are richer; helpers\n * depend only on this.\n */\nexport type SeoMeta = {\n description?: null | string\n image?: unknown\n noindex?: boolean | null\n title?: null | string\n /** When set, used as the whole title — no site name, no separator. */\n titleOverride?: null | string\n}\n"],"names":[],"mappings":"AA2BA;;;;CAIC,GACD,WAOC"}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { FieldHook } from 'payload';
|
||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/
|
||||||
|
export declare const validateFaviconField: FieldHook;
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Field validation for the favicon upload: Google rejects favicons under 48×48,
|
||||||
|
* so warn the editor at save time if the uploaded icon is too small or not
|
||||||
|
* square. This ENFORCES the requirement instead of silently shipping a favicon
|
||||||
|
* Google won't display.
|
||||||
|
*
|
||||||
|
* Attach to the favicon field's validate (or as a beforeValidate hook on the
|
||||||
|
* Media relationship). Non-blocking by default — returns a warning string that
|
||||||
|
* Payload surfaces; make it throw if you want a hard block.
|
||||||
|
*
|
||||||
|
* Note: dimensions come from the related Media doc (Payload stores width/height
|
||||||
|
* for image uploads), so this checks the resolved upload, not the raw file.
|
||||||
|
*/ export const validateFaviconField = async ({ req, value })=>{
|
||||||
|
if (!value) {
|
||||||
|
return value;
|
||||||
|
} // no favicon set → nothing to validate (optional field)
|
||||||
|
try {
|
||||||
|
const media = await req.payload.findByID({
|
||||||
|
id: typeof value === 'object' ? value.id : value,
|
||||||
|
collection: 'media',
|
||||||
|
depth: 0
|
||||||
|
});
|
||||||
|
const width = media.width;
|
||||||
|
const height = media.height;
|
||||||
|
const mimeType = media.mimeType;
|
||||||
|
// SVG scales infinitely — skip size checks.
|
||||||
|
if (mimeType === 'image/svg+xml') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
|
if (width < 48 || height < 48) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` + `display it in search results. Upload a larger square icon (96 or 192px).`);
|
||||||
|
}
|
||||||
|
if (width !== height) {
|
||||||
|
req.payload.logger.warn(`[ipal] Favicon is not square (${width}×${height}). Use a square icon ` + `so it isn't cropped in the browser tab or search results.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Media lookup failed — don't block the save over a validation warning.
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=validateFavicon.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/seo/validateFavicon.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\n/**\n * Field validation for the favicon upload: Google rejects favicons under 48×48,\n * so warn the editor at save time if the uploaded icon is too small or not\n * square. This ENFORCES the requirement instead of silently shipping a favicon\n * Google won't display.\n *\n * Attach to the favicon field's validate (or as a beforeValidate hook on the\n * Media relationship). Non-blocking by default — returns a warning string that\n * Payload surfaces; make it throw if you want a hard block.\n *\n * Note: dimensions come from the related Media doc (Payload stores width/height\n * for image uploads), so this checks the resolved upload, not the raw file.\n */\nexport const validateFaviconField: FieldHook = async ({ req, value }) => {\n if (!value) {return value} // no favicon set → nothing to validate (optional field)\n\n try {\n const media = await req.payload.findByID({\n id: typeof value === 'object' ? (value as { id: string }).id : value,\n collection: 'media',\n depth: 0,\n })\n\n const width = (media as { width?: number }).width\n const height = (media as { height?: number }).height\n const mimeType = (media as { mimeType?: string }).mimeType\n\n // SVG scales infinitely — skip size checks.\n if (mimeType === 'image/svg+xml') {return value}\n\n if (typeof width === 'number' && typeof height === 'number') {\n if (width < 48 || height < 48) {\n req.payload.logger.warn(\n `[ipal] Favicon is ${width}×${height}px. Google requires ≥48×48 to ` +\n `display it in search results. Upload a larger square icon (96 or 192px).`,\n )\n }\n if (width !== height) {\n req.payload.logger.warn(\n `[ipal] Favicon is not square (${width}×${height}). Use a square icon ` +\n `so it isn't cropped in the browser tab or search results.`,\n )\n }\n }\n } catch {\n // Media lookup failed — don't block the save over a validation warning.\n }\n\n return value\n}\n"],"names":["validateFaviconField","req","value","media","payload","findByID","id","collection","depth","width","height","mimeType","logger","warn"],"mappings":"AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMA,uBAAkC,OAAO,EAAEC,GAAG,EAAEC,KAAK,EAAE;IAClE,IAAI,CAACA,OAAO;QAAC,OAAOA;IAAK,EAAE,wDAAwD;IAEnF,IAAI;QACF,MAAMC,QAAQ,MAAMF,IAAIG,OAAO,CAACC,QAAQ,CAAC;YACvCC,IAAI,OAAOJ,UAAU,WAAW,AAACA,MAAyBI,EAAE,GAAGJ;YAC/DK,YAAY;YACZC,OAAO;QACT;QAEA,MAAMC,QAAQ,AAACN,MAA6BM,KAAK;QACjD,MAAMC,SAAS,AAACP,MAA8BO,MAAM;QACpD,MAAMC,WAAW,AAACR,MAAgCQ,QAAQ;QAE1D,4CAA4C;QAC5C,IAAIA,aAAa,iBAAiB;YAAC,OAAOT;QAAK;QAE/C,IAAI,OAAOO,UAAU,YAAY,OAAOC,WAAW,UAAU;YAC3D,IAAID,QAAQ,MAAMC,SAAS,IAAI;gBAC7BT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,kBAAkB,EAAEJ,MAAM,CAAC,EAAEC,OAAO,8BAA8B,CAAC,GAClE,CAAC,wEAAwE,CAAC;YAEhF;YACA,IAAID,UAAUC,QAAQ;gBACpBT,IAAIG,OAAO,CAACQ,MAAM,CAACC,IAAI,CACrB,CAAC,8BAA8B,EAAEJ,MAAM,CAAC,EAAEC,OAAO,qBAAqB,CAAC,GACrE,CAAC,yDAAyD,CAAC;YAEjE;QACF;IACF,EAAE,OAAM;IACN,wEAAwE;IAC1E;IAEA,OAAOR;AACT,EAAC"}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export declare function MediaPreconnect(): import("react/jsx-runtime").JSX.Element | null;
|
||||||
|
export default MediaPreconnect;
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/ export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||||
|
if (!publicUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin;
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
;
|
||||||
|
}
|
||||||
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
href: origin,
|
||||||
|
rel: "preconnect"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
href: origin,
|
||||||
|
rel: "dns-prefetch"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export default MediaPreconnect;
|
||||||
|
|
||||||
|
//# sourceMappingURL=MediaPreconnect.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/MediaPreconnect.tsx"],"sourcesContent":["/**\n * Emits <link rel=\"preconnect\"> + <link rel=\"dns-prefetch\"> for the media CDN\n * domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the\n * media host early — before it hits the first <img>. Saves ~150–300ms on the\n * first image load.\n *\n * Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),\n * so there's ONE source of truth — no per-project hardcoded domain. Renders\n * nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to\n * preconnect).\n *\n * Server Component — drop it in the <head> of your locale layout:\n *\n * import { MediaPreconnect } from '@intecion/ipal-kit/rsc'\n * // in <head> (or top of <body> — Next hoists link tags):\n * <MediaPreconnect />\n */\nexport function MediaPreconnect() {\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '')\n if (!publicUrl) {return null}\n\n // Origin only (scheme + host) — preconnect targets an origin, not a path.\n let origin: string\n try {\n origin = new URL(publicUrl).origin\n } catch {\n return null // malformed URL → skip rather than emit a broken tag\n }\n\n return (\n <>\n <link crossOrigin=\"anonymous\" href={origin} rel=\"preconnect\" />\n <link href={origin} rel=\"dns-prefetch\" />\n </>\n )\n}\n\nexport default MediaPreconnect\n"],"names":["MediaPreconnect","publicUrl","process","env","R2_PUBLIC_URL","replace","origin","URL","link","crossOrigin","href","rel"],"mappings":";AAAA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA;IACd,MAAMC,YAAYC,QAAQC,GAAG,CAACC,aAAa,EAAEC,QAAQ,OAAO;IAC5D,IAAI,CAACJ,WAAW;QAAC,OAAO;IAAI;IAE5B,0EAA0E;IAC1E,IAAIK;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIN,WAAWK,MAAM;IACpC,EAAE,OAAM;QACN,OAAO,KAAK,qDAAqD;;IACnE;IAEA,qBACE;;0BACE,KAACE;gBAAKC,aAAY;gBAAYC,MAAMJ;gBAAQK,KAAI;;0BAChD,KAACH;gBAAKE,MAAMJ;gBAAQK,KAAI;;;;AAG9B;AAEA,eAAeX,gBAAe"}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Plugin } from 'payload';
|
||||||
|
/**
|
||||||
|
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||||
|
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||||
|
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||||
|
*
|
||||||
|
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||||
|
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||||
|
* panel. See docs/storage.md for the required variables.
|
||||||
|
*
|
||||||
|
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||||
|
* no-op passthrough so the project falls back to Payload's default local disk
|
||||||
|
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||||
|
* gracefully when a transport isn't configured.
|
||||||
|
*
|
||||||
|
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||||
|
*/
|
||||||
|
export declare const buildR2Storage: (collections?: string[]) => Plugin;
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { s3Storage } from '@payloadcms/storage-s3';
|
||||||
|
/**
|
||||||
|
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||||
|
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||||
|
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||||
|
*
|
||||||
|
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||||
|
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||||
|
* panel. See docs/storage.md for the required variables.
|
||||||
|
*
|
||||||
|
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||||
|
* no-op passthrough so the project falls back to Payload's default local disk
|
||||||
|
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||||
|
* gracefully when a transport isn't configured.
|
||||||
|
*
|
||||||
|
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||||
|
*/ export const buildR2Storage = (collections = [
|
||||||
|
'media'
|
||||||
|
])=>{
|
||||||
|
const bucket = process.env.R2_BUCKET;
|
||||||
|
const endpoint = process.env.R2_ENDPOINT;
|
||||||
|
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||||
|
// Any missing → skip R2, fall back to local disk. Warn so it's not silent.
|
||||||
|
if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
return (config)=>{
|
||||||
|
// Only warn when SOME vars are set (partial config = likely a mistake).
|
||||||
|
if (bucket || endpoint || accessKeyId || secretAccessKey) {
|
||||||
|
console.warn('[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' + 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
|
// See docs/storage.md.
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
|
;
|
||||||
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
|
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||||
|
publicUrl,
|
||||||
|
prefix,
|
||||||
|
filename
|
||||||
|
].filter(Boolean).join('/') : undefined;
|
||||||
|
const collectionsConfig = {};
|
||||||
|
for (const slug of collections){
|
||||||
|
collectionsConfig[slug] = generateFileURL ? {
|
||||||
|
generateFileURL
|
||||||
|
} : true;
|
||||||
|
}
|
||||||
|
return s3Storage({
|
||||||
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
|
config: {
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey
|
||||||
|
},
|
||||||
|
endpoint,
|
||||||
|
region: 'auto',
|
||||||
|
// R2 requires path-style addressing for S3 compatibility.
|
||||||
|
forcePathStyle: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildR2Storage.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js';
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/index.ts"],"sourcesContent":["export { buildR2Storage } from './buildR2Storage.js'\n"],"names":["buildR2Storage"],"mappings":"AAAA,SAASA,cAAc,QAAQ,sBAAqB"}
|
||||||
Vendored
+12
-1
@@ -1,8 +1,10 @@
|
|||||||
import { buildCookieSettings } from './globals/CookieSettings/index.js';
|
import { buildCookieSettings } from './globals/CookieSettings/index.js';
|
||||||
|
import { buildNotifications } from './globals/Notifications/index.js';
|
||||||
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js';
|
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js';
|
||||||
import { buildSiteSettings } from './globals/SiteSettings/index.js';
|
import { buildSiteSettings } from './globals/SiteSettings/index.js';
|
||||||
import { injectRoles } from './modules/access/index.js';
|
import { injectRoles } from './modules/access/index.js';
|
||||||
import { buildArchiveFields } from './modules/content/index.js';
|
import { buildArchiveFields } from './modules/content/index.js';
|
||||||
|
import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js';
|
||||||
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js';
|
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js';
|
||||||
import { buildSystemPagesFields } from './modules/pages/index.js';
|
import { buildSystemPagesFields } from './modules/pages/index.js';
|
||||||
@@ -83,7 +85,16 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
|
|||||||
buildSiteIntegrations({
|
buildSiteIntegrations({
|
||||||
additionalFields: options.integrationsFields
|
additionalFields: options.integrationsFields
|
||||||
}),
|
}),
|
||||||
buildCookieSettings()
|
buildCookieSettings(),
|
||||||
|
buildNotifications()
|
||||||
|
];
|
||||||
|
// --- endpoints ---
|
||||||
|
// Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe
|
||||||
|
// message through the currently selected transport, so the panel's "send
|
||||||
|
// test" button can confirm delivery without leaving the admin UI.
|
||||||
|
config.endpoints = [
|
||||||
|
...config.endpoints ?? [],
|
||||||
|
testEmailEndpoint
|
||||||
];
|
];
|
||||||
// --- hooks: onInit ---
|
// --- hooks: onInit ---
|
||||||
const incomingOnInit = config.onInit;
|
const incomingOnInit = config.onInit;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user