1
0
mirror of https://github.com/outline/outline.git synced 2025-03-14 10:07:11 +00:00

feat: Server side translation setup ()

* Server side translation setup

* docs
This commit is contained in:
Tom Moor
2023-01-07 11:52:09 -08:00
committed by GitHub
parent a333f48102
commit 53414ec3ba
13 changed files with 185 additions and 78 deletions

@ -5,7 +5,6 @@ import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
import { Router } from "react-router-dom";
import { initI18n } from "@shared/i18n";
import stores from "~/stores";
import Analytics from "~/components/Analytics";
import Dialogs from "~/components/Dialogs";
@ -15,6 +14,7 @@ import ScrollToTop from "~/components/ScrollToTop";
import Theme from "~/components/Theme";
import Toasts from "~/components/Toasts";
import env from "~/env";
import { initI18n } from "~/utils/i18n";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
import Routes from "./routes";

@ -3,7 +3,7 @@
import localStorage from "../../__mocks__/localStorage";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { initI18n } from "@shared/i18n";
import { initI18n } from "../utils/i18n";
initI18n();

@ -1,8 +1,8 @@
import i18n from "i18next";
import de_DE from "./locales/de_DE/translation.json";
import en_US from "./locales/en_US/translation.json";
import pt_PT from "./locales/pt_PT/translation.json";
import { initI18n } from ".";
import de_DE from "../../shared/i18n/locales/de_DE/translation.json";
import en_US from "../../shared/i18n/locales/en_US/translation.json";
import pt_PT from "../../shared/i18n/locales/pt_PT/translation.json";
import { initI18n } from "./i18n";
describe("i18n env is unset", () => {
beforeEach(() => {

@ -17,6 +17,11 @@ import {
zhCN,
zhTW,
} from "date-fns/locale";
import i18n from "i18next";
import backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { languages } from "@shared/i18n";
import { unicodeCLDRtoBCP47, unicodeBCP47toCLDR } from "@shared/utils/date";
const locales = {
de_DE: de,
@ -38,8 +43,50 @@ const locales = {
zh_TW: zhTW,
};
export function dateLocale(userLocale: string | null | undefined) {
return userLocale ? locales[userLocale] : undefined;
/**
* Returns the date-fns locale object for the given user language preference.
*
* @param language The user language
* @returns The date-fns locale.
*/
export function dateLocale(language: string | null | undefined) {
return language ? locales[language] : undefined;
}
/**
* Initializes i18n library, loading all available translations from the
* API backend.
*
* @param defaultLanguage The default language to use if the user's language
* is not supported.
* @returns i18n instance
*/
export function initI18n(defaultLanguage = "en_US") {
const lng = unicodeCLDRtoBCP47(defaultLanguage);
i18n
.use(backend)
.use(initReactI18next)
.init({
compatibilityJSON: "v3",
backend: {
// this must match the path defined in routes. It's the path that the
// frontend UI code will hit to load missing translations.
loadPath: (languages: string[]) =>
`/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
lng,
fallbackLng: lng,
supportedLngs: languages.map(unicodeCLDRtoBCP47),
keySeparator: false,
returnNull: false,
});
return i18n;
}
export { locales };

@ -8,7 +8,9 @@ module.exports = {
defaultNamespace: "translation",
// Default namespace used in your i18next config
defaultValue: "",
defaultValue(locale, namespace, key) {
return key;
},
// Default value to give to empty keys
indentation: 2,
@ -60,10 +62,6 @@ module.exports = {
skipDefaultValues: false,
// Whether to ignore default values.
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
verbose: false,
// Display info about the parsing including some stats
@ -71,15 +69,6 @@ module.exports = {
// Exit with an exit code of 1 on warnings
customValueTemplate: null,
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", // t('my-key', {maxLength: 150})
// }
i18nextOptions: {
compatibilityJSON: "v3",

@ -104,6 +104,7 @@
"gemoji": "6.x",
"http-errors": "2.0.0",
"i18next": "^22.4.8",
"i18next-fs-backend": "^2.1.1",
"i18next-http-backend": "^2.1.1",
"immutable": "^4.0.0",
"inline-css": "^4.0.1",

@ -1,4 +1,5 @@
import crypto from "crypto";
import { t } from "i18next";
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { IntegrationService } from "@shared/types";
@ -18,6 +19,7 @@ import {
import SearchHelper from "@server/models/helpers/SearchHelper";
import { presentSlackAttachment } from "@server/presenters";
import { APIContext } from "@server/types";
import { opts } from "@server/utils/i18n";
import * as Slack from "@server/utils/slack";
import { assertPresent } from "@server/validation";
@ -150,21 +152,6 @@ router.post("hooks.slack", async (ctx: APIContext) => {
assertPresent(user_id, "user_id is required");
verifySlackToken(token);
// Handle "help" command or no input
if (text.trim() === "help" || !text.trim()) {
ctx.body = {
response_type: "ephemeral",
text: "How to use /outline",
attachments: [
{
text:
"To search your knowledge base use `/outline keyword`. \nYouve already learned how to get help with `/outline help`.",
},
],
};
return;
}
let user, team;
// attempt to find the corresponding team for this request based on the team_id
team = await Team.findOne({
@ -225,12 +212,39 @@ router.post("hooks.slack", async (ctx: APIContext) => {
}
}
// Handle "help" command or no input
if (text.trim() === "help" || !text.trim()) {
ctx.body = {
response_type: "ephemeral",
text: "How to use /outline",
attachments: [
{
text: t(
"To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.",
{
command: `/outline keyword`,
command2: `/outline help`,
...opts(user),
}
),
},
],
};
return;
}
// This should be super rare, how does someone end up being able to make a valid
// request from Slack that connects to no teams in Outline.
if (!team) {
ctx.body = {
response_type: "ephemeral",
text: `Sorry, we couldnt find an integration for your team. Head to your ${env.APP_NAME} settings to set one up.`,
text: t(
`Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.`,
{
...opts(user),
appName: env.APP_NAME,
}
),
};
return;
}
@ -292,7 +306,13 @@ router.post("hooks.slack", async (ctx: APIContext) => {
query: text,
results: totalCount,
});
const haventSignedIn = `(It looks like you havent signed in to ${env.APP_NAME} yet, so results may be limited)`;
const haventSignedIn = t(
`It looks like you havent signed in to {{ appName }} yet, so results may be limited`,
{
...opts(user),
appName: env.APP_NAME,
}
);
// Map search results to the format expected by the Slack API
if (results.length) {
@ -312,7 +332,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
? [
{
name: "post",
text: "Post to Channel",
text: t("Post to Channel", opts(user)),
type: "button",
value: result.document.id,
},
@ -324,15 +344,24 @@ router.post("hooks.slack", async (ctx: APIContext) => {
ctx.body = {
text: user
? `This is what we found for "${text}"…`
: `This is what we found for "${text}" ${haventSignedIn}`,
? t(`This is what we found for "{{ term }}"`, {
...opts(user),
term: text,
})
: t(`This is what we found for "{{ term }}"`, {
term: text,
}) + ` (${haventSignedIn})…`,
attachments,
};
} else {
ctx.body = {
text: user
? `No results for "${text}"`
: `No results for "${text}" ${haventSignedIn}`,
? t(`No results for "{{ term }}"`, {
...opts(user),
term: text,
})
: t(`No results for "{{ term }}"`, { term: text }) +
` (${haventSignedIn})…`,
};
}
});

@ -9,6 +9,7 @@ import mount from "koa-mount";
import enforceHttps, { xForwardedProtoResolver } from "koa-sslify";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { initI18n } from "@server/utils/i18n";
import routes from "../routes";
import api from "../routes/api";
import auth from "../routes/auth";
@ -37,6 +38,8 @@ if (env.CDN_URL) {
}
export default function init(app: Koa = new Koa()): Koa {
initI18n();
if (isProduction) {
// Force redirect to HTTPS protocol unless explicitly disabled
if (env.FORCE_HTTPS) {

@ -1,6 +1,7 @@
import Logger from "@server/logging/Logger";
import { setResource } from "@server/logging/tracer";
import { traceFunction } from "@server/logging/tracing";
import { initI18n } from "@server/utils/i18n";
import {
globalEventQueue,
processorEventQueue,
@ -11,6 +12,8 @@ import processors from "../queues/processors";
import tasks from "../queues/tasks";
export default function init() {
initI18n();
// This queue processes the global event bus
globalEventQueue.process(
traceFunction({

58
server/utils/i18n.ts Normal file

@ -0,0 +1,58 @@
import path from "path";
import i18n from "i18next";
import backend from "i18next-fs-backend";
import { languages } from "@shared/i18n";
import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "@shared/utils/date";
import env from "@server/env";
import { User } from "@server/models";
/**
* Returns i18n options for the given user or the default server language if
* no user is provided.
*
* @param user The user to get options for
* @returns i18n options
*/
export function opts(user?: User | null) {
return {
lng: unicodeCLDRtoBCP47(user?.language ?? env.DEFAULT_LANGUAGE),
};
}
/**
* Initializes i18n library, loading all available translations from the
* filesystem.
*
* @returns i18n instance
*/
export function initI18n() {
const lng = unicodeCLDRtoBCP47(env.DEFAULT_LANGUAGE);
i18n.use(backend).init({
compatibilityJSON: "v3",
backend: {
loadPath: (language: string) => {
return path.resolve(
path.join(
__dirname,
"..",
"..",
"shared",
"i18n",
"locales",
unicodeBCP47toCLDR(language),
"translation.json"
)
);
},
},
preload: languages.map(unicodeCLDRtoBCP47),
interpolation: {
escapeValue: false,
},
lng,
fallbackLng: lng,
keySeparator: false,
returnNull: false,
});
return i18n;
}

@ -1,8 +1,3 @@
import i18n from "i18next";
import backend from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "../utils/date";
// Note: Updating the available languages? Make sure to also update the
// locales array in app/utils/i18n.js to enable translation for timestamps.
export const languageOptions = [
@ -77,32 +72,3 @@ export const languageOptions = [
];
export const languages: string[] = languageOptions.map((i) => i.value);
export const initI18n = (defaultLanguage = "en_US") => {
const lng = unicodeCLDRtoBCP47(defaultLanguage);
i18n
.use(backend)
.use(initReactI18next)
.init({
compatibilityJSON: "v3",
backend: {
// this must match the path defined in routes. It's the path that the
// frontend UI code will hit to load missing translations.
loadPath: (languages: string[]) =>
`/locales/${unicodeBCP47toCLDR(languages[0])}.json`,
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
lng,
fallbackLng: lng,
supportedLngs: languages.map(unicodeCLDRtoBCP47),
// Uncomment when debugging translation framework, otherwise it's noisy
keySeparator: false,
returnNull: false,
});
return i18n;
};

@ -823,5 +823,11 @@
"This month": "This month",
"Last month": "Last month",
"This year": "This year",
"To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.",
"Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.",
"It looks like you havent signed in to {{ appName }} yet, so results may be limited": "It looks like you havent signed in to {{ appName }} yet, so results may be limited",
"Post to Channel": "Post to Channel",
"This is what we found for \"{{ term }}\"": "This is what we found for \"{{ term }}\"",
"No results for \"{{ term }}\"": "No results for \"{{ term }}\"",
"Uploading": "Uploading"
}

@ -8798,6 +8798,11 @@ husky@^8.0.2:
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.2.tgz#5816a60db02650f1f22c8b69b928fd6bcd77a236"
integrity sha512-Tkv80jtvbnkK3mYWxPZePGFpQ/tT3HNSs/sasF9P2YfkMezDl3ON37YN6jUUI4eTg5LcyVynlb6r4eyvOmspvg==
i18next-fs-backend@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-2.1.1.tgz#07c6393be856c5a398e3dfc1257bf8439841cd89"
integrity sha512-FTnj+UmNgT3YRml5ruRv0jMZDG7odOL/OP5PF5mOqvXud2vHrPOOs68Zdk6iqzL47cnnM0ZVkK2BAvpFeDJToA==
i18next-http-backend@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.1.tgz#72a21d61c2e96eea9ad45ba1b9dd0090e119709a"