init commit for iPAL-kit plugin
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import type { LocalizedSlugs } from './localizedPath.js'
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale } from './helpers.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 function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs {
|
||||
const result: LocalizedSlugs = {}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { I18nConfig, LocaleDefinition } from './types.js'
|
||||
|
||||
/**
|
||||
* Returns all configured locale codes.
|
||||
*/
|
||||
export function getLocaleCodes(config: I18nConfig): string[] {
|
||||
return config.locales.map((locale) => locale.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default locale code.
|
||||
*/
|
||||
export function getDefaultLocale(config: I18nConfig): string {
|
||||
return config.defaultLocale
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid configured locale code.
|
||||
*/
|
||||
export function isValidLocale(code: string, config: I18nConfig): boolean {
|
||||
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: string,
|
||||
config: I18nConfig,
|
||||
): LocaleDefinition | undefined {
|
||||
return config.locales.find((locale) => locale.code === code)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { I18nConfig } from './types.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 = {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* First path segment of a URL pathname, or '' for root.
|
||||
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
|
||||
*/
|
||||
function firstSegment(pathname: string): string {
|
||||
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,
|
||||
}: CreateLocaleMiddlewareArgs) {
|
||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||
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|.*\\..*).*)']
|
||||
@@ -0,0 +1,23 @@
|
||||
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 function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig {
|
||||
const { defaultLocale, fallback = true, locales } = config
|
||||
|
||||
return {
|
||||
defaultLocale,
|
||||
fallback,
|
||||
locales: locales.map((locale) => ({
|
||||
code: locale.code,
|
||||
label: locale.label,
|
||||
...(locale.rtl && { rtl: true }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { isValidLocale } from './helpers.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 function buildLocalizedPath({
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
locale,
|
||||
prefix,
|
||||
slugs,
|
||||
}: BuildPathArgs): string | undefined {
|
||||
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}`
|
||||
}
|
||||
|
||||
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 function switchLocalePath({
|
||||
config,
|
||||
homeSlug = 'home',
|
||||
prefix,
|
||||
slugs,
|
||||
targetLocale,
|
||||
}: SwitchLocaleArgs): string {
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
|
||||
if (path) {return path}
|
||||
|
||||
const archiveSegment = prefix?.[targetLocale]
|
||||
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
|
||||
|
||||
return `/${targetLocale}`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
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'
|
||||
|
||||
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 function negotiateLocale({
|
||||
acceptLanguage,
|
||||
config,
|
||||
cookieLocale,
|
||||
}: NegotiateLocaleArgs): string {
|
||||
// 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: null | string | undefined,
|
||||
config: I18nConfig,
|
||||
): string | undefined {
|
||||
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: string): string[] {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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[]]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
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: I18nConfig): void {
|
||||
const { defaultLocale, locales } = config
|
||||
|
||||
if (!locales?.length) {
|
||||
throw new Error('[ipal] i18n: "locales" must contain at least one locale.')
|
||||
}
|
||||
|
||||
const codes = new Set<string>()
|
||||
|
||||
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(', ')}].`,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user