Summary: Adding initial work for localization support. Summary in https://grist.quip.com/OtZKA6RHdQ6T/Internationalization-and-Localization Test Plan: Not yet Reviewers: paulfitz Reviewed By: paulfitz Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3633pull/296/head
parent
cd64237dad
commit
5219932a1f
@ -0,0 +1,86 @@
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import i18next from 'i18next';
|
||||
|
||||
export async function setupLocale() {
|
||||
const now = Date.now();
|
||||
const supportedLngs = getGristConfig().supportedLngs ?? ['en'];
|
||||
let lng = window.navigator.language || 'en';
|
||||
// If user agent language is not in the list of supported languages, use the default one.
|
||||
if (!supportedLngs.includes(lng)) {
|
||||
// Test if server supports general language.
|
||||
if (lng.includes("-") && supportedLngs.includes(lng.split("-")[0])) {
|
||||
lng = lng.split("-")[0]!;
|
||||
} else {
|
||||
lng = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
const ns = getGristConfig().namespaces ?? ['core'];
|
||||
// Initialize localization plugin
|
||||
try {
|
||||
// We don't await this promise, as it is resolved synchronously due to initImmediate: false.
|
||||
i18next.init({
|
||||
// By default we use english language.
|
||||
fallbackLng: 'en',
|
||||
// Fallback from en-US, en-GB, etc to en.
|
||||
nonExplicitSupportedLngs: true,
|
||||
// We will load resources ourselves.
|
||||
initImmediate: false,
|
||||
// Read language from navigator object.
|
||||
lng,
|
||||
// By default we use core namespace.
|
||||
defaultNS: 'core',
|
||||
// Read namespaces that are supported by the server.
|
||||
// TODO: this can be converted to a dynamic list of namespaces, for async components.
|
||||
// for now just import all what server offers.
|
||||
// We can fallback to core namespace for any addons.
|
||||
fallbackNS: 'core',
|
||||
ns,
|
||||
supportedLngs
|
||||
}).catch((err: any) => {
|
||||
// This should not happen, the promise should be resolved synchronously, without
|
||||
// any errors reported.
|
||||
console.error("i18next failed unexpectedly", err);
|
||||
});
|
||||
// Detect what is resolved languages to load.
|
||||
const languages = i18next.languages;
|
||||
// Fetch all json files (all of which should be already preloaded);
|
||||
const loadPath = `${document.baseURI}locales/{{lng}}.{{ns}}.json`;
|
||||
const pathsToLoad: Promise<any>[] = [];
|
||||
async function load(lang: string, n: string) {
|
||||
const resourceUrl = loadPath.replace('{{lng}}', lang).replace('{{ns}}', n);
|
||||
const response = await fetch(resourceUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${resourceUrl}`);
|
||||
}
|
||||
i18next.addResourceBundle(lang, n, await response.json());
|
||||
}
|
||||
for (const lang of languages) {
|
||||
for (const n of ns) {
|
||||
pathsToLoad.push(load(lang, n));
|
||||
}
|
||||
}
|
||||
await Promise.all(pathsToLoad);
|
||||
console.log("Localization initialized in " + (Date.now() - now) + "ms");
|
||||
} catch (error: any) {
|
||||
reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the translation of the given key, using the given options.
|
||||
*/
|
||||
export function t(key: string, args?: any): string {
|
||||
if (!i18next.exists(key)) {
|
||||
const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`);
|
||||
reportError(error);
|
||||
}
|
||||
return i18next.t(key, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given key exists in the any supported language.
|
||||
*/
|
||||
export function hasTranslation(key: string) {
|
||||
return i18next.exists(key);
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import {lstatSync, readdirSync} from 'fs';
|
||||
import {createInstance, i18n} from 'i18next';
|
||||
import i18fsBackend from 'i18next-fs-backend';
|
||||
import {LanguageDetector} from 'i18next-http-middleware';
|
||||
import path from 'path';
|
||||
|
||||
export function setupLocale(appRoot: string): i18n {
|
||||
// We are using custom instance and leave the global object intact.
|
||||
const instance = createInstance();
|
||||
// By default locales are located in the appRoot folder, unless the environment variable
|
||||
// GRIST_LOCALES_DIR is set.
|
||||
const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, 'static', 'locales');
|
||||
const supportedNamespaces: Set<string> = new Set();
|
||||
const supportedLngs: Set<string> = new Set(readdirSync(localeDir).map((fileName) => {
|
||||
const fullPath = path.join(localeDir, fileName);
|
||||
const isDirectory = lstatSync(fullPath).isDirectory();
|
||||
if (isDirectory) {
|
||||
return "";
|
||||
}
|
||||
const baseName = path.basename(fileName, '.json');
|
||||
const lang = baseName.split('.')[0];
|
||||
const namespace = baseName.split('.')[1];
|
||||
if (!lang || !namespace) {
|
||||
throw new Error("Unrecognized resource file " + fileName);
|
||||
}
|
||||
supportedNamespaces.add(namespace);
|
||||
return lang;
|
||||
}).filter((lang) => lang));
|
||||
if (!supportedLngs.has('en') || !supportedNamespaces.has('core')) {
|
||||
throw new Error("Missing core English language file");
|
||||
}
|
||||
// Initialize localization filesystem plugin that will read the locale files from the localeDir.
|
||||
instance.use(i18fsBackend);
|
||||
// Initialize localization language detector plugin that will read the language from the request.
|
||||
instance.use(LanguageDetector);
|
||||
|
||||
let errorDuringLoad: Error | undefined;
|
||||
instance.init({
|
||||
// Load all files synchronously.
|
||||
initImmediate: false,
|
||||
preload: [...supportedLngs],
|
||||
supportedLngs: [...supportedLngs],
|
||||
defaultNS: 'core',
|
||||
ns: [...supportedNamespaces],
|
||||
fallbackLng: 'en',
|
||||
backend: {
|
||||
loadPath: `${localeDir}/{{lng}}.{{ns}}.json`
|
||||
},
|
||||
}, (err: any) => {
|
||||
if (err) {
|
||||
errorDuringLoad = err;
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
// This should not happen, the promise should be resolved synchronously, without
|
||||
// any errors reported.
|
||||
console.error("i18next failed unexpectedly", err);
|
||||
});
|
||||
if (errorDuringLoad) {
|
||||
throw errorDuringLoad;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function readLoadedLngs(instance?: i18n): readonly string[] {
|
||||
if (!instance) { return []; }
|
||||
return instance?.options.preload || ['en'];
|
||||
}
|
||||
|
||||
export function readLoadedNamespaces(instance?: i18n): readonly string[] {
|
||||
if (!instance) { return []; }
|
||||
if (Array.isArray(instance?.options.ns)) {
|
||||
return instance.options.ns;
|
||||
}
|
||||
return instance?.options.ns ? [instance.options.ns as string] : ['core'];
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
{
|
||||
"Welcome": "Welcome to Grist!",
|
||||
"Loading": "Loading",
|
||||
"AddNew": "Add New",
|
||||
"OtherSites": "Other Sites",
|
||||
"OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
|
||||
"OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:",
|
||||
"AllDocuments": "All Documents",
|
||||
"ExamplesAndTemplates": "Examples and Templates",
|
||||
"MoreExamplesAndTemplates": "More Examples and Templates"
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import {assert, driver} from 'mocha-webdriver';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
import {getAppRoot} from 'app/server/lib/places';
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from 'path';
|
||||
|
||||
describe("Localization", function() {
|
||||
this.timeout(20000);
|
||||
setupTestSuite();
|
||||
|
||||
before(async function() {
|
||||
const session = await gu.session().personalSite.anon.login();
|
||||
await session.loadRelPath("/");
|
||||
});
|
||||
|
||||
it("uses default options for English language", async function() {
|
||||
// Currently, there is not much translated, so test just what we have.
|
||||
assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'Welcome to Grist!');
|
||||
// Grist config should contain the list of supported languages;
|
||||
const gristConfig: any = await driver.executeScript("return window.gristConfig");
|
||||
|
||||
// core and en is required.
|
||||
assert.isTrue(gristConfig.namespaces.includes("core"));
|
||||
assert.isTrue(gristConfig.supportedLngs.includes("en"));
|
||||
});
|
||||
|
||||
it("loads all files from resource folder", async function() {
|
||||
if (server.isExternalServer()) {
|
||||
this.skip();
|
||||
}
|
||||
// Grist config should contain the list of supported languages;
|
||||
const gristConfig: any = await driver.executeScript("return window.gristConfig");
|
||||
// Should report all supported languages and namespaces.
|
||||
const localeDirectory = path.join(getAppRoot(), 'static', 'locales');
|
||||
// Read all file names from localeDirectory
|
||||
const langs: Set<string> = new Set();
|
||||
const namespaces: Set<string> = new Set();
|
||||
for (const file of fs.readdirSync(localeDirectory)) {
|
||||
if (file.endsWith(".json")) {
|
||||
const lang = file.split('.')[0];
|
||||
const ns = file.split('.')[1];
|
||||
langs.add(lang);
|
||||
namespaces.add(ns);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort());
|
||||
assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort());
|
||||
});
|
||||
|
||||
// Now make a Polish language file, and test that it is used.
|
||||
describe("with Polish language file", function() {
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let tempLocale: string;
|
||||
before(async function() {
|
||||
if (server.isExternalServer()) {
|
||||
this.skip();
|
||||
}
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
// Add another language to the list of supported languages.
|
||||
tempLocale = makeCopy();
|
||||
createLanguage(tempLocale, "pl");
|
||||
process.env.GRIST_LOCALES_DIR = tempLocale;
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
});
|
||||
|
||||
it("detects correct language from client headers", async function() {
|
||||
const homeUrl = `${server.getHost()}/o/docs`;
|
||||
// Read response from server, and check that it contains the correct language.
|
||||
const enResponse = await (await fetch(homeUrl)).text();
|
||||
const plResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "pl-PL,pl;q=1"}})).text();
|
||||
const ptResponse = await (await fetch(homeUrl, {headers: {"Accept-Language": "pt-PR,pt;q=1"}})).text();
|
||||
|
||||
function present(response: string, ...langs: string[]) {
|
||||
for (const lang of langs) {
|
||||
assert.include(response, `href="locales/${lang}.core.json"`);
|
||||
}
|
||||
}
|
||||
|
||||
function notPresent(response: string, ...langs: string[]) {
|
||||
for (const lang of langs) {
|
||||
assert.notInclude(response, `href="locales/${lang}.core.json"`);
|
||||
}
|
||||
}
|
||||
|
||||
// English locale is preloaded always.
|
||||
present(enResponse, "en");
|
||||
present(plResponse, "en");
|
||||
present(ptResponse, "en");
|
||||
|
||||
// Other locales are not preloaded for English.
|
||||
notPresent(enResponse, "pl", "pl-PL", "en-US");
|
||||
|
||||
// For Polish we have additional pl locale.
|
||||
present(plResponse, "pl");
|
||||
// But only pl code is preloaded.
|
||||
notPresent(plResponse, "pl-PL");
|
||||
|
||||
// For Portuguese we have only en.
|
||||
notPresent(ptResponse, "pt", "pt-PR", "pl", "en-US");
|
||||
});
|
||||
|
||||
it("loads correct languages from file system", async function() {
|
||||
modifyByCode(tempLocale, "en", {Welcome: 'TestMessage'});
|
||||
await driver.navigate().refresh();
|
||||
assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage');
|
||||
const gristConfig: any = await driver.executeScript("return window.gristConfig");
|
||||
assert.deepEqual(gristConfig.supportedLngs, ['en', 'pl']);
|
||||
});
|
||||
});
|
||||
|
||||
it("breaks the server if something is wrong with resource files", async () => {
|
||||
const oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
try {
|
||||
// Wrong path to locales.
|
||||
process.env.GRIST_LOCALES_DIR = __filename;
|
||||
await assert.isRejected(server.restart());
|
||||
// Empty folder.
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'grist_test_'));
|
||||
process.env.GRIST_LOCALES_DIR = tempDirectory;
|
||||
await assert.isRejected(server.restart());
|
||||
// Wrong file format.
|
||||
fs.writeFileSync(path.join(tempDirectory, 'dummy.json'), 'invalid json');
|
||||
await assert.isRejected(server.restart());
|
||||
} finally {
|
||||
oldEnv.restore();
|
||||
await server.restart();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new language by coping existing "en" resources.
|
||||
*/
|
||||
function createLanguage(localesPath: string, code: string) {
|
||||
for (const file of fs.readdirSync(localesPath)) {
|
||||
const newFile = file.replace('en', code);
|
||||
fs.copyFileSync(path.join(localesPath, file), path.join(localesPath, newFile));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a copy of all resource files and returns path to the temporary directory.
|
||||
*/
|
||||
function makeCopy() {
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'grist_test_'));
|
||||
const localeDirectory = path.join(getAppRoot(), 'static', 'locales');
|
||||
// Copy all files from localeDirectory to tempDirectory.
|
||||
fs.readdirSync(localeDirectory).forEach(file => {
|
||||
fs.copyFileSync(path.join(localeDirectory, file), path.join(tempDirectory, file));
|
||||
});
|
||||
return tempDirectory;
|
||||
}
|
||||
|
||||
function modifyByCode(localeDir: string, code: string, obj: any) {
|
||||
// Read current core localization file.
|
||||
const filePath = path.join(localeDir, `${code}.core.json`);
|
||||
const resources = JSON.parse(fs.readFileSync(filePath).toString());
|
||||
const newResource = Object.assign(resources, obj);
|
||||
fs.writeFileSync(filePath, JSON.stringify(newResource));
|
||||
}
|
||||
});
|
Loading…
Reference in new issue