mirror of
https://github.com/outline/outline.git
synced 2025-03-14 10:07:11 +00:00
feat: Server side translation setup (#4657)
* Server side translation setup * docs
This commit is contained in:
@ -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`. \nYou’ve 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 }}. \nYou’ve 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 couldn’t find an integration for your team. Head to your ${env.APP_NAME} settings to set one up.`,
|
||||
text: t(
|
||||
`Sorry, we couldn’t 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 haven’t signed in to ${env.APP_NAME} yet, so results may be limited)`;
|
||||
const haventSignedIn = t(
|
||||
`It looks like you haven’t 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
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 }}. \nYou’ve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.",
|
||||
"Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.",
|
||||
"It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "It looks like you haven’t 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"
|
||||
|
Reference in New Issue
Block a user