Compare commits

...
22 Commits
Author SHA1 Message Date
radoslaw.smolinski cca2b20c3d 1.5.6 2026-09-28 19:44:34 +02:00
radoslaw.smolinski 105c454d73 Added OG support 2026-09-28 19:44:28 +02:00
radoslaw.smolinski cf6feefebc 1.5.5 2026-09-28 16:47:11 +02:00
radoslaw.smolinski 1857c8f771 Sitemap language normalize trough all websites 2026-09-28 16:47:06 +02:00
radoslaw.smolinski 542ad4ab9c 1.5.4 2026-09-28 16:35:00 +02:00
radoslaw.smolinski 9bf8599116 Fix for plugin.ts 2026-09-28 16:34:54 +02:00
radoslaw.smolinski 170c9a53f1 1.5.3 2026-09-28 16:25:36 +02:00
radoslaw.smolinski e0d5095099 Fix for sitemap.css n2 2026-09-28 16:22:49 +02:00
radoslaw.smolinski 08a8a10478 1.5.2 2026-09-28 16:13:46 +02:00
radoslaw.smolinski 7bbaf14d14 Fixed sitemap.css 2026-09-28 16:13:33 +02:00
radoslaw.smolinski b7d0b01122 1.5.1 2026-09-28 16:01:09 +02:00
radoslaw.smolinski 4627266577 2FA Fix 2026-09-28 16:01:03 +02:00
radoslaw.smolinski 471ec4b12a 1.5.0 2026-09-28 15:49:57 +02:00
radoslaw.smolinski 060a61fd41 Added secuirty 2FA for users 2026-09-28 15:49:36 +02:00
radoslaw.smolinski 7cef95225a 1.4.4 2026-09-28 14:57:54 +02:00
radoslaw.smolinski 0ae62226bc Path fix for sitemap css asset 2026-09-28 14:57:49 +02:00
radoslaw.smolinski 610ab6fdf5 1.4.3 2026-09-28 14:54:30 +02:00
radoslaw.smolinski 81275c0395 Added css asset for sitemap 2026-09-28 14:54:21 +02:00
radoslaw.smolinski 4a46651839 1.4.2 2026-09-28 14:47:19 +02:00
radoslaw.smolinski e7f06548fb Added styling for sitemap generator 2026-09-28 14:47:13 +02:00
radoslaw.smolinski e2a703fc72 1.4.1 2026-09-28 14:29:50 +02:00
radoslaw.smolinski de75d8374d Rebuilded sitemap generator 2026-09-28 14:29:42 +02:00
21 changed files with 897 additions and 184 deletions
+76
View File
@@ -0,0 +1,76 @@
/*
* Starter stylesheet for a sitemap served via buildSitemapXml({ cssUrl }).
* Uses type="text/css" on XML (W3C "Associating Style Sheets with XML") — NOT
* XSLT, so no browser deprecation warning. Selectors are the XML tag names.
*
* Copy to your project's /public/sitemap.css and adjust colors/spacing to taste.
* Crawlers ignore this; it only affects the human-readable browser view.
*/
urlset {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #090d16;
color: #f1f5f9;
padding: 2rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
line-height: 1.5;
}
/* Each URL entry as a card. */
url {
display: block;
background: #111827;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
}
/* The URL itself. */
loc {
display: block;
font-size: 0.95rem;
font-weight: 600;
color: #f97316;
margin-bottom: 0.5rem;
word-break: break-all;
}
/* Metadata line: lastmod / changefreq / priority, each with a label. */
lastmod,
changefreq,
priority {
display: inline-block;
font-size: 0.8rem;
color: #94a3b8;
margin-right: 1.5rem;
}
lastmod::before {
content: 'Ostatnia modyfikacja: ';
color: #64748b;
}
changefreq::before {
content: 'Częstotliwość: ';
color: #64748b;
}
priority::before {
content: 'Priorytet: ';
color: #64748b;
}
/* hreflang alternates as small pills. */
link {
display: inline-block;
font-size: 0.75rem;
background: #1e293b;
color: #38bdf8;
border: 1px solid #334155;
padding: 0.15rem 0.45rem;
border-radius: 4px;
margin: 0.4rem 0.35rem 0 0;
}
+154
View File
@@ -0,0 +1,154 @@
/*
* Universal, minimalist & elegant stylesheet for XML Sitemap.
* Neutral palette with automatic dark and light mode support.
* W3C standard: type="text/css" on XML (zero browser deprecation warnings).
*
* Ships with @intecion/ipal-kit. Copy to /public/sitemap.css and adjust freely.
*/
:root {
--bg: #fafafa;
--card: #ffffff;
--border: #e5e7eb;
--border-hover: #d1d5db;
--text-main: #111827;
--text-secondary: #4b5563;
--text-muted: #9ca3af;
--url-color: #1e293b;
--badge-bg: #f3f4f6;
--badge-border: #e5e7eb;
--badge-text: #4b5563;
--accent: #027bd0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #090a0f;
--card: #12131a;
--border: #1e202e;
--border-hover: #2e3247;
--text-main: #f9fafb;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--url-color: #f3f4f6;
--badge-bg: #1a1c26;
--badge-border: #282b3d;
--badge-text: #9ca3af;
--accent: #027bd0;
}
}
urlset {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: var(--text-main);
padding: 3rem 1.5rem;
max-width: 1040px;
margin: 0 auto;
min-height: 100vh;
box-sizing: border-box;
line-height: 1.5;
}
/* Minimalist header */
urlset::before {
content: "XML Sitemap";
display: block;
order: -2;
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-main);
padding-bottom: 0.4rem;
}
/* Brand note under the header — crafted by Intecion Group */
urlset::after {
content: "Intecion.com, Technology — engineered for modern digital experiences.";
display: block;
order: -1;
font-size: 0.8rem;
color: var(--text-muted);
padding-bottom: 1.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
/* URL card */
url {
display: block;
width: 100%;
order: 0;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.65rem;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
url:hover {
border-color: var(--border-hover);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
}
/* URL address */
loc {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
color: var(--url-color);
word-break: break-all;
line-height: 1.45;
margin-bottom: 0.45rem;
}
/* Metadata row */
lastmod,
changefreq,
priority {
display: inline-block;
font-size: 0.775rem;
color: var(--text-secondary);
margin-right: 1.25rem;
margin-top: 0.15rem;
}
lastmod::before {
content: "Updated: ";
color: var(--text-muted);
}
changefreq::before {
content: "Frequency: ";
color: var(--text-muted);
}
priority::before {
content: "Priority: ";
color: var(--text-muted);
}
/* Alternate language pills */
link {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
background-color: var(--badge-bg);
border: 1px solid var(--badge-border);
color: var(--badge-text);
padding: 0.1rem 0.45rem;
border-radius: 4px;
margin-right: 0.3rem;
margin-top: 0.35rem;
text-transform: uppercase;
}
link::before {
content: attr(hreflang);
}
+16 -1
View File
@@ -17,12 +17,27 @@ export type PageMetadata = {
url: string; url: string;
}[]; }[];
locale?: string; locale?: string;
/** Site/brand name for the OG card. */
siteName?: string;
title: string; title: string;
/** 'website' | 'article' etc. Defaults to 'website'. */
type?: string;
/** Canonical URL of this page. */
url?: string;
}; };
/** robots directives — set to noindex/follow for legal/thin/search pages. */ /**
* robots directives. Indexed pages get generous snippet/preview limits by
* default (Google shows richer results); noindex pages get index:false.
*/
robots?: { robots?: {
follow: boolean; follow: boolean;
index: boolean; index: boolean;
/** 'none' | 'standard' | 'large' — image preview size in results. */
'max-image-preview'?: 'large' | 'none' | 'standard';
/** Max text snippet length; -1 = no limit. Next maps to max-snippet. */
'max-snippet'?: number;
/** Max video preview seconds; -1 = no limit. */
'max-video-preview'?: number;
}; };
title: string; title: string;
}; };
+20 -4
View File
@@ -71,16 +71,32 @@ import { buildHreflangAlternates } from './hreflang.js';
...images && { ...images && {
images images
}, },
locale type: 'website',
locale,
...siteName ? {
siteName
} : {},
...canonical ? {
url: canonical
} : {}
}, },
// noindex → tell search engines to exclude the page but still follow links // robots: noindex pages are excluded (follow keeps link authority). Indexed
// (authority flows through). For legal/thin/search-result pages. // pages get generous snippet/preview limits so Google can show rich results
// (long snippets, large image previews, full video previews).
...meta?.noindex ? { ...meta?.noindex ? {
robots: { robots: {
follow: true, follow: true,
index: false index: false
} }
} : {} } : {
robots: {
follow: true,
index: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1
}
}
}; };
} }
File diff suppressed because one or more lines are too long
+29 -17
View File
@@ -1,33 +1,45 @@
import type { SitemapEntry } from './buildSitemapEntries.js'; import type { SitemapEntry } from './buildSitemapEntries.js';
type BuildSitemapXmlOptions = {
/** /**
* Serializes sitemap entries to an XML STRING with an XSL stylesheet reference, * URL of a CSS stylesheet to make the sitemap readable in the browser, e.g.
* so /sitemap.xml renders as a readable table in the browser (not raw XML) while * '/sitemap.css'. Uses `type="text/css"` — the W3C "Associating Style Sheets
* staying a valid sitemap for crawlers. * with XML" mechanism, which is NOT deprecated (unlike XSLT / type="text/xsl",
* which Chrome/WebKit are removing). CSS on XML shows no warning, styles the
* raw tags directly (e.g. `url { display: block }` turns the wall of text into
* cards), and crawlers ignore the directive entirely.
*/
cssUrl?: string;
};
/**
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* and (with `cssUrl`) styled in the browser via plain CSS.
* *
* Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts * Two viewing modes:
* (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* the styled table, serve a custom route that returns this string instead: * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
* import { sitemap } from '@/lib/content' // your entries source * import { sitemap } from '@/lib/content'
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' }) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* Put sitemap.xsl in the project's /public (copy from the plugin's assets, or * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* ignore it and read the XML. Include hreflang alternates as <xhtml:link>. * clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (the styled route OR the Next MetadataRoute). Two sitemaps at different paths * Two sitemaps confuse crawlers.
* confuse crawlers.
*/ */
export declare function buildSitemapXml(entries: SitemapEntry[], opts?: { export declare function buildSitemapXml(entries: SitemapEntry[], opts?: BuildSitemapXmlOptions): string;
stylesheetUrl?: string; export {};
}): string;
+18 -17
View File
@@ -1,33 +1,35 @@
/** /**
* Serializes sitemap entries to an XML STRING with an XSL stylesheet reference, * Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* so /sitemap.xml renders as a readable table in the browser (not raw XML) while * and (with `cssUrl`) styled in the browser via plain CSS.
* staying a valid sitemap for crawlers.
* *
* Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts * Two viewing modes:
* (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* the styled table, serve a custom route that returns this string instead: * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
* import { sitemap } from '@/lib/content' // your entries source * import { sitemap } from '@/lib/content'
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' }) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* Put sitemap.xsl in the project's /public (copy from the plugin's assets, or * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* ignore it and read the XML. Include hreflang alternates as <xhtml:link>. * clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (the styled route OR the Next MetadataRoute). Two sitemaps at different paths * Two sitemaps confuse crawlers.
* confuse crawlers.
*/ export function buildSitemapXml(entries, opts = {}) { */ export function buildSitemapXml(entries, opts = {}) {
const { stylesheetUrl } = opts; const { cssUrl } = opts;
const esc = (s)=>s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;'); const esc = (s)=>s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const urls = entries.map((e)=>{ const urls = entries.map((e)=>{
const parts = [ const parts = [
@@ -39,7 +41,6 @@
} }
if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`); if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`);
if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`); if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`);
// hreflang alternates
const alternates = e.alternates?.languages; const alternates = e.alternates?.languages;
if (alternates) { if (alternates) {
for (const [lang, href] of Object.entries(alternates)){ for (const [lang, href] of Object.entries(alternates)){
@@ -50,7 +51,7 @@
} }
return ` <url>\n${parts.join('\n')}\n </url>`; return ` <url>\n${parts.join('\n')}\n </url>`;
}).join('\n'); }).join('\n');
const stylesheet = stylesheetUrl ? `<?xml-stylesheet type="text/xsl" href="${esc(stylesheetUrl)}"?>\n` : ''; const stylesheet = cssUrl ? `<?xml-stylesheet type="text/css" href="${esc(cssUrl)}"?>\n` : '';
return `<?xml version="1.0" encoding="UTF-8"?>\n` + stylesheet + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls + `\n</urlset>`; return `<?xml version="1.0" encoding="UTF-8"?>\n` + stylesheet + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` + `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + urls + `\n</urlset>`;
} }
File diff suppressed because one or more lines are too long
+44 -4
View File
@@ -42,6 +42,50 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
let config = { let config = {
...incomingConfig ...incomingConfig
}; };
// --- custom admin route (e.g. '/its' instead of '/admin') ---
// Sets config.routes.admin; the project must move its app/(payload)/<route>/
// folder to match (plugin can't create files in the project's app/).
if (options.adminRoute) {
config.routes = {
...config.routes ?? {},
admin: options.adminRoute
};
}
// --- enforced 2FA (TOTP) via @clocklimited/payload-2fa ---
// Enforced by default (forceSetup) unless twoFactor is explicitly false. The
// plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle
// it, so projects that opt out (twoFactor: false) needn't install it, and the
// import never runs under generate:importmap when 2FA is off. Wrapping access
// control (not just admin UI) means TOTP gates data access — no API bypass.
if (options.twoFactor !== false) {
const tf = options.twoFactor;
if (!tf?.issuer) {
throw new Error('[ipal] twoFactor.issuer is required (name shown in the authenticator ' + 'app). Pass twoFactor: { issuer: "Your Site" }, or twoFactor: false to ' + 'opt out (discouraged).');
}
try {
// Dynamic specifier via a variable so TS doesn't try to resolve this
// optional peer dependency at build time (it isn't in the plugin's own
// node_modules). Avoids TS2307 without @ts-expect-error; the module
// exists at runtime in projects that installed it.
// @ts-ignore
const mod = await import('@clocklimited/payload-2fa');
// The package exports `payloadTotp`; older/other builds may use
// `totpPlugin`. Accept either so a rename doesn't break us.
const totp = mod.payloadTotp ?? mod.totpPlugin;
if (typeof totp !== 'function') {
throw new Error('expected export payloadTotp (or totpPlugin) to be a function — ' + 'check the installed @clocklimited/payload-2fa version');
}
config = await totp({
collection: tf.collectionSlug ?? 'users',
forceSetup: true,
totp: {
issuer: tf.issuer
}
})(config);
} catch (err) {
throw new Error('[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' + 'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' + `opt out (discouraged). Original error: ${String(err)}`);
}
}
// --- i18n --- // --- i18n ---
config.localization = buildLocalizationConfig(options.i18n); config.localization = buildLocalizationConfig(options.i18n);
// --- access: inject roles into the client's auth collection --- // --- access: inject roles into the client's auth collection ---
@@ -88,10 +132,6 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
buildCookieSettings(), buildCookieSettings(),
buildNotifications() 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 = [
...config.endpoints ?? [], ...config.endpoints ?? [],
testEmailEndpoint testEmailEndpoint
+1 -1
View File
File diff suppressed because one or more lines are too long
+27
View File
@@ -15,6 +15,17 @@ export type IpalOptions = {
* (admin > editor > user) into the client's auth collection. * (admin > editor > user) into the client's auth collection.
*/ */
access?: AccessOption; access?: AccessOption;
/**
* Custom admin panel route, e.g. '/its' instead of the default '/admin'.
* The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —
* the project must ALSO move its panel folder to match:
* app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin
* can't create files in the project's app/. See docs/security.md.
*
* This is obscurity, not security: it hides the panel from dumb bots scanning
* /admin, but real protection is strong auth + 2FA + rate limiting.
*/
adminRoute?: string;
/** /**
* Collections whose entries live under an archive page — blog posts, case * Collections whose entries live under an archive page — blog posts, case
* studies, anything with a listing. Adds an "archive page" assignment per * studies, anything with a listing. Adds an "archive page" assignment per
@@ -45,4 +56,20 @@ export type IpalOptions = {
seo?: SeoOption; seo?: SeoOption;
/** Additional fields injected into SiteSettings global */ /** Additional fields injected into SiteSettings global */
siteSettingsFields?: Field[]; siteSettingsFields?: Field[];
/**
* Two-factor authentication (TOTP), ENFORCED for every user. Wires
* @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every
* user must configure an authenticator app after login; TOTP is checked before
* data access (not just the admin UI). Requires the peer dep installed and an
* issuer name (shown in the authenticator app).
*
* Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)
* — default is enforced. See docs/security.md.
*/
twoFactor?: {
/** Auth collection slug. Defaults to 'users'. */
collectionSlug?: string;
/** Name shown in the authenticator app (e.g. company/site name). */
issuer: string;
} | false;
}; };
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA4CC"} {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { AccessOption } from './modules/access/types.js'\nimport type { ContentOption } from './modules/content/types.js'\nimport type { FormsOption } from './modules/forms/types.js'\nimport type { I18nConfig } from './modules/i18n/types.js'\nimport type { PagesOption } from './modules/pages/types.js'\nimport type { SeoOption } from './modules/seo/types.js'\n\n/**\n * Configuration options for the IPAL plugin.\n * Passed by the client project in payload.config.ts.\n */\nexport type IpalOptions = {\n /**\n * Role-based access control. Injects a fixed `roles` field\n * (admin > editor > user) into the client's auth collection.\n */\n access?: AccessOption\n\n /**\n * Custom admin panel route, e.g. '/its' instead of the default '/admin'.\n * The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —\n * the project must ALSO move its panel folder to match:\n * app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin\n * can't create files in the project's app/. See docs/security.md.\n *\n * This is obscurity, not security: it hides the panel from dumb bots scanning\n * /admin, but real protection is strong auth + 2FA + rate limiting.\n */\n adminRoute?: string\n\n /**\n * Collections whose entries live under an archive page — blog posts, case\n * studies, anything with a listing. Adds an \"archive page\" assignment per\n * collection in SiteSettings; the assigned page's localized slug becomes the\n * URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.\n */\n content?: ContentOption\n\n /** Disable the plugin without uninstalling (keeps DB schema intact) */\n enabled?: boolean\n\n /**\n * Forms — form-builder collections (forms, form-submissions) plus the\n * callable submitForm (Turnstile + persistence + SMTP-from-panel email).\n */\n forms?: FormsOption\n\n /** Internationalization — locales, default locale, fallback behavior */\n i18n: I18nConfig\n\n /** Additional fields injected into SiteIntegrations global */\n integrationsFields?: Field[]\n\n /**\n * System-page assignments (homepage, privacy, cookies) in SiteSettings.\n * Provide the slug of the client's Pages collection to enable.\n */\n pages?: PagesOption\n\n /**\n * SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)\n * and enables locale-aware metadata helpers.\n */\n seo?: SeoOption\n\n /** Additional fields injected into SiteSettings global */\n siteSettingsFields?: Field[]\n\n /**\n * Two-factor authentication (TOTP), ENFORCED for every user. Wires\n * @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every\n * user must configure an authenticator app after login; TOTP is checked before\n * data access (not just the admin UI). Requires the peer dep installed and an\n * issuer name (shown in the authenticator app).\n *\n * Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)\n * — default is enforced. See docs/security.md.\n */\n twoFactor?:\n | {\n /** Auth collection slug. Defaults to 'users'. */\n collectionSlug?: string\n /** Name shown in the authenticator app (e.g. company/site name). */\n issuer: string\n }\n | false\n}\n"],"names":[],"mappings":"AASA;;;CAGC,GACD,WA2EC"}
+104
View File
@@ -198,3 +198,107 @@ NIE wymuszaj `require-trusted-types-for 'script'`. Powód:
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony - Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia. Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.
## Zmiana ścieżki panelu admina (/admin → /its)
Ukrycie panelu przed botami skanującymi znane ścieżki (`/admin`, `/wp-admin`).
Plugin ustawia ścieżkę przez opcję `adminRoute`:
```ts
// payload.config.ts
ipalKit({
i18n: i18nConfig,
adminRoute: '/its', // panel pod /its zamiast /admin
})
```
### WYMAGANE — przenieś folder panelu w projekcie
Plugin ustawia `config.routes.admin`, ale NIE tworzy plików w `app/` projektu.
Musisz przenieść folder panelu, żeby ścieżka zadziałała:
```
# PRZED:
app/(payload)/admin/[[...segments]]/page.tsx
app/(payload)/admin/[[...segments]]/not-found.tsx
# PO (nazwa folderu = adminRoute bez ukośnika):
app/(payload)/its/[[...segments]]/page.tsx
app/(payload)/its/[[...segments]]/not-found.tsx
```
Bez przeniesienia folderu: `config.routes.admin = '/its'`, ale `/its` daje 404
(brak pliku), a `/admin` też nie działa (config zmieniony). Oba muszą się zgadzać.
### To OBSCURITY, nie SECURITY
Zmiana ścieżki utrudnia automatyczne skany, ale NIE jest zabezpieczeniem.
Prawdziwa ochrona panelu:
- **2FA** dla każdego użytkownika (planowane — wymuszenie przez plugin)
- Silne hasła
- Rate limiting na logowaniu
- IP allowlist (jeśli panel tylko dla zespołu)
- buildSecurityHeaders (nagłówki)
Zmiana `/admin → /its` to warstwa (odsiewa głupie boty), nie zamek. Traktuj jako
dodatek do prawdziwych zabezpieczeń, nie zamiast nich.
## 2FA (TOTP) — WYMUSZONE dla każdego użytkownika
Plugin wymusza dwuskładnikowe uwierzytelnianie (TOTP) dla WSZYSTKICH użytkowników
panelu — bez możliwości wyłączenia per użytkownik. Każdy projekt ma to z automatu.
Używa sprawdzonego `@clocklimited/payload-2fa` (wrapuje access control — TOTP
sprawdzane przed dostępem do DANYCH, nie tylko UI panelu).
### Zależność
```bash
pnpm add @clocklimited/[email protected]
```
Uwaga: pakiet nie ma jeszcze stabilnego 3.0.0 — użyj konkretnej wersji beta
(albo `^3.0.0-0`, żeby dopuścić prereleasy). Sam `^3.0.0` da błąd
ERR_PNPM_NO_MATCHING_VERSION.
To PEER dependency — ipal-kit importuje ją dynamicznie tylko gdy 2FA włączone
(domyślnie). Bez niej i z włączonym 2FA plugin rzuci jasny błąd.
### Konfiguracja (payload.config.ts)
```ts
ipalKit({
i18n: i18nConfig,
twoFactor: {
issuer: 'Nazwa Firmy', // pokazywane w aplikacji authenticator (Google Auth itp.)
// collectionSlug: 'users', // domyślnie 'users'
},
})
```
Plugin ustawia `forceSetup: true` — każdy użytkownik MUSI skonfigurować TOTP po
zalogowaniu (przekierowanie na setup). Nie ma opcji „włącz/wyłącz" dla użytkownika.
### Wyłączenie (ODRADZANE)
```ts
twoFactor: false // TYLKO gdy projekt naprawdę nie może użyć 2FA (rzadkie)
```
Domyślnie 2FA jest WYMUSZONE. `false` to świadoma rezygnacja — unikaj.
### Jak działa dla użytkownika
1. Loguje się (email + hasło)
2. Przy pierwszym logowaniu: przekierowanie na Setup TOTP (QR + sekret)
3. Skanuje QR aplikacją (Google Authenticator, Authy, 1Password, Microsoft Auth)
4. Wpisuje kod → 2FA aktywne
5. Kolejne logowania: email + hasło + kod TOTP
### Reset 2FA (admin)
Admin może zresetować 2FA innego użytkownika (gdy zgubi telefon) — przez
`adminManageAccess` w konfiguracji @clocklimited. Patrz jego dokumentacja.
### Dlaczego @clocklimited, nie inne
Wybrany, bo wrapuje ACCESS CONTROL (TOTP przed dostępem do danych) + forceSetup
(wymuszenie dla wszystkich). Inne pluginy 2FA dla Payload gatują tylko nawigację
/admin — user z hasłem może omijać przez REST/GraphQL/Bearer. @clocklimited chroni
dostęp do danych, nie tylko UI.
+97 -19
View File
@@ -843,6 +843,31 @@ const jsonLd = buildArticleJsonLd({
Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result. Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.
## Robots — rich results (max-snippet, image/video preview)
Strony indeksowane dostają automatycznie dyrektywy pozwalające Google na bogate
wyniki (buildMetadata generuje):
- `max-snippet: -1` — pełnej długości snippet (bez limitu)
- `max-image-preview: large` — duży podgląd obrazu w wynikach
- `max-video-preview: -1` — pełny podgląd wideo
Strony noindex (polityki) dostają `index: false, follow: true` — bez powyższych.
Zero konfiguracji — działa automatycznie dla każdej strony przez createPageMetadata.
## Open Graph — pełne pola (type, siteName, url, description)
buildMetadata generuje kompletny OG dla social share (Facebook, LinkedIn, Slack):
- `og:title` — tytuł strony
- `og:description` — meta.description → fallback siteDescription (dziedziczy)
- `og:image` — defaultShareImage / obraz strony
- `og:type` — 'website'
- `og:site_name` — siteName z panelu
- `og:url` — canonical strony
- `og:locale` — język strony
Wszystko z panelu, automatycznie. Opis OG dziedziczy z meta.description (albo
globalnego siteDescription gdy pusty) — patrz niżej.
## Fallback meta description (siteDescription) ## Fallback meta description (siteDescription)
Strona bez `meta.description` → plugin używa globalnego `siteDescription` z Strona bez `meta.description` → plugin używa globalnego `siteDescription` z
@@ -886,17 +911,17 @@ Generative Engine Optimization) i audytach „Agentic Browsing”.
Wymaga wypełnionego siteDescription i sensownych meta.description stron Wymaga wypełnionego siteDescription i sensownych meta.description stron
(inaczej llms.txt będzie ubogi). (inaczej llms.txt będzie ubogi).
## Sitemap jako czytelna tabela (XSL stylesheet) ## Sitemap czytelny w przeglądarce (czysty XML)
Domyślny `/sitemap.xml` to surowy XML — Google go czyta, ale człowiek widzi Domyślny `/sitemap.xml` (Next MetadataRoute) działa dla Google, ale w przeglądarce
„ścianę tagów". Można ostylować przez XSL (przeglądarka renderuje tabelę), bywa nieczytelny. Możesz serwować go jako **czysty, sformatowany XML** przez
zachowując poprawność dla crawlerów. `buildSitemapXml` — przeglądarka pokaże wbudowane drzewo XML (wcięcia, zwijanie,
kolorowanie składni), bez żadnej transformacji.
### Ograniczenie Next > **NIE używaj XSLT.** Przeglądarki (Chrome i in.) WYCOFUJĄ XSLT — arkusz
> `<?xml-stylesheet?>` pokazuje ostrzeżenie i wkrótce przestanie działać.
Standardowy `app/sitemap.ts` (Next MetadataRoute) **nie pozwala** wstrzyknąć > Rozwiązanie: serwuj czysty XML z poprawnym Content-Type; przeglądarka
`<?xml-stylesheet?>`. Żeby mieć styl, serwuj sitemap własnym route przez > renderuje swój natywny widok drzewa XML sama.
`buildSitemapXml` (string XML z odwołaniem do XSL):
```ts ```ts
// app/sitemap.xml/route.ts (zamiast app/sitemap.ts) // app/sitemap.xml/route.ts (zamiast app/sitemap.ts)
@@ -907,24 +932,77 @@ export const dynamic = 'force-dynamic'
export async function GET() { export async function GET() {
const entries = await sitemap() const entries = await sitemap()
const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' }) const xml = buildSitemapXml(entries)
return new Response(xml, { return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' }, headers: { 'Content-Type': 'application/xml; charset=utf-8' },
}) })
} }
``` ```
### Plik XSL w /public `buildSitemapXml` zwraca wcięty XML. Dwa tryby wyświetlania:
Skopiuj `sitemap.xsl` (z pluginu: `node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.xsl`) **Bez `cssUrl`** → przeglądarka pokazuje natywny widok drzewa XML (wcięcia, zwijanie).
do `public/sitemap.xsl` w projekcie. Zawiera responsywną tabelę (numer, URL,
data, języki) z dark mode. `stylesheetUrl: '/sitemap.xsl'` wskazuje na niego. **Z `cssUrl`** → stylujesz XML własnym CSS (kafelki, etykiety). To W3C standard
„Associating Style Sheets with XML" — `type="text/css"`, NIE wycofywane (w
przeciwieństwie do XSLT/`text/xsl`). Zero ostrzeżenia, ładny wygląd, bezpieczne
dla Google (crawlery ignorują dyrektywę).
```ts
const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
```
### Starter CSS (public/sitemap.css)
Plugin dostarcza gotowy plik — skopiuj do projektu:
```bash
cp node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.css public/sitemap.css
```
Albo skopiuj poniższy starter i dostosuj do designu. Selektory to
bezpośrednio nazwy tagów XML:
```css
/* public/sitemap.css */
urlset {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #090d16; color: #f1f5f9;
padding: 2rem 1.5rem; max-width: 1200px; margin: 0 auto;
}
url { /* każdy adres jako kafelek */
display: block;
background: #111827; border: 1px solid #1e293b; border-radius: 8px;
padding: 1rem 1.25rem; margin-bottom: 0.75rem;
}
loc { /* adres URL */
display: block; font-size: 0.95rem; font-weight: 600;
color: #f97316; margin-bottom: 0.5rem; word-break: break-all;
}
lastmod, changefreq, priority {
display: inline-block; font-size: 0.8rem; color: #94a3b8; margin-right: 1.5rem;
}
lastmod::before { content: 'Ostatnia modyfikacja: '; color: #64748b; }
changefreq::before { content: 'Częstotliwość: '; color: #64748b; }
priority::before { content: 'Priorytet: '; color: #64748b; }
link { /* tagi hreflang */
display: inline-block; font-size: 0.75rem;
background: #1e293b; color: #38bdf8; border: 1px solid #334155;
padding: 0.15rem 0.45rem; border-radius: 4px; margin: 0.4rem 0.35rem 0 0;
}
```
Kluczowe: `display: block` na `<url>`/`<loc>` zamienia „ścianę tekstu" w kafelki.
Etykiety („Ostatnia modyfikacja:") przez `::before`. Dostosuj kolory do projektu.
**Ograniczenie:** `<loc>` to tag XML, nie `<a href>` — CSS nie zrobi z niego
klikalnego linku (niektóre przeglądarki autodetektują URL). Zysk to czytelność
i organizacja, nie klikalność. Dla sitemap (głównie dla robotów) to akceptowalne.
### WAŻNE — jeden sitemap, nie dwa ### WAŻNE — jeden sitemap, nie dwa
Jeśli używasz route `app/sitemap.xml/route.ts` (styled), **USUŃ** `app/sitemap.ts` Jeśli używasz `app/sitemap.xml/route.ts`, USUŃ `app/sitemap.ts` (MetadataRoute).
(MetadataRoute). Dwa sitemapy pod różnymi ścieżkami mylą crawlery. Wybierz jeden: Dwa sitemapy pod różnymi ścieżkami mylą crawlery. Wybierz jeden:
- **styled** (`sitemap.xml/route.ts` + buildSitemapXml + XSL) — ładna tabela - **route.ts + buildSitemapXml** — czytelny XML w przeglądarce
- **prosty** (`sitemap.ts` + reeksport z lib/content) — bez stylu, mniej kodu - **sitemap.ts** (reeksport z lib/content) — mniej kodu, mniej czytelny w przeglądarce
Styl to kosmetyka (Google czyta oba tak samo) — rób, jeśli klient/audyt tego chce. Oba tak samo dobre dla Google — to kwestia czytelności dla człowieka, nie SEO.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.4.0", "version": "1.5.6",
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.", "description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -67,6 +67,7 @@
"slugify": "^1.6.6" "slugify": "^1.6.6"
}, },
"peerDependencies": { "peerDependencies": {
"@clocklimited/payload-2fa": "^3.0.0",
"@payloadcms/next": "^3.88.0", "@payloadcms/next": "^3.88.0",
"@payloadcms/plugin-form-builder": "^3.88.0", "@payloadcms/plugin-form-builder": "^3.88.0",
"@payloadcms/plugin-seo": "^3.88.0", "@payloadcms/plugin-seo": "^3.88.0",
+154
View File
@@ -0,0 +1,154 @@
/*
* Universal, minimalist & elegant stylesheet for XML Sitemap.
* Neutral palette with automatic dark and light mode support.
* W3C standard: type="text/css" on XML (zero browser deprecation warnings).
*
* Ships with @intecion/ipal-kit. Copy to /public/sitemap.css and adjust freely.
*/
:root {
--bg: #fafafa;
--card: #ffffff;
--border: #e5e7eb;
--border-hover: #d1d5db;
--text-main: #111827;
--text-secondary: #4b5563;
--text-muted: #9ca3af;
--url-color: #1e293b;
--badge-bg: #f3f4f6;
--badge-border: #e5e7eb;
--badge-text: #4b5563;
--accent: #027bd0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #090a0f;
--card: #12131a;
--border: #1e202e;
--border-hover: #2e3247;
--text-main: #f9fafb;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--url-color: #f3f4f6;
--badge-bg: #1a1c26;
--badge-border: #282b3d;
--badge-text: #9ca3af;
--accent: #027bd0;
}
}
urlset {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: var(--text-main);
padding: 3rem 1.5rem;
max-width: 1040px;
margin: 0 auto;
min-height: 100vh;
box-sizing: border-box;
line-height: 1.5;
}
/* Minimalist header */
urlset::before {
content: "XML Sitemap";
display: block;
order: -2;
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-main);
padding-bottom: 0.4rem;
}
/* Brand note under the header — crafted by Intecion Group */
urlset::after {
content: "Intecion.com, Technology — engineered for modern digital experiences.";
display: block;
order: -1;
font-size: 0.8rem;
color: var(--text-muted);
padding-bottom: 1.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
/* URL card */
url {
display: block;
width: 100%;
order: 0;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.65rem;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
url:hover {
border-color: var(--border-hover);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
}
/* URL address */
loc {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
color: var(--url-color);
word-break: break-all;
line-height: 1.45;
margin-bottom: 0.45rem;
}
/* Metadata row */
lastmod,
changefreq,
priority {
display: inline-block;
font-size: 0.775rem;
color: var(--text-secondary);
margin-right: 1.25rem;
margin-top: 0.15rem;
}
lastmod::before {
content: "Updated: ";
color: var(--text-muted);
}
changefreq::before {
content: "Frequency: ";
color: var(--text-muted);
}
priority::before {
content: "Priority: ";
color: var(--text-muted);
}
/* Alternate language pills */
link {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
background-color: var(--badge-bg);
border: 1px solid var(--badge-border);
color: var(--badge-text);
padding: 0.1rem 0.45rem;
border-radius: 4px;
margin-right: 0.3rem;
margin-top: 0.35rem;
text-transform: uppercase;
}
link::before {
content: attr(hreflang);
}
-85
View File
@@ -1,85 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
<xsl:template match="/">
<html lang="pl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Sitemap</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0; padding: 2rem 1rem; line-height: 1.5;
color: #1a1a1a; background: #f7f7f8;
}
.wrap { max-width: 1100px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
.meta { color: #666; font-size: .9rem; margin-bottom: 1.5rem; }
.count { font-weight: 600; }
table { width: 100%; border-collapse: collapse; background: #fff;
border-radius: .5rem; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
th, td { text-align: left; padding: .65rem .9rem; border-bottom: 1px solid #eee;
font-size: .9rem; }
th { background: #fafafa; font-weight: 600; color: #444;
position: sticky; top: 0; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f5f8ff; }
a { color: #2563eb; text-decoration: none; word-break: break-all; }
a:hover { text-decoration: underline; }
.num { color: #999; width: 3rem; text-align: right; font-variant-numeric: tabular-nums; }
.langs { color: #666; font-size: .8rem; }
@media (prefers-color-scheme: dark) {
body { color: #e5e5e5; background: #18181b; }
table { background: #232327; box-shadow: none; }
th { background: #27272a; color: #ccc; }
th, td { border-color: #333; }
tr:hover td { background: #1e293b; }
.meta, .num, .langs { color: #888; }
}
</style>
</head>
<body>
<div class="wrap">
<h1>Mapa witryny (Sitemap)</h1>
<p class="meta">
Ten plik jest odczytywany przez wyszukiwarki.
Adresów: <span class="count"><xsl:value-of select="count(s:urlset/s:url)"/></span>
</p>
<table>
<thead>
<tr>
<th class="num">#</th>
<th>Adres URL</th>
<th>Ostatnia zmiana</th>
<th>Języki</th>
</tr>
</thead>
<tbody>
<xsl:for-each select="s:urlset/s:url">
<tr>
<td class="num"><xsl:value-of select="position()"/></td>
<td>
<a href="{s:loc}"><xsl:value-of select="s:loc"/></a>
</td>
<td><xsl:value-of select="s:lastmod"/></td>
<td class="langs">
<xsl:for-each select="xhtml:link">
<xsl:value-of select="@hreflang"/>
<xsl:if test="position() != last()"><xsl:text>, </xsl:text></xsl:if>
</xsl:for-each>
</td>
</tr>
</xsl:for-each>
</tbody>
</table>
</div>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
+33 -4
View File
@@ -20,12 +20,27 @@ export type PageMetadata = {
description?: string description?: string
images?: { url: string }[] images?: { url: string }[]
locale?: string locale?: string
/** Site/brand name for the OG card. */
siteName?: string
title: string title: string
/** 'website' | 'article' etc. Defaults to 'website'. */
type?: string
/** Canonical URL of this page. */
url?: string
} }
/** robots directives — set to noindex/follow for legal/thin/search pages. */ /**
* robots directives. Indexed pages get generous snippet/preview limits by
* default (Google shows richer results); noindex pages get index:false.
*/
robots?: { robots?: {
follow: boolean follow: boolean
index: boolean index: boolean
/** 'none' | 'standard' | 'large' — image preview size in results. */
'max-image-preview'?: 'large' | 'none' | 'standard'
/** Max text snippet length; -1 = no limit. Next maps to max-snippet. */
'max-snippet'?: number
/** Max video preview seconds; -1 = no limit. */
'max-video-preview'?: number
} }
title: string title: string
} }
@@ -140,10 +155,24 @@ export function buildMetadata({
title, title,
...(description && { description }), ...(description && { description }),
...(images && { images }), ...(images && { images }),
type: 'website',
locale, locale,
...(siteName ? { siteName } : {}),
...(canonical ? { url: canonical } : {}),
}, },
// noindex → tell search engines to exclude the page but still follow links // robots: noindex pages are excluded (follow keeps link authority). Indexed
// (authority flows through). For legal/thin/search-result pages. // pages get generous snippet/preview limits so Google can show rich results
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}), // (long snippets, large image previews, full video previews).
...(meta?.noindex
? { robots: { follow: true, index: false } }
: {
robots: {
follow: true,
index: true,
'max-image-preview': 'large' as const,
'max-snippet': -1,
'max-video-preview': -1,
},
}),
} }
} }
+31 -20
View File
@@ -1,39 +1,53 @@
import type { SitemapEntry } from './buildSitemapEntries.js' import type { SitemapEntry } from './buildSitemapEntries.js'
type BuildSitemapXmlOptions = {
/** /**
* Serializes sitemap entries to an XML STRING with an XSL stylesheet reference, * URL of a CSS stylesheet to make the sitemap readable in the browser, e.g.
* so /sitemap.xml renders as a readable table in the browser (not raw XML) while * '/sitemap.css'. Uses `type="text/css"` — the W3C "Associating Style Sheets
* staying a valid sitemap for crawlers. * with XML" mechanism, which is NOT deprecated (unlike XSLT / type="text/xsl",
* which Chrome/WebKit are removing). CSS on XML shows no warning, styles the
* raw tags directly (e.g. `url { display: block }` turns the wall of text into
* cards), and crawlers ignore the directive entirely.
*/
cssUrl?: string
}
/**
* Serializes sitemap entries to a clean, indented XML STRING — valid for crawlers
* and (with `cssUrl`) styled in the browser via plain CSS.
* *
* Why this exists alongside the Next MetadataRoute sitemap: Next's app/sitemap.ts * Two viewing modes:
* (returning SitemapEntry[]) does NOT let you inject <?xml-stylesheet?>. To get * - No cssUrl → the browser's native formatted XML tree (indented, collapsible).
* the styled table, serve a custom route that returns this string instead: * - With cssUrl → a `<?xml-stylesheet type="text/css">` directive; the project's
* CSS styles the XML tags (cards, labels via ::before). NOT XSLT — that's being
* removed from browsers and shows a deprecation warning. CSS is safe and
* W3C-standard.
* *
* // app/sitemap.xml/route.ts * // app/sitemap.xml/route.ts
* import { buildSitemapXml } from '@intecion/ipal-kit' * import { buildSitemapXml } from '@intecion/ipal-kit'
* import { sitemap } from '@/lib/content' // your entries source * import { sitemap } from '@/lib/content'
* export const dynamic = 'force-dynamic' * export const dynamic = 'force-dynamic'
* export async function GET() { * export async function GET() {
* const entries = await sitemap() * const entries = await sitemap()
* const xml = buildSitemapXml(entries, { stylesheetUrl: '/sitemap.xsl' }) * const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })
* return new Response(xml, { * return new Response(xml, {
* headers: { 'Content-Type': 'application/xml; charset=utf-8' }, * headers: { 'Content-Type': 'application/xml; charset=utf-8' },
* }) * })
* } * }
* *
* Put sitemap.xsl in the project's /public (copy from the plugin's assets, or * Put sitemap.css in the project's /public and style the tags (see docs/seo.md
* serve it from a route). The <?xml-stylesheet?> points browsers at it; crawlers * for a starter). Note: <loc> is an XML tag, not <a href> — CSS can't make it a
* ignore it and read the XML. Include hreflang alternates as <xhtml:link>. * clickable link (some browsers auto-detect URLs); the win is readability, not
* clickability.
* *
* NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one * NOTE: if you use this custom route, DON'T also keep app/sitemap.ts — pick one.
* (the styled route OR the Next MetadataRoute). Two sitemaps at different paths * Two sitemaps confuse crawlers.
* confuse crawlers.
*/ */
export function buildSitemapXml( export function buildSitemapXml(
entries: SitemapEntry[], entries: SitemapEntry[],
opts: { stylesheetUrl?: string } = {}, opts: BuildSitemapXmlOptions = {},
): string { ): string {
const { stylesheetUrl } = opts const { cssUrl } = opts
const esc = (s: string): string => const esc = (s: string): string =>
s s
@@ -53,7 +67,6 @@ export function buildSitemapXml(
} }
if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`) if (e.changeFrequency) parts.push(` <changefreq>${e.changeFrequency}</changefreq>`)
if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`) if (typeof e.priority === 'number') parts.push(` <priority>${e.priority}</priority>`)
// hreflang alternates
const alternates = e.alternates?.languages const alternates = e.alternates?.languages
if (alternates) { if (alternates) {
for (const [lang, href] of Object.entries(alternates)) { for (const [lang, href] of Object.entries(alternates)) {
@@ -68,9 +81,7 @@ export function buildSitemapXml(
}) })
.join('\n') .join('\n')
const stylesheet = stylesheetUrl const stylesheet = cssUrl ? `<?xml-stylesheet type="text/css" href="${esc(cssUrl)}"?>\n` : ''
? `<?xml-stylesheet type="text/xsl" href="${esc(stylesheetUrl)}"?>\n`
: ''
return ( return (
`<?xml version="1.0" encoding="UTF-8"?>\n` + `<?xml version="1.0" encoding="UTF-8"?>\n` +
+56 -7
View File
@@ -49,6 +49,61 @@ export const ipalKit = (options: IpalOptions): Plugin => {
let config = { ...incomingConfig } let config = { ...incomingConfig }
// --- custom admin route (e.g. '/its' instead of '/admin') ---
// Sets config.routes.admin; the project must move its app/(payload)/<route>/
// folder to match (plugin can't create files in the project's app/).
if (options.adminRoute) {
config.routes = { ...(config.routes ?? {}), admin: options.adminRoute }
}
// --- enforced 2FA (TOTP) via @clocklimited/payload-2fa ---
// Enforced by default (forceSetup) unless twoFactor is explicitly false. The
// plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle
// it, so projects that opt out (twoFactor: false) needn't install it, and the
// import never runs under generate:importmap when 2FA is off. Wrapping access
// control (not just admin UI) means TOTP gates data access — no API bypass.
if (options.twoFactor !== false) {
const tf = options.twoFactor
if (!tf?.issuer) {
throw new Error(
'[ipal] twoFactor.issuer is required (name shown in the authenticator ' +
'app). Pass twoFactor: { issuer: "Your Site" }, or twoFactor: false to ' +
'opt out (discouraged).',
)
}
try {
// Dynamic specifier via a variable so TS doesn't try to resolve this
// optional peer dependency at build time (it isn't in the plugin's own
// node_modules). Avoids TS2307 without @ts-expect-error; the module
// exists at runtime in projects that installed it.
// @ts-ignore
const mod = (await import('@clocklimited/payload-2fa')) as {
payloadTotp?: (opts: Record<string, unknown>) => Plugin
totpPlugin?: (opts: Record<string, unknown>) => Plugin
}
// The package exports `payloadTotp`; older/other builds may use
// `totpPlugin`. Accept either so a rename doesn't break us.
const totp = mod.payloadTotp ?? mod.totpPlugin
if (typeof totp !== 'function') {
throw new Error(
'expected export payloadTotp (or totpPlugin) to be a function — ' +
'check the installed @clocklimited/payload-2fa version',
)
}
config = await totp({
collection: tf.collectionSlug ?? 'users',
forceSetup: true, // ENFORCED — every user must set up TOTP; no opt-out
totp: { issuer: tf.issuer },
})(config)
} catch (err) {
throw new Error(
'[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' +
'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' +
`opt out (discouraged). Original error: ${String(err)}`,
)
}
}
// --- i18n --- // --- i18n ---
config.localization = buildLocalizationConfig(options.i18n) config.localization = buildLocalizationConfig(options.i18n)
@@ -99,18 +154,12 @@ export const ipalKit = (options: IpalOptions): Plugin => {
buildNotifications(), 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] config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint]
// --- hooks: onInit --- // --- hooks: onInit ---
const incomingOnInit = config.onInit const incomingOnInit = config.onInit
config.onInit = async (payload) => { config.onInit = async (payload) => {
if (incomingOnInit) { if (incomingOnInit) {await incomingOnInit(payload)}
await incomingOnInit(payload)
}
payload.logger.info('[ipal] Plugin initialized.') payload.logger.info('[ipal] Plugin initialized.')
} }
+31
View File
@@ -18,6 +18,18 @@ export type IpalOptions = {
*/ */
access?: AccessOption access?: AccessOption
/**
* Custom admin panel route, e.g. '/its' instead of the default '/admin'.
* The plugin sets config.routes.admin. IMPORTANT: this alone isn't enough —
* the project must ALSO move its panel folder to match:
* app/(payload)/its/[[...segments]]/page.tsx (and the not-found). The plugin
* can't create files in the project's app/. See docs/security.md.
*
* This is obscurity, not security: it hides the panel from dumb bots scanning
* /admin, but real protection is strong auth + 2FA + rate limiting.
*/
adminRoute?: string
/** /**
* Collections whose entries live under an archive page — blog posts, case * Collections whose entries live under an archive page — blog posts, case
* studies, anything with a listing. Adds an "archive page" assignment per * studies, anything with a listing. Adds an "archive page" assignment per
@@ -55,4 +67,23 @@ export type IpalOptions = {
/** Additional fields injected into SiteSettings global */ /** Additional fields injected into SiteSettings global */
siteSettingsFields?: Field[] siteSettingsFields?: Field[]
/**
* Two-factor authentication (TOTP), ENFORCED for every user. Wires
* @clocklimited/payload-2fa with forceSetup — no per-project opt-out. Every
* user must configure an authenticator app after login; TOTP is checked before
* data access (not just the admin UI). Requires the peer dep installed and an
* issuer name (shown in the authenticator app).
*
* Pass `false` ONLY for a project that genuinely can't use 2FA (rare, discouraged)
* — default is enforced. See docs/security.md.
*/
twoFactor?:
| {
/** Auth collection slug. Defaults to 'users'. */
collectionSlug?: string
/** Name shown in the authenticator app (e.g. company/site name). */
issuer: string
}
| false
} }