diff --git a/app/index.tsx b/app/index.tsx index a9ac00128..cbcc0430e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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"; diff --git a/app/test/setup.ts b/app/test/setup.ts index 64b8c6ee6..2fe3b3669 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -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(); diff --git a/shared/i18n/index.test.ts b/app/utils/i18n.test.ts similarity index 90% rename from shared/i18n/index.test.ts rename to app/utils/i18n.test.ts index 64b7fdab5..12752c0ce 100644 --- a/shared/i18n/index.test.ts +++ b/app/utils/i18n.test.ts @@ -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(() => { diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts index 6089068bc..6add90954 100644 --- a/app/utils/i18n.ts +++ b/app/utils/i18n.ts @@ -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 }; diff --git a/i18next-parser.config.js b/i18next-parser.config.js index a5ffa187a..0d79a31af 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -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", diff --git a/package.json b/package.json index 396ccb565..57e6e6dba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/routes/api/hooks.ts b/server/routes/api/hooks.ts index 744f02dc2..8c1beb100 100644 --- a/server/routes/api/hooks.ts +++ b/server/routes/api/hooks.ts @@ -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})…`, }; } }); diff --git a/server/services/web.ts b/server/services/web.ts index af89f585e..a57947184 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -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) { diff --git a/server/services/worker.ts b/server/services/worker.ts index 71588f9a5..b2a5449c4 100644 --- a/server/services/worker.ts +++ b/server/services/worker.ts @@ -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({ diff --git a/server/utils/i18n.ts b/server/utils/i18n.ts new file mode 100644 index 000000000..0e43db3af --- /dev/null +++ b/server/utils/i18n.ts @@ -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; +} diff --git a/shared/i18n/index.ts b/shared/i18n/index.ts index c8ab0a617..5eb04ad5c 100644 --- a/shared/i18n/index.ts +++ b/shared/i18n/index.ts @@ -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; -}; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index c4af6ff79..3a94a8e42 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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" } diff --git a/yarn.lock b/yarn.lock index 8d03e6319..0873b1a16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"