From 419207ac12ac53a2fedc72260922a8722730434d Mon Sep 17 00:00:00 2001 From: rasm-its Date: Tue, 8 Sep 2026 18:12:58 +0200 Subject: [PATCH] Added support for single localization --- dist/modules/i18n/localeMiddleware.d.ts | 44 +++++------ dist/modules/i18n/localeMiddleware.js | 22 ++++-- dist/modules/i18n/localeMiddleware.js.map | 2 +- dist/modules/i18n/localizedPath.js | 13 ++- dist/modules/i18n/localizedPath.js.map | 2 +- dist/modules/seo/hreflang.d.ts | 20 ++--- dist/modules/seo/hreflang.js | 31 ++++---- dist/modules/seo/hreflang.js.map | 2 +- docs/blocks.md | 24 ++++++ docs/deployment.md | 2 +- docs/i18n.md | 96 ++++++++++++++++++++++- docs/seo.md | 25 ++++++ docs/storage.md | 21 +++++ src/modules/i18n/localeMiddleware.ts | 34 +++++--- src/modules/i18n/localizedPath.ts | 14 +++- src/modules/seo/hreflang.ts | 37 +++++---- 16 files changed, 287 insertions(+), 102 deletions(-) diff --git a/dist/modules/i18n/localeMiddleware.d.ts b/dist/modules/i18n/localeMiddleware.d.ts index bfb561b..e8b3064 100644 --- a/dist/modules/i18n/localeMiddleware.d.ts +++ b/dist/modules/i18n/localeMiddleware.d.ts @@ -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). diff --git a/dist/modules/i18n/localeMiddleware.js b/dist/modules/i18n/localeMiddleware.js index d13d3c1..a73253d 100644 --- a/dist/modules/i18n/localeMiddleware.js +++ b/dist/modules/i18n/localeMiddleware.js @@ -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(); diff --git a/dist/modules/i18n/localeMiddleware.js.map b/dist/modules/i18n/localeMiddleware.js.map index 11a4b37..c80f4d8 100644 --- a/dist/modules/i18n/localeMiddleware.js.map +++ b/dist/modules/i18n/localeMiddleware.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'\nimport { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'\n\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n cookies: { get: (name: string) => { value: string } | undefined }\n headers: { get: (name: string) => null | string }\n nextUrl: { clone: () => URL; pathname: string; search: string }\n url: string\n}\n\n/**\n * What the factory returns — the caller (in Next middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult =\n | { cookie?: { name: string; value: string }; location: string; type: 'redirect' }\n | { cookie?: { name: string; value: string }; type: 'next' }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\n /**\n * Consent category that gates *persisting* the locale cookie. The locale is\n * always detected (routing works regardless), but the choice is only written\n * to a cookie once the visitor has consented to this category. Defaults to\n * 'functional'. Pass 'necessary' to always persist (treat locale as strictly\n * necessary), which restores the pre-consent behaviour.\n */\n consentCategory?: 'functional' | 'necessary'\n /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */\n consentCookieName?: string\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string\n}\n\n/**\n * First path segment of a URL pathname, or '' for root.\n * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.\n */\nfunction firstSegment(pathname: string): string {\n return pathname.split('/').filter(Boolean)[0] ?? ''\n}\n\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that middleware.ts must physically live in the client app.\n *\n * @example\n * // middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function proxy(req) {\n * const r = localeMiddleware(req)\n * // Both results may carry an optional cookie — 'next' when the visitor\n * // switched language (URL locale differs from the stored one) and consented,\n * // 'redirect' on the initial locale negotiation. Set it whenever present.\n * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)\n * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport function createLocaleMiddleware({\n config,\n consentCategory = 'functional',\n consentCookieName = CONSENT_COOKIE,\n cookieName = LOCALE_COOKIE_NAME,\n}: CreateLocaleMiddlewareArgs) {\n // Whether the locale cookie may be written: 'necessary' is always granted;\n // 'functional' (default) requires the visitor to have consented.\n function mayPersistLocale(request: MiddlewareRequest): boolean {\n if (consentCategory === 'necessary') {return true}\n const consent = parseConsent(request.cookies.get(consentCookieName)?.value)\n return consent?.[consentCategory] === true\n }\n\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Already locale-prefixed (e.g. the visitor switched language by\n // navigating to /en). Routing is fine — but if the URL's locale differs\n // from the stored cookie, the visitor is *choosing* a language, and we\n // should remember it — provided they consented to the gating category.\n // Without consent we leave the cookie untouched: the switch works for this\n // visit but isn't persisted, which is exactly the functional-cookie rule.\n const urlLocale = firstSegment(pathname)\n if (isValidLocale(urlLocale, config)) {\n const currentCookie = request.cookies.get(cookieName)?.value ?? null\n if (currentCookie !== urlLocale && mayPersistLocale(request)) {\n return { type: 'next', cookie: { name: cookieName, value: urlLocale } }\n }\n return { type: 'next' }\n }\n\n // Resolve the locale to use\n const locale = negotiateLocale({\n acceptLanguage: request.headers.get('accept-language'),\n config,\n cookieLocale: request.cookies.get(cookieName)?.value ?? null,\n })\n\n // Redirect to the locale-prefixed path, preserving the rest\n const url = request.nextUrl.clone()\n url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`\n\n // Persist the locale choice ONLY if the visitor consented to the gating\n // category. 'necessary' is always granted, so passing consentCategory:\n // 'necessary' always persists. For 'functional' (default), we read the\n // consent cookie and only write the locale cookie when functional is true.\n // Without consent the locale is still detected each request (routing works),\n // it just isn't remembered across visits — which is the whole point of\n // gating a functional cookie behind consent.\n return {\n type: 'redirect',\n location: url.toString(),\n ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}),\n }\n }\n}\n\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\\\..*).*)']\n"],"names":["CONSENT_COOKIE","parseConsent","isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","consentCategory","consentCookieName","cookieName","mayPersistLocale","request","consent","cookies","get","value","localeMiddleware","nextUrl","urlLocale","currentCookie","type","cookie","name","locale","acceptLanguage","headers","cookieLocale","url","clone","location","toString","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAEA,SAASA,cAAc,EAAEC,YAAY,QAAQ,wBAAuB;AACpE,SAASC,aAAa,EAAEC,kBAAkB,EAAEC,eAAe,QAAQ,mBAAkB;AAsCrF;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,kBAAkB,YAAY,EAC9BC,oBAAoBb,cAAc,EAClCc,aAAaX,kBAAkB,EACJ;IAC3B,2EAA2E;IAC3E,iEAAiE;IACjE,SAASY,iBAAiBC,OAA0B;QAClD,IAAIJ,oBAAoB,aAAa;YAAC,OAAO;QAAI;QACjD,MAAMK,UAAUhB,aAAae,QAAQE,OAAO,CAACC,GAAG,CAACN,oBAAoBO;QACrE,OAAOH,SAAS,CAACL,gBAAgB,KAAK;IACxC;IAEA,OAAO,SAASS,iBAAiBL,OAA0B;QACzD,MAAM,EAAEV,QAAQ,EAAE,GAAGU,QAAQM,OAAO;QAEpC,iEAAiE;QACjE,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,0EAA0E;QAC1E,MAAMC,YAAYlB,aAAaC;QAC/B,IAAIJ,cAAcqB,WAAWZ,SAAS;YACpC,MAAMa,gBAAgBR,QAAQE,OAAO,CAACC,GAAG,CAACL,aAAaM,SAAS;YAChE,IAAII,kBAAkBD,aAAaR,iBAAiBC,UAAU;gBAC5D,OAAO;oBAAES,MAAM;oBAAQC,QAAQ;wBAAEC,MAAMb;wBAAYM,OAAOG;oBAAU;gBAAE;YACxE;YACA,OAAO;gBAAEE,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMG,SAASxB,gBAAgB;YAC7ByB,gBAAgBb,QAAQc,OAAO,CAACX,GAAG,CAAC;YACpCR;YACAoB,cAAcf,QAAQE,OAAO,CAACC,GAAG,CAACL,aAAaM,SAAS;QAC1D;QAEA,4DAA4D;QAC5D,MAAMY,MAAMhB,QAAQM,OAAO,CAACW,KAAK;QACjCD,IAAI1B,QAAQ,GAAG,CAAC,CAAC,EAAEsB,SAAStB,aAAa,MAAM,KAAKA,UAAU;QAE9D,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,6EAA6E;QAC7E,uEAAuE;QACvE,6CAA6C;QAC7C,OAAO;YACLmB,MAAM;YACNS,UAAUF,IAAIG,QAAQ;YACtB,GAAIpB,iBAAiBC,WAAW;gBAAEU,QAAQ;oBAAEC,MAAMb;oBAAYM,OAAOQ;gBAAO;YAAE,IAAI,CAAC,CAAC;QACtF;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMQ,6BAA6B;IAAC;CAAmC,CAAA"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from '../i18n/index.js'\nimport { negotiateLocale, isValidLocale, LOCALE_COOKIE_NAME } from '../i18n/index.js'\nimport { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'\n\n/**\n * Minimal request shape the middleware reads. Kept structural so the plugin\n * doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.\n */\ntype MiddlewareRequest = {\n nextUrl: { pathname: string; search: string; clone: () => URL }\n cookies: { get: (name: string) => { value: string } | undefined }\n headers: { get: (name: string) => string | null }\n url: string\n}\n\n/**\n * What the factory returns — the caller (in Next middleware.ts) decides how to\n * act: `redirect` means send a 307 to `location` and set the locale cookie;\n * `next` means let the request pass through untouched.\n */\nexport type LocaleMiddlewareResult =\n | { type: 'next'; cookie?: { name: string; value: string } }\n | { type: 'redirect'; location: string; cookie?: { name: string; value: string } }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\n /** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */\n cookieName?: string\n /**\n * Consent category that gates *persisting* the locale cookie. The locale is\n * always detected (routing works regardless), but the choice is only written\n * to a cookie once the visitor has consented to this category. Defaults to\n * 'functional'. Pass 'necessary' to always persist (treat locale as strictly\n * necessary), which restores the pre-consent behaviour.\n */\n consentCategory?: 'necessary' | 'functional'\n /** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */\n consentCookieName?: string\n}\n\n/**\n * First path segment of a URL pathname, or '' for root.\n * '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.\n */\nfunction firstSegment(pathname: string): string {\n return pathname.split('/').filter(Boolean)[0] ?? ''\n}\n\n/**\n * Builds locale-routing logic for Next.js middleware.\n *\n * Behavior:\n * - path already starts with a valid locale (/pl/...) → pass through\n * - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale\n * comes from negotiateLocale (cookie → Accept-Language → default)\n * - the chosen locale is written to a cookie so the next visit is stable\n *\n * The plugin returns a decision; the thin middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that middleware.ts must physically live in the client app.\n *\n * @example\n * // middleware.ts (client project) — one wiring file, no logic:\n * import { NextResponse } from 'next/server'\n * import { localeMiddleware } from './ipal.middleware' // created from this factory\n * export function proxy(req) {\n * const r = localeMiddleware(req)\n * // Both results may carry an optional cookie — 'next' when the visitor\n * // switched language (URL locale differs from the stored one) and consented,\n * // 'redirect' on the initial locale negotiation. Set it whenever present.\n * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)\n * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)\n * return res\n * }\n */\nexport function createLocaleMiddleware({\n config,\n cookieName = LOCALE_COOKIE_NAME,\n consentCategory = 'functional',\n consentCookieName = CONSENT_COOKIE,\n}: CreateLocaleMiddlewareArgs) {\n // Whether the locale cookie may be written: 'necessary' is always granted;\n // 'functional' (default) requires the visitor to have consented.\n function mayPersistLocale(request: MiddlewareRequest): boolean {\n if (consentCategory === 'necessary') return true\n const consent = parseConsent(request.cookies.get(consentCookieName)?.value)\n return consent?.[consentCategory] === true\n }\n\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Single-locale sites have no /pl, /en prefix and no negotiation — one\n // language, no redirect. The middleware becomes a pass-through: paths stay\n // as-is (/o-nas), nothing to detect or persist. (Projects that are truly\n // single-locale usually don't even mount this middleware, but guarding here\n // makes it safe if they do.)\n if (config.locales.length === 1) {\n return { type: 'next' }\n }\n\n // Already locale-prefixed (e.g. the visitor switched language by\n // navigating to /en). Routing is fine — but if the URL's locale differs\n // from the stored cookie, the visitor is *choosing* a language, and we\n // should remember it — provided they consented to the gating category.\n // Without consent we leave the cookie untouched: the switch works for this\n // visit but isn't persisted, which is exactly the functional-cookie rule.\n const urlLocale = firstSegment(pathname)\n if (isValidLocale(urlLocale, config)) {\n const currentCookie = request.cookies.get(cookieName)?.value ?? null\n if (currentCookie !== urlLocale && mayPersistLocale(request)) {\n return { type: 'next', cookie: { name: cookieName, value: urlLocale } }\n }\n return { type: 'next' }\n }\n\n // Resolve the locale to use\n const locale = negotiateLocale({\n cookieLocale: request.cookies.get(cookieName)?.value ?? null,\n acceptLanguage: request.headers.get('accept-language'),\n config,\n })\n\n // Redirect to the locale-prefixed path, preserving the rest\n const url = request.nextUrl.clone()\n url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`\n\n // Persist the locale choice ONLY if the visitor consented to the gating\n // category. 'necessary' is always granted, so passing consentCategory:\n // 'necessary' always persists. For 'functional' (default), we read the\n // consent cookie and only write the locale cookie when functional is true.\n // Without consent the locale is still detected each request (routing works),\n // it just isn't remembered across visits — which is the whole point of\n // gating a functional cookie behind consent.\n return {\n type: 'redirect',\n location: url.toString(),\n ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}),\n }\n }\n}\n\n/**\n * Default Next.js middleware matcher: run on everything except API routes, the\n * admin panel, Next internals, and files with an extension (static assets).\n */\nexport const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\\\..*).*)']\n"],"names":["negotiateLocale","isValidLocale","LOCALE_COOKIE_NAME","CONSENT_COOKIE","parseConsent","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","cookieName","consentCategory","consentCookieName","mayPersistLocale","request","consent","cookies","get","value","localeMiddleware","nextUrl","locales","length","type","urlLocale","currentCookie","cookie","name","locale","cookieLocale","acceptLanguage","headers","url","clone","location","toString","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AACA,SAASA,eAAe,EAAEC,aAAa,EAAEC,kBAAkB,QAAQ,mBAAkB;AACrF,SAASC,cAAc,EAAEC,YAAY,QAAQ,wBAAuB;AAsCpE;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,aAAaV,kBAAkB,EAC/BW,kBAAkB,YAAY,EAC9BC,oBAAoBX,cAAc,EACP;IAC3B,2EAA2E;IAC3E,iEAAiE;IACjE,SAASY,iBAAiBC,OAA0B;QAClD,IAAIH,oBAAoB,aAAa,OAAO;QAC5C,MAAMI,UAAUb,aAAaY,QAAQE,OAAO,CAACC,GAAG,CAACL,oBAAoBM;QACrE,OAAOH,SAAS,CAACJ,gBAAgB,KAAK;IACxC;IAEA,OAAO,SAASQ,iBAAiBL,OAA0B;QACzD,MAAM,EAAEV,QAAQ,EAAE,GAAGU,QAAQM,OAAO;QAEpC,uEAAuE;QACvE,2EAA2E;QAC3E,yEAAyE;QACzE,4EAA4E;QAC5E,6BAA6B;QAC7B,IAAIX,OAAOY,OAAO,CAACC,MAAM,KAAK,GAAG;YAC/B,OAAO;gBAAEC,MAAM;YAAO;QACxB;QAEA,iEAAiE;QACjE,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,0EAA0E;QAC1E,MAAMC,YAAYrB,aAAaC;QAC/B,IAAIL,cAAcyB,WAAWf,SAAS;YACpC,MAAMgB,gBAAgBX,QAAQE,OAAO,CAACC,GAAG,CAACP,aAAaQ,SAAS;YAChE,IAAIO,kBAAkBD,aAAaX,iBAAiBC,UAAU;gBAC5D,OAAO;oBAAES,MAAM;oBAAQG,QAAQ;wBAAEC,MAAMjB;wBAAYQ,OAAOM;oBAAU;gBAAE;YACxE;YACA,OAAO;gBAAED,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMK,SAAS9B,gBAAgB;YAC7B+B,cAAcf,QAAQE,OAAO,CAACC,GAAG,CAACP,aAAaQ,SAAS;YACxDY,gBAAgBhB,QAAQiB,OAAO,CAACd,GAAG,CAAC;YACpCR;QACF;QAEA,4DAA4D;QAC5D,MAAMuB,MAAMlB,QAAQM,OAAO,CAACa,KAAK;QACjCD,IAAI5B,QAAQ,GAAG,CAAC,CAAC,EAAEwB,SAASxB,aAAa,MAAM,KAAKA,UAAU;QAE9D,wEAAwE;QACxE,uEAAuE;QACvE,uEAAuE;QACvE,2EAA2E;QAC3E,6EAA6E;QAC7E,uEAAuE;QACvE,6CAA6C;QAC7C,OAAO;YACLmB,MAAM;YACNW,UAAUF,IAAIG,QAAQ;YACtB,GAAItB,iBAAiBC,WAAW;gBAAEY,QAAQ;oBAAEC,MAAMjB;oBAAYQ,OAAOU;gBAAO;YAAE,IAAI,CAAC,CAAC;QACtF;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMQ,6BAA6B;IAAC;CAAmC,CAAA"} \ No newline at end of file diff --git a/dist/modules/i18n/localizedPath.js b/dist/modules/i18n/localizedPath.js index 40a37be..3a65ce5 100644 --- a/dist/modules/i18n/localizedPath.js +++ b/dist/modules/i18n/localizedPath.js @@ -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 — diff --git a/dist/modules/i18n/localizedPath.js.map b/dist/modules/i18n/localizedPath.js.map index 1f73694..fa8b475 100644 --- a/dist/modules/i18n/localizedPath.js.map +++ b/dist/modules/i18n/localizedPath.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/i18n/localizedPath.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { isValidLocale } from './helpers.js'\n\n/**\n * Minimal shape the path builder needs from a document.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on\n * this contract: a map of locale code → slug for that locale. The template\n * supplies it (e.g. by reading the localized slug field across locales).\n */\nexport type LocalizedSlugs = Record\n\ntype BuildPathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root (served at /{locale} with no trailing\n * segment). Defaults to 'home'. Matched against the slug in the target locale.\n */\n homeSlug?: string\n /** Target locale to build the path for */\n locale: string\n /**\n * Localized segment the document lives under, e.g.\n * `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.\n *\n * These are the slugs of the collection's archive page, so the prefix is\n * whatever an editor named that page — and it differs per locale for free.\n * A document under a prefix is never the home page, so homeSlug is ignored.\n */\n prefix?: LocalizedSlugs\n /** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a locale-prefixed path for a document in a target locale.\n *\n * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug\n * collapses to the locale root. Returns undefined if the document has no slug\n * in the target locale (caller decides fallback behavior).\n *\n * @example\n * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })\n * // → '/en' (home slug collapses to root)\n *\n * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })\n * // → '/en/about'\n *\n * buildLocalizedPath({\n * slugs: { pl: 'moj-post', en: 'my-post' },\n * prefix: { pl: 'artykuly', en: 'articles' },\n * locale: 'en',\n * config,\n * })\n * // → '/en/articles/my-post'\n */\nexport function buildLocalizedPath({\n config,\n homeSlug = 'home',\n locale,\n prefix,\n slugs,\n}: BuildPathArgs): string | undefined {\n if (!isValidLocale(locale, config)) {\n return undefined\n }\n\n const slug = slugs[locale]\n if (!slug) {\n return undefined\n }\n\n if (prefix) {\n const segment = prefix[locale]\n // No archive slug in this locale means the entry is unreachable there —\n // there's no path to point at, so hreflang should omit it rather than\n // invent /en/moj-post.\n if (!segment) {\n return undefined\n }\n return `/${locale}/${segment}/${slug}`\n }\n\n if (slug === homeSlug) {\n return `/${locale}`\n }\n\n return `/${locale}/${slug}`\n}\n\ntype SwitchLocaleArgs = {\n config: I18nConfig\n homeSlug?: string\n prefix?: LocalizedSlugs\n slugs: LocalizedSlugs\n targetLocale: string\n}\n\n/**\n * Resolves the equivalent path for the same document in a different locale —\n * the language-switcher use case (/pl/strona-glowna → /en/home).\n *\n * Never dead-ends on a 404. When the document has no slug in the target locale,\n * falls back to the archive it belongs to (/en/articles) if there is one, and\n * to the locale root otherwise — the closest place the visitor would want.\n */\nexport function switchLocalePath({\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n targetLocale,\n}: SwitchLocaleArgs): string {\n const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })\n if (path) {return path}\n\n const archiveSegment = prefix?.[targetLocale]\n if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}\n\n return `/${targetLocale}`\n}\n"],"names":["isValidLocale","buildLocalizedPath","config","homeSlug","locale","prefix","slugs","undefined","slug","segment","switchLocalePath","targetLocale","path","archiveSegment"],"mappings":"AAEA,SAASA,aAAa,QAAQ,eAAc;AAiC5C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC,mBAAmB,EACjCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,MAAM,EACNC,KAAK,EACS;IACd,IAAI,CAACN,cAAcI,QAAQF,SAAS;QAClC,OAAOK;IACT;IAEA,MAAMC,OAAOF,KAAK,CAACF,OAAO;IAC1B,IAAI,CAACI,MAAM;QACT,OAAOD;IACT;IAEA,IAAIF,QAAQ;QACV,MAAMI,UAAUJ,MAAM,CAACD,OAAO;QAC9B,wEAAwE;QACxE,sEAAsE;QACtE,uBAAuB;QACvB,IAAI,CAACK,SAAS;YACZ,OAAOF;QACT;QACA,OAAO,CAAC,CAAC,EAAEH,OAAO,CAAC,EAAEK,QAAQ,CAAC,EAAED,MAAM;IACxC;IAEA,IAAIA,SAASL,UAAU;QACrB,OAAO,CAAC,CAAC,EAAEC,QAAQ;IACrB;IAEA,OAAO,CAAC,CAAC,EAAEA,OAAO,CAAC,EAAEI,MAAM;AAC7B;AAUA;;;;;;;CAOC,GACD,OAAO,SAASE,iBAAiB,EAC/BR,MAAM,EACNC,WAAW,MAAM,EACjBE,MAAM,EACNC,KAAK,EACLK,YAAY,EACK;IACjB,MAAMC,OAAOX,mBAAmB;QAAEC;QAAQC;QAAUC,QAAQO;QAAcN;QAAQC;IAAM;IACxF,IAAIM,MAAM;QAAC,OAAOA;IAAI;IAEtB,MAAMC,iBAAiBR,QAAQ,CAACM,aAAa;IAC7C,IAAIE,gBAAgB;QAAC,OAAO,CAAC,CAAC,EAAEF,aAAa,CAAC,EAAEE,gBAAgB;IAAA;IAEhE,OAAO,CAAC,CAAC,EAAEF,cAAc;AAC3B"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/i18n/localizedPath.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nimport { isValidLocale } from './helpers.js'\n\n/**\n * Minimal shape the path builder needs from a document.\n *\n * The plugin doesn't know the client's Pages type, so it depends only on\n * this contract: a map of locale code → slug for that locale. The template\n * supplies it (e.g. by reading the localized slug field across locales).\n */\nexport type LocalizedSlugs = Record\n\ntype BuildPathArgs = {\n config: I18nConfig\n /**\n * Slug that represents the site root (served at /{locale} with no trailing\n * segment). Defaults to 'home'. Matched against the slug in the target locale.\n */\n homeSlug?: string\n /** Target locale to build the path for */\n locale: string\n /**\n * Localized segment the document lives under, e.g.\n * `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.\n *\n * These are the slugs of the collection's archive page, so the prefix is\n * whatever an editor named that page — and it differs per locale for free.\n * A document under a prefix is never the home page, so homeSlug is ignored.\n */\n prefix?: LocalizedSlugs\n /** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */\n slugs: LocalizedSlugs\n}\n\n/**\n * Builds a locale-prefixed path for a document in a target locale.\n *\n * Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug\n * collapses to the locale root. Returns undefined if the document has no slug\n * in the target locale (caller decides fallback behavior).\n *\n * @example\n * buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })\n * // → '/en' (home slug collapses to root)\n *\n * buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })\n * // → '/en/about'\n *\n * buildLocalizedPath({\n * slugs: { pl: 'moj-post', en: 'my-post' },\n * prefix: { pl: 'artykuly', en: 'articles' },\n * locale: 'en',\n * config,\n * })\n * // → '/en/articles/my-post'\n */\nexport function buildLocalizedPath({\n config,\n homeSlug = 'home',\n locale,\n prefix,\n slugs,\n}: BuildPathArgs): string | undefined {\n if (!isValidLocale(locale, config)) {\n return undefined\n }\n\n const slug = slugs[locale]\n if (!slug) {\n return undefined\n }\n\n // Single-locale sites have no /pl, /en prefix — the language segment is\n // dropped entirely (path is /o-nas, not /pl/o-nas). Detected automatically:\n // one configured locale means one language, so no prefix is needed. The\n // project's folder structure matches (app/[[...slug]] without [locale]).\n const singleLocale = config.locales.length === 1\n const localeSegment = singleLocale ? '' : `/${locale}`\n\n if (prefix) {\n const segment = prefix[locale]\n // No archive slug in this locale means the entry is unreachable there —\n // there's no path to point at, so hreflang should omit it rather than\n // invent /en/moj-post.\n if (!segment) {\n return undefined\n }\n return `${localeSegment}/${segment}/${slug}`\n }\n\n if (slug === homeSlug) {\n // Home collapses to the root: '/' for single-locale, '/pl' otherwise.\n return localeSegment || '/'\n }\n\n return `${localeSegment}/${slug}`\n}\n\ntype SwitchLocaleArgs = {\n config: I18nConfig\n homeSlug?: string\n prefix?: LocalizedSlugs\n slugs: LocalizedSlugs\n targetLocale: string\n}\n\n/**\n * Resolves the equivalent path for the same document in a different locale —\n * the language-switcher use case (/pl/strona-glowna → /en/home).\n *\n * Never dead-ends on a 404. When the document has no slug in the target locale,\n * falls back to the archive it belongs to (/en/articles) if there is one, and\n * to the locale root otherwise — the closest place the visitor would want.\n */\nexport function switchLocalePath({\n config,\n homeSlug = 'home',\n prefix,\n slugs,\n targetLocale,\n}: SwitchLocaleArgs): string {\n const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })\n if (path) {return path}\n\n const archiveSegment = prefix?.[targetLocale]\n if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}\n\n return `/${targetLocale}`\n}\n"],"names":["isValidLocale","buildLocalizedPath","config","homeSlug","locale","prefix","slugs","undefined","slug","singleLocale","locales","length","localeSegment","segment","switchLocalePath","targetLocale","path","archiveSegment"],"mappings":"AAEA,SAASA,aAAa,QAAQ,eAAc;AAiC5C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASC,mBAAmB,EACjCC,MAAM,EACNC,WAAW,MAAM,EACjBC,MAAM,EACNC,MAAM,EACNC,KAAK,EACS;IACd,IAAI,CAACN,cAAcI,QAAQF,SAAS;QAClC,OAAOK;IACT;IAEA,MAAMC,OAAOF,KAAK,CAACF,OAAO;IAC1B,IAAI,CAACI,MAAM;QACT,OAAOD;IACT;IAEA,wEAAwE;IACxE,4EAA4E;IAC5E,wEAAwE;IACxE,yEAAyE;IACzE,MAAME,eAAeP,OAAOQ,OAAO,CAACC,MAAM,KAAK;IAC/C,MAAMC,gBAAgBH,eAAe,KAAK,CAAC,CAAC,EAAEL,QAAQ;IAEtD,IAAIC,QAAQ;QACV,MAAMQ,UAAUR,MAAM,CAACD,OAAO;QAC9B,wEAAwE;QACxE,sEAAsE;QACtE,uBAAuB;QACvB,IAAI,CAACS,SAAS;YACZ,OAAON;QACT;QACA,OAAO,GAAGK,cAAc,CAAC,EAAEC,QAAQ,CAAC,EAAEL,MAAM;IAC9C;IAEA,IAAIA,SAASL,UAAU;QACrB,sEAAsE;QACtE,OAAOS,iBAAiB;IAC1B;IAEA,OAAO,GAAGA,cAAc,CAAC,EAAEJ,MAAM;AACnC;AAUA;;;;;;;CAOC,GACD,OAAO,SAASM,iBAAiB,EAC/BZ,MAAM,EACNC,WAAW,MAAM,EACjBE,MAAM,EACNC,KAAK,EACLS,YAAY,EACK;IACjB,MAAMC,OAAOf,mBAAmB;QAAEC;QAAQC;QAAUC,QAAQW;QAAcV;QAAQC;IAAM;IACxF,IAAIU,MAAM;QAAC,OAAOA;IAAI;IAEtB,MAAMC,iBAAiBZ,QAAQ,CAACU,aAAa;IAC7C,IAAIE,gBAAgB;QAAC,OAAO,CAAC,CAAC,EAAEF,aAAa,CAAC,EAAEE,gBAAgB;IAAA;IAEhE,OAAO,CAAC,CAAC,EAAEF,cAAc;AAC3B"} \ No newline at end of file diff --git a/dist/modules/seo/hreflang.d.ts b/dist/modules/seo/hreflang.d.ts index ee7756a..101e0be 100644 --- a/dist/modules/seo/hreflang.d.ts +++ b/dist/modules/seo/hreflang.d.ts @@ -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; +export declare function buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix, }: BuildHreflangArgs): Record; export {}; diff --git a/dist/modules/seo/hreflang.js b/dist/modules/seo/hreflang.js index d122773..43cc971 100644 --- a/dist/modules/seo/hreflang.js +++ b/dist/modules/seo/hreflang.js @@ -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; } diff --git a/dist/modules/seo/hreflang.js.map b/dist/modules/seo/hreflang.js.map index 677a9db..8b0a2c5 100644 --- a/dist/modules/seo/hreflang.js.map +++ b/dist/modules/seo/hreflang.js.map @@ -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 {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record = {}\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"} \ No newline at end of file +{"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 {\n const origin = baseUrl?.replace(/\\/$/, '') ?? ''\n const alternates: Record = {}\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"} \ No newline at end of file diff --git a/docs/blocks.md b/docs/blocks.md index 785735d..398ca7e 100644 --- a/docs/blocks.md +++ b/docs/blocks.md @@ -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 + case 'faq': return + // ...20 case'ów +} + +// DOBRZE — mapa + RenderBlocks (silnik z pluginu) +const registry = { hero: Hero, faq: FAQ, /* ... */ } + +``` + +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): diff --git a/docs/deployment.md b/docs/deployment.md index 03dde0b..08221e7 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -121,7 +121,7 @@ utrata SEO. **Każdy projekt** tego potrzebuje w next.config: ```ts const nextConfig: NextConfig = { htmlLimitedBots: - /Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i, + /Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i, // ... } ``` diff --git a/docs/i18n.md b/docs/i18n.md index 0e9ef80..8d21671 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -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`. > Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną -> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty. \ No newline at end of file +> 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 ... +} +``` + +### 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. \ No newline at end of file diff --git a/docs/seo.md b/docs/seo.md index fa13d42..29c93a7 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -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 diff --git a/docs/storage.md b/docs/storage.md index 7856aa0..1ed080a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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**: diff --git a/src/modules/i18n/localeMiddleware.ts b/src/modules/i18n/localeMiddleware.ts index 774cac6..11f9458 100644 --- a/src/modules/i18n/localeMiddleware.ts +++ b/src/modules/i18n/localeMiddleware.ts @@ -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 diff --git a/src/modules/i18n/localizedPath.ts b/src/modules/i18n/localizedPath.ts index 39bad99..1166b71 100644 --- a/src/modules/i18n/localizedPath.ts +++ b/src/modules/i18n/localizedPath.ts @@ -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 = { diff --git a/src/modules/seo/hreflang.ts b/src/modules/seo/hreflang.ts index 0bad089..4054da4 100644 --- a/src/modules/seo/hreflang.ts +++ b/src/modules/seo/hreflang.ts @@ -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 { const origin = baseUrl?.replace(/\/$/, '') ?? '' const alternates: Record = {} + // 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 }