Skip to content

Commit 5a68778

Browse files
committed
feat: add middleware for useTranslations()
1 parent 4d1caab commit 5a68778

File tree

6 files changed

+184
-5
lines changed

6 files changed

+184
-5
lines changed

packages/root/src/core/core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export {
88
StringParamsProvider,
99
useStringParams,
1010
} from './hooks/useStringParams.js';
11+
export {
12+
TranslationMiddlewareProvider,
13+
useTranslationMiddleware,
14+
} from './hooks/useTranslationsMiddleware.js';
1115
export {
1216
I18nContext,
1317
useI18nContext,

packages/root/src/core/hooks/useTranslations.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {I18nContext, useI18nContext} from './useI18nContext.js';
22
import {useStringParams} from './useStringParams.js';
3+
import {useTranslationMiddleware} from './useTranslationsMiddleware.js';
34

45
/**
56
* A hook that returns a function that can be used to translate a string, and
@@ -23,14 +24,32 @@ export function useTranslations() {
2324
}
2425
const translations = i18nContext?.translations || {};
2526
const stringParams = useStringParams();
27+
const middleware = useTranslationMiddleware();
2628
const t = (str: string, params?: Record<string, string | number>) => {
27-
const key = normalizeString(str);
29+
let input = str;
30+
middleware.beforeTranslateFns.forEach((fn) => {
31+
input = fn(input);
32+
});
33+
const key = normalizeString(input);
2834
let translation = translations[key] ?? key ?? '';
29-
const allParams = {...stringParams, ...params};
30-
for (const paramName of Object.keys(allParams)) {
31-
const paramValue = String(allParams[paramName] ?? '');
32-
translation = translation.replaceAll(`{${paramName}}`, paramValue);
35+
middleware.afterTranslateFns.forEach((fn) => {
36+
translation = fn(input);
37+
});
38+
middleware.beforeReplaceParamsFns.forEach((fn) => {
39+
translation = fn(translation);
40+
});
41+
42+
// Replace string params, e.g. "Hello, {name}".
43+
if (translation.includes('{') && translation.includes('}')) {
44+
translation = replaceStringParams(translation, {
45+
...stringParams,
46+
...params,
47+
});
3348
}
49+
50+
middleware.afterReplaceParamsFns.forEach((fn) => {
51+
translation = fn(translation);
52+
});
3453
return translation;
3554
};
3655
return t;
@@ -54,3 +73,23 @@ function removeTrailingWhitespace(str: string) {
5473
.trimEnd()
5574
.replace(/&nbsp;$/, '');
5675
}
76+
77+
/**
78+
* Replaces string placeholder params, e.g.
79+
*
80+
* ```
81+
* replaceStringParams('Hello, {name}!', {name: 'Joe'})
82+
* // => 'Hello, Joe!'
83+
* ```
84+
*/
85+
function replaceStringParams(
86+
str: string,
87+
params: Record<string, string | number>
88+
): string {
89+
return str.replace(/{([^}]+)}/g, (match, key) => {
90+
if (key in params) {
91+
return String(params[key]);
92+
}
93+
return match;
94+
});
95+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {ComponentChildren, FunctionalComponent, createContext} from 'preact';
2+
import {useContext} from 'preact/hooks';
3+
4+
type TransformFn = (str: string) => string;
5+
6+
export interface TranslationMiddleware {
7+
/** Transform the string before translation lookup. */
8+
beforeTranslate?: TransformFn;
9+
/** Transform the string after translation lookup. */
10+
afterTranslate?: TransformFn;
11+
/** Transform the string before `{param}` values are replaced. */
12+
beforeReplaceParams?: TransformFn;
13+
/** Transform the string after `{param}` values are replaced. */
14+
afterReplaceParams?: TransformFn;
15+
}
16+
17+
export interface TranslationMiddlewareContext {
18+
beforeTranslateFns: TransformFn[];
19+
afterTranslateFns: TransformFn[];
20+
beforeReplaceParamsFns: TransformFn[];
21+
afterReplaceParamsFns: TransformFn[];
22+
}
23+
24+
const TRANSLATION_MIDDLEWARE_CONTEXT =
25+
createContext<TranslationMiddlewareContext | null>(null);
26+
27+
export interface TranslationMiddlewareProviderProps {
28+
value?: TranslationMiddleware;
29+
children?: ComponentChildren;
30+
}
31+
32+
export const TranslationMiddlewareProvider: FunctionalComponent<
33+
TranslationMiddlewareProviderProps
34+
> = (props) => {
35+
const parent = useContext(TRANSLATION_MIDDLEWARE_CONTEXT) || {
36+
beforeTranslateFns: [],
37+
afterTranslateFns: [],
38+
beforeReplaceParamsFns: [],
39+
afterReplaceParamsFns: [],
40+
};
41+
const merged: TranslationMiddlewareContext = {
42+
beforeTranslateFns: [...parent.beforeTranslateFns],
43+
afterTranslateFns: [...parent.afterTranslateFns],
44+
beforeReplaceParamsFns: [...parent.beforeReplaceParamsFns],
45+
afterReplaceParamsFns: [...parent.afterReplaceParamsFns],
46+
};
47+
if (props.value?.beforeTranslate) {
48+
merged.beforeTranslateFns.push(props.value.beforeTranslate);
49+
}
50+
if (props.value?.afterTranslate) {
51+
merged.afterTranslateFns.push(props.value.afterTranslate);
52+
}
53+
if (props.value?.beforeReplaceParams) {
54+
merged.beforeReplaceParamsFns.push(props.value.beforeReplaceParams);
55+
}
56+
if (props.value?.afterReplaceParams) {
57+
merged.afterReplaceParamsFns.push(props.value.afterReplaceParams);
58+
}
59+
return (
60+
<TRANSLATION_MIDDLEWARE_CONTEXT.Provider value={merged}>
61+
{props.children}
62+
</TRANSLATION_MIDDLEWARE_CONTEXT.Provider>
63+
);
64+
};
65+
66+
export function useTranslationMiddleware(): TranslationMiddlewareContext {
67+
return (
68+
useContext(TRANSLATION_MIDDLEWARE_CONTEXT) || {
69+
beforeTranslateFns: [],
70+
afterTranslateFns: [],
71+
beforeReplaceParamsFns: [],
72+
afterReplaceParamsFns: [],
73+
}
74+
);
75+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default {
2+
prettyHtml: true,
3+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {
2+
TranslationMiddlewareProvider,
3+
useTranslations,
4+
} from '../../../../dist/core.js';
5+
6+
export default function Page() {
7+
return (
8+
<TranslationMiddlewareProvider
9+
value={{
10+
beforeTranslate: (s: string) => s.toUpperCase(),
11+
afterTranslate: (s: string) => s + '!',
12+
}}
13+
>
14+
<Content />
15+
</TranslationMiddlewareProvider>
16+
);
17+
}
18+
19+
function Content() {
20+
const t = useTranslations();
21+
return <p>{t('hello')}</p>;
22+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {promises as fs} from 'node:fs';
2+
import path from 'node:path';
3+
import {assert, beforeEach, test, expect, afterEach} from 'vitest';
4+
import {fileExists} from '../src/utils/fsutils.js';
5+
import {Fixture, loadFixture} from './testutils.js';
6+
7+
let fixture: Fixture;
8+
9+
beforeEach(async () => {
10+
fixture = await loadFixture('./fixtures/translation-middleware');
11+
});
12+
13+
afterEach(async () => {
14+
if (fixture) {
15+
await fixture.cleanup();
16+
}
17+
});
18+
19+
test('apply translation middleware', async () => {
20+
await fixture.build();
21+
const indexPath = path.join(fixture.distDir, 'html/index.html');
22+
assert.isTrue(await fileExists(indexPath));
23+
const html = await fs.readFile(indexPath, 'utf-8');
24+
expect(html).toMatchInlineSnapshot(`
25+
"<!doctype html>
26+
<html>
27+
<head>
28+
<meta charset=\\"utf-8\\">
29+
</head>
30+
<body>
31+
<p>HELLO!</p>
32+
</body>
33+
</html>
34+
"
35+
`);
36+
});

0 commit comments

Comments
 (0)