Skip to content
This repository has been archived by the owner on Sep 25, 2023. It is now read-only.

add next.js i18n to fastify-krabs and express-krabs #34

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions packages/express-krabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -18,14 +20,18 @@ async function krabs(
app: any,
config?: Config,
): Promise<void> {
// @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);

Expand Down Expand Up @@ -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) {
Expand Down
48 changes: 43 additions & 5 deletions packages/fastify-krabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -14,25 +17,60 @@ export default async function krabs(
reply: FastifyReply,
handle: any,
app: any,
config?: Config): Promise<void> {
config?: Config,
): Promise<void> {
// @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({
error: 'Invalid tenant',
});
}

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) {
Expand All @@ -44,4 +82,4 @@ export default async function krabs(

handle(request, reply);
return;
};
}
3 changes: 2 additions & 1 deletion packages/fastify-krabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions packages/utils/accept-header.ts
Original file line number Diff line number Diff line change
@@ -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<string, { orig: string; pos: number }>();
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<string>();

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;
}
7 changes: 7 additions & 0 deletions packages/utils/config/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ type NonEmptyArray<A> = A[] & { 0: A };
export type Tenant = {
name: string;
domains: NonEmptyArray<Domain>;
i18n?: I18NConfig;
};

export interface I18NConfig {
defaultLocale: string;
localeDetection?: false;
locales: string[];
}

export type Config = {
tenants: Tenant[];
port: number | string;
Expand Down
13 changes: 13 additions & 0 deletions packages/utils/i18n/get-accept-preferred-locale.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
34 changes: 34 additions & 0 deletions packages/utils/i18n/normalize-locale-path.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}