init commit for iPAL-kit plugin

This commit is contained in:
2026-07-18 20:30:23 +02:00
parent 10638127a9
commit 5733a9c8bf
119 changed files with 6892 additions and 411 deletions
+56
View File
@@ -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
}
+32
View File
@@ -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)
}
+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'
+98
View File
@@ -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|.*\\..*).*)']
+23
View File
@@ -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 }),
})),
}
}
+122
View File
@@ -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}`
}
+93
View File
@@ -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)
}
+21
View File
@@ -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[]]
}
+43
View File
@@ -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(', ')}].`,
)
}
}