Compare commits
6
Commits
v1.2.1
...
db2ac80a1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db2ac80a1e | ||
|
|
99490bfc4d | ||
|
|
fce17654f8 | ||
|
|
419207ac12 | ||
|
|
e30ac71044 | ||
|
|
781e348ded |
+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
|
||||
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
|
||||
*/
|
||||
type MiddlewareRequest = {
|
||||
nextUrl: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
clone: () => URL;
|
||||
};
|
||||
cookies: {
|
||||
get: (name: string) => {
|
||||
value: string;
|
||||
} | undefined;
|
||||
};
|
||||
headers: {
|
||||
get: (name: string) => null | string;
|
||||
};
|
||||
nextUrl: {
|
||||
clone: () => URL;
|
||||
pathname: string;
|
||||
search: string;
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
url: string;
|
||||
};
|
||||
@@ -25,21 +25,23 @@ type MiddlewareRequest = {
|
||||
* `next` means let the request pass through untouched.
|
||||
*/
|
||||
export type LocaleMiddlewareResult = {
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
location: string;
|
||||
type: 'redirect';
|
||||
} | {
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
type: 'next';
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
} | {
|
||||
type: 'redirect';
|
||||
location: string;
|
||||
cookie?: {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
type CreateLocaleMiddlewareArgs = {
|
||||
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
|
||||
* 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
|
||||
* necessary), which restores the pre-consent behaviour.
|
||||
*/
|
||||
consentCategory?: 'functional' | 'necessary';
|
||||
consentCategory?: 'necessary' | 'functional';
|
||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||
consentCookieName?: string;
|
||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||
cookieName?: string;
|
||||
};
|
||||
/**
|
||||
* Builds locale-routing logic for Next.js middleware.
|
||||
@@ -80,7 +80,7 @@ type CreateLocaleMiddlewareArgs = {
|
||||
* 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
|
||||
* 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 { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
|
||||
/**
|
||||
* First path segment of a URL pathname, or '' for root.
|
||||
* '/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)
|
||||
* 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;
|
||||
// 'functional' (default) requires the visitor to have consented.
|
||||
function mayPersistLocale(request) {
|
||||
if (consentCategory === 'necessary') {
|
||||
return true;
|
||||
}
|
||||
if (consentCategory === 'necessary') return true;
|
||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
|
||||
return consent?.[consentCategory] === true;
|
||||
}
|
||||
return function localeMiddleware(request) {
|
||||
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
|
||||
// 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
|
||||
@@ -68,9 +76,9 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
|
||||
}
|
||||
// Resolve the locale to use
|
||||
const locale = negotiateLocale({
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||
acceptLanguage: request.headers.get('accept-language'),
|
||||
config,
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null
|
||||
config
|
||||
});
|
||||
// Redirect to the locale-prefixed path, preserving the rest
|
||||
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) {
|
||||
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) {
|
||||
const segment = prefix[locale];
|
||||
// No archive slug in this locale means the entry is unreachable there —
|
||||
@@ -36,12 +42,13 @@ import { isValidLocale } from './helpers.js';
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
return `/${locale}/${segment}/${slug}`;
|
||||
return `${localeSegment}/${segment}/${slug}`;
|
||||
}
|
||||
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 —
|
||||
|
||||
+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;
|
||||
title: string;
|
||||
};
|
||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||
robots?: {
|
||||
follow: boolean;
|
||||
index: boolean;
|
||||
@@ -39,6 +40,13 @@ type BuildMetadataArgs = {
|
||||
meta?: null | SeoMeta;
|
||||
/** Page title or site name first. Defaults to 'page-first'. */
|
||||
order?: TitleOrder;
|
||||
/**
|
||||
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||
* page-title source when meta.title is empty — the browser tab and search
|
||||
* result should show the page name, not go blank, when an editor didn't fill
|
||||
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||
*/
|
||||
pageTitle?: null | string;
|
||||
/**
|
||||
* Localized segment the document lives under (an archive page's slugs).
|
||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||
@@ -69,5 +77,5 @@ type BuildMetadataArgs = {
|
||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||
* in — the plugin composes, it doesn't fetch.
|
||||
*/
|
||||
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||
export declare function buildMetadata({ baseUrl, config, homeSlug, imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs, }: BuildMetadataArgs): PageMetadata;
|
||||
export {};
|
||||
|
||||
Vendored
+16
-11
@@ -9,13 +9,16 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
|
||||
* pieces (meta group, site name, image URL, localized slugs) and passes them
|
||||
* in — the plugin composes, it doesn't fetch.
|
||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, prefix, query, separator, siteName, slugs }) {
|
||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
||||
// string in the tab, not a composition.
|
||||
*/ export function buildMetadata({ baseUrl, config, homeSlug = 'home', imageUrl, locale, meta, order, pageTitle, prefix, query, separator, siteName, slugs }) {
|
||||
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||
// composed with the site name, instead of just the site name or a blank.
|
||||
const override = meta?.titleOverride?.trim();
|
||||
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined;
|
||||
const title = override || composeTitle({
|
||||
order,
|
||||
pageTitle: meta?.title,
|
||||
pageTitle: resolvedPageTitle,
|
||||
separator,
|
||||
siteName
|
||||
});
|
||||
@@ -60,12 +63,6 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
languages
|
||||
}
|
||||
},
|
||||
...meta?.noindex ? {
|
||||
robots: {
|
||||
follow: true,
|
||||
index: false
|
||||
}
|
||||
} : {},
|
||||
openGraph: {
|
||||
title,
|
||||
...description && {
|
||||
@@ -75,7 +72,15 @@ import { buildHreflangAlternates } from './hreflang.js';
|
||||
images
|
||||
},
|
||||
locale
|
||||
}
|
||||
},
|
||||
// noindex → tell search engines to exclude the page but still follow links
|
||||
// (authority flows through). For legal/thin/search-result pages.
|
||||
...meta?.noindex ? {
|
||||
robots: {
|
||||
follow: true,
|
||||
index: false
|
||||
}
|
||||
} : {}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
@@ -90,6 +90,7 @@ import { slugsAcrossLocales } from './slugsAcrossLocales.js';
|
||||
...base,
|
||||
imageUrl: resolveOgImage(doc),
|
||||
meta: doc.meta,
|
||||
pageTitle: doc.title,
|
||||
prefix,
|
||||
query,
|
||||
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';
|
||||
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. */
|
||||
baseUrl?: string;
|
||||
config: I18nConfig;
|
||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||
homeSlug?: string;
|
||||
/**
|
||||
@@ -11,32 +13,22 @@ type BuildHreflangArgs = {
|
||||
* are omitted — an entry with no archive in that language has no URL there.
|
||||
*/
|
||||
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`.
|
||||
*
|
||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||
* 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
|
||||
* buildHreflangAlternates({
|
||||
* slugs: { pl: 'o-nas', en: 'about' },
|
||||
* config,
|
||||
* baseUrl: 'https://example.com',
|
||||
* })
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||
*/
|
||||
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 {};
|
||||
|
||||
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`.
|
||||
*
|
||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||
* 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
|
||||
* buildHreflangAlternates({
|
||||
* slugs: { pl: 'o-nas', en: 'about' },
|
||||
* config,
|
||||
* baseUrl: 'https://example.com',
|
||||
* })
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
*/ export function buildHreflangAlternates({ baseUrl, config, homeSlug = 'home', prefix, slugs }) {
|
||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||
*/ export function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug = 'home', prefix }) {
|
||||
const origin = baseUrl?.replace(/\/$/, '') ?? '';
|
||||
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)){
|
||||
const path = buildLocalizedPath({
|
||||
slugs,
|
||||
locale,
|
||||
config,
|
||||
homeSlug,
|
||||
locale,
|
||||
prefix,
|
||||
slugs
|
||||
prefix
|
||||
});
|
||||
if (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
|
||||
// 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.
|
||||
const defaultLocalePath = alternates[getDefaultLocale(config)];
|
||||
const defaultLocalePath = alternates[config.defaultLocale];
|
||||
if (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
|
||||
(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
|
||||
|
||||
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>`.
|
||||
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)
|
||||
|
||||
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
||||
|
||||
@@ -359,7 +359,11 @@ export default async function LocaleLayout({ children, params }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<MediaPreconnect /> {/* preconnect CDN, jeśli R2 */}
|
||||
<ConsentProvider texts={texts}>
|
||||
<main>{children}</main>
|
||||
<CookieBanner />
|
||||
@@ -392,6 +396,11 @@ const pageMetadata = createPageMetadata({
|
||||
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> {
|
||||
const { locale, slug } = await params
|
||||
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)
|
||||
- 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ą
|
||||
|
||||
## 11. Metadata / SEO (szczegóły)
|
||||
@@ -486,10 +497,15 @@ przypisanie strony-archiwum w System Pages.
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
|
||||
// app/robots.ts
|
||||
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
|
||||
|
||||
@@ -155,3 +155,97 @@ 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`.
|
||||
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||
> 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.
|
||||
@@ -62,3 +62,80 @@ ZAWSZE wyłączaj w dev: `hsts: process.env.NODE_ENV === 'production'`.
|
||||
`additional` z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options
|
||||
na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez `additional` —
|
||||
plugin go nie generuje, bo zależy od projektu.
|
||||
|
||||
### Dlaczego CSP zostaje w projekcie (nie plugin)
|
||||
|
||||
HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je
|
||||
generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2,
|
||||
analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
||||
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||
projekt dostarcza CSP dopasowany do siebie.
|
||||
|
||||
### Budowa CSP — domeny z env, nie hardkod
|
||||
|
||||
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||
dopasuj do tego, co projekt faktycznie ładuje:
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const r2Url = process.env.R2_PUBLIC_URL || ''
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
// skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
|
||||
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
|
||||
// style: self + inline (Tailwind) + Google Fonts
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
// obrazy: self + media R2 (z env!) + data:
|
||||
`img-src 'self' data: ${r2Url}`.trim(),
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"connect-src 'self' https://www.google-analytics.com",
|
||||
// ramki: Turnstile (widget captcha)
|
||||
"frame-src https://challenges.cloudflare.com",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'", // zastępuje X-Frame-Options w nowych przeglądarkach
|
||||
].join('; ')
|
||||
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production',
|
||||
additional: [{ key: 'Content-Security-Policy', value: csp }],
|
||||
})
|
||||
```
|
||||
|
||||
Dopasuj źródła do projektu: mapy Google (`https://maps.googleapis.com`,
|
||||
`https://*.google.com`), inne embedy, inne analytics. To, czego nie wymienisz,
|
||||
zostanie zablokowane.
|
||||
|
||||
### WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only
|
||||
|
||||
CSP za ścisły **psuje stronę** (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj
|
||||
enforcing CSP na ślepo. Metoda bezpieczna:
|
||||
|
||||
1. **Najpierw raportowanie** — użyj klucza `Content-Security-Policy-Report-Only`
|
||||
(nie `Content-Security-Policy`). Przeglądarka RAPORTUJE naruszenia w konsoli,
|
||||
ale NIE blokuje — strona działa normalnie.
|
||||
```ts
|
||||
additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
|
||||
```
|
||||
2. **Otwórz stronę** → DevTools → Console → szukaj „Content Security Policy"
|
||||
violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej
|
||||
dyrektywy CSP.
|
||||
3. **Przejdź przez cały serwis** — strona główna, formularze (Turnstile!),
|
||||
galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
|
||||
4. **Dopiero gdy konsola czysta** → zmień klucz na `Content-Security-Policy`
|
||||
(enforcing). Teraz CSP chroni, nie psując.
|
||||
|
||||
### Weryfikacja nagłówków na produkcji
|
||||
|
||||
```bash
|
||||
# sprawdź, które nagłówki faktycznie wychodzą:
|
||||
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"
|
||||
```
|
||||
|
||||
Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK
|
||||
wszystkich mimo buildSecurityHeaders w config → sprawdź, czy `headers()` jest
|
||||
wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
||||
|
||||
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||
+99
-25
@@ -53,6 +53,31 @@ Per strona (tab SEO):
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
renderowanych stron (SSR) Next.js **streamuje metadata do `<body>`**, nie `<head>`,
|
||||
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,
|
||||
część botów), widzą je poza head → ignorują → utrata SEO.
|
||||
lądują w body w surowym HTML. Crawlery bez JS (Screaming Frog, część botów) widzą
|
||||
je poza head → ignorują → utrata SEO.
|
||||
|
||||
Google *twierdzi*, że wykonuje JS i widzi przeniesione tagi, ale praktyka
|
||||
(i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić
|
||||
metadata do head dla crawlerów.
|
||||
**To wyścig czasowy (race condition):** gdy baza odpowie szybko, metadata zdąży
|
||||
do head; gdy wolniej (albo crawler odpytuje wiele stron naraz, obciążając bazę),
|
||||
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
|
||||
// next.config.ts
|
||||
const nextConfig: NextConfig = {
|
||||
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
|
||||
// dla crawlerów SEO — zamiast streamingu do <body>.
|
||||
htmlLimitedBots:
|
||||
/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
|
||||
metadata do `<head>` w surowym HTML (blocking). Użytkownicy dalej dostają
|
||||
streaming (szybkie ładowanie); crawlery dostają poprawny head.
|
||||
`htmlLimitedBots` wyłącza streaming dla tych User-Agentów (metadata w head). ALE
|
||||
to słabsze niż ISR — bo strona dalej renderuje dynamicznie (zapytanie do bazy →
|
||||
wolniej, ryzyko race). **ISR eliminuje przyczynę, htmlLimitedBots łagodzi objaw.**
|
||||
Najlepiej: ISR + brak jawnego head. htmlLimitedBots jako dodatkowa warstwa.
|
||||
|
||||
### Objawy (że masz ten problem)
|
||||
|
||||
- Screaming Frog: „canonical/hreflang/title outside <head>"
|
||||
- Search Console: „brak canonical", favicon nie pokazuje się (glob)
|
||||
- W surowym HTML canonical/title są PO `</head>`, na końcu body, ze skryptem
|
||||
`document.querySelectorAll('body link[rel=icon]')...appendChild`
|
||||
- Screaming Frog: „canonical/hreflang/title outside <head>" (na wielu podstronach)
|
||||
- Search Console: „brak canonical", favicon glob
|
||||
- Surowy HTML: canonical/title PO `</head>`, na końcu body, ze skryptem 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
|
||||
# jako Googlebot — metadata MUSI być w <head>
|
||||
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical
|
||||
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS)
|
||||
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona
|
||||
# 10 zapytań jako Googlebot — ile ma canonical w <head>
|
||||
for i in $(seq 1 10); do
|
||||
curl -s -A "Googlebot" 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
|
||||
(„User-declared canonical: None"), hreflang i title. Jedna linia w config
|
||||
naprawia wszystko naraz.
|
||||
Testuj też PODSTRONY (nie tylko główną) — race częściej dotyka podstron.
|
||||
DevTools (F12) NIE nadaje się do testu — hoistuje tagi do head automatycznie,
|
||||
pokazując fałszywie poprawny head. Używaj `curl` / Ctrl+U (surowe źródło).
|
||||
|
||||
## 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ł
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
||||
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.4",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"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 { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
type MiddlewareRequest = {
|
||||
nextUrl: { pathname: string; search: string; clone: () => URL }
|
||||
cookies: { get: (name: string) => { value: string } | undefined }
|
||||
headers: { get: (name: string) => null | string }
|
||||
nextUrl: { clone: () => URL; pathname: string; search: string }
|
||||
headers: { get: (name: string) => string | null }
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -20,11 +19,13 @@ type MiddlewareRequest = {
|
||||
* `next` means let the request pass through untouched.
|
||||
*/
|
||||
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 = {
|
||||
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
|
||||
* 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
|
||||
* necessary), which restores the pre-consent behaviour.
|
||||
*/
|
||||
consentCategory?: 'functional' | 'necessary'
|
||||
consentCategory?: 'necessary' | 'functional'
|
||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||
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({
|
||||
config,
|
||||
cookieName = LOCALE_COOKIE_NAME,
|
||||
consentCategory = 'functional',
|
||||
consentCookieName = CONSENT_COOKIE,
|
||||
cookieName = LOCALE_COOKIE_NAME,
|
||||
}: CreateLocaleMiddlewareArgs) {
|
||||
// Whether the locale cookie may be written: 'necessary' is always granted;
|
||||
// 'functional' (default) requires the visitor to have consented.
|
||||
function mayPersistLocale(request: MiddlewareRequest): boolean {
|
||||
if (consentCategory === 'necessary') {return true}
|
||||
if (consentCategory === 'necessary') return true
|
||||
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
|
||||
return consent?.[consentCategory] === true
|
||||
}
|
||||
@@ -91,6 +90,15 @@ export function createLocaleMiddleware({
|
||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||
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
|
||||
// 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
|
||||
@@ -108,9 +116,9 @@ export function createLocaleMiddleware({
|
||||
|
||||
// Resolve the locale to use
|
||||
const locale = negotiateLocale({
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||
acceptLanguage: request.headers.get('accept-language'),
|
||||
config,
|
||||
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
|
||||
})
|
||||
|
||||
// Redirect to the locale-prefixed path, preserving the rest
|
||||
|
||||
@@ -71,6 +71,13 @@ export function buildLocalizedPath({
|
||||
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) {
|
||||
const segment = prefix[locale]
|
||||
// No archive slug in this locale means the entry is unreachable there —
|
||||
@@ -79,14 +86,15 @@ export function buildLocalizedPath({
|
||||
if (!segment) {
|
||||
return undefined
|
||||
}
|
||||
return `/${locale}/${segment}/${slug}`
|
||||
return `${localeSegment}/${segment}/${slug}`
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -22,6 +22,7 @@ export type PageMetadata = {
|
||||
locale?: string
|
||||
title: string
|
||||
}
|
||||
/** robots directives — set to noindex/follow for legal/thin/search pages. */
|
||||
robots?: {
|
||||
follow: boolean
|
||||
index: boolean
|
||||
@@ -43,6 +44,13 @@ type BuildMetadataArgs = {
|
||||
meta?: null | SeoMeta
|
||||
/** Page title or site name first. Defaults to 'page-first'. */
|
||||
order?: TitleOrder
|
||||
/**
|
||||
* The document's own title (e.g. page.title = 'Sprzątanie biur'). Used as the
|
||||
* page-title source when meta.title is empty — the browser tab and search
|
||||
* result should show the page name, not go blank, when an editor didn't fill
|
||||
* the SEO title. Priority: titleOverride > meta.title > pageTitle.
|
||||
*/
|
||||
pageTitle?: null | string
|
||||
/**
|
||||
* Localized segment the document lives under (an archive page's slugs).
|
||||
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
|
||||
@@ -82,16 +90,21 @@ export function buildMetadata({
|
||||
locale,
|
||||
meta,
|
||||
order,
|
||||
pageTitle,
|
||||
prefix,
|
||||
query,
|
||||
separator,
|
||||
siteName,
|
||||
slugs,
|
||||
}: BuildMetadataArgs): PageMetadata {
|
||||
// titleOverride wins outright: an editor who filled it in wants that exact
|
||||
// string in the tab, not a composition.
|
||||
// Title source priority: titleOverride (exact, wins outright) > meta.title
|
||||
// (SEO title an editor set) > pageTitle (the document's own name). This means
|
||||
// a page with no SEO title still shows its name (e.g. 'Sprzątanie biur')
|
||||
// composed with the site name, instead of just the site name or a blank.
|
||||
const override = meta?.titleOverride?.trim()
|
||||
const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
|
||||
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
|
||||
const title =
|
||||
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
|
||||
const description = meta?.description?.trim() || undefined
|
||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||
|
||||
@@ -116,12 +129,14 @@ export function buildMetadata({
|
||||
...(canonical && { canonical }),
|
||||
...(Object.keys(languages).length > 0 && { languages }),
|
||||
},
|
||||
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
||||
openGraph: {
|
||||
title,
|
||||
...(description && { description }),
|
||||
...(images && { images }),
|
||||
locale,
|
||||
},
|
||||
// noindex → tell search engines to exclude the page but still follow links
|
||||
// (authority flows through). For legal/thin/search-result pages.
|
||||
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ type PageMetadataContext = {
|
||||
type DocShape = {
|
||||
id: number | string
|
||||
meta?: null | SeoMeta
|
||||
/** The document's own title (page name), used as the fallback page title. */
|
||||
title?: null | string
|
||||
}
|
||||
|
||||
/** plugin-seo stores the OG image as an upload relationship. */
|
||||
@@ -151,6 +153,7 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
|
||||
...base,
|
||||
imageUrl: resolveOgImage(doc),
|
||||
meta: doc.meta,
|
||||
pageTitle: doc.title,
|
||||
prefix,
|
||||
query,
|
||||
slugs,
|
||||
|
||||
+18
-19
@@ -1,11 +1,12 @@
|
||||
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
|
||||
|
||||
import { buildLocalizedPath, getDefaultLocale, getLocaleCodes } from '../i18n/index.js'
|
||||
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
|
||||
|
||||
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. */
|
||||
baseUrl?: string
|
||||
config: I18nConfig
|
||||
/** Home slug that collapses to the locale root. Defaults to 'home'. */
|
||||
homeSlug?: string
|
||||
/**
|
||||
@@ -14,46 +15,44 @@ type BuildHreflangArgs = {
|
||||
* are omitted — an entry with no archive in that language has no URL there.
|
||||
*/
|
||||
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`.
|
||||
*
|
||||
* Bridges SEO and i18n: for each configured locale that the document has a
|
||||
* slug in, it produces the locale-aware path (via buildLocalizedPath),
|
||||
* 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
|
||||
* buildHreflangAlternates({
|
||||
* slugs: { pl: 'o-nas', en: 'about' },
|
||||
* config,
|
||||
* baseUrl: 'https://example.com',
|
||||
* })
|
||||
* // → {
|
||||
* // pl: 'https://example.com/pl/o-nas',
|
||||
* // en: 'https://example.com/en/about',
|
||||
* // 'x-default': 'https://example.com/pl/o-nas',
|
||||
* // }
|
||||
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
|
||||
*/
|
||||
export function buildHreflangAlternates({
|
||||
baseUrl,
|
||||
slugs,
|
||||
config,
|
||||
baseUrl,
|
||||
homeSlug = 'home',
|
||||
prefix,
|
||||
slugs,
|
||||
}: BuildHreflangArgs): Record<string, string> {
|
||||
const origin = baseUrl?.replace(/\/$/, '') ?? ''
|
||||
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)) {
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
||||
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||
if (path) {
|
||||
alternates[locale] = `${origin}${path}`
|
||||
}
|
||||
@@ -64,7 +63,7 @@ export function buildHreflangAlternates({
|
||||
// redirect is ambiguous (Googlebot with no/foreign Accept-Language). Point it
|
||||
// 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.
|
||||
const defaultLocalePath = alternates[getDefaultLocale(config)]
|
||||
const defaultLocalePath = alternates[config.defaultLocale]
|
||||
if (defaultLocalePath) {
|
||||
alternates['x-default'] = defaultLocalePath
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user