diff --git a/dist/modules/seo/buildMetadata.d.ts b/dist/modules/seo/buildMetadata.d.ts index 17da050..a69a257 100644 --- a/dist/modules/seo/buildMetadata.d.ts +++ b/dist/modules/seo/buildMetadata.d.ts @@ -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 {}; diff --git a/dist/modules/seo/buildMetadata.js b/dist/modules/seo/buildMetadata.js index 8635813..8814fac 100644 --- a/dist/modules/seo/buildMetadata.js +++ b/dist/modules/seo/buildMetadata.js @@ -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 + } + } : {} }; } diff --git a/dist/modules/seo/buildMetadata.js.map b/dist/modules/seo/buildMetadata.js.map index 8674ebd..fb4a2d4 100644 --- a/dist/modules/seo/buildMetadata.js.map +++ b/dist/modules/seo/buildMetadata.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/seo/buildMetadata.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { TitleOrder } from './composeTitle.js'\nimport type { SeoMeta } from './types.js'\n\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { composeTitle } from './composeTitle.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string\n languages?: Record\n }\n description?: string\n openGraph?: {\n description?: string\n images?: { url: string }[]\n locale?: string\n title: string\n }\n robots?: {\n follow: boolean\n index: boolean\n }\n title: string\n}\n\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string\n /** Current locale being rendered. */\n locale: string\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name for title composition and OG. */\n siteName?: null | string\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs\n}\n\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport function buildMetadata({\n baseUrl,\n config,\n homeSlug = 'home',\n imageUrl,\n locale,\n meta,\n order,\n prefix,\n query,\n separator,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // titleOverride wins outright: an editor who filled it in wants that exact\n // string in the tab, not a composition.\n const override = meta?.titleOverride?.trim()\n const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })\n const description = meta?.description?.trim() || undefined\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n\n const suffix = query ?? ''\n\n const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined\n\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n if (suffix) {\n for (const code of Object.keys(languages)) {\n languages[code] = `${languages[code]}${suffix}`\n }\n }\n\n const images = imageUrl ? [{ url: imageUrl }] : undefined\n\n return {\n title,\n ...(description && { description }),\n alternates: {\n ...(canonical && { canonical }),\n ...(Object.keys(languages).length > 0 && { languages }),\n },\n ...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n locale,\n },\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","prefix","query","separator","siteName","slugs","override","titleOverride","trim","title","pageTitle","description","undefined","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","noindex","robots","follow","index","openGraph"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AA6DvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,wCAAwC;IACxC,MAAMC,WAAWP,MAAMQ,eAAeC;IACtC,MAAMC,QAAQH,YAAYf,aAAa;QAAES;QAAOU,WAAWX,MAAMU;QAAON;QAAWC;IAAS;IAC5F,MAAMO,cAAcZ,MAAMY,aAAaH,UAAUI;IACjD,MAAMC,SAASnB,SAASoB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASb,SAAS;IAExB,MAAMc,cAAc1B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQG;QAAQI;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGH;IAErE,MAAMM,YAAY1B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUK;QAAQI;IAAM;IACrF,IAAIU,QAAQ;QACV,KAAK,MAAMI,QAAQC,OAAOC,IAAI,CAACH,WAAY;YACzCA,SAAS,CAACC,KAAK,GAAG,GAAGD,SAAS,CAACC,KAAK,GAAGJ,QAAQ;QACjD;IACF;IAEA,MAAMO,SAASzB,WAAW;QAAC;YAAE0B,KAAK1B;QAAS;KAAE,GAAGe;IAEhD,OAAO;QACLH;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCa,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACA,GAAInB,MAAM2B,UAAU;YAAEC,QAAQ;gBAAEC,QAAQ;gBAAMC,OAAO;YAAM;QAAE,IAAI,CAAC,CAAC;QACnEC,WAAW;YACTrB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIW,UAAU;gBAAEA;YAAO,CAAC;YACxBxB;QACF;IACF;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/seo/buildMetadata.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport type { TitleOrder } from './composeTitle.js'\nimport type { SeoMeta } from './types.js'\n\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { composeTitle } from './composeTitle.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * Subset of Next.js `Metadata` this helper produces. Kept local so the plugin\n * doesn't depend on `next` types; the shape is assignable to Next's Metadata.\n */\nexport type PageMetadata = {\n alternates?: {\n canonical?: string\n languages?: Record\n }\n description?: string\n openGraph?: {\n description?: string\n images?: { url: string }[]\n locale?: string\n title: string\n }\n /** robots directives — set to noindex/follow for legal/thin/search pages. */\n robots?: {\n follow: boolean\n index: boolean\n }\n title: string\n}\n\ntype BuildMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /** Resolved OG image URL (page image or site defaultShareImage). */\n imageUrl?: null | string\n /** Current locale being rendered. */\n locale: string\n /** SEO meta from the document (plugin-seo group). */\n meta?: null | SeoMeta\n /** Page title or site name first. Defaults to 'page-first'. */\n order?: TitleOrder\n /**\n * The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the\n * page-title source when meta.title is empty — the browser tab and search\n * result should show the page name, not go blank, when an editor didn't fill\n * the SEO title. Priority: titleOverride > meta.title > pageTitle.\n */\n pageTitle?: null | string\n /**\n * Localized segment the document lives under (an archive page's slugs).\n * Feeds both canonical and hreflang, so /pl/artykuly/moj-post and\n * /en/articles/my-post point at each other correctly.\n */\n prefix?: LocalizedSlugs\n /**\n * Query string appended to canonical and every hreflang, e.g. '?page=2'.\n *\n * A paginated listing must be canonical to itself — pointing page 2 at page 1\n * tells Google the entries on it don't exist. Alternates carry the same page,\n * since /pl/artykuly?page=2 corresponds to /en/articles?page=2.\n */\n query?: string\n /** Separator between page title and site name. Defaults to ' | '. */\n separator?: string\n /** Site name for title composition and OG. */\n siteName?: null | string\n /** slug per locale for this document — drives canonical + hreflang. */\n slugs: LocalizedSlugs\n}\n\n/**\n * Assembles a Next.js-compatible Metadata object from document SEO fields and\n * site-level data. Locale-aware: canonical points at the current locale's\n * path, and hreflang alternates cover every locale the document exists in.\n *\n * Designed for use inside Next.js `generateMetadata`. The caller resolves the\n * pieces (meta group, site name, image URL, localized slugs) and passes them\n * in — the plugin composes, it doesn't fetch.\n */\nexport function buildMetadata({\n baseUrl,\n config,\n homeSlug = 'home',\n imageUrl,\n locale,\n meta,\n order,\n pageTitle,\n prefix,\n query,\n separator,\n siteName,\n slugs,\n}: BuildMetadataArgs): PageMetadata {\n // Title source priority: titleOverride (exact, wins outright) > meta.title\n // (SEO title an editor set) > pageTitle (the document's own name). This means\n // a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')\n // composed with the site name, instead of just the site name or a blank.\n const override = meta?.titleOverride?.trim()\n const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined\n const title =\n override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })\n const description = meta?.description?.trim() || undefined\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n\n const suffix = query ?? ''\n\n const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined\n\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n if (suffix) {\n for (const code of Object.keys(languages)) {\n languages[code] = `${languages[code]}${suffix}`\n }\n }\n\n const images = imageUrl ? [{ url: imageUrl }] : undefined\n\n return {\n title,\n ...(description && { description }),\n alternates: {\n ...(canonical && { canonical }),\n ...(Object.keys(languages).length > 0 && { languages }),\n },\n openGraph: {\n title,\n ...(description && { description }),\n ...(images && { images }),\n locale,\n },\n // noindex → tell search engines to exclude the page but still follow links\n // (authority flows through). For legal/thin/search-result pages.\n ...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),\n }\n}\n"],"names":["buildLocalizedPath","composeTitle","buildHreflangAlternates","buildMetadata","baseUrl","config","homeSlug","imageUrl","locale","meta","order","pageTitle","prefix","query","separator","siteName","slugs","override","titleOverride","trim","resolvedPageTitle","title","undefined","description","origin","replace","suffix","currentPath","canonical","languages","code","Object","keys","images","url","alternates","length","openGraph","noindex","robots","follow","index"],"mappings":"AAIA,SAASA,kBAAkB,QAAQ,mBAAkB;AACrD,SAASC,YAAY,QAAQ,oBAAmB;AAChD,SAASC,uBAAuB,QAAQ,gBAAe;AAqEvD;;;;;;;;CAQC,GACD,OAAO,SAASC,cAAc,EAC5BC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,QAAQ,EACRC,MAAM,EACNC,IAAI,EACJC,KAAK,EACLC,SAAS,EACTC,MAAM,EACNC,KAAK,EACLC,SAAS,EACTC,QAAQ,EACRC,KAAK,EACa;IAClB,2EAA2E;IAC3E,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,MAAMC,WAAWR,MAAMS,eAAeC;IACtC,MAAMC,oBAAoBX,MAAMY,OAAOF,UAAUR,WAAWQ,UAAUG;IACtE,MAAMD,QACJJ,YAAYhB,aAAa;QAAES;QAAOC,WAAWS;QAAmBN;QAAWC;IAAS;IACtF,MAAMQ,cAAcd,MAAMc,aAAaJ,UAAUG;IACjD,MAAME,SAASpB,SAASqB,QAAQ,OAAO,OAAO;IAE9C,MAAMC,SAASb,SAAS;IAExB,MAAMc,cAAc3B,mBAAmB;QAAEK;QAAQC;QAAUE;QAAQI;QAAQI;IAAM;IACjF,MAAMY,YAAYD,cAAc,GAAGH,SAASG,cAAcD,QAAQ,GAAGJ;IAErE,MAAMO,YAAY3B,wBAAwB;QAAEE;QAASC;QAAQC;QAAUM;QAAQI;IAAM;IACrF,IAAIU,QAAQ;QACV,KAAK,MAAMI,QAAQC,OAAOC,IAAI,CAACH,WAAY;YACzCA,SAAS,CAACC,KAAK,GAAG,GAAGD,SAAS,CAACC,KAAK,GAAGJ,QAAQ;QACjD;IACF;IAEA,MAAMO,SAAS1B,WAAW;QAAC;YAAE2B,KAAK3B;QAAS;KAAE,GAAGe;IAEhD,OAAO;QACLD;QACA,GAAIE,eAAe;YAAEA;QAAY,CAAC;QAClCY,YAAY;YACV,GAAIP,aAAa;gBAAEA;YAAU,CAAC;YAC9B,GAAIG,OAAOC,IAAI,CAACH,WAAWO,MAAM,GAAG,KAAK;gBAAEP;YAAU,CAAC;QACxD;QACAQ,WAAW;YACThB;YACA,GAAIE,eAAe;gBAAEA;YAAY,CAAC;YAClC,GAAIU,UAAU;gBAAEA;YAAO,CAAC;YACxBzB;QACF;QACA,2EAA2E;QAC3E,iEAAiE;QACjE,GAAIC,MAAM6B,UAAU;YAAEC,QAAQ;gBAAEC,QAAQ;gBAAMC,OAAO;YAAM;QAAE,IAAI,CAAC,CAAC;IACrE;AACF"} \ No newline at end of file diff --git a/dist/modules/seo/createPageMetadata.js b/dist/modules/seo/createPageMetadata.js index 0880024..43fd99a 100644 --- a/dist/modules/seo/createPageMetadata.js +++ b/dist/modules/seo/createPageMetadata.js @@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js'; ...base, imageUrl: resolveOgImage(doc), meta: doc.meta, + pageTitle: doc.title, prefix, query, slugs diff --git a/dist/modules/seo/createPageMetadata.js.map b/dist/modules/seo/createPageMetadata.js.map index e1b3faf..929c8da 100644 --- a/dist/modules/seo/createPageMetadata.js.map +++ b/dist/modules/seo/createPageMetadata.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/seo/createPageMetadata.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { resolveRoute } from '../content/index.js'\nimport { buildMetadata } from './buildMetadata.js'\nimport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nimport { slugsAcrossLocales } from './slugsAcrossLocales.js'\n\ntype CreatePageMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n /** Collection holding pages. Defaults to 'pages'. */\n collection?: string\n config: I18nConfig\n /**\n * Archive-backed collections, same value as the plugin option. Pass it and\n * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang;\n * omit it and only pages are handled.\n */\n content?: ContentOption\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */\n siteNameField?: string\n}\n\ntype PageMetadataContext = {\n locale: string\n /**\n * Page number from ?page= on an archive listing. Pass it and page 2 gets a\n * canonical to itself; leave it out and every page claims to be page 1.\n */\n page?: number\n payload: BasePayload\n /** Route slug segments; empty/undefined means the locale root. */\n slug?: string[]\n}\n\ntype DocShape = {\n id: number | string\n meta?: null | SeoMeta\n}\n\n/** plugin-seo stores the OG image as an upload relationship. */\nfunction resolveOgImage(doc: DocShape): null | string {\n const image = (doc.meta as { image?: unknown } | null | undefined)?.image\n if (image && typeof image === 'object' && 'url' in image) {\n return (image as { url: string }).url ?? null\n }\n return null\n}\n\n/**\n * Metadata for the page route, with every resolver already wired.\n *\n * `createMetadataGenerator` asks the client for resolvers because it can't know\n * their collections. But for the plugin's own conventions it does know: pages\n * live in one collection, the site name sits on SiteSettings, the OG image sits\n * on the plugin-seo `meta` group, the home page is whatever System Pages points\n * at, and — with `content` — entries live under their collection's archive page.\n * Re-declaring all that in every project is copy-paste, so this resolves it.\n *\n * Reach for `createMetadataGenerator` instead when a route doesn't follow those\n * conventions — custom image logic, a different global, hand-rolled paths.\n *\n * The plugin never calls getPayload, and Next calls generateMetadata without a\n * payload, so the client keeps a small wrapper:\n *\n * ```ts\n * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * return pageMetadata({ payload: await getPayload({ config }), locale, slug })\n * }\n * ```\n */\nexport function createPageMetadata(args: CreatePageMetadataArgs) {\n const {\n baseUrl,\n collection = 'pages',\n config,\n content,\n settingsSlug = 'site-settings',\n siteNameField = 'siteName',\n } = args\n\n return async function pageMetadata({\n slug,\n locale,\n page,\n payload,\n }: PageMetadataContext): Promise {\n const site = await readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField })\n\n const base = {\n baseUrl,\n config,\n homeSlug: site.homeSlug,\n locale,\n order: site.order,\n separator: site.separator,\n siteName: site.siteName,\n }\n\n const route = await resolveRoute({\n content,\n locale,\n page,\n pagesSlug: collection,\n payload,\n segments: slug,\n settingsSlug,\n })\n\n // Unknown route (the page component will 404) — still return something\n // coherent rather than throwing during metadata generation.\n if (!route) {\n return buildMetadata({ ...base, meta: null, slugs: {} })\n }\n\n const doc = route.doc as DocShape\n\n // An entry sits under its archive, so its URLs need that segment — and the\n // segment differs per locale, since it's the archive page's own slug.\n const prefix =\n route.type === 'entry'\n ? await slugsAcrossLocales({\n id: (route.archive as DocShape).id,\n collection,\n config,\n payload,\n })\n : undefined\n\n const slugs = await slugsAcrossLocales({\n id: doc.id,\n collection: route.type === 'entry' ? route.collection : collection,\n config,\n payload,\n })\n\n // Page 2 of a listing is its own URL, not a variant of page 1.\n const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined\n\n return buildMetadata({\n ...base,\n imageUrl: resolveOgImage(doc),\n meta: doc.meta,\n prefix,\n query,\n slugs,\n })\n }\n}\n"],"names":["resolveRoute","buildMetadata","readSiteMetaConfig","slugsAcrossLocales","resolveOgImage","doc","image","meta","url","createPageMetadata","args","baseUrl","collection","config","content","settingsSlug","siteNameField","pageMetadata","slug","locale","page","payload","site","base","homeSlug","order","separator","siteName","route","pagesSlug","segments","slugs","prefix","type","id","archive","undefined","query","imageUrl"],"mappings":"AAOA,SAASA,YAAY,QAAQ,sBAAqB;AAClD,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,kBAAkB,QAAQ,0BAAyB;AAqC5D,8DAA8D,GAC9D,SAASC,eAAeC,GAAa;IACnC,MAAMC,QAASD,IAAIE,IAAI,EAA6CD;IACpE,IAAIA,SAAS,OAAOA,UAAU,YAAY,SAASA,OAAO;QACxD,OAAO,AAACA,MAA0BE,GAAG,IAAI;IAC3C;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,mBAAmBC,IAA4B;IAC7D,MAAM,EACJC,OAAO,EACPC,aAAa,OAAO,EACpBC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,gBAAgB,UAAU,EAC3B,GAAGN;IAEJ,OAAO,eAAeO,aAAa,EACjCC,IAAI,EACJC,MAAM,EACNC,IAAI,EACJC,OAAO,EACa;QACpB,MAAMC,OAAO,MAAMpB,mBAAmB;YAAEiB;YAAQE;YAASN;YAAcC;QAAc;QAErF,MAAMO,OAAO;YACXZ;YACAE;YACAW,UAAUF,KAAKE,QAAQ;YACvBL;YACAM,OAAOH,KAAKG,KAAK;YACjBC,WAAWJ,KAAKI,SAAS;YACzBC,UAAUL,KAAKK,QAAQ;QACzB;QAEA,MAAMC,QAAQ,MAAM5B,aAAa;YAC/Bc;YACAK;YACAC;YACAS,WAAWjB;YACXS;YACAS,UAAUZ;YACVH;QACF;QAEA,uEAAuE;QACvE,4DAA4D;QAC5D,IAAI,CAACa,OAAO;YACV,OAAO3B,cAAc;gBAAE,GAAGsB,IAAI;gBAAEhB,MAAM;gBAAMwB,OAAO,CAAC;YAAE;QACxD;QAEA,MAAM1B,MAAMuB,MAAMvB,GAAG;QAErB,2EAA2E;QAC3E,sEAAsE;QACtE,MAAM2B,SACJJ,MAAMK,IAAI,KAAK,UACX,MAAM9B,mBAAmB;YACvB+B,IAAI,AAACN,MAAMO,OAAO,CAAcD,EAAE;YAClCtB;YACAC;YACAQ;QACF,KACAe;QAEN,MAAML,QAAQ,MAAM5B,mBAAmB;YACrC+B,IAAI7B,IAAI6B,EAAE;YACVtB,YAAYgB,MAAMK,IAAI,KAAK,UAAUL,MAAMhB,UAAU,GAAGA;YACxDC;YACAQ;QACF;QAEA,+DAA+D;QAC/D,MAAMgB,QAAQT,MAAMK,IAAI,KAAK,aAAaL,MAAMR,IAAI,GAAG,IAAI,CAAC,MAAM,EAAEQ,MAAMR,IAAI,EAAE,GAAGgB;QAEnF,OAAOnC,cAAc;YACnB,GAAGsB,IAAI;YACPe,UAAUlC,eAAeC;YACzBE,MAAMF,IAAIE,IAAI;YACdyB;YACAK;YACAN;QACF;IACF;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/seo/createPageMetadata.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { PageMetadata } from './buildMetadata.js'\nimport type { SeoMeta } from './types.js'\n\nimport { resolveRoute } from '../content/index.js'\nimport { buildMetadata } from './buildMetadata.js'\nimport { readSiteMetaConfig } from './readSiteMetaConfig.js'\nimport { slugsAcrossLocales } from './slugsAcrossLocales.js'\n\ntype CreatePageMetadataArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. */\n baseUrl?: string\n /** Collection holding pages. Defaults to 'pages'. */\n collection?: string\n config: I18nConfig\n /**\n * Archive-backed collections, same value as the plugin option. Pass it and\n * entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang;\n * omit it and only pages are handled.\n */\n content?: ContentOption\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /** Field on SiteSettings holding the site name. Defaults to 'siteName'. */\n siteNameField?: string\n}\n\ntype PageMetadataContext = {\n locale: string\n /**\n * Page number from ?page= on an archive listing. Pass it and page 2 gets a\n * canonical to itself; leave it out and every page claims to be page 1.\n */\n page?: number\n payload: BasePayload\n /** Route slug segments; empty/undefined means the locale root. */\n slug?: string[]\n}\n\ntype DocShape = {\n id: number | string\n meta?: null | SeoMeta\n /** The document's own title (page name), used as the fallback page title. */\n title?: null | string\n}\n\n/** plugin-seo stores the OG image as an upload relationship. */\nfunction resolveOgImage(doc: DocShape): null | string {\n const image = (doc.meta as { image?: unknown } | null | undefined)?.image\n if (image && typeof image === 'object' && 'url' in image) {\n return (image as { url: string }).url ?? null\n }\n return null\n}\n\n/**\n * Metadata for the page route, with every resolver already wired.\n *\n * `createMetadataGenerator` asks the client for resolvers because it can't know\n * their collections. But for the plugin's own conventions it does know: pages\n * live in one collection, the site name sits on SiteSettings, the OG image sits\n * on the plugin-seo `meta` group, the home page is whatever System Pages points\n * at, and — with `content` — entries live under their collection's archive page.\n * Re-declaring all that in every project is copy-paste, so this resolves it.\n *\n * Reach for `createMetadataGenerator` instead when a route doesn't follow those\n * conventions — custom image logic, a different global, hand-rolled paths.\n *\n * The plugin never calls getPayload, and Next calls generateMetadata without a\n * payload, so the client keeps a small wrapper:\n *\n * ```ts\n * const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content })\n *\n * export async function generateMetadata({ params }) {\n * const { locale, slug } = await params\n * return pageMetadata({ payload: await getPayload({ config }), locale, slug })\n * }\n * ```\n */\nexport function createPageMetadata(args: CreatePageMetadataArgs) {\n const {\n baseUrl,\n collection = 'pages',\n config,\n content,\n settingsSlug = 'site-settings',\n siteNameField = 'siteName',\n } = args\n\n return async function pageMetadata({\n slug,\n locale,\n page,\n payload,\n }: PageMetadataContext): Promise {\n const site = await readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField })\n\n const base = {\n baseUrl,\n config,\n homeSlug: site.homeSlug,\n locale,\n order: site.order,\n separator: site.separator,\n siteName: site.siteName,\n }\n\n const route = await resolveRoute({\n content,\n locale,\n page,\n pagesSlug: collection,\n payload,\n segments: slug,\n settingsSlug,\n })\n\n // Unknown route (the page component will 404) — still return something\n // coherent rather than throwing during metadata generation.\n if (!route) {\n return buildMetadata({ ...base, meta: null, slugs: {} })\n }\n\n const doc = route.doc as DocShape\n\n // An entry sits under its archive, so its URLs need that segment — and the\n // segment differs per locale, since it's the archive page's own slug.\n const prefix =\n route.type === 'entry'\n ? await slugsAcrossLocales({\n id: (route.archive as DocShape).id,\n collection,\n config,\n payload,\n })\n : undefined\n\n const slugs = await slugsAcrossLocales({\n id: doc.id,\n collection: route.type === 'entry' ? route.collection : collection,\n config,\n payload,\n })\n\n // Page 2 of a listing is its own URL, not a variant of page 1.\n const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined\n\n return buildMetadata({\n ...base,\n imageUrl: resolveOgImage(doc),\n meta: doc.meta,\n pageTitle: doc.title,\n prefix,\n query,\n slugs,\n })\n }\n}\n"],"names":["resolveRoute","buildMetadata","readSiteMetaConfig","slugsAcrossLocales","resolveOgImage","doc","image","meta","url","createPageMetadata","args","baseUrl","collection","config","content","settingsSlug","siteNameField","pageMetadata","slug","locale","page","payload","site","base","homeSlug","order","separator","siteName","route","pagesSlug","segments","slugs","prefix","type","id","archive","undefined","query","imageUrl","pageTitle","title"],"mappings":"AAOA,SAASA,YAAY,QAAQ,sBAAqB;AAClD,SAASC,aAAa,QAAQ,qBAAoB;AAClD,SAASC,kBAAkB,QAAQ,0BAAyB;AAC5D,SAASC,kBAAkB,QAAQ,0BAAyB;AAuC5D,8DAA8D,GAC9D,SAASC,eAAeC,GAAa;IACnC,MAAMC,QAASD,IAAIE,IAAI,EAA6CD;IACpE,IAAIA,SAAS,OAAOA,UAAU,YAAY,SAASA,OAAO;QACxD,OAAO,AAACA,MAA0BE,GAAG,IAAI;IAC3C;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,mBAAmBC,IAA4B;IAC7D,MAAM,EACJC,OAAO,EACPC,aAAa,OAAO,EACpBC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,gBAAgB,UAAU,EAC3B,GAAGN;IAEJ,OAAO,eAAeO,aAAa,EACjCC,IAAI,EACJC,MAAM,EACNC,IAAI,EACJC,OAAO,EACa;QACpB,MAAMC,OAAO,MAAMpB,mBAAmB;YAAEiB;YAAQE;YAASN;YAAcC;QAAc;QAErF,MAAMO,OAAO;YACXZ;YACAE;YACAW,UAAUF,KAAKE,QAAQ;YACvBL;YACAM,OAAOH,KAAKG,KAAK;YACjBC,WAAWJ,KAAKI,SAAS;YACzBC,UAAUL,KAAKK,QAAQ;QACzB;QAEA,MAAMC,QAAQ,MAAM5B,aAAa;YAC/Bc;YACAK;YACAC;YACAS,WAAWjB;YACXS;YACAS,UAAUZ;YACVH;QACF;QAEA,uEAAuE;QACvE,4DAA4D;QAC5D,IAAI,CAACa,OAAO;YACV,OAAO3B,cAAc;gBAAE,GAAGsB,IAAI;gBAAEhB,MAAM;gBAAMwB,OAAO,CAAC;YAAE;QACxD;QAEA,MAAM1B,MAAMuB,MAAMvB,GAAG;QAErB,2EAA2E;QAC3E,sEAAsE;QACtE,MAAM2B,SACJJ,MAAMK,IAAI,KAAK,UACX,MAAM9B,mBAAmB;YACvB+B,IAAI,AAACN,MAAMO,OAAO,CAAcD,EAAE;YAClCtB;YACAC;YACAQ;QACF,KACAe;QAEN,MAAML,QAAQ,MAAM5B,mBAAmB;YACrC+B,IAAI7B,IAAI6B,EAAE;YACVtB,YAAYgB,MAAMK,IAAI,KAAK,UAAUL,MAAMhB,UAAU,GAAGA;YACxDC;YACAQ;QACF;QAEA,+DAA+D;QAC/D,MAAMgB,QAAQT,MAAMK,IAAI,KAAK,aAAaL,MAAMR,IAAI,GAAG,IAAI,CAAC,MAAM,EAAEQ,MAAMR,IAAI,EAAE,GAAGgB;QAEnF,OAAOnC,cAAc;YACnB,GAAGsB,IAAI;YACPe,UAAUlC,eAAeC;YACzBE,MAAMF,IAAIE,IAAI;YACdgC,WAAWlC,IAAImC,KAAK;YACpBR;YACAK;YACAN;QACF;IACF;AACF"} \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index 858f3d7..52545d2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` — -plugin go nie generuje, bo zależy od projektu. \ No newline at end of file +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:///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. \ No newline at end of file diff --git a/src/modules/seo/buildMetadata.ts b/src/modules/seo/buildMetadata.ts index abc64c7..f1e18bc 100644 --- a/src/modules/seo/buildMetadata.ts +++ b/src/modules/seo/buildMetadata.ts @@ -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 } } : {}), } } diff --git a/src/modules/seo/createPageMetadata.ts b/src/modules/seo/createPageMetadata.ts index bf6db3e..7f43138 100644 --- a/src/modules/seo/createPageMetadata.ts +++ b/src/modules/seo/createPageMetadata.ts @@ -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,