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 * as React from "react";
|
||||||
import { render } from "react-dom";
|
import { render } from "react-dom";
|
||||||
import { Router } from "react-router-dom";
|
import { Router } from "react-router-dom";
|
||||||
import { initI18n } from "@shared/i18n";
|
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
import Analytics from "~/components/Analytics";
|
import Analytics from "~/components/Analytics";
|
||||||
import Dialogs from "~/components/Dialogs";
|
import Dialogs from "~/components/Dialogs";
|
||||||
@@ -15,6 +14,7 @@ import ScrollToTop from "~/components/ScrollToTop";
|
|||||||
import Theme from "~/components/Theme";
|
import Theme from "~/components/Theme";
|
||||||
import Toasts from "~/components/Toasts";
|
import Toasts from "~/components/Toasts";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
|
import { initI18n } from "~/utils/i18n";
|
||||||
import Desktop from "./components/DesktopEventHandler";
|
import Desktop from "./components/DesktopEventHandler";
|
||||||
import LazyPolyfill from "./components/LazyPolyfills";
|
import LazyPolyfill from "./components/LazyPolyfills";
|
||||||
import Routes from "./routes";
|
import Routes from "./routes";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import localStorage from "../../__mocks__/localStorage";
|
import localStorage from "../../__mocks__/localStorage";
|
||||||
import Enzyme from "enzyme";
|
import Enzyme from "enzyme";
|
||||||
import Adapter from "enzyme-adapter-react-16";
|
import Adapter from "enzyme-adapter-react-16";
|
||||||
import { initI18n } from "@shared/i18n";
|
import { initI18n } from "../utils/i18n";
|
||||||
|
|
||||||
initI18n();
|
initI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import de_DE from "./locales/de_DE/translation.json";
|
import de_DE from "../../shared/i18n/locales/de_DE/translation.json";
|
||||||
import en_US from "./locales/en_US/translation.json";
|
import en_US from "../../shared/i18n/locales/en_US/translation.json";
|
||||||
import pt_PT from "./locales/pt_PT/translation.json";
|
import pt_PT from "../../shared/i18n/locales/pt_PT/translation.json";
|
||||||
import { initI18n } from ".";
|
import { initI18n } from "./i18n";
|
||||||
|
|
||||||
describe("i18n env is unset", () => {
|
describe("i18n env is unset", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
zhCN,
|
zhCN,
|
||||||
zhTW,
|
zhTW,
|
||||||
} from "date-fns/locale";
|
} 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 = {
|
const locales = {
|
||||||
de_DE: de,
|
de_DE: de,
|
||||||
@@ -38,8 +43,50 @@ const locales = {
|
|||||||
zh_TW: zhTW,
|
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 };
|
export { locales };
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ module.exports = {
|
|||||||
defaultNamespace: "translation",
|
defaultNamespace: "translation",
|
||||||
// Default namespace used in your i18next config
|
// Default namespace used in your i18next config
|
||||||
|
|
||||||
defaultValue: "",
|
defaultValue(locale, namespace, key) {
|
||||||
|
return key;
|
||||||
|
},
|
||||||
// Default value to give to empty keys
|
// Default value to give to empty keys
|
||||||
|
|
||||||
indentation: 2,
|
indentation: 2,
|
||||||
@@ -60,10 +62,6 @@ module.exports = {
|
|||||||
skipDefaultValues: false,
|
skipDefaultValues: false,
|
||||||
// Whether to ignore default values.
|
// 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,
|
verbose: false,
|
||||||
// Display info about the parsing including some stats
|
// Display info about the parsing including some stats
|
||||||
|
|
||||||
@@ -71,15 +69,6 @@ module.exports = {
|
|||||||
// Exit with an exit code of 1 on warnings
|
// Exit with an exit code of 1 on warnings
|
||||||
|
|
||||||
customValueTemplate: null,
|
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: {
|
i18nextOptions: {
|
||||||
compatibilityJSON: "v3",
|
compatibilityJSON: "v3",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"gemoji": "6.x",
|
"gemoji": "6.x",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i18next": "^22.4.8",
|
"i18next": "^22.4.8",
|
||||||
|
"i18next-fs-backend": "^2.1.1",
|
||||||
"i18next-http-backend": "^2.1.1",
|
"i18next-http-backend": "^2.1.1",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^4.0.0",
|
||||||
"inline-css": "^4.0.1",
|
"inline-css": "^4.0.1",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
import { t } from "i18next";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { escapeRegExp } from "lodash";
|
import { escapeRegExp } from "lodash";
|
||||||
import { IntegrationService } from "@shared/types";
|
import { IntegrationService } from "@shared/types";
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||||
import { presentSlackAttachment } from "@server/presenters";
|
import { presentSlackAttachment } from "@server/presenters";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
|
import { opts } from "@server/utils/i18n";
|
||||||
import * as Slack from "@server/utils/slack";
|
import * as Slack from "@server/utils/slack";
|
||||||
import { assertPresent } from "@server/validation";
|
import { assertPresent } from "@server/validation";
|
||||||
|
|
||||||
@@ -150,21 +152,6 @@ router.post("hooks.slack", async (ctx: APIContext) => {
|
|||||||
assertPresent(user_id, "user_id is required");
|
assertPresent(user_id, "user_id is required");
|
||||||
verifySlackToken(token);
|
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;
|
let user, team;
|
||||||
// attempt to find the corresponding team for this request based on the team_id
|
// attempt to find the corresponding team for this request based on the team_id
|
||||||
team = await Team.findOne({
|
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
|
// 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.
|
// request from Slack that connects to no teams in Outline.
|
||||||
if (!team) {
|
if (!team) {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
response_type: "ephemeral",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -292,7 +306,13 @@ router.post("hooks.slack", async (ctx: APIContext) => {
|
|||||||
query: text,
|
query: text,
|
||||||
results: totalCount,
|
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
|
// Map search results to the format expected by the Slack API
|
||||||
if (results.length) {
|
if (results.length) {
|
||||||
@@ -312,7 +332,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: "post",
|
name: "post",
|
||||||
text: "Post to Channel",
|
text: t("Post to Channel", opts(user)),
|
||||||
type: "button",
|
type: "button",
|
||||||
value: result.document.id,
|
value: result.document.id,
|
||||||
},
|
},
|
||||||
@@ -324,15 +344,24 @@ router.post("hooks.slack", async (ctx: APIContext) => {
|
|||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
text: user
|
text: user
|
||||||
? `This is what we found for "${text}"…`
|
? t(`This is what we found for "{{ term }}"`, {
|
||||||
: `This is what we found for "${text}" ${haventSignedIn}…`,
|
...opts(user),
|
||||||
|
term: text,
|
||||||
|
})
|
||||||
|
: t(`This is what we found for "{{ term }}"`, {
|
||||||
|
term: text,
|
||||||
|
}) + ` (${haventSignedIn})…`,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
text: user
|
text: user
|
||||||
? `No results for "${text}"`
|
? t(`No results for "{{ term }}"`, {
|
||||||
: `No results for "${text}" ${haventSignedIn}`,
|
...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 enforceHttps, { xForwardedProtoResolver } from "koa-sslify";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
|
import { initI18n } from "@server/utils/i18n";
|
||||||
import routes from "../routes";
|
import routes from "../routes";
|
||||||
import api from "../routes/api";
|
import api from "../routes/api";
|
||||||
import auth from "../routes/auth";
|
import auth from "../routes/auth";
|
||||||
@@ -37,6 +38,8 @@ if (env.CDN_URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function init(app: Koa = new Koa()): Koa {
|
export default function init(app: Koa = new Koa()): Koa {
|
||||||
|
initI18n();
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
// Force redirect to HTTPS protocol unless explicitly disabled
|
// Force redirect to HTTPS protocol unless explicitly disabled
|
||||||
if (env.FORCE_HTTPS) {
|
if (env.FORCE_HTTPS) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import { setResource } from "@server/logging/tracer";
|
import { setResource } from "@server/logging/tracer";
|
||||||
import { traceFunction } from "@server/logging/tracing";
|
import { traceFunction } from "@server/logging/tracing";
|
||||||
|
import { initI18n } from "@server/utils/i18n";
|
||||||
import {
|
import {
|
||||||
globalEventQueue,
|
globalEventQueue,
|
||||||
processorEventQueue,
|
processorEventQueue,
|
||||||
@@ -11,6 +12,8 @@ import processors from "../queues/processors";
|
|||||||
import tasks from "../queues/tasks";
|
import tasks from "../queues/tasks";
|
||||||
|
|
||||||
export default function init() {
|
export default function init() {
|
||||||
|
initI18n();
|
||||||
|
|
||||||
// This queue processes the global event bus
|
// This queue processes the global event bus
|
||||||
globalEventQueue.process(
|
globalEventQueue.process(
|
||||||
traceFunction({
|
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
|
// Note: Updating the available languages? Make sure to also update the
|
||||||
// locales array in app/utils/i18n.js to enable translation for timestamps.
|
// locales array in app/utils/i18n.js to enable translation for timestamps.
|
||||||
export const languageOptions = [
|
export const languageOptions = [
|
||||||
@@ -77,32 +72,3 @@ export const languageOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const languages: string[] = languageOptions.map((i) => i.value);
|
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",
|
"This month": "This month",
|
||||||
"Last month": "Last month",
|
"Last month": "Last month",
|
||||||
"This year": "This year",
|
"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"
|
"Uploading": "Uploading"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8798,6 +8798,11 @@ husky@^8.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.2.tgz#5816a60db02650f1f22c8b69b928fd6bcd77a236"
|
resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.2.tgz#5816a60db02650f1f22c8b69b928fd6bcd77a236"
|
||||||
integrity sha512-Tkv80jtvbnkK3mYWxPZePGFpQ/tT3HNSs/sasF9P2YfkMezDl3ON37YN6jUUI4eTg5LcyVynlb6r4eyvOmspvg==
|
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:
|
i18next-http-backend@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.1.tgz#72a21d61c2e96eea9ad45ba1b9dd0090e119709a"
|
resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.1.1.tgz#72a21d61c2e96eea9ad45ba1b9dd0090e119709a"
|
||||||
|
|||||||
Reference in New Issue
Block a user