Skip to content

Commit 53414ec

Browse files
authored
feat: Server side translation setup (outline#4657)
* Server side translation setup * docs
1 parent a333f48 commit 53414ec

File tree

13 files changed

+185
-78
lines changed

13 files changed

+185
-78
lines changed

app/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Provider } from "mobx-react";
55
import * as React from "react";
66
import { render } from "react-dom";
77
import { Router } from "react-router-dom";
8-
import { initI18n } from "@shared/i18n";
98
import stores from "~/stores";
109
import Analytics from "~/components/Analytics";
1110
import Dialogs from "~/components/Dialogs";
@@ -15,6 +14,7 @@ import ScrollToTop from "~/components/ScrollToTop";
1514
import Theme from "~/components/Theme";
1615
import Toasts from "~/components/Toasts";
1716
import env from "~/env";
17+
import { initI18n } from "~/utils/i18n";
1818
import Desktop from "./components/DesktopEventHandler";
1919
import LazyPolyfill from "./components/LazyPolyfills";
2020
import Routes from "./routes";

app/test/setup.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import localStorage from "../../__mocks__/localStorage";
44
import Enzyme from "enzyme";
55
import Adapter from "enzyme-adapter-react-16";
6-
import { initI18n } from "@shared/i18n";
6+
import { initI18n } from "../utils/i18n";
77

88
initI18n();
99

shared/i18n/index.test.ts app/utils/i18n.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import i18n from "i18next";
2-
import de_DE from "./locales/de_DE/translation.json";
3-
import en_US from "./locales/en_US/translation.json";
4-
import pt_PT from "./locales/pt_PT/translation.json";
5-
import { initI18n } from ".";
2+
import de_DE from "../../shared/i18n/locales/de_DE/translation.json";
3+
import en_US from "../../shared/i18n/locales/en_US/translation.json";
4+
import pt_PT from "../../shared/i18n/locales/pt_PT/translation.json";
5+
import { initI18n } from "./i18n";
66

77
describe("i18n env is unset", () => {
88
beforeEach(() => {

app/utils/i18n.ts

+49-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
zhCN,
1818
zhTW,
1919
} from "date-fns/locale";
20+
import i18n from "i18next";
21+
import backend from "i18next-http-backend";
22+
import { initReactI18next } from "react-i18next";
23+
import { languages } from "@shared/i18n";
24+
import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
2025

2126
const locales = {
2227
de_DE: de,
@@ -38,8 +43,50 @@ const locales = {
3843
zh_TW: zhTW,
3944
};
4045

41-
export function dateLocale(userLocale: string | null | undefined) {
42-
return userLocale ? locales[userLocale] : undefined;
46+
/**
47+
* Returns the date-fns locale object for the given user language preference.
48+
*
49+
* @param language The user language
50+
* @returns The date-fns locale.
51+
*/
52+
export function dateLocale(language: string | null | undefined) {
53+
return language ? locales[language] : undefined;
54+
}
55+
56+
/**
57+
* Initializes i18n library, loading all available translations from the
58+
* API backend.
59+
*
60+
* @param defaultLanguage The default language to use if the user's language
61+
* is not supported.
62+
* @returns i18n instance
63+
*/
64+
export function initI18n(defaultLanguage = "en_US") {
65+
const lng = unicodeCLDRtoBCP47(defaultLanguage);
66+
i18n
67+
.use(backend)
68+
.use(initReactI18next)
69+
.init({
70+
compatibilityJSON: "v3",
71+
backend: {
72+
// this must match the path defined in routes. It's the path that the
73+
// frontend UI code will hit to load missing translations.
74+
loadPath: (languages: string[]) =>
75+
`/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
76+
},
77+
interpolation: {
78+
escapeValue: false,
79+
},
80+
react: {
81+
useSuspense: false,
82+
},
83+
lng,
84+
fallbackLng: lng,
85+
supportedLngs: languages.map(unicodeCLDRtoBCP47),
86+
keySeparator: false,
87+
returnNull: false,
88+
});
89+
return i18n;
4390
}
4491

4592
export { locales };

i18next-parser.config.js

+3-14
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ module.exports = {
88
defaultNamespace: "translation",
99
// Default namespace used in your i18next config
1010

11-
defaultValue: "",
11+
defaultValue(locale, namespace, key) {
12+
return key;
13+
},
1214
// Default value to give to empty keys
1315

1416
indentation: 2,
@@ -60,26 +62,13 @@ module.exports = {
6062
skipDefaultValues: false,
6163
// Whether to ignore default values.
6264

63-
useKeysAsDefaultValue: true,
64-
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
65-
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
66-
6765
verbose: false,
6866
// Display info about the parsing including some stats
6967

7068
failOnWarnings: false,
7169
// Exit with an exit code of 1 on warnings
7270

7371
customValueTemplate: null,
74-
// If you wish to customize the value output the value as an object, you can set your own format.
75-
// ${defaultValue} is the default value you set in your translation function.
76-
// Any other custom property will be automatically extracted.
77-
//
78-
// Example:
79-
// {
80-
// message: "${defaultValue}",
81-
// description: "${maxLength}", // t('my-key', {maxLength: 150})
82-
// }
8372

8473
i18nextOptions: {
8574
compatibilityJSON: "v3",

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"gemoji": "6.x",
105105
"http-errors": "2.0.0",
106106
"i18next": "^22.4.8",
107+
"i18next-fs-backend": "^2.1.1",
107108
"i18next-http-backend": "^2.1.1",
108109
"immutable": "^4.0.0",
109110
"inline-css": "^4.0.1",

server/routes/api/hooks.ts

+51-22
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import crypto from "crypto";
2+
import { t } from "i18next";
23
import Router from "koa-router";
34
import { escapeRegExp } from "lodash";
45
import { IntegrationService } from "@shared/types";
@@ -18,6 +19,7 @@ import {
1819
import SearchHelper from "@server/models/helpers/SearchHelper";
1920
import { presentSlackAttachment } from "@server/presenters";
2021
import { APIContext } from "@server/types";
22+
import { opts } from "@server/utils/i18n";
2123
import * as Slack from "@server/utils/slack";
2224
import { assertPresent } from "@server/validation";
2325

@@ -150,21 +152,6 @@ router.post("hooks.slack", async (ctx: APIContext) => {
150152
assertPresent(user_id, "user_id is required");
151153
verifySlackToken(token);
152154

153-
// Handle "help" command or no input
154-
if (text.trim() === "help" || !text.trim()) {
155-
ctx.body = {
156-
response_type: "ephemeral",
157-
text: "How to use /outline",
158-
attachments: [
159-
{
160-
text:
161-
"To search your knowledge base use `/outline keyword`. \nYou’ve already learned how to get help with `/outline help`.",
162-
},
163-
],
164-
};
165-
return;
166-
}
167-
168155
let user, team;
169156
// attempt to find the corresponding team for this request based on the team_id
170157
team = await Team.findOne({
@@ -225,12 +212,39 @@ router.post("hooks.slack", async (ctx: APIContext) => {
225212
}
226213
}
227214

215+
// Handle "help" command or no input
216+
if (text.trim() === "help" || !text.trim()) {
217+
ctx.body = {
218+
response_type: "ephemeral",
219+
text: "How to use /outline",
220+
attachments: [
221+
{
222+
text: t(
223+
"To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.",
224+
{
225+
command: `/outline keyword`,
226+
command2: `/outline help`,
227+
...opts(user),
228+
}
229+
),
230+
},
231+
],
232+
};
233+
return;
234+
}
235+
228236
// This should be super rare, how does someone end up being able to make a valid
229237
// request from Slack that connects to no teams in Outline.
230238
if (!team) {
231239
ctx.body = {
232240
response_type: "ephemeral",
233-
text: `Sorry, we couldn’t find an integration for your team. Head to your ${env.APP_NAME} settings to set one up.`,
241+
text: t(
242+
`Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`,
243+
{
244+
...opts(user),
245+
appName: env.APP_NAME,
246+
}
247+
),
234248
};
235249
return;
236250
}
@@ -292,7 +306,13 @@ router.post("hooks.slack", async (ctx: APIContext) => {
292306
query: text,
293307
results: totalCount,
294308
});
295-
const haventSignedIn = `(It looks like you haven’t signed in to ${env.APP_NAME} yet, so results may be limited)`;
309+
const haventSignedIn = t(
310+
`It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`,
311+
{
312+
...opts(user),
313+
appName: env.APP_NAME,
314+
}
315+
);
296316

297317
// Map search results to the format expected by the Slack API
298318
if (results.length) {
@@ -312,7 +332,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
312332
? [
313333
{
314334
name: "post",
315-
text: "Post to Channel",
335+
text: t("Post to Channel", opts(user)),
316336
type: "button",
317337
value: result.document.id,
318338
},
@@ -324,15 +344,24 @@ router.post("hooks.slack", async (ctx: APIContext) => {
324344

325345
ctx.body = {
326346
text: user
327-
? `This is what we found for "${text}"…`
328-
: `This is what we found for "${text}" ${haventSignedIn}…`,
347+
? t(`This is what we found for "{{ term }}"`, {
348+
...opts(user),
349+
term: text,
350+
})
351+
: t(`This is what we found for "{{ term }}"`, {
352+
term: text,
353+
}) + ` (${haventSignedIn})…`,
329354
attachments,
330355
};
331356
} else {
332357
ctx.body = {
333358
text: user
334-
? `No results for "${text}"`
335-
: `No results for "${text}" ${haventSignedIn}`,
359+
? t(`No results for "{{ term }}"`, {
360+
...opts(user),
361+
term: text,
362+
})
363+
: t(`No results for "{{ term }}"`, { term: text }) +
364+
` (${haventSignedIn})…`,
336365
};
337366
}
338367
});

server/services/web.ts

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import mount from "koa-mount";
99
import enforceHttps, { xForwardedProtoResolver } from "koa-sslify";
1010
import env from "@server/env";
1111
import Logger from "@server/logging/Logger";
12+
import { initI18n } from "@server/utils/i18n";
1213
import routes from "../routes";
1314
import api from "../routes/api";
1415
import auth from "../routes/auth";
@@ -37,6 +38,8 @@ if (env.CDN_URL) {
3738
}
3839

3940
export default function init(app: Koa = new Koa()): Koa {
41+
initI18n();
42+
4043
if (isProduction) {
4144
// Force redirect to HTTPS protocol unless explicitly disabled
4245
if (env.FORCE_HTTPS) {

server/services/worker.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Logger from "@server/logging/Logger";
22
import { setResource } from "@server/logging/tracer";
33
import { traceFunction } from "@server/logging/tracing";
4+
import { initI18n } from "@server/utils/i18n";
45
import {
56
globalEventQueue,
67
processorEventQueue,
@@ -11,6 +12,8 @@ import processors from "../queues/processors";
1112
import tasks from "../queues/tasks";
1213

1314
export default function init() {
15+
initI18n();
16+
1417
// This queue processes the global event bus
1518
globalEventQueue.process(
1619
traceFunction({

server/utils/i18n.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from "path";
2+
import i18n from "i18next";
3+
import backend from "i18next-fs-backend";
4+
import { languages } from "@shared/i18n";
5+
import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "@shared/utils/date";
6+
import env from "@server/env";
7+
import { User } from "@server/models";
8+
9+
/**
10+
* Returns i18n options for the given user or the default server language if
11+
* no user is provided.
12+
*
13+
* @param user The user to get options for
14+
* @returns i18n options
15+
*/
16+
export function opts(user?: User | null) {
17+
return {
18+
lng: unicodeCLDRtoBCP47(user?.language ?? env.DEFAULT_LANGUAGE),
19+
};
20+
}
21+
22+
/**
23+
* Initializes i18n library, loading all available translations from the
24+
* filesystem.
25+
*
26+
* @returns i18n instance
27+
*/
28+
export function initI18n() {
29+
const lng = unicodeCLDRtoBCP47(env.DEFAULT_LANGUAGE);
30+
i18n.use(backend).init({
31+
compatibilityJSON: "v3",
32+
backend: {
33+
loadPath: (language: string) => {
34+
return path.resolve(
35+
path.join(
36+
__dirname,
37+
"..",
38+
"..",
39+
"shared",
40+
"i18n",
41+
"locales",
42+
unicodeBCP47toCLDR(language),
43+
"translation.json"
44+
)
45+
);
46+
},
47+
},
48+
preload: languages.map(unicodeCLDRtoBCP47),
49+
interpolation: {
50+
escapeValue: false,
51+
},
52+
lng,
53+
fallbackLng: lng,
54+
keySeparator: false,
55+
returnNull: false,
56+
});
57+
return i18n;
58+
}

0 commit comments

Comments
 (0)