diff --git a/packages/express-krabs/index.ts b/packages/express-krabs/index.ts index 83fa57b..f74b778 100644 --- a/packages/express-krabs/index.ts +++ b/packages/express-krabs/index.ts @@ -6,6 +6,8 @@ import { Config } from '../utils/config/config'; import findTenant from '../utils/tenants/findTenant'; import resolveRoutes from '../utils/routes/resolve'; import { currentEnv, environmentWarningMessage } from '../utils/env'; +import { normalizeLocalePath } from '../utils/i18n/normalize-locale-path'; +import { getAcceptPreferredLocale } from '../utils/i18n/get-accept-preferred-locale'; if (!currentEnv) { console.warn(environmentWarningMessage); @@ -18,14 +20,18 @@ async function krabs( app: any, config?: Config, ): Promise { + // @ts-ignore + req.locale = null; + const { tenants, enableVhostHeader } = config ?? (await getTenantConfig()); const { hostname } = req; - const vhostHeader = enableVhostHeader && req.headers['x-vhost'] as string; + const vhostHeader = enableVhostHeader && (req.headers['x-vhost'] as string); const host = vhostHeader || hostname; const parsedUrl = parse(req.url, true); - const { pathname = '/', query } = parsedUrl; + + let { pathname = '/', query } = parsedUrl; const tenant = findTenant(tenants, host); @@ -54,6 +60,32 @@ async function krabs( return; } + if ( + tenant?.i18n?.locales.length && + tenant?.i18n?.defaultLocale && + tenant?.i18n?.locales.includes(tenant?.i18n?.defaultLocale) + ) { + const newPath = normalizeLocalePath(pathname as string, tenant.i18n.locales); + const preferredLocale = getAcceptPreferredLocale(tenant.i18n, req.headers); + + const detectedLocale = newPath?.detectedLocale || preferredLocale || tenant.i18n.defaultLocale; + + if ( + detectedLocale.toLowerCase() !== newPath?.detectedLocale && + detectedLocale.toLowerCase() !== tenant.i18n.defaultLocale + ) { + const redirectUrl = `/${detectedLocale}${pathname}${parsedUrl.search ?? ''}`; + res.redirect(redirectUrl); + } + + if (detectedLocale) { + // @ts-ignore + req.locale = detectedLocale; + } + + pathname = newPath.pathname; + } + const route = resolveRoutes(tenant.name, String(pathname)); if (route) { diff --git a/packages/fastify-krabs/index.ts b/packages/fastify-krabs/index.ts index 5d0a934..b5f7d26 100644 --- a/packages/fastify-krabs/index.ts +++ b/packages/fastify-krabs/index.ts @@ -4,6 +4,9 @@ import { Config } from '../utils/config/config'; import findTenant from '../utils/tenants/findTenant'; import resolveRoutes from '../utils/routes/resolve'; import { currentEnv, environmentWarningMessage } from '../utils/env'; +import { normalizeLocalePath } from '../utils/i18n/normalize-locale-path'; +import { getAcceptPreferredLocale } from '../utils/i18n/get-accept-preferred-locale'; +import { parse } from 'url'; if (!currentEnv) { console.warn(environmentWarningMessage); @@ -14,18 +17,22 @@ export default async function krabs( reply: FastifyReply, handle: any, app: any, - config?: Config): Promise { + config?: Config, +): Promise { + // @ts-ignore + request.raw.locale = null; const { tenants, enableVhostHeader } = config ?? (await getTenantConfig()); - - const vhostHeader = enableVhostHeader && request.headers['x-vhost'] as string; + + const vhostHeader = enableVhostHeader && (request.headers['x-vhost'] as string); const rawHostname = request.hostname; - const pathName = request.url; + let pathName = request.url; const query = request.query; const hostname = rawHostname.replace(/:\d+$/, ''); const host = vhostHeader || hostname; const tenant = findTenant(tenants, host); + const parsedUrl = parse(request.url, true); if (!tenant) { reply.status(500).send({ @@ -33,6 +40,37 @@ export default async function krabs( }); } + if (pathName?.startsWith('/_next')) { + handle(request.raw, reply.raw); + return; + } + + if ( + tenant?.i18n?.locales.length && + tenant?.i18n?.defaultLocale && + tenant?.i18n?.locales.includes(tenant?.i18n?.defaultLocale) + ) { + const newPath = normalizeLocalePath(pathName as string, tenant.i18n.locales); + const preferredLocale = getAcceptPreferredLocale(tenant.i18n, request.headers); + + const detectedLocale = newPath?.detectedLocale || preferredLocale || tenant.i18n.defaultLocale; + + if ( + detectedLocale.toLowerCase() !== newPath?.detectedLocale && + detectedLocale.toLowerCase() !== tenant.i18n.defaultLocale + ) { + const redirectUrl = `/${detectedLocale}${pathName}${parsedUrl.search ?? ''}`; + reply.redirect(redirectUrl); + } + + if (detectedLocale) { + // @ts-ignore + request.raw.locale = detectedLocale; + } + + pathName = newPath.pathname; + } + const route = resolveRoutes(tenant?.name as string, pathName); if (route) { @@ -44,4 +82,4 @@ export default async function krabs( handle(request, reply); return; -}; \ No newline at end of file +} diff --git a/packages/fastify-krabs/package.json b/packages/fastify-krabs/package.json index ae362db..2910ba6 100644 --- a/packages/fastify-krabs/package.json +++ b/packages/fastify-krabs/package.json @@ -11,7 +11,8 @@ "license": "MIT", "private": false, "dependencies": { - "fastify": "^3.19.2" + "fastify": "^3.19.2", + "url": "^0.11.0" }, "devDependencies": { "react": "^17.0.2", diff --git a/packages/utils/accept-header.ts b/packages/utils/accept-header.ts new file mode 100644 index 0000000..57c3c4f --- /dev/null +++ b/packages/utils/accept-header.ts @@ -0,0 +1,133 @@ +export function acceptLanguage(header = '', preferences?: string[]) { + return ( + parse(header, preferences, { + type: 'accept-language', + prefixMatch: true, + })[0] || '' + ); +} + +interface Selection { + pos: number; + pref?: number; + q: number; + token: string; +} + +interface Options { + prefixMatch?: boolean; + type: 'accept-language'; +} + +function parse(raw: string, preferences: string[] | undefined, options: Options) { + const lowers = new Map(); + const header = raw.replace(/[ \t]/g, ''); + + if (preferences) { + let pos = 0; + for (const preference of preferences) { + const lower = preference.toLowerCase(); + lowers.set(lower, { orig: preference, pos: pos++ }); + if (options.prefixMatch) { + const parts = lower.split('-'); + while ((parts.pop(), parts.length > 0)) { + const joined = parts.join('-'); + if (!lowers.has(joined)) { + lowers.set(joined, { orig: preference, pos: pos++ }); + } + } + } + } + } + + const parts = header.split(','); + const selections: Selection[] = []; + const map = new Set(); + + for (let i = 0; i < parts.length; ++i) { + const part = parts[i]; + if (!part) { + continue; + } + + const params = part.split(';'); + if (params.length > 2) { + throw new Error(`Invalid ${options.type} header`); + } + + let token = params[0].toLowerCase(); + if (!token) { + throw new Error(`Invalid ${options.type} header`); + } + + const selection: Selection = { token, pos: i, q: 1 }; + if (preferences && lowers.has(token)) { + selection.pref = lowers.get(token)!.pos; + } + + map.add(selection.token); + + if (params.length === 2) { + const q = params[1]; + const [key, value] = q.split('='); + + if (!value || (key !== 'q' && key !== 'Q')) { + throw new Error(`Invalid ${options.type} header`); + } + + const score = parseFloat(value); + if (score === 0) { + continue; + } + + if (Number.isFinite(score) && score <= 1 && score >= 0.001) { + selection.q = score; + } + } + + selections.push(selection); + } + + selections.sort((a, b) => { + if (b.q !== a.q) { + return b.q - a.q; + } + + if (b.pref !== a.pref) { + if (a.pref === undefined) { + return 1; + } + + if (b.pref === undefined) { + return -1; + } + + return a.pref - b.pref; + } + + return a.pos - b.pos; + }); + + const values = selections.map((selection) => selection.token); + if (!preferences || !preferences.length) { + return values; + } + + const preferred: string[] = []; + for (const selection of values) { + if (selection === '*') { + for (const [preference, value] of Array.from(lowers)) { + if (!map.has(preference)) { + preferred.push(value.orig); + } + } + } else { + const lower = selection.toLowerCase(); + if (lowers.has(lower)) { + preferred.push(lowers.get(lower)!.orig); + } + } + } + + return preferred; +} diff --git a/packages/utils/config/config.d.ts b/packages/utils/config/config.d.ts index 968272e..ca073d8 100644 --- a/packages/utils/config/config.d.ts +++ b/packages/utils/config/config.d.ts @@ -8,8 +8,15 @@ type NonEmptyArray = A[] & { 0: A }; export type Tenant = { name: string; domains: NonEmptyArray; + i18n?: I18NConfig; }; +export interface I18NConfig { + defaultLocale: string; + localeDetection?: false; + locales: string[]; +} + export type Config = { tenants: Tenant[]; port: number | string; diff --git a/packages/utils/i18n/get-accept-preferred-locale.ts b/packages/utils/i18n/get-accept-preferred-locale.ts new file mode 100644 index 0000000..e56e66a --- /dev/null +++ b/packages/utils/i18n/get-accept-preferred-locale.ts @@ -0,0 +1,13 @@ +import { acceptLanguage } from '../accept-header'; +import { I18NConfig } from '../config/config'; + +export function getAcceptPreferredLocale( + i18n: I18NConfig, + headers?: { [key: string]: string | string[] | undefined }, +) { + const value = headers?.['accept-language']; + + if (!!i18n.localeDetection && value && !Array.isArray(value)) { + return acceptLanguage(value, i18n.locales); + } +} diff --git a/packages/utils/i18n/normalize-locale-path.ts b/packages/utils/i18n/normalize-locale-path.ts new file mode 100644 index 0000000..aab376c --- /dev/null +++ b/packages/utils/i18n/normalize-locale-path.ts @@ -0,0 +1,34 @@ +export interface PathLocale { + detectedLocale?: string; + pathname: string; +} + +/** + * For a pathname that may include a locale from a list of locales, it + * removes the locale from the pathname returning it alongside with the + * detected locale. + * + * @param pathname A pathname that may include a locale. + * @param locales A list of locales. + * @returns The detected locale and pathname without locale + */ +export function normalizeLocalePath(pathname: string, locales?: string[]): PathLocale { + let detectedLocale: string | undefined; + // first item will be empty string from splitting at first char + const pathnameParts = pathname.split('/'); + + (locales || []).some((locale) => { + if (pathnameParts[1] && pathnameParts[1].toLowerCase() === locale.toLowerCase()) { + detectedLocale = locale; + pathnameParts.splice(1, 1); + pathname = pathnameParts.join('/') || '/'; + return true; + } + return false; + }); + + return { + pathname, + detectedLocale, + }; +}