This commit is contained in:
2026-07-31 23:21:51 +02:00
parent f6b4b36df8
commit f14f2cfd98
294 changed files with 5880 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import type { LocalizedSlugs } from './localizedPath.js';
import type { I18nConfig } from './types.js';
/**
* A localized field as Payload returns it when queried with `locale: 'all'`:
* a map of locale code → value.
*/
type LocalizedField = Record<string, unknown>;
type GetLocalizedSlugsArgs = {
config: I18nConfig;
/**
* The document's localized slug field, as returned by Payload with
* `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.
*/
slugField: LocalizedField | null | undefined;
};
/**
* Normalizes a document's localized slug field into the LocalizedSlugs
* contract consumed by buildLocalizedPath / switchLocalePath.
*
* Pure function — the template fetches the document (payload.findByID with
* `locale: 'all'`, where the slug field must be `localized: true`) and passes
* the raw field in. The plugin never touches the database.
*
* Keeps only entries whose locale is configured and whose slug is a
* non-empty string, so callers get a clean, trustworthy map.
*
* @example
* const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
* const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
* // → { pl: 'strona-glowna', en: 'home' }
* switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'
*/
export declare function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs;
export {};
+39
View File
@@ -0,0 +1,39 @@
import { isValidLocale } from './helpers.js';
/**
* Normalizes a document's localized slug field into the LocalizedSlugs
* contract consumed by buildLocalizedPath / switchLocalePath.
*
* Pure function — the template fetches the document (payload.findByID with
* `locale: 'all'`, where the slug field must be `localized: true`) and passes
* the raw field in. The plugin never touches the database.
*
* Keeps only entries whose locale is configured and whose slug is a
* non-empty string, so callers get a clean, trustworthy map.
*
* @example
* const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
* const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
* // → { pl: 'strona-glowna', en: 'home' }
* switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'
*/ export function getLocalizedSlugs({ config, slugField }) {
const result = {};
if (!slugField || typeof slugField !== 'object') {
return result;
}
for (const [locale, value] of Object.entries(slugField)){
if (!isValidLocale(locale, config)) {
continue;
}
if (typeof value !== 'string') {
continue;
}
const slug = value.trim();
if (!slug) {
continue;
}
result[locale] = slug;
}
return result;
}
//# sourceMappingURL=getLocalizedSlugs.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/getLocalizedSlugs.ts"],"sourcesContent":["import type { LocalizedSlugs } from './localizedPath.js'\nimport type { I18nConfig } from './types.js'\n\nimport { isValidLocale } from './helpers.js'\n\n/**\n * A localized field as Payload returns it when queried with `locale: 'all'`:\n * a map of locale code → value.\n */\ntype LocalizedField = Record<string, unknown>\n\ntype GetLocalizedSlugsArgs = {\n config: I18nConfig\n /**\n * The document's localized slug field, as returned by Payload with\n * `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.\n */\n slugField: LocalizedField | null | undefined\n}\n\n/**\n * Normalizes a document's localized slug field into the LocalizedSlugs\n * contract consumed by buildLocalizedPath / switchLocalePath.\n *\n * Pure function — the template fetches the document (payload.findByID with\n * `locale: 'all'`, where the slug field must be `localized: true`) and passes\n * the raw field in. The plugin never touches the database.\n *\n * Keeps only entries whose locale is configured and whose slug is a\n * non-empty string, so callers get a clean, trustworthy map.\n *\n * @example\n * const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })\n * const slugs = getLocalizedSlugs({ slugField: doc.slug, config })\n * // → { pl: 'strona-glowna', en: 'home' }\n * switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'\n */\nexport function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs {\n const result: LocalizedSlugs = {}\n\n if (!slugField || typeof slugField !== 'object') {\n return result\n }\n\n for (const [locale, value] of Object.entries(slugField)) {\n if (!isValidLocale(locale, config)) {continue}\n if (typeof value !== 'string') {continue}\n\n const slug = value.trim()\n if (!slug) {continue}\n\n result[locale] = slug\n }\n\n return result\n}\n"],"names":["isValidLocale","getLocalizedSlugs","config","slugField","result","locale","value","Object","entries","slug","trim"],"mappings":"AAGA,SAASA,aAAa,QAAQ,eAAc;AAiB5C;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,kBAAkB,EAAEC,MAAM,EAAEC,SAAS,EAAyB;IAC5E,MAAMC,SAAyB,CAAC;IAEhC,IAAI,CAACD,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOC;IACT;IAEA,KAAK,MAAM,CAACC,QAAQC,MAAM,IAAIC,OAAOC,OAAO,CAACL,WAAY;QACvD,IAAI,CAACH,cAAcK,QAAQH,SAAS;YAAC;QAAQ;QAC7C,IAAI,OAAOI,UAAU,UAAU;YAAC;QAAQ;QAExC,MAAMG,OAAOH,MAAMI,IAAI;QACvB,IAAI,CAACD,MAAM;YAAC;QAAQ;QAEpBL,MAAM,CAACC,OAAO,GAAGI;IACnB;IAEA,OAAOL;AACT"}
+17
View File
@@ -0,0 +1,17 @@
import type { I18nConfig, LocaleDefinition } from './types.js';
/**
* Returns all configured locale codes.
*/
export declare function getLocaleCodes(config: I18nConfig): string[];
/**
* Returns the default locale code.
*/
export declare function getDefaultLocale(config: I18nConfig): string;
/**
* Checks if a string is a valid configured locale code.
*/
export declare function isValidLocale(code: string, config: I18nConfig): boolean;
/**
* Returns the full locale definition for a given code, or undefined if not found.
*/
export declare function getLocaleDefinition(code: string, config: I18nConfig): LocaleDefinition | undefined;
+22
View File
@@ -0,0 +1,22 @@
/**
* Returns all configured locale codes.
*/ export function getLocaleCodes(config) {
return config.locales.map((locale)=>locale.code);
}
/**
* Returns the default locale code.
*/ export function getDefaultLocale(config) {
return config.defaultLocale;
}
/**
* Checks if a string is a valid configured locale code.
*/ export function isValidLocale(code, config) {
return config.locales.some((locale)=>locale.code === code);
}
/**
* Returns the full locale definition for a given code, or undefined if not found.
*/ export function getLocaleDefinition(code, config) {
return config.locales.find((locale)=>locale.code === code);
}
//# sourceMappingURL=helpers.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/helpers.ts"],"sourcesContent":["import type { I18nConfig, LocaleDefinition } from './types.js'\n\n/**\n * Returns all configured locale codes.\n */\nexport function getLocaleCodes(config: I18nConfig): string[] {\n return config.locales.map((locale) => locale.code)\n}\n\n/**\n * Returns the default locale code.\n */\nexport function getDefaultLocale(config: I18nConfig): string {\n return config.defaultLocale\n}\n\n/**\n * Checks if a string is a valid configured locale code.\n */\nexport function isValidLocale(code: string, config: I18nConfig): boolean {\n return config.locales.some((locale) => locale.code === code)\n}\n\n/**\n * Returns the full locale definition for a given code, or undefined if not found.\n */\nexport function getLocaleDefinition(\n code: string,\n config: I18nConfig,\n): LocaleDefinition | undefined {\n return config.locales.find((locale) => locale.code === code)\n}\n"],"names":["getLocaleCodes","config","locales","map","locale","code","getDefaultLocale","defaultLocale","isValidLocale","some","getLocaleDefinition","find"],"mappings":"AAEA;;CAEC,GACD,OAAO,SAASA,eAAeC,MAAkB;IAC/C,OAAOA,OAAOC,OAAO,CAACC,GAAG,CAAC,CAACC,SAAWA,OAAOC,IAAI;AACnD;AAEA;;CAEC,GACD,OAAO,SAASC,iBAAiBL,MAAkB;IACjD,OAAOA,OAAOM,aAAa;AAC7B;AAEA;;CAEC,GACD,OAAO,SAASC,cAAcH,IAAY,EAAEJ,MAAkB;IAC5D,OAAOA,OAAOC,OAAO,CAACO,IAAI,CAAC,CAACL,SAAWA,OAAOC,IAAI,KAAKA;AACzD;AAEA;;CAEC,GACD,OAAO,SAASK,oBACdL,IAAY,EACZJ,MAAkB;IAElB,OAAOA,OAAOC,OAAO,CAACS,IAAI,CAAC,CAACP,SAAWA,OAAOC,IAAI,KAAKA;AACzD"}
+10
View File
@@ -0,0 +1,10 @@
export { getLocalizedSlugs } from './getLocalizedSlugs.js';
export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js';
export type { LocaleMiddlewareResult } from './localeMiddleware.js';
export { buildLocalizationConfig } from './localizationConfig.js';
export { buildLocalizedPath, switchLocalePath } from './localizedPath.js';
export type { LocalizedSlugs } from './localizedPath.js';
export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js';
export type { I18nConfig, LocaleDefinition } from './types.js';
export { validateI18nConfig } from './validation.js';
+9
View File
@@ -0,0 +1,9 @@
export { getLocalizedSlugs } from './getLocalizedSlugs.js';
export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js';
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js';
export { buildLocalizationConfig } from './localizationConfig.js';
export { buildLocalizedPath, switchLocalePath } from './localizedPath.js';
export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js';
export { validateI18nConfig } from './validation.js';
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/index.ts"],"sourcesContent":["export { getLocalizedSlugs } from './getLocalizedSlugs.js'\nexport { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'\nexport type { LocaleMiddlewareResult } from './localeMiddleware.js'\nexport { buildLocalizationConfig } from './localizationConfig.js'\nexport { buildLocalizedPath, switchLocalePath } from './localizedPath.js'\nexport type { LocalizedSlugs } from './localizedPath.js'\nexport { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'\nexport type { I18nConfig, LocaleDefinition } from './types.js'\nexport { validateI18nConfig } from './validation.js'\n"],"names":["getLocalizedSlugs","getDefaultLocale","getLocaleCodes","getLocaleDefinition","isValidLocale","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","buildLocalizationConfig","buildLocalizedPath","switchLocalePath","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","validateI18nConfig"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,yBAAwB;AAC1D,SAASC,gBAAgB,EAAEC,cAAc,EAAEC,mBAAmB,EAAEC,aAAa,QAAQ,eAAc;AACnG,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,wBAAuB;AAE1F,SAASC,uBAAuB,QAAQ,0BAAyB;AACjE,SAASC,kBAAkB,EAAEC,gBAAgB,QAAQ,qBAAoB;AAEzE,SAASC,kBAAkB,EAAEC,mBAAmB,EAAEC,eAAe,QAAQ,uBAAsB;AAE/F,SAASC,kBAAkB,QAAQ,kBAAiB"}
+73
View File
@@ -0,0 +1,73 @@
import type { I18nConfig } from './types.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 = {
cookies: {
get: (name: string) => {
value: string;
} | undefined;
};
headers: {
get: (name: string) => null | string;
};
nextUrl: {
clone: () => URL;
pathname: string;
search: string;
};
url: string;
};
/**
* What the factory returns — the caller (in Next next-middleware.ts) decides how to
* act: `redirect` means send a 307 to `location` and set the locale cookie;
* `next` means let the request pass through untouched.
*/
export type LocaleMiddlewareResult = {
cookie: {
name: string;
value: string;
};
location: string;
type: 'redirect';
} | {
type: 'next';
};
type CreateLocaleMiddlewareArgs = {
config: I18nConfig;
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string;
};
/**
* Builds locale-routing logic for Next.js middleware.
*
* Behavior:
* - path already starts with a valid locale (/pl/...) → pass through
* - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale
* comes from negotiateLocale (cookie → Accept-Language → default)
* - the chosen locale is written to a cookie so the next visit is stable
*
* The plugin returns a decision; the thin next-middleware.ts in the client project
* turns it into a NextResponse. This keeps all logic in the plugin while
* respecting that next-middleware.ts must physically live in the client app.
*
* @example
* // next-middleware.ts (client project) — one wiring file, no logic:
* import { NextResponse } from 'next/server'
* import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function middleware(req) {
* const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next()
* const res = NextResponse.redirect(r.location)
* res.cookies.set(r.cookie.name, r.cookie.value)
* return res
* }
*/
export declare function createLocaleMiddleware({ config, cookieName, }: 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).
*/
export declare const DEFAULT_MIDDLEWARE_MATCHER: string[];
export {};
+67
View File
@@ -0,0 +1,67 @@
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', '/' → ''.
*/ function firstSegment(pathname) {
return pathname.split('/').filter(Boolean)[0] ?? '';
}
/**
* Builds locale-routing logic for Next.js middleware.
*
* Behavior:
* - path already starts with a valid locale (/pl/...) → pass through
* - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale
* comes from negotiateLocale (cookie → Accept-Language → default)
* - the chosen locale is written to a cookie so the next visit is stable
*
* The plugin returns a decision; the thin next-middleware.ts in the client project
* turns it into a NextResponse. This keeps all logic in the plugin while
* respecting that next-middleware.ts must physically live in the client app.
*
* @example
* // next-middleware.ts (client project) — one wiring file, no logic:
* import { NextResponse } from 'next/server'
* import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function middleware(req) {
* const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next()
* const res = NextResponse.redirect(r.location)
* res.cookies.set(r.cookie.name, r.cookie.value)
* return res
* }
*/ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME }) {
return function localeMiddleware(request) {
const { pathname } = request.nextUrl;
// Already locale-prefixed → nothing to do
if (isValidLocale(firstSegment(pathname), config)) {
return {
type: 'next'
};
}
// Resolve the locale to use
const locale = negotiateLocale({
acceptLanguage: request.headers.get('accept-language'),
config,
cookieLocale: request.cookies.get(cookieName)?.value ?? null
});
// Redirect to the locale-prefixed path, preserving the rest
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
return {
type: 'redirect',
cookie: {
name: cookieName,
value: locale
},
location: url.toString()
};
};
}
/**
* Default Next.js middleware matcher: run on everything except API routes, the
* admin panel, Next internals, and files with an extension (static assets).
*/ export const DEFAULT_MIDDLEWARE_MATCHER = [
'/((?!api|admin|_next|.*\\..*).*)'
];
//# sourceMappingURL=localeMiddleware.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/localeMiddleware.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\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 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' } | { type: 'next' }\n\ntype CreateLocaleMiddlewareArgs = {\n config: I18nConfig\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 next-middleware.ts in the client project\n * turns it into a NextResponse. This keeps all logic in the plugin while\n * respecting that next-middleware.ts must physically live in the client app.\n *\n * @example\n * // next-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 middleware(req) {\n * const r = localeMiddleware(req)\n * if (r.type === 'next') return NextResponse.next()\n * const res = NextResponse.redirect(r.location)\n * 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}: CreateLocaleMiddlewareArgs) {\n return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {\n const { pathname } = request.nextUrl\n\n // Already locale-prefixed → nothing to do\n if (isValidLocale(firstSegment(pathname), config)) {\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 return {\n type: 'redirect',\n cookie: { name: cookieName, value: locale },\n location: url.toString(),\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":["isValidLocale","LOCALE_COOKIE_NAME","negotiateLocale","firstSegment","pathname","split","filter","Boolean","createLocaleMiddleware","config","cookieName","localeMiddleware","request","nextUrl","type","locale","acceptLanguage","headers","get","cookieLocale","cookies","value","url","clone","cookie","name","location","toString","DEFAULT_MIDDLEWARE_MATCHER"],"mappings":"AAEA,SAASA,aAAa,EAAEC,kBAAkB,EAAEC,eAAe,QAAQ,mBAAkB;AA2BrF;;;CAGC,GACD,SAASC,aAAaC,QAAgB;IACpC,OAAOA,SAASC,KAAK,CAAC,KAAKC,MAAM,CAACC,QAAQ,CAAC,EAAE,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,uBAAuB,EACrCC,MAAM,EACNC,aAAaT,kBAAkB,EACJ;IAC3B,OAAO,SAASU,iBAAiBC,OAA0B;QACzD,MAAM,EAAER,QAAQ,EAAE,GAAGQ,QAAQC,OAAO;QAEpC,0CAA0C;QAC1C,IAAIb,cAAcG,aAAaC,WAAWK,SAAS;YACjD,OAAO;gBAAEK,MAAM;YAAO;QACxB;QAEA,4BAA4B;QAC5B,MAAMC,SAASb,gBAAgB;YAC7Bc,gBAAgBJ,QAAQK,OAAO,CAACC,GAAG,CAAC;YACpCT;YACAU,cAAcP,QAAQQ,OAAO,CAACF,GAAG,CAACR,aAAaW,SAAS;QAC1D;QAEA,4DAA4D;QAC5D,MAAMC,MAAMV,QAAQC,OAAO,CAACU,KAAK;QACjCD,IAAIlB,QAAQ,GAAG,CAAC,CAAC,EAAEW,SAASX,aAAa,MAAM,KAAKA,UAAU;QAE9D,OAAO;YACLU,MAAM;YACNU,QAAQ;gBAAEC,MAAMf;gBAAYW,OAAON;YAAO;YAC1CW,UAAUJ,IAAIK,QAAQ;QACxB;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,MAAMC,6BAA6B;IAAC;CAAmC,CAAA"}
+9
View File
@@ -0,0 +1,9 @@
import type { Config } from 'payload';
import type { I18nConfig } from './types.js';
type PayloadLocalizationConfig = NonNullable<Config['localization']>;
/**
* Transforms validated i18n config into Payload's localization object.
* Pure function — no side effects, no validation (caller validates first).
*/
export declare function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig;
export {};
+19
View File
@@ -0,0 +1,19 @@
/**
* Transforms validated i18n config into Payload's localization object.
* Pure function — no side effects, no validation (caller validates first).
*/ export function buildLocalizationConfig(config) {
const { defaultLocale, fallback = true, locales } = config;
return {
defaultLocale,
fallback,
locales: locales.map((locale)=>({
code: locale.code,
label: locale.label,
...locale.rtl && {
rtl: true
}
}))
};
}
//# sourceMappingURL=localizationConfig.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/localizationConfig.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { I18nConfig } from './types.js'\n\ntype PayloadLocalizationConfig = NonNullable<Config['localization']>\n\n/**\n * Transforms validated i18n config into Payload's localization object.\n * Pure function — no side effects, no validation (caller validates first).\n */\nexport function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig {\n const { defaultLocale, fallback = true, locales } = config\n\n return {\n defaultLocale,\n fallback,\n locales: locales.map((locale) => ({\n code: locale.code,\n label: locale.label,\n ...(locale.rtl && { rtl: true }),\n })),\n }\n}\n"],"names":["buildLocalizationConfig","config","defaultLocale","fallback","locales","map","locale","code","label","rtl"],"mappings":"AAMA;;;CAGC,GACD,OAAO,SAASA,wBAAwBC,MAAkB;IACxD,MAAM,EAAEC,aAAa,EAAEC,WAAW,IAAI,EAAEC,OAAO,EAAE,GAAGH;IAEpD,OAAO;QACLC;QACAC;QACAC,SAASA,QAAQC,GAAG,CAAC,CAACC,SAAY,CAAA;gBAChCC,MAAMD,OAAOC,IAAI;gBACjBC,OAAOF,OAAOE,KAAK;gBACnB,GAAIF,OAAOG,GAAG,IAAI;oBAAEA,KAAK;gBAAK,CAAC;YACjC,CAAA;IACF;AACF"}
+70
View File
@@ -0,0 +1,70 @@
import type { I18nConfig } from './types.js';
/**
* Minimal shape the path builder needs from a document.
*
* The plugin doesn't know the client's Pages type, so it depends only on
* this contract: a map of locale code → slug for that locale. The template
* supplies it (e.g. by reading the localized slug field across locales).
*/
export type LocalizedSlugs = Record<string, string>;
type BuildPathArgs = {
config: I18nConfig;
/**
* Slug that represents the site root (served at /{locale} with no trailing
* segment). Defaults to 'home'. Matched against the slug in the target locale.
*/
homeSlug?: string;
/** Target locale to build the path for */
locale: string;
/**
* Localized segment the document lives under, e.g.
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
*
* These are the slugs of the collection's archive page, so the prefix is
* whatever an editor named that page — and it differs per locale for free.
* A document under a prefix is never the home page, so homeSlug is ignored.
*/
prefix?: LocalizedSlugs;
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
slugs: LocalizedSlugs;
};
/**
* Builds a locale-prefixed path for a document in a target locale.
*
* Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug
* collapses to the locale root. Returns undefined if the document has no slug
* in the target locale (caller decides fallback behavior).
*
* @example
* buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })
* // → '/en' (home slug collapses to root)
*
* buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })
* // → '/en/about'
*
* buildLocalizedPath({
* slugs: { pl: 'moj-post', en: 'my-post' },
* prefix: { pl: 'artykuly', en: 'articles' },
* locale: 'en',
* config,
* })
* // → '/en/articles/my-post'
*/
export declare function buildLocalizedPath({ config, homeSlug, locale, prefix, slugs, }: BuildPathArgs): string | undefined;
type SwitchLocaleArgs = {
config: I18nConfig;
homeSlug?: string;
prefix?: LocalizedSlugs;
slugs: LocalizedSlugs;
targetLocale: string;
};
/**
* Resolves the equivalent path for the same document in a different locale —
* the language-switcher use case (/pl/strona-glowna → /en/home).
*
* Never dead-ends on a 404. When the document has no slug in the target locale,
* falls back to the archive it belongs to (/en/articles) if there is one, and
* to the locale root otherwise — the closest place the visitor would want.
*/
export declare function switchLocalePath({ config, homeSlug, prefix, slugs, targetLocale, }: SwitchLocaleArgs): string;
export {};
+71
View File
@@ -0,0 +1,71 @@
import { isValidLocale } from './helpers.js';
/**
* Builds a locale-prefixed path for a document in a target locale.
*
* Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug
* collapses to the locale root. Returns undefined if the document has no slug
* in the target locale (caller decides fallback behavior).
*
* @example
* buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })
* // → '/en' (home slug collapses to root)
*
* buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })
* // → '/en/about'
*
* buildLocalizedPath({
* slugs: { pl: 'moj-post', en: 'my-post' },
* prefix: { pl: 'artykuly', en: 'articles' },
* locale: 'en',
* config,
* })
* // → '/en/articles/my-post'
*/ export function buildLocalizedPath({ config, homeSlug = 'home', locale, prefix, slugs }) {
if (!isValidLocale(locale, config)) {
return undefined;
}
const slug = slugs[locale];
if (!slug) {
return undefined;
}
if (prefix) {
const segment = prefix[locale];
// No archive slug in this locale means the entry is unreachable there —
// there's no path to point at, so hreflang should omit it rather than
// invent /en/moj-post.
if (!segment) {
return undefined;
}
return `/${locale}/${segment}/${slug}`;
}
if (slug === homeSlug) {
return `/${locale}`;
}
return `/${locale}/${slug}`;
}
/**
* Resolves the equivalent path for the same document in a different locale —
* the language-switcher use case (/pl/strona-glowna → /en/home).
*
* Never dead-ends on a 404. When the document has no slug in the target locale,
* falls back to the archive it belongs to (/en/articles) if there is one, and
* to the locale root otherwise — the closest place the visitor would want.
*/ export function switchLocalePath({ config, homeSlug = 'home', prefix, slugs, targetLocale }) {
const path = buildLocalizedPath({
config,
homeSlug,
locale: targetLocale,
prefix,
slugs
});
if (path) {
return path;
}
const archiveSegment = prefix?.[targetLocale];
if (archiveSegment) {
return `/${targetLocale}/${archiveSegment}`;
}
return `/${targetLocale}`;
}
//# sourceMappingURL=localizedPath.js.map
+1
View File
@@ -0,0 +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<string, string>\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"}
+29
View File
@@ -0,0 +1,29 @@
import type { I18nConfig } from './types.js';
/** Cookie name the template uses to persist a visitor's locale choice. */
export declare const LOCALE_COOKIE_NAME = "ipal-locale";
type NegotiateLocaleArgs = {
/** Raw Accept-Language header value */
acceptLanguage?: null | string;
config: I18nConfig;
/** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */
cookieLocale?: null | string;
};
/**
* Resolves which locale to serve, in priority order:
* 1. Cookie (explicit prior choice)
* 2. Accept-Language header (best match against configured locales)
* 3. Configured default locale
*
* Pure function — the template feeds it request data and acts on the result
* (redirect, cookie set). No Next.js or request objects here.
*/
export declare function negotiateLocale({ acceptLanguage, config, cookieLocale, }: NegotiateLocaleArgs): string;
/**
* Parses an Accept-Language header and returns the best-matching configured
* locale, or undefined if none match.
*
* Matches on the primary subtag (e.g. "en-US" matches configured "en"),
* respecting the header's quality-value ordering.
*/
export declare function matchAcceptLanguage(acceptLanguage: null | string | undefined, config: I18nConfig): string | undefined;
export {};
+68
View File
@@ -0,0 +1,68 @@
import { getLocaleCodes, isValidLocale } from './helpers.js';
/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale';
/**
* Resolves which locale to serve, in priority order:
* 1. Cookie (explicit prior choice)
* 2. Accept-Language header (best match against configured locales)
* 3. Configured default locale
*
* Pure function — the template feeds it request data and acts on the result
* (redirect, cookie set). No Next.js or request objects here.
*/ export function negotiateLocale({ acceptLanguage, config, cookieLocale }) {
// 1. Explicit prior choice wins
if (cookieLocale && isValidLocale(cookieLocale, config)) {
return cookieLocale;
}
// 2. Best match from Accept-Language
const fromHeader = matchAcceptLanguage(acceptLanguage, config);
if (fromHeader) {
return fromHeader;
}
// 3. Fall back to configured default
return config.defaultLocale;
}
/**
* Parses an Accept-Language header and returns the best-matching configured
* locale, or undefined if none match.
*
* Matches on the primary subtag (e.g. "en-US" matches configured "en"),
* respecting the header's quality-value ordering.
*/ export function matchAcceptLanguage(acceptLanguage, config) {
if (!acceptLanguage) {
return undefined;
}
const available = getLocaleCodes(config);
const ranked = parseAcceptLanguage(acceptLanguage);
for (const tag of ranked){
// Exact match (e.g. "pt-BR" === "pt-BR")
const exact = available.find((code)=>code.toLowerCase() === tag);
if (exact) {
return exact;
}
// Primary-subtag match (e.g. "en-us" → "en")
const primary = tag.split('-')[0];
const partial = available.find((code)=>code.toLowerCase().split('-')[0] === primary);
if (partial) {
return partial;
}
}
return undefined;
}
/**
* Parses an Accept-Language header into locale tags ordered by descending
* quality value. Tags are lowercased for comparison.
*
* "en-US,en;q=0.9,pl;q=0.8" → ["en-us", "en", "pl"]
*/ function parseAcceptLanguage(header) {
return header.split(',').map((part)=>{
const [tag, ...params] = part.trim().split(';');
const qParam = params.find((p)=>p.trim().startsWith('q='));
const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1;
return {
quality: Number.isNaN(quality) ? 0 : quality,
tag: tag.trim().toLowerCase()
};
}).filter((entry)=>entry.tag && entry.tag !== '*').sort((a, b)=>b.quality - a.quality).map((entry)=>entry.tag);
}
//# sourceMappingURL=negotiateLocale.js.map
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
/**
* Single locale definition provided by the client project.
*/
export type LocaleDefinition = {
/** BCP-47 language code, e.g. 'pl', 'en', 'de' */
code: string;
/** Human-readable label, e.g. 'Polski', 'English' */
label: string;
/** Right-to-left script (defaults to false) */
rtl?: boolean;
};
/**
* Full i18n configuration passed through plugin options.
*/
export type I18nConfig = {
defaultLocale: string;
/** Enable locale fallback when content is missing (defaults to true) */
fallback?: boolean;
locales: [LocaleDefinition, ...LocaleDefinition[]];
};
+7
View File
@@ -0,0 +1,7 @@
/**
* Single locale definition provided by the client project.
*/ /**
* Full i18n configuration passed through plugin options.
*/ export { };
//# sourceMappingURL=types.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/types.ts"],"sourcesContent":["/**\n * Single locale definition provided by the client project.\n */\nexport type LocaleDefinition = {\n /** BCP-47 language code, e.g. 'pl', 'en', 'de' */\n code: string\n /** Human-readable label, e.g. 'Polski', 'English' */\n label: string\n /** Right-to-left script (defaults to false) */\n rtl?: boolean\n}\n\n/**\n * Full i18n configuration passed through plugin options.\n */\nexport type I18nConfig = {\n defaultLocale: string\n /** Enable locale fallback when content is missing (defaults to true) */\n fallback?: boolean\n locales: [LocaleDefinition, ...LocaleDefinition[]]\n}\n"],"names":[],"mappings":"AAAA;;CAEC,GAUD;;CAEC,GACD,WAKC"}
+6
View File
@@ -0,0 +1,6 @@
import type { I18nConfig } from './types.js';
/**
* Validates i18n config at plugin initialization.
* Throws descriptive errors — fail fast, no silent defaults.
*/
export declare function validateI18nConfig(config: I18nConfig): void;
+30
View File
@@ -0,0 +1,30 @@
const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/;
/**
* Validates i18n config at plugin initialization.
* Throws descriptive errors — fail fast, no silent defaults.
*/ export function validateI18nConfig(config) {
const { defaultLocale, locales } = config;
if (!locales?.length) {
throw new Error('[ipal] i18n: "locales" must contain at least one locale.');
}
const codes = new Set();
for (const locale of locales){
if (!locale.code || !locale.label) {
throw new Error(`[ipal] i18n: Every locale must have "code" and "label". Received: ${JSON.stringify(locale)}`);
}
if (!LOCALE_CODE_PATTERN.test(locale.code)) {
throw new Error(`[ipal] i18n: Invalid locale code "${locale.code}". Expected format: "pl", "en", "pt-BR".`);
}
if (codes.has(locale.code)) {
throw new Error(`[ipal] i18n: Duplicate locale code "${locale.code}".`);
}
codes.add(locale.code);
}
if (!codes.has(defaultLocale)) {
throw new Error(`[ipal] i18n: defaultLocale "${defaultLocale}" not found in locales [${[
...codes
].join(', ')}].`);
}
}
//# sourceMappingURL=validation.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/i18n/validation.ts"],"sourcesContent":["import type { I18nConfig } from './types.js'\n\nconst LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/\n\n/**\n * Validates i18n config at plugin initialization.\n * Throws descriptive errors — fail fast, no silent defaults.\n */\nexport function validateI18nConfig(config: I18nConfig): void {\n const { defaultLocale, locales } = config\n\n if (!locales?.length) {\n throw new Error('[ipal] i18n: \"locales\" must contain at least one locale.')\n }\n\n const codes = new Set<string>()\n\n for (const locale of locales) {\n if (!locale.code || !locale.label) {\n throw new Error(\n `[ipal] i18n: Every locale must have \"code\" and \"label\". Received: ${JSON.stringify(locale)}`,\n )\n }\n\n if (!LOCALE_CODE_PATTERN.test(locale.code)) {\n throw new Error(\n `[ipal] i18n: Invalid locale code \"${locale.code}\". Expected format: \"pl\", \"en\", \"pt-BR\".`,\n )\n }\n\n if (codes.has(locale.code)) {\n throw new Error(`[ipal] i18n: Duplicate locale code \"${locale.code}\".`)\n }\n\n codes.add(locale.code)\n }\n\n if (!codes.has(defaultLocale)) {\n throw new Error(\n `[ipal] i18n: defaultLocale \"${defaultLocale}\" not found in locales [${[...codes].join(', ')}].`,\n )\n }\n}\n"],"names":["LOCALE_CODE_PATTERN","validateI18nConfig","config","defaultLocale","locales","length","Error","codes","Set","locale","code","label","JSON","stringify","test","has","add","join"],"mappings":"AAEA,MAAMA,sBAAsB;AAE5B;;;CAGC,GACD,OAAO,SAASC,mBAAmBC,MAAkB;IACnD,MAAM,EAAEC,aAAa,EAAEC,OAAO,EAAE,GAAGF;IAEnC,IAAI,CAACE,SAASC,QAAQ;QACpB,MAAM,IAAIC,MAAM;IAClB;IAEA,MAAMC,QAAQ,IAAIC;IAElB,KAAK,MAAMC,UAAUL,QAAS;QAC5B,IAAI,CAACK,OAAOC,IAAI,IAAI,CAACD,OAAOE,KAAK,EAAE;YACjC,MAAM,IAAIL,MACR,CAAC,kEAAkE,EAAEM,KAAKC,SAAS,CAACJ,SAAS;QAEjG;QAEA,IAAI,CAACT,oBAAoBc,IAAI,CAACL,OAAOC,IAAI,GAAG;YAC1C,MAAM,IAAIJ,MACR,CAAC,kCAAkC,EAAEG,OAAOC,IAAI,CAAC,wCAAwC,CAAC;QAE9F;QAEA,IAAIH,MAAMQ,GAAG,CAACN,OAAOC,IAAI,GAAG;YAC1B,MAAM,IAAIJ,MAAM,CAAC,oCAAoC,EAAEG,OAAOC,IAAI,CAAC,EAAE,CAAC;QACxE;QAEAH,MAAMS,GAAG,CAACP,OAAOC,IAAI;IACvB;IAEA,IAAI,CAACH,MAAMQ,GAAG,CAACZ,gBAAgB;QAC7B,MAAM,IAAIG,MACR,CAAC,4BAA4B,EAAEH,cAAc,wBAAwB,EAAE;eAAII;SAAM,CAACU,IAAI,CAAC,MAAM,EAAE,CAAC;IAEpG;AACF"}