Compare commits
4
Commits
v1.2.0
...
e30ac71044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 |
Vendored
+9
-1
@@ -19,6 +19,7 @@ export type PageMetadata = {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
robots?: {
|
robots?: {
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
index: boolean;
|
index: boolean;
|
||||||
@@ -39,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
|
||||||
@@ -69,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
+16
-11
@@ -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
|
||||||
});
|
});
|
||||||
@@ -60,12 +63,6 @@ import { buildHreflangAlternates } from './hreflang.js';
|
|||||||
languages
|
languages
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...meta?.noindex ? {
|
|
||||||
robots: {
|
|
||||||
follow: true,
|
|
||||||
index: false
|
|
||||||
}
|
|
||||||
} : {},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
...description && {
|
...description && {
|
||||||
@@ -75,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
+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 {};
|
||||||
|
|||||||
+46
-44
@@ -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
|
||||||
if (doc.meta?.noindex) {
|
* indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry
|
||||||
return false;
|
* is an audit finding. Matched against the slug in any locale.
|
||||||
|
*/ const EXCLUDED_SITEMAP_SLUGS = new Set([
|
||||||
|
'404',
|
||||||
|
'500',
|
||||||
|
'not-found',
|
||||||
|
'error'
|
||||||
|
]);
|
||||||
|
/** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) {
|
||||||
|
if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug);
|
||||||
|
if (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug)){
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc) {
|
||||||
|
if (doc._status && doc._status !== 'published') return false;
|
||||||
|
if (doc.meta?.noindex) return false;
|
||||||
|
if (hasExcludedSlug(doc.slug)) return false;
|
||||||
return true;
|
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
+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
+78
-1
@@ -61,4 +61,81 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
|||||||
|
|
||||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||||
plugin go nie generuje, bo zależy od projektu.
|
plugin go nie generuje, bo zależy od projektu.
|
||||||
|
|
||||||
|
### Dlaczego CSP zostaje w projekcie (nie plugin)
|
||||||
|
|
||||||
|
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
|
||||||
|
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
|
||||||
|
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
||||||
|
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||||
|
projekt dostarcza CSP dopasowany do siebie.
|
||||||
|
|
||||||
|
### Budowa CSP — domeny z env, nie hardkod
|
||||||
|
|
||||||
|
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||||
|
dopasuj do tego, co projekt faktycznie ładuje:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
const r2Url = process.env.R2_PUBLIC_URL || ''
|
||||||
|
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
|
||||||
|
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
|
||||||
|
// style: self + inline (Tailwind) + Google Fonts
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
// obrazy: self + media R2 (z env!) + data:
|
||||||
|
`img-src 'self' data: ${r2Url}`.trim(),
|
||||||
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
|
"connect-src 'self' https://www.google-analytics.com",
|
||||||
|
// ramki: Turnstile (widget captcha)
|
||||||
|
"frame-src https://challenges.cloudflare.com",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
|
||||||
|
].join('; ')
|
||||||
|
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production',
|
||||||
|
additional: [{ key: 'Content-Security-Policy', value: csp }],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
|
||||||
|
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
|
||||||
|
zostanie zablokowane.
|
||||||
|
|
||||||
|
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
|
||||||
|
|
||||||
|
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
|
||||||
|
enforcing CSP na ślepo. Metoda bezpieczna:
|
||||||
|
|
||||||
|
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
|
||||||
|
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
|
||||||
|
ale NIE blokuje — strona działa normalnie.
|
||||||
|
```ts
|
||||||
|
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
|
||||||
|
```
|
||||||
|
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
|
||||||
|
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
|
||||||
|
dyrektywy CSP.
|
||||||
|
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
|
||||||
|
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
|
||||||
|
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
|
||||||
|
(enforcing). Teraz CSP chroni, nie psując.
|
||||||
|
|
||||||
|
### Weryfikacja nagłówków na produkcji
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sprawdź, które nagłówki faktycznie wychodzą:
|
||||||
|
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
|
||||||
|
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
|
||||||
|
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
||||||
|
|
||||||
|
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||||
|
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||||
|
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.2.0",
|
"version": "1.2.2",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type PageMetadata = {
|
|||||||
locale?: string
|
locale?: string
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||||
robots?: {
|
robots?: {
|
||||||
follow: boolean
|
follow: boolean
|
||||||
index: boolean
|
index: boolean
|
||||||
@@ -43,6 +44,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
|
||||||
@@ -82,16 +90,21 @@ export function buildMetadata({
|
|||||||
locale,
|
locale,
|
||||||
meta,
|
meta,
|
||||||
order,
|
order,
|
||||||
|
pageTitle,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
separator,
|
separator,
|
||||||
siteName,
|
siteName,
|
||||||
slugs,
|
slugs,
|
||||||
}: BuildMetadataArgs): PageMetadata {
|
}: BuildMetadataArgs): PageMetadata {
|
||||||
// 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 title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
|
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
|
||||||
|
const title =
|
||||||
|
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
|
||||||
const description = meta?.description?.trim() || undefined
|
const description = meta?.description?.trim() || undefined
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||||
|
|
||||||
@@ -116,12 +129,14 @@ export function buildMetadata({
|
|||||||
...(canonical && { canonical }),
|
...(canonical && { canonical }),
|
||||||
...(Object.keys(languages).length > 0 && { languages }),
|
...(Object.keys(languages).length > 0 && { languages }),
|
||||||
},
|
},
|
||||||
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
...(description && { description }),
|
...(description && { description }),
|
||||||
...(images && { images }),
|
...(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,11 +1,10 @@
|
|||||||
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 { getLocalizedSlugs } from '../i18n/index.js'
|
||||||
import { archiveFieldName } from '../content/index.js'
|
import { buildLocalizedPath } from '../i18n/index.js'
|
||||||
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
|
|
||||||
import { buildHreflangAlternates } from './hreflang.js'
|
import { buildHreflangAlternates } from './hreflang.js'
|
||||||
|
import type { ContentOption } from '../content/index.js'
|
||||||
|
import { archiveFieldName } from '../content/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
* One sitemap entry, shaped for Next's `app/sitemap.ts`.
|
||||||
@@ -16,50 +15,70 @@ import { buildHreflangAlternates } from './hreflang.js'
|
|||||||
* is the common, weaker kind.
|
* is the common, weaker kind.
|
||||||
*/
|
*/
|
||||||
export type SitemapEntry = {
|
export type SitemapEntry = {
|
||||||
alternates?: { languages: Record<string, string> }
|
|
||||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
|
|
||||||
lastModified?: Date | string
|
|
||||||
priority?: number
|
|
||||||
url: string
|
url: string
|
||||||
|
lastModified?: string | Date
|
||||||
|
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
|
||||||
|
priority?: number
|
||||||
|
alternates?: { languages: Record<string, string> }
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionEntry = {
|
type CollectionEntry = {
|
||||||
|
slug: string
|
||||||
/** Localized segment for entries (archive page slugs), when applicable. */
|
/** Localized segment for entries (archive page slugs), when applicable. */
|
||||||
prefixSlugs?: Record<string, string>
|
prefixSlugs?: Record<string, string>
|
||||||
slug: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocRow = {
|
type DocRow = {
|
||||||
_status?: string
|
id: string | number
|
||||||
id: number | string
|
|
||||||
meta?: { noindex?: boolean } | null
|
|
||||||
slug?: unknown
|
slug?: unknown
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
|
_status?: string
|
||||||
|
meta?: { noindex?: boolean } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skip drafts and anything flagged noindex in the SEO tab. */
|
/**
|
||||||
|
* 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: unknown): boolean {
|
||||||
|
if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug)
|
||||||
|
if (slug && typeof slug === 'object') {
|
||||||
|
for (const value of Object.values(slug as Record<string, unknown>)) {
|
||||||
|
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skip drafts, noindex, and system/error pages (404 etc.). */
|
||||||
function isIndexable(doc: DocRow): boolean {
|
function isIndexable(doc: DocRow): boolean {
|
||||||
if (doc._status && doc._status !== 'published') {return false}
|
if (doc._status && doc._status !== 'published') return false
|
||||||
if (doc.meta?.noindex) {return false}
|
if (doc.meta?.noindex) return false
|
||||||
|
if (hasExcludedSlug(doc.slug)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,17 +96,17 @@ function entryFor(
|
|||||||
homeSlug: string | undefined,
|
homeSlug: string | undefined,
|
||||||
prefix: Record<string, string> | undefined,
|
prefix: Record<string, string> | undefined,
|
||||||
changeFrequency: SitemapEntry['changeFrequency'],
|
changeFrequency: SitemapEntry['changeFrequency'],
|
||||||
): null | SitemapEntry {
|
): SitemapEntry | null {
|
||||||
const slugs =
|
const slugs =
|
||||||
doc.slug && typeof doc.slug === 'object'
|
doc.slug && typeof doc.slug === 'object'
|
||||||
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
|
? getLocalizedSlugs({ slugField: doc.slug as Record<string, unknown>, config })
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||||
if (!path) {return null}
|
if (!path) return null
|
||||||
|
|
||||||
const origin = baseUrl.replace(/\/$/, '')
|
const origin = baseUrl.replace(/\/$/, '')
|
||||||
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `${origin}${path}`,
|
url: `${origin}${path}`,
|
||||||
@@ -119,14 +138,14 @@ function entryFor(
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function buildSitemapEntries({
|
export async function buildSitemapEntries({
|
||||||
baseUrl,
|
|
||||||
changeFrequency = 'weekly',
|
|
||||||
config,
|
|
||||||
content,
|
|
||||||
homeSlug,
|
|
||||||
pagesSlug = 'pages',
|
|
||||||
payload,
|
payload,
|
||||||
|
config,
|
||||||
|
baseUrl,
|
||||||
|
pagesSlug = 'pages',
|
||||||
|
content,
|
||||||
settingsSlug = 'site-settings',
|
settingsSlug = 'site-settings',
|
||||||
|
homeSlug,
|
||||||
|
changeFrequency = 'weekly',
|
||||||
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
}: BuildSitemapArgs): Promise<SitemapEntry[]> {
|
||||||
const locales = config.locales.map((l) => l.code)
|
const locales = config.locales.map((l) => l.code)
|
||||||
const defaultLocale = config.defaultLocale
|
const defaultLocale = config.defaultLocale
|
||||||
@@ -135,8 +154,8 @@ export async function buildSitemapEntries({
|
|||||||
// 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 as never,
|
slug: settingsSlug as never,
|
||||||
depth: 1,
|
|
||||||
locale: 'all' as never,
|
locale: 'all' as never,
|
||||||
|
depth: 1,
|
||||||
})) as Record<string, unknown>
|
})) as Record<string, unknown>
|
||||||
|
|
||||||
const resolvedHomeSlug =
|
const resolvedHomeSlug =
|
||||||
@@ -159,14 +178,14 @@ export async function buildSitemapEntries({
|
|||||||
// 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 as never,
|
collection: collection.slug as never,
|
||||||
|
locale: 'all' as never,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 0, // no pagination — sitemap wants everything
|
limit: 0, // no pagination — sitemap wants everything
|
||||||
locale: 'all' as never,
|
|
||||||
pagination: false as never,
|
pagination: false as never,
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const raw of result.docs as DocRow[]) {
|
for (const raw of result.docs as DocRow[]) {
|
||||||
if (!isIndexable(raw)) {continue}
|
if (!isIndexable(raw)) 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(
|
const entry = entryFor(
|
||||||
@@ -178,7 +197,7 @@ export async function buildSitemapEntries({
|
|||||||
collection.prefixSlugs,
|
collection.prefixSlugs,
|
||||||
changeFrequency,
|
changeFrequency,
|
||||||
)
|
)
|
||||||
if (entry) {entries.push(entry)}
|
if (entry) entries.push(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,9 +206,9 @@ export async function buildSitemapEntries({
|
|||||||
|
|
||||||
/** Pulls a slug string from a populated relationship in a specific locale. */
|
/** Pulls a slug string from a populated relationship in a specific locale. */
|
||||||
function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
||||||
if (!rel || typeof rel !== 'object') {return undefined}
|
if (!rel || typeof rel !== 'object') return undefined
|
||||||
const slug = (rel as { slug?: unknown }).slug
|
const slug = (rel as { slug?: unknown }).slug
|
||||||
if (typeof slug === 'string') {return slug}
|
if (typeof slug === 'string') return slug
|
||||||
if (slug && typeof slug === 'object') {
|
if (slug && typeof slug === 'object') {
|
||||||
const v = (slug as Record<string, unknown>)[locale]
|
const v = (slug as Record<string, unknown>)[locale]
|
||||||
return typeof v === 'string' ? v : undefined
|
return typeof v === 'string' ? v : undefined
|
||||||
@@ -199,14 +218,14 @@ function extractSlugInLocale(rel: unknown, locale: string): string | undefined {
|
|||||||
|
|
||||||
/** Builds a locale→slug map from a populated archive relationship. */
|
/** Builds a locale→slug map from a populated archive relationship. */
|
||||||
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined {
|
function slugMapAllLocales(rel: unknown, locales: string[]): Record<string, string> | undefined {
|
||||||
if (!rel || typeof rel !== 'object') {return undefined}
|
if (!rel || typeof rel !== 'object') return undefined
|
||||||
const slug = (rel as { slug?: unknown }).slug
|
const slug = (rel as { slug?: unknown }).slug
|
||||||
if (!slug || typeof slug !== 'object') {return undefined}
|
if (!slug || typeof slug !== 'object') return undefined
|
||||||
|
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
const v = (slug as Record<string, unknown>)[locale]
|
const v = (slug as Record<string, unknown>)[locale]
|
||||||
if (typeof v === 'string') {map[locale] = v}
|
if (typeof v === 'string') map[locale] = v
|
||||||
}
|
}
|
||||||
return Object.keys(map).length ? map : undefined
|
return Object.keys(map).length ? map : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type PageMetadataContext = {
|
|||||||
type DocShape = {
|
type DocShape = {
|
||||||
id: number | string
|
id: number | string
|
||||||
meta?: null | SeoMeta
|
meta?: null | SeoMeta
|
||||||
|
/** The document's own title (page name), used as the fallback page title. */
|
||||||
|
title?: null | string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** plugin-seo stores the OG image as an upload relationship. */
|
/** plugin-seo stores the OG image as an upload relationship. */
|
||||||
@@ -151,6 +153,7 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
|
|||||||
...base,
|
...base,
|
||||||
imageUrl: resolveOgImage(doc),
|
imageUrl: resolveOgImage(doc),
|
||||||
meta: doc.meta,
|
meta: doc.meta,
|
||||||
|
pageTitle: doc.title,
|
||||||
prefix,
|
prefix,
|
||||||
query,
|
query,
|
||||||
slugs,
|
slugs,
|
||||||
|
|||||||
Reference in New Issue
Block a user