Compare commits

...
2 Commits
Author SHA1 Message Date
radoslaw.smolinski e30ac71044 1.2.2 2026-09-08 14:25:26 +02:00
radoslaw.smolinski 781e348ded Page title generator on browser tab rebuilded. 2026-09-08 14:25:15 +02:00
9 changed files with 129 additions and 20 deletions
+9 -1
View File
@@ -19,6 +19,7 @@ export type PageMetadata = {
locale?: string;
title: string;
};
/** robots directives — set to noindex/follow for legal/thin/search pages. */
robots?: {
follow: boolean;
index: boolean;
@@ -39,6 +40,13 @@ type BuildMetadataArgs = {
meta?: null | SeoMeta;
/** Page title or site name first. Defaults to 'page-first'. */
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).
* 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
* 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 {};
+15 -10
View File
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
* pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch.
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) {
// titleOverride wins outright: an editor who filled it in wants that exact
// string in the tab, not a composition.
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
// Title source priority: titleOverride (exact, wins outright) > meta.title
// (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 resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
const title = override || composeTitle({
order,
pageTitle: meta?.title,
pageTitle: resolvedPageTitle,
separator,
siteName
});
@@ -60,12 +63,6 @@ import { buildHreflangAlternates } from './hreflang.js';
languages
}
},
...meta?.noindex ? {
robots: {
follow: true,
index: false
}
} : {},
openGraph: {
title,
...description && {
@@ -75,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
images
},
locale
},
// noindex → tell search engines to exclude the page but still follow links
// (authority flows through). For legal/thin/search-result pages.
...meta?.noindex ? {
robots: {
follow: true,
index: false
}
} : {}
};
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
...base,
imageUrl: resolveOgImage(doc),
meta: doc.meta,
pageTitle: doc.title,
prefix,
query,
slugs
File diff suppressed because one or more lines are too long
+77
View File
@@ -62,3 +62,80 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
plugin go nie generuje, bo zależy od projektu.
### Dlaczego CSP zostaje w projekcie (nie plugin)
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
projekt dostarcza CSP dopasowany do siebie.
### Budowa CSP — domeny z env, nie hardkod
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
dopasuj do tego, co projekt faktycznie ładuje:
```ts
// next.config.ts
const r2Url = process.env.R2_PUBLIC_URL || ''
const csp = [
"default-src 'self'",
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
// style: self + inline (Tailwind) + Google Fonts
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// obrazy: self + media R2 (z env!) + data:
`img-src 'self' data: ${r2Url}`.trim(),
"font-src 'self' https://fonts.gstatic.com data:",
"connect-src 'self' https://www.google-analytics.com",
// ramki: Turnstile (widget captcha)
"frame-src https://challenges.cloudflare.com",
"form-action 'self'",
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
].join('; ')
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production',
additional: [{ key: 'Content-Security-Policy', value: csp }],
})
```
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
zostanie zablokowane.
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
enforcing CSP na ślepo. Metoda bezpieczna:
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
ale NIE blokuje — strona działa normalnie.
```ts
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
```
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
dyrektywy CSP.
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
(enforcing). Teraz CSP chroni, nie psując.
### Weryfikacja nagłówków na produkcji
```bash
# sprawdź, które nagłówki faktycznie wychodzą:
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
```
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
> albo upewnij się, że CF przepuszcza nagłówki z origin.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@intecion/ipal-kit",
"version": "1.2.1",
"version": "1.2.2",
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
"license": "MIT",
"repository": {
+19 -4
View File
@@ -22,6 +22,7 @@ export type PageMetadata = {
locale?: string
title: string
}
/** robots directives — set to noindex/follow for legal/thin/search pages. */
robots?: {
follow: boolean
index: boolean
@@ -43,6 +44,13 @@ type BuildMetadataArgs = {
meta?: null | SeoMeta
/** Page title or site name first. Defaults to 'page-first'. */
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).
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
@@ -82,16 +90,21 @@ export function buildMetadata({
locale,
meta,
order,
pageTitle,
prefix,
query,
separator,
siteName,
slugs,
}: BuildMetadataArgs): PageMetadata {
// titleOverride wins outright: an editor who filled it in wants that exact
// string in the tab, not a composition.
// Title source priority: titleOverride (exact, wins outright) > meta.title
// (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 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 origin = baseUrl?.replace(/\/$/, '') ?? ''
@@ -116,12 +129,14 @@ export function buildMetadata({
...(canonical && { canonical }),
...(Object.keys(languages).length > 0 && { languages }),
},
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
openGraph: {
title,
...(description && { description }),
...(images && { images }),
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 } } : {}),
}
}
+3
View File
@@ -43,6 +43,8 @@ type PageMetadataContext = {
type DocShape = {
id: number | string
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. */
@@ -151,6 +153,7 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
...base,
imageUrl: resolveOgImage(doc),
meta: doc.meta,
pageTitle: doc.title,
prefix,
query,
slugs,