Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db2ac80a1e | ||
|
|
99490bfc4d | ||
|
|
fce17654f8 | ||
|
|
419207ac12 | ||
|
|
e30ac71044 | ||
|
|
781e348ded | ||
|
|
e16a18e488 | ||
|
|
7cd3cbaae5 |
+22
-22
@@ -1,21 +1,21 @@
|
|||||||
import type { I18nConfig } from './types.js';
|
import type { I18nConfig } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||||
*/
|
*/
|
||||||
type MiddlewareRequest = {
|
type MiddlewareRequest = {
|
||||||
|
nextUrl: {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
clone: () => URL;
|
||||||
|
};
|
||||||
cookies: {
|
cookies: {
|
||||||
get: (name: string) => {
|
get: (name: string) => {
|
||||||
value: string;
|
value: string;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
};
|
};
|
||||||
headers: {
|
headers: {
|
||||||
get: (name: string) => null | string;
|
get: (name: string) => string | null;
|
||||||
};
|
|
||||||
nextUrl: {
|
|
||||||
clone: () => URL;
|
|
||||||
pathname: string;
|
|
||||||
search: string;
|
|
||||||
};
|
};
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
|
|||||||
* `next` means let the request pass through untouched.
|
* `next` means let the request pass through untouched.
|
||||||
*/
|
*/
|
||||||
export type LocaleMiddlewareResult = {
|
export type LocaleMiddlewareResult = {
|
||||||
cookie?: {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
location: string;
|
|
||||||
type: 'redirect';
|
|
||||||
} | {
|
|
||||||
cookie?: {
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
type: 'next';
|
type: 'next';
|
||||||
|
cookie?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
} | {
|
||||||
|
type: 'redirect';
|
||||||
|
location: string;
|
||||||
|
cookie?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
type CreateLocaleMiddlewareArgs = {
|
type CreateLocaleMiddlewareArgs = {
|
||||||
config: I18nConfig;
|
config: I18nConfig;
|
||||||
|
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||||
|
cookieName?: string;
|
||||||
/**
|
/**
|
||||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||||
* always detected (routing works regardless), but the choice is only written
|
* always detected (routing works regardless), but the choice is only written
|
||||||
@@ -47,11 +49,9 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||||
* necessary), which restores the pre-consent behaviour.
|
* necessary), which restores the pre-consent behaviour.
|
||||||
*/
|
*/
|
||||||
consentCategory?: 'functional' | 'necessary';
|
consentCategory?: 'necessary' | 'functional';
|
||||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||||
consentCookieName?: string;
|
consentCookieName?: string;
|
||||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
|
||||||
cookieName?: string;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Builds locale-routing logic for Next.js middleware.
|
* Builds locale-routing logic for Next.js middleware.
|
||||||
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* return res
|
* return res
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export declare function createLocaleMiddleware({ config, consentCategory, consentCookieName, cookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
export declare function createLocaleMiddleware({ config, cookieName, consentCategory, consentCookieName, }: CreateLocaleMiddlewareArgs): (request: MiddlewareRequest) => LocaleMiddlewareResult;
|
||||||
/**
|
/**
|
||||||
* Default Next.js middleware matcher: run on everything except API routes, the
|
* Default Next.js middleware matcher: run on everything except API routes, the
|
||||||
* admin panel, Next internals, and files with an extension (static assets).
|
* admin panel, Next internals, and files with an extension (static assets).
|
||||||
|
|||||||
+15
-7
@@ -1,5 +1,5 @@
|
|||||||
|
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js';
|
||||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
|
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
|
||||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
|
|
||||||
/**
|
/**
|
||||||
* First path segment of a URL pathname, or '' for root.
|
* First path segment of a URL pathname, or '' for root.
|
||||||
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
||||||
@@ -32,18 +32,26 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
|||||||
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
|
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
|
||||||
* return res
|
* return res
|
||||||
* }
|
* }
|
||||||
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
|
*/ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE }) {
|
||||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||||
// 'functional' (default) requires the visitor to have consented.
|
// 'functional' (default) requires the visitor to have consented.
|
||||||
function mayPersistLocale(request) {
|
function mayPersistLocale(request) {
|
||||||
if (consentCategory === 'necessary') {
|
if (consentCategory === 'necessary') return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
||||||
return consent?.[consentCategory] === true;
|
return consent?.[consentCategory] === true;
|
||||||
}
|
}
|
||||||
return function localeMiddleware(request) {
|
return function localeMiddleware(request) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
// Single-locale sites have no /pl, /en prefix and no negotiation — one
|
||||||
|
// language, no redirect. The middleware becomes a pass-through: paths stay
|
||||||
|
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
|
||||||
|
// single-locale usually don't even mount this middleware, but guarding here
|
||||||
|
// makes it safe if they do.)
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return {
|
||||||
|
type: 'next'
|
||||||
|
};
|
||||||
|
}
|
||||||
// Already locale-prefixed (e.g. the visitor switched language by
|
// Already locale-prefixed (e.g. the visitor switched language by
|
||||||
// navigating to /en). Routing is fine — but if the URL's locale differs
|
// navigating to /en). Routing is fine — but if the URL's locale differs
|
||||||
// from the stored cookie, the visitor is *choosing* a language, and we
|
// from the stored cookie, the visitor is *choosing* a language, and we
|
||||||
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
|||||||
}
|
}
|
||||||
// Resolve the locale to use
|
// Resolve the locale to use
|
||||||
const locale = negotiateLocale({
|
const locale = negotiateLocale({
|
||||||
|
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||||
acceptLanguage: request.headers.get('accept-language'),
|
acceptLanguage: request.headers.get('accept-language'),
|
||||||
config,
|
config
|
||||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null
|
|
||||||
});
|
});
|
||||||
// Redirect to the locale-prefixed path, preserving the rest
|
// Redirect to the locale-prefixed path, preserving the rest
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-3
@@ -28,6 +28,12 @@ import { isValidLocale } from './helpers.js';
|
|||||||
if (!slug) {
|
if (!slug) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
// Single-locale sites have no /pl, /en prefix — the language segment is
|
||||||
|
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
|
||||||
|
// one configured locale means one language, so no prefix is needed. The
|
||||||
|
// project's folder structure matches (app/[[...slug]] without [locale]).
|
||||||
|
const singleLocale = config.locales.length === 1;
|
||||||
|
const localeSegment = singleLocale ? '' : `/${locale}`;
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
const segment = prefix[locale];
|
const segment = prefix[locale];
|
||||||
// No archive slug in this locale means the entry is unreachable there —
|
// No archive slug in this locale means the entry is unreachable there —
|
||||||
@@ -36,12 +42,13 @@ import { isValidLocale } from './helpers.js';
|
|||||||
if (!segment) {
|
if (!segment) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `/${locale}/${segment}/${slug}`;
|
return `${localeSegment}/${segment}/${slug}`;
|
||||||
}
|
}
|
||||||
if (slug === homeSlug) {
|
if (slug === homeSlug) {
|
||||||
return `/${locale}`;
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
|
return localeSegment || '/';
|
||||||
}
|
}
|
||||||
return `/${locale}/${slug}`;
|
return `${localeSegment}/${slug}`;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Resolves the equivalent path for the same document in a different locale —
|
* Resolves the equivalent path for the same document in a different locale —
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
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
Vendored
+6
-14
@@ -1,8 +1,10 @@
|
|||||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
|
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js';
|
||||||
type BuildHreflangArgs = {
|
type BuildHreflangArgs = {
|
||||||
|
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
||||||
|
slugs: LocalizedSlugs;
|
||||||
|
config: I18nConfig;
|
||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
config: I18nConfig;
|
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string;
|
homeSlug?: string;
|
||||||
/**
|
/**
|
||||||
@@ -11,32 +13,22 @@ type BuildHreflangArgs = {
|
|||||||
* are omitted — an entry with no archive in that language has no URL there.
|
* are omitted — an entry with no archive in that language has no URL there.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs;
|
prefix?: LocalizedSlugs;
|
||||||
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
|
||||||
slugs: LocalizedSlugs;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/
|
*/
|
||||||
export declare function buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs, }: BuildHreflangArgs): Record<string, string>;
|
export declare function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix, }: BuildHreflangArgs): Record<string, string>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Vendored
+15
-16
@@ -1,37 +1,36 @@
|
|||||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js';
|
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js';
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
*/ export function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug = 'home', prefix }) {
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||||
const alternates = {};
|
const alternates = {};
|
||||||
|
// Single-locale sites have no language alternatives — hreflang describes
|
||||||
|
// relationships BETWEEN language versions, and there's only one. Emitting
|
||||||
|
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
|
||||||
|
// its canonical, but no alternate-language links.
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return alternates;
|
||||||
|
}
|
||||||
for (const locale of getLocaleCodes(config)){
|
for (const locale of getLocaleCodes(config)){
|
||||||
const path = buildLocalizedPath({
|
const path = buildLocalizedPath({
|
||||||
|
slugs,
|
||||||
|
locale,
|
||||||
config,
|
config,
|
||||||
homeSlug,
|
homeSlug,
|
||||||
locale,
|
prefix
|
||||||
prefix,
|
|
||||||
slugs
|
|
||||||
});
|
});
|
||||||
if (path) {
|
if (path) {
|
||||||
alternates[locale] = `${origin}${path}`;
|
alternates[locale] = `${origin}${path}`;
|
||||||
@@ -42,7 +41,7 @@ import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/in
|
|||||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||||
// at the default locale (the primary market) so search shows that version by
|
// at the default locale (the primary market) so search shows that version by
|
||||||
// default instead of guessing. Only set when the default locale has a URL.
|
// default instead of guessing. Only set when the default locale has a URL.
|
||||||
const defaultLocalePath = alternates[getDefaultLocale(config)];
|
const defaultLocalePath = alternates[config.defaultLocale];
|
||||||
if (defaultLocalePath) {
|
if (defaultLocalePath) {
|
||||||
alternates['x-default'] = defaultLocalePath;
|
alternates['x-default'] = defaultLocalePath;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n config: I18nConfig\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * Also emits `x-default` pointing at the default locale — the version Google\n * serves when the user's language/region matches no hreflang, and the fallback\n * when the root ('/') redirect is ambiguous (Googlebot with no/foreign\n * Accept-Language).\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → {\n * // pl: 'https://example.com/pl/o-nas',\n * // en: 'https://example.com/en/about',\n * // 'x-default': 'https://example.com/pl/o-nas',\n * // }\n */\nexport function buildHreflangAlternates({\n baseUrl,\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[getDefaultLocale(config)]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getDefaultLocale","getLocaleCodes","buildHreflangAlternates","baseUrl","config","homeSlug","prefix","slugs","origin","replace","alternates","locale","path","defaultLocalePath"],"mappings":"AAEA,SAASA,kBAAkB,EAAEC,gBAAgB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBvF;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,wBAAwB,EACtCC,OAAO,EACPC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,KAAK,EACa;IAClB,MAAMC,SAASL,SAASM,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,KAAK,MAAMC,UAAUV,eAAeG,QAAS;QAC3C,MAAMQ,OAAOb,mBAAmB;YAAEK;YAAQC;YAAUM;YAAQL;YAAQC;QAAM;QAC1E,IAAIK,MAAM;YACRF,UAAU,CAACC,OAAO,GAAG,GAAGH,SAASI,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBH,UAAU,CAACV,iBAAiBI,QAAQ;IAC9D,IAAIS,mBAAmB;QACrBH,UAAU,CAAC,YAAY,GAAGG;IAC5B;IAEA,OAAOH;AACT"}
|
{"version":3,"sources":["../../../src/modules/seo/hreflang.ts"],"sourcesContent":["import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype BuildHreflangArgs = {\n /** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */\n slugs: LocalizedSlugs\n config: I18nConfig\n /** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */\n baseUrl?: string\n /** Home slug that collapses to the locale root. Defaults to 'home'. */\n homeSlug?: string\n /**\n * Localized segment the document lives under (an archive page's slugs),\n * e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix\n * are omitted — an entry with no archive in that language has no URL there.\n */\n prefix?: LocalizedSlugs\n}\n\n/**\n * Builds a map of locale → URL for hreflang alternate links, suitable for\n * Next.js Metadata `alternates.languages`.\n *\n * Bridges SEO and i18n: for each configured locale that the document has a\n * slug in, it produces the locale-aware path (via buildLocalizedPath),\n * optionally prefixed with an absolute origin.\n *\n * @example\n * buildHreflangAlternates({\n * slugs: { pl: 'o-nas', en: 'about' },\n * config,\n * baseUrl: 'https://example.com',\n * })\n * // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }\n */\nexport function buildHreflangAlternates({\n slugs,\n config,\n baseUrl,\n homeSlug = 'home',\n prefix,\n}: BuildHreflangArgs): Record<string, string> {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record<string, string> = {}\n\n // Single-locale sites have no language alternatives — hreflang describes\n // relationships BETWEEN language versions, and there's only one. Emitting\n // hreflang (or x-default) here would be wrong, so return empty: the page keeps\n // its canonical, but no alternate-language links.\n if (config.locales.length === 1) {\n return alternates\n }\n\n for (const locale of getLocaleCodes(config)) {\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (path) {\n alternates[locale] = `${origin}${path}`\n }\n }\n\n // x-default: the version Google serves when the user's language/region doesn't\n // match any hreflang — and, crucially here, the fallback when the root ('/')\n // redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it\n // at the default locale (the primary market) so search shows that version by\n // default instead of guessing. Only set when the default locale has a URL.\n const defaultLocalePath = alternates[config.defaultLocale]\n if (defaultLocalePath) {\n alternates['x-default'] = defaultLocalePath\n }\n\n return alternates\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","buildHreflangAlternates","slugs","config","baseUrl","homeSlug","prefix","origin","replace","alternates","locales","length","locale","path","defaultLocalePath","defaultLocale"],"mappings":"AACA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,wBAAwB,EACtCC,KAAK,EACLC,MAAM,EACNC,OAAO,EACPC,WAAW,MAAM,EACjBC,MAAM,EACY;IAClB,MAAMC,SAASH,SAASI,QAAQ,OAAO,OAAO;IAC9C,MAAMC,aAAqC,CAAC;IAE5C,yEAAyE;IACzE,0EAA0E;IAC1E,+EAA+E;IAC/E,kDAAkD;IAClD,IAAIN,OAAOO,OAAO,CAACC,MAAM,KAAK,GAAG;QAC/B,OAAOF;IACT;IAEA,KAAK,MAAMG,UAAUZ,eAAeG,QAAS;QAC3C,MAAMU,OAAOd,mBAAmB;YAAEG;YAAOU;YAAQT;YAAQE;YAAUC;QAAO;QAC1E,IAAIO,MAAM;YACRJ,UAAU,CAACG,OAAO,GAAG,GAAGL,SAASM,MAAM;QACzC;IACF;IAEA,+EAA+E;IAC/E,6EAA6E;IAC7E,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAMC,oBAAoBL,UAAU,CAACN,OAAOY,aAAa,CAAC;IAC1D,IAAID,mBAAmB;QACrBL,UAAU,CAAC,YAAY,GAAGK;IAC5B;IAEA,OAAOL;AACT"}
|
||||||
@@ -10,6 +10,30 @@ registry (prop), obsługuje zagnieżdżanie i rozszerzenia per-blok. Bloki
|
|||||||
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
Brak opcji w payload.config — bloki definiujesz w swoich kolekcjach
|
||||||
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
(pole typu `blocks`). Plugin dostarcza tylko silnik renderujący.
|
||||||
|
|
||||||
|
## ⚠️ NIE pisz własnego renderera bloków (switch)
|
||||||
|
|
||||||
|
**Renderer bloków to `RenderBlocks` z pluginu — NIGDY własny `switch`/`if`.**
|
||||||
|
Częsty błąd: projekt pisze własny `BlockRenderer` z `switch (block.blockType)`
|
||||||
|
i 20 case'ami. To łamie A0 — plugin ma silnik, projekt dostarcza tylko MAPĘ
|
||||||
|
komponentów.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ŹLE — własny switch w projekcie (gadatliwy, bez enhanceProps, rośnie liniowo)
|
||||||
|
switch (block.blockType) {
|
||||||
|
case 'hero': return <Hero {...block} />
|
||||||
|
case 'faq': return <FAQ {...block} />
|
||||||
|
// ...20 case'ów
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOBRZE — mapa + RenderBlocks (silnik z pluginu)
|
||||||
|
const registry = { hero: Hero, faq: FAQ, /* ... */ }
|
||||||
|
<RenderBlocks blocks={page.layout} components={registry} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Dlaczego RenderBlocks, nie switch: enhanceProps (anchory nav, itp.), guardy,
|
||||||
|
obsługa zagnieżdżeń, spójność między projektami. Switch tego nie ma i rośnie
|
||||||
|
z każdym blokiem. Mapa jest płaska i deklaratywna.
|
||||||
|
|
||||||
## Front — RenderBlocks
|
## Front — RenderBlocks
|
||||||
|
|
||||||
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
|
Import z `@intecion/ipal-kit/rsc` (to komponent serwerowy):
|
||||||
|
|||||||
@@ -129,6 +129,22 @@ const nextConfig: NextConfig = {
|
|||||||
Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`.
|
Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`.
|
||||||
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
|
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
|
||||||
|
|
||||||
|
## 3a2. ISR — cache stron (metadata w head + szybkość)
|
||||||
|
|
||||||
|
Dla stron contentowych (page.tsx) użyj ISR: `export const revalidate = 3600`.
|
||||||
|
Cache'uje całą stronę z gotowym `<head>` → metadata zawsze w head (nie body,
|
||||||
|
brak race condition streamingu), TTFB ~20ms, brak 503 (cold start).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||||
|
export const revalidate = 3600 // 1h; albo krócej, albo on-demand
|
||||||
|
```
|
||||||
|
|
||||||
|
UWAGA: ISR i `force-dynamic` się WYKLUCZAJĄ. Strony → ISR (revalidate).
|
||||||
|
sitemap/robots → force-dynamic (bo generują przy żądaniu). Nie mieszaj na jednej
|
||||||
|
trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand
|
||||||
|
revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md.
|
||||||
|
|
||||||
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
||||||
|
|
||||||
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
||||||
|
|||||||
@@ -359,7 +359,11 @@ export default async function LocaleLayout({ children, params }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
|
{/* BEZ jawnego <head>! Sztywny <head> wypycha metadata do <body>
|
||||||
|
(canonical/title poza head → crawlery ich nie widzą). Next zarządza
|
||||||
|
<head> sam; MediaPreconnect w body, React 19 hoistuje link do head. */}
|
||||||
<body>
|
<body>
|
||||||
|
<MediaPreconnect /> {/* preconnect CDN, jeśli R2 */}
|
||||||
<ConsentProvider texts={texts}>
|
<ConsentProvider texts={texts}>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<CookieBanner />
|
<CookieBanner />
|
||||||
@@ -392,6 +396,11 @@ const pageMetadata = createPageMetadata({
|
|||||||
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ISR — cache strony z gotowym <head>. Eliminuje race condition streamingu
|
||||||
|
// metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503.
|
||||||
|
// Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu).
|
||||||
|
export const revalidate = 3600
|
||||||
|
|
||||||
export async function generateMetadata({ params }): Promise<Metadata> {
|
export async function generateMetadata({ params }): Promise<Metadata> {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||||
@@ -408,6 +417,8 @@ export default async function Page({ params, searchParams }) {
|
|||||||
|
|
||||||
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
||||||
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
||||||
|
- **ISR (`revalidate`)** → metadata zawsze w `<head>` (nie body), szybki TTFB.
|
||||||
|
KRYTYCZNE dla SEO — patrz seo.md (metadata w head).
|
||||||
- `depth: 2` → relacje w blokach (form) się populują
|
- `depth: 2` → relacje w blokach (form) się populują
|
||||||
|
|
||||||
## 11. Metadata / SEO (szczegóły)
|
## 11. Metadata / SEO (szczegóły)
|
||||||
@@ -486,10 +497,15 @@ przypisanie strony-archiwum w System Pages.
|
|||||||
```ts
|
```ts
|
||||||
// app/sitemap.ts
|
// app/sitemap.ts
|
||||||
export { sitemap as default } from '@/lib/content'
|
export { sitemap as default } from '@/lib/content'
|
||||||
|
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
|
||||||
// app/robots.ts
|
// app/robots.ts
|
||||||
export { robots as default } from '@/lib/content'
|
export { robots as default } from '@/lib/content'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`force-dynamic` w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/
|
||||||
|
Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą,
|
||||||
|
której kontener budujący nie widzi → build pada. Szczegóły: [deployment.md](./deployment.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kiedy coś nie działa
|
## Kiedy coś nie działa
|
||||||
|
|||||||
+95
-1
@@ -154,4 +154,98 @@ Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą)
|
|||||||
|
|
||||||
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
||||||
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||||
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
||||||
|
|
||||||
|
## Strona jednojęzyczna (bez prefiksu /pl)
|
||||||
|
|
||||||
|
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: `/o-nas`, nie
|
||||||
|
`/pl/o-nas`. Plugin wykrywa to automatycznie — **jeden locale w config = tryb
|
||||||
|
jednojęzyczny**. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
|
||||||
|
się same:
|
||||||
|
|
||||||
|
- **buildLocalizedPath** → `/o-nas` (bez `/pl`), home → `/`
|
||||||
|
- **buildHreflangAlternates** → pusto (jeden język = brak alternatyw językowych)
|
||||||
|
- **localeMiddleware** → pass-through (brak przekierowania `/` → `/pl`, brak negocjacji)
|
||||||
|
- **canonical** → `https://klient.pl/o-nas` (bez prefiksu)
|
||||||
|
|
||||||
|
### Config — jeden locale
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// i18n.config.ts
|
||||||
|
export const i18nConfig = {
|
||||||
|
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
|
||||||
|
defaultLocale: 'pl',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Struktura katalogów — BEZ [locale]
|
||||||
|
|
||||||
|
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu `[locale]`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# JEDNOJĘZYCZNY (bez [locale])
|
||||||
|
app/(frontend)/
|
||||||
|
layout.tsx # locale stałe z config, nie z params
|
||||||
|
not-found.tsx
|
||||||
|
[[...slug]]/page.tsx # /o-nas, /kontakt
|
||||||
|
|
||||||
|
# WIELOJĘZYCZNY (z [locale]) — dla porównania
|
||||||
|
app/(frontend)/[locale]/
|
||||||
|
layout.tsx # locale z params
|
||||||
|
[[...slug]]/page.tsx # /pl/o-nas, /en/about
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout jednojęzyczny — locale z config
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/layout.tsx (bez [locale])
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe, nie z params
|
||||||
|
const settings = await getSettings(locale)
|
||||||
|
// ...reszta jak zwykle, ale locale jest stałe
|
||||||
|
return <html lang={locale}>...</html>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strony jednojęzyczne — locale z config
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
|
||||||
|
import { i18nConfig } from '@/i18n.config'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { slug } = await params // TYLKO slug, nie locale
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe
|
||||||
|
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({ params }) {
|
||||||
|
const { slug } = await params
|
||||||
|
const locale = i18nConfig.defaultLocale // stałe
|
||||||
|
const route = await resolveRoute(locale, slug ?? [], pageNum)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
|
||||||
|
zamiast z URL. Cała różnica to: brak `[locale]` w strukturze, locale z config.
|
||||||
|
|
||||||
|
### Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
|
||||||
|
|
||||||
|
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz
|
||||||
|
go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez
|
||||||
|
przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
|
||||||
|
|
||||||
|
### Przejście jedno- → wielojęzyczny (later)
|
||||||
|
|
||||||
|
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
|
||||||
|
- dodaj locale do config
|
||||||
|
- przenieś strukturę do `[locale]/`
|
||||||
|
- layout/strony czytają locale z params
|
||||||
|
- wróci prefiks `/pl`, `/en` + hreflang
|
||||||
|
|
||||||
|
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
|
||||||
|
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
|
||||||
|
`/pl` jest, ale dodanie języka to tylko config, nie przebudowa struktury.
|
||||||
+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.
|
||||||
+99
-25
@@ -53,6 +53,31 @@ Per strona (tab SEO):
|
|||||||
|
|
||||||
Domyślnie: `Tytuł | Nazwa witryny`.
|
Domyślnie: `Tytuł | Nazwa witryny`.
|
||||||
|
|
||||||
|
### Skąd bierze się „Tytuł" (priorytet źródła)
|
||||||
|
|
||||||
|
Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:
|
||||||
|
|
||||||
|
1. **titleOverride** — jeśli wypełniony, jest całym tytułem (bez składania).
|
||||||
|
2. **meta.title** — tytuł SEO wpisany w tab SEO.
|
||||||
|
3. **page.title** — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.
|
||||||
|
|
||||||
|
Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:
|
||||||
|
|
||||||
|
- **buildAutoFillMetaHook** (przy ZAPISIE) — wypełnia puste `meta.title` z pola
|
||||||
|
dokumentu (`title`). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
|
||||||
|
- **pageTitle w buildMetadata** (przy RENDEROWANIU) — jeśli meta.title mimo to
|
||||||
|
puste (np. auto-fill niewpięty), używa `page.title`. Druga linia obrony.
|
||||||
|
|
||||||
|
Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie
|
||||||
|
pusty tytuł ani sam siteName.
|
||||||
|
|
||||||
|
> **Uwaga — „Strona Główna” w tytule:** jeśli strona główna ma nazwę dokumentu
|
||||||
|
> „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie
|
||||||
|
> „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz **titleOverride**
|
||||||
|
> dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na
|
||||||
|
> coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest
|
||||||
|
> sama treść nazwy, nie brak tytułu.
|
||||||
|
|
||||||
## Front — createPageMetadata
|
## Front — createPageMetadata
|
||||||
|
|
||||||
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
|
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
|
||||||
@@ -457,54 +482,103 @@ typu, itp.):
|
|||||||
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
- jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast
|
||||||
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)
|
||||||
|
|
||||||
## KRYTYCZNE: metadata w <head> dla Google (htmlLimitedBots)
|
## KRYTYCZNE: metadata w <head> dla Google
|
||||||
|
|
||||||
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
|
**Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu.** Dla dynamicznie
|
||||||
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
||||||
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
|
i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon
|
||||||
lądują w body w surowym HTML. Crawlery, które nie wykonują JS (Screaming Frog,
|
lądują w body w surowym HTML. Crawlery bez JS (Screaming Frog, część botów) widzą
|
||||||
część botów), widzą je poza head → ignorują → utrata SEO.
|
je poza head → ignorują → utrata SEO.
|
||||||
|
|
||||||
Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka
|
**To wyścig czasowy (race condition):** gdy baza odpowie szybko, metadata zdąży
|
||||||
(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić
|
do head; gdy wolniej (albo crawler odpytuje wiele stron naraz, obciążając bazę),
|
||||||
metadata do head dla crawlerów.
|
Next zamyka `</head>` i dokleja metadata w `<body>`. Dlatego pojedynczy `curl`
|
||||||
|
może pokazać head OK, a test 10 zapytań — 5/10 w body. **Testuj wielokrotnie.**
|
||||||
|
|
||||||
### Rozwiązanie — htmlLimitedBots w next.config
|
### Rozwiązanie GŁÓWNE — ISR (revalidate) w stronach
|
||||||
|
|
||||||
|
Najskuteczniejsze: **cache całej strony (ISR)**. Strona generowana raz z gotowym
|
||||||
|
`<head>`, kolejne żądania serwują cache — zero zapytań do bazy przy renderowaniu,
|
||||||
|
więc race condition ZNIKA (metadata zawsze w head). Bonus: TTFB spada z ~500ms do
|
||||||
|
~20ms, znikają sporadyczne 503 (cold start).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/(frontend)/[locale]/[[...slug]]/page.tsx
|
||||||
|
export const revalidate = 3600 // cache 1h, regeneracja w tle
|
||||||
|
```
|
||||||
|
|
||||||
|
> **UWAGA — ISR a treść z panelu:** strona cache'owana `revalidate` sekund NIE
|
||||||
|
> pokaże zmian redaktora od razu (czeka do rewalidacji). Dla treści zmienianej
|
||||||
|
> rzadko OK. Jeśli redaktor ma widzieć zmiany natychmiast — użyj **on-demand
|
||||||
|
> revalidation**: hook `afterChange` w kolekcji → `revalidatePath(path)` (patrz
|
||||||
|
> HOOKS.md). Albo krótszy `revalidate` (np. 300 = 5 min). NIE łącz ISR z
|
||||||
|
> `force-dynamic` — wykluczają się.
|
||||||
|
|
||||||
|
### Rozwiązanie DRUGIE — usuń jawny <head> z layoutu
|
||||||
|
|
||||||
|
Sztywny `<head>` w layoucie App Router wymusza przedwczesne zamknięcie head —
|
||||||
|
zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj `<head>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ŹLE — jawny <head> zamyka head za wcześnie
|
||||||
|
<html lang={locale}>
|
||||||
|
<head><MediaPreconnect /></head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>
|
||||||
|
<MediaPreconnect />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
React 19 sam przenosi `<link rel="preconnect">` do head. Jawny `<head>` jest
|
||||||
|
zbędny i szkodliwy (wymusza wczesne zamknięcie).
|
||||||
|
|
||||||
|
### Rozwiązanie TRZECIE — htmlLimitedBots (uzupełnienie)
|
||||||
|
|
||||||
|
Dodatkowo można wymusić blocking metadata dla crawlerów (przydatne, gdy strona
|
||||||
|
z jakiegoś powodu nie może być ISR):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// next.config.ts
|
// next.config.ts
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
|
|
||||||
// dla crawlerów SEO — zamiast streamingu do <body>.
|
|
||||||
htmlLimitedBots:
|
htmlLimitedBots:
|
||||||
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||||
// ...reszta
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`htmlLimitedBots` mówi Next: dla tych User-Agentów wyłącz streaming, wstaw
|
`htmlLimitedBots` wyłącza streaming dla tych User-Agentów (metadata w head). ALE
|
||||||
metadata do `<head>` w surowym HTML (blocking). Użytkownicy dalej dostają
|
to słabsze niż ISR — bo strona dalej renderuje dynamicznie (zapytanie do bazy →
|
||||||
streaming (szybkie ładowanie); crawlery dostają poprawny head.
|
wolniej, ryzyko race). **ISR eliminuje przyczynę, htmlLimitedBots łagodzi objaw.**
|
||||||
|
Najlepiej: ISR + brak jawnego head. htmlLimitedBots jako dodatkowa warstwa.
|
||||||
|
|
||||||
### Objawy (że masz ten problem)
|
### Objawy (że masz ten problem)
|
||||||
|
|
||||||
- Screaming Frog: „canonical/hreflang/title outside <head>"
|
- Screaming Frog: „canonical/hreflang/title outside <head>" (na wielu podstronach)
|
||||||
- Search Console: „brak canonical", favicon nie pokazuje się (glob)
|
- Search Console: „brak canonical", favicon glob
|
||||||
- W surowym HTML canonical/title są PO `</head>`, na końcu body, ze skryptem
|
- Surowy HTML: canonical/title PO `</head>`, na końcu body, ze skryptem appendChild
|
||||||
`document.querySelectorAll('body link[rel=icon]')...appendChild`
|
- Test 10 zapytań: część w head, część w body (race condition)
|
||||||
|
|
||||||
### Weryfikacja
|
### Weryfikacja — TESTUJ WIELOKROTNIE (nie pojedynczo)
|
||||||
|
|
||||||
|
Pojedynczy `curl` może trafić w „szczęśliwy" timing. Testuj 10 razy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# jako Googlebot — metadata MUSI być w <head>
|
# 10 zapytań jako Googlebot — ile ma canonical w <head>
|
||||||
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical
|
for i in $(seq 1 10); do
|
||||||
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS)
|
curl -s -A "Googlebot" https://twojadomena.pl/pl/strona | \
|
||||||
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona
|
python3 -c "import sys; h=sys.stdin.read(); e=h.find('</head>'); c=h.find('rel=\"canonical\"'); print('head' if 0<c<e else 'BODY')"
|
||||||
|
done
|
||||||
|
# Cel: 10x 'head'. Jeśli część 'BODY' → race condition, dodaj ISR.
|
||||||
```
|
```
|
||||||
|
|
||||||
Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical
|
Testuj też PODSTRONY (nie tylko główną) — race częściej dotyka podstron.
|
||||||
(„User-declared canonical: None"), hreflang i title. Jedna linia w config
|
DevTools (F12) NIE nadaje się do testu — hoistuje tagi do head automatycznie,
|
||||||
naprawia wszystko naraz.
|
pokazując fałszywie poprawny head. Używaj `curl` / Ctrl+U (surowe źródło).
|
||||||
|
|
||||||
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
## SEO wielojęzyczne — hreflang, x-default, redirect roota
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,27 @@ pnpm dev
|
|||||||
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Root subdomeny media zwraca 404 (to normalne)
|
||||||
|
|
||||||
|
`media.klient.pl/plik.jpg` → R2 zwraca plik. Ale `media.klient.pl/` (sam root,
|
||||||
|
bez pliku) → **404**, bo R2 nie ma obiektu pod rootem. To NORMALNE zachowanie
|
||||||
|
R2, nie błąd.
|
||||||
|
|
||||||
|
Audyty SEO (Screaming Frog) czasem zgłaszają to 404 — bo crawler widzi URL-e
|
||||||
|
plików (`media.../logo.svg`) i próbuje roota. Ale:
|
||||||
|
- **NIE linkuj do samego roota** `media.klient.pl/` — tylko do plików. Kod nie
|
||||||
|
powinien nigdzie mieć `media.klient.pl/` bez nazwy pliku.
|
||||||
|
- Root media 404 **nie szkodzi SEO** głównej domeny (Google indeksuje klient.pl,
|
||||||
|
nie media.klient.pl). Nikt nie trafia na root media.
|
||||||
|
|
||||||
|
**Plugin tego nie naprawi** — subdomena media to serwis R2/Cloudflare, nie
|
||||||
|
aplikacja Next. Żądania do media.klient.pl nie docierają do Twojego kodu.
|
||||||
|
|
||||||
|
Jeśli chcesz „czysto" w Search Console (opcjonalne): Cloudflare → Rules →
|
||||||
|
Redirect Rules → gdy hostname = `media.klient.pl` i path = `/` → 301 na
|
||||||
|
`klient.pl`. Jednorazowo w panelu CF. Ale to kosmetyka — root media 404 jest
|
||||||
|
nieszkodliwe.
|
||||||
|
|
||||||
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
||||||
|
|
||||||
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.2.0",
|
"version": "1.2.4",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { I18nConfig } from './types.js'
|
import type { I18nConfig } from '../i18n/index.js'
|
||||||
|
import { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js'
|
||||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'
|
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'
|
||||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal request shape the middleware reads. Kept structural so the plugin
|
* Minimal request shape the middleware reads. Kept structural so the plugin
|
||||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||||
*/
|
*/
|
||||||
type MiddlewareRequest = {
|
type MiddlewareRequest = {
|
||||||
|
nextUrl: { pathname: string; search: string; clone: () => URL }
|
||||||
cookies: { get: (name: string) => { value: string } | undefined }
|
cookies: { get: (name: string) => { value: string } | undefined }
|
||||||
headers: { get: (name: string) => null | string }
|
headers: { get: (name: string) => string | null }
|
||||||
nextUrl: { clone: () => URL; pathname: string; search: string }
|
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +19,13 @@ type MiddlewareRequest = {
|
|||||||
* `next` means let the request pass through untouched.
|
* `next` means let the request pass through untouched.
|
||||||
*/
|
*/
|
||||||
export type LocaleMiddlewareResult =
|
export type LocaleMiddlewareResult =
|
||||||
| { cookie?: { name: string; value: string }; location: string; type: 'redirect' }
|
| { type: 'next'; cookie?: { name: string; value: string } }
|
||||||
| { cookie?: { name: string; value: string }; type: 'next' }
|
| { type: 'redirect'; location: string; cookie?: { name: string; value: string } }
|
||||||
|
|
||||||
type CreateLocaleMiddlewareArgs = {
|
type CreateLocaleMiddlewareArgs = {
|
||||||
config: I18nConfig
|
config: I18nConfig
|
||||||
|
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||||
|
cookieName?: string
|
||||||
/**
|
/**
|
||||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||||
* always detected (routing works regardless), but the choice is only written
|
* always detected (routing works regardless), but the choice is only written
|
||||||
@@ -32,11 +33,9 @@ type CreateLocaleMiddlewareArgs = {
|
|||||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||||
* necessary), which restores the pre-consent behaviour.
|
* necessary), which restores the pre-consent behaviour.
|
||||||
*/
|
*/
|
||||||
consentCategory?: 'functional' | 'necessary'
|
consentCategory?: 'necessary' | 'functional'
|
||||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||||
consentCookieName?: string
|
consentCookieName?: string
|
||||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
|
||||||
cookieName?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,14 +75,14 @@ function firstSegment(pathname: string): string {
|
|||||||
*/
|
*/
|
||||||
export function createLocaleMiddleware({
|
export function createLocaleMiddleware({
|
||||||
config,
|
config,
|
||||||
|
cookieName = LOCALE_COOKIE_NAME,
|
||||||
consentCategory = 'functional',
|
consentCategory = 'functional',
|
||||||
consentCookieName = CONSENT_COOKIE,
|
consentCookieName = CONSENT_COOKIE,
|
||||||
cookieName = LOCALE_COOKIE_NAME,
|
|
||||||
}: CreateLocaleMiddlewareArgs) {
|
}: CreateLocaleMiddlewareArgs) {
|
||||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||||
// 'functional' (default) requires the visitor to have consented.
|
// 'functional' (default) requires the visitor to have consented.
|
||||||
function mayPersistLocale(request: MiddlewareRequest): boolean {
|
function mayPersistLocale(request: MiddlewareRequest): boolean {
|
||||||
if (consentCategory === 'necessary') {return true}
|
if (consentCategory === 'necessary') return true
|
||||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
|
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
|
||||||
return consent?.[consentCategory] === true
|
return consent?.[consentCategory] === true
|
||||||
}
|
}
|
||||||
@@ -91,6 +90,15 @@ export function createLocaleMiddleware({
|
|||||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Single-locale sites have no /pl, /en prefix and no negotiation — one
|
||||||
|
// language, no redirect. The middleware becomes a pass-through: paths stay
|
||||||
|
// as-is (/o-nas), nothing to detect or persist. (Projects that are truly
|
||||||
|
// single-locale usually don't even mount this middleware, but guarding here
|
||||||
|
// makes it safe if they do.)
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return { type: 'next' }
|
||||||
|
}
|
||||||
|
|
||||||
// Already locale-prefixed (e.g. the visitor switched language by
|
// Already locale-prefixed (e.g. the visitor switched language by
|
||||||
// navigating to /en). Routing is fine — but if the URL's locale differs
|
// navigating to /en). Routing is fine — but if the URL's locale differs
|
||||||
// from the stored cookie, the visitor is *choosing* a language, and we
|
// from the stored cookie, the visitor is *choosing* a language, and we
|
||||||
@@ -108,9 +116,9 @@ export function createLocaleMiddleware({
|
|||||||
|
|
||||||
// Resolve the locale to use
|
// Resolve the locale to use
|
||||||
const locale = negotiateLocale({
|
const locale = negotiateLocale({
|
||||||
|
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||||
acceptLanguage: request.headers.get('accept-language'),
|
acceptLanguage: request.headers.get('accept-language'),
|
||||||
config,
|
config,
|
||||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to the locale-prefixed path, preserving the rest
|
// Redirect to the locale-prefixed path, preserving the rest
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export function buildLocalizedPath({
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-locale sites have no /pl, /en prefix — the language segment is
|
||||||
|
// dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:
|
||||||
|
// one configured locale means one language, so no prefix is needed. The
|
||||||
|
// project's folder structure matches (app/[[...slug]] without [locale]).
|
||||||
|
const singleLocale = config.locales.length === 1
|
||||||
|
const localeSegment = singleLocale ? '' : `/${locale}`
|
||||||
|
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
const segment = prefix[locale]
|
const segment = prefix[locale]
|
||||||
// No archive slug in this locale means the entry is unreachable there —
|
// No archive slug in this locale means the entry is unreachable there —
|
||||||
@@ -79,14 +86,15 @@ export function buildLocalizedPath({
|
|||||||
if (!segment) {
|
if (!segment) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return `/${locale}/${segment}/${slug}`
|
return `${localeSegment}/${segment}/${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug === homeSlug) {
|
if (slug === homeSlug) {
|
||||||
return `/${locale}`
|
// Home collapses to the root: '/' for single-locale, '/pl' otherwise.
|
||||||
|
return localeSegment || '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `/${locale}/${slug}`
|
return `${localeSegment}/${slug}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwitchLocaleArgs = {
|
type SwitchLocaleArgs = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+18
-19
@@ -1,11 +1,12 @@
|
|||||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
||||||
|
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
|
||||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
|
|
||||||
|
|
||||||
type BuildHreflangArgs = {
|
type BuildHreflangArgs = {
|
||||||
|
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
||||||
|
slugs: LocalizedSlugs
|
||||||
|
config: I18nConfig
|
||||||
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
config: I18nConfig
|
|
||||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||||
homeSlug?: string
|
homeSlug?: string
|
||||||
/**
|
/**
|
||||||
@@ -14,46 +15,44 @@ type BuildHreflangArgs = {
|
|||||||
* are omitted — an entry with no archive in that language has no URL there.
|
* are omitted — an entry with no archive in that language has no URL there.
|
||||||
*/
|
*/
|
||||||
prefix?: LocalizedSlugs
|
prefix?: LocalizedSlugs
|
||||||
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
|
|
||||||
slugs: LocalizedSlugs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Builds a map of locale → URL for hreflang alternate links, suitable for
|
||||||
* Next.js Metadata `alternates.languages`.
|
* Next.js Metadata `alternates.languages`.
|
||||||
*
|
*
|
||||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||||
* optionally prefixed with an absolute origin.
|
* optionally prefixed with an absolute origin.
|
||||||
*
|
*
|
||||||
* Also emits `x-default` pointing at the default locale — the version Google
|
|
||||||
* serves when the user's language/region matches no hreflang, and the fallback
|
|
||||||
* when the root ('/') redirect is ambiguous (Googlebot with no/foreign
|
|
||||||
* Accept-Language).
|
|
||||||
*
|
|
||||||
* @example
|
* @example
|
||||||
* buildHreflangAlternates({
|
* buildHreflangAlternates({
|
||||||
* slugs: { pl: 'o-nas', en: 'about' },
|
* slugs: { pl: 'o-nas', en: 'about' },
|
||||||
* config,
|
* config,
|
||||||
* baseUrl: 'https://example.com',
|
* baseUrl: 'https://example.com',
|
||||||
* })
|
* })
|
||||||
* // → {
|
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||||
* // pl: 'https://example.com/pl/o-nas',
|
|
||||||
* // en: 'https://example.com/en/about',
|
|
||||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
|
||||||
* // }
|
|
||||||
*/
|
*/
|
||||||
export function buildHreflangAlternates({
|
export function buildHreflangAlternates({
|
||||||
baseUrl,
|
slugs,
|
||||||
config,
|
config,
|
||||||
|
baseUrl,
|
||||||
homeSlug = 'home',
|
homeSlug = 'home',
|
||||||
prefix,
|
prefix,
|
||||||
slugs,
|
|
||||||
}: BuildHreflangArgs): Record<string, string> {
|
}: BuildHreflangArgs): Record<string, string> {
|
||||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||||
const alternates: Record<string, string> = {}
|
const alternates: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Single-locale sites have no language alternatives — hreflang describes
|
||||||
|
// relationships BETWEEN language versions, and there's only one. Emitting
|
||||||
|
// hreflang (or x-default) here would be wrong, so return empty: the page keeps
|
||||||
|
// its canonical, but no alternate-language links.
|
||||||
|
if (config.locales.length === 1) {
|
||||||
|
return alternates
|
||||||
|
}
|
||||||
|
|
||||||
for (const locale of getLocaleCodes(config)) {
|
for (const locale of getLocaleCodes(config)) {
|
||||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||||
if (path) {
|
if (path) {
|
||||||
alternates[locale] = `${origin}${path}`
|
alternates[locale] = `${origin}${path}`
|
||||||
}
|
}
|
||||||
@@ -64,7 +63,7 @@ export function buildHreflangAlternates({
|
|||||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||||
// at the default locale (the primary market) so search shows that version by
|
// at the default locale (the primary market) so search shows that version by
|
||||||
// default instead of guessing. Only set when the default locale has a URL.
|
// default instead of guessing. Only set when the default locale has a URL.
|
||||||
const defaultLocalePath = alternates[getDefaultLocale(config)]
|
const defaultLocalePath = alternates[config.defaultLocale]
|
||||||
if (defaultLocalePath) {
|
if (defaultLocalePath) {
|
||||||
alternates['x-default'] = defaultLocalePath
|
alternates['x-default'] = defaultLocalePath
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user