You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/server/localization.ts

98 lines
3.7 KiB

import {appSettings} from 'app/server/lib/AppSettings';
import log from 'app/server/lib/log';
import {lstatSync, readdirSync, readFileSync} from 'fs';
import {createInstance, i18n} from 'i18next';
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 preload: [string, string, string][] = [];
const supportedNamespaces: Set<string> = new Set();
const supportedLngs: Set<string> = new Set();
for(const fileName of readdirSync(localeDir)) {
const fullPath = path.join(localeDir, fileName);
const isDirectory = lstatSync(fullPath).isDirectory();
if (isDirectory) {
continue;
}
const baseName = path.basename(fileName, '.json');
const lang = baseName.split('.')[0]?.replace(/_/g, '-');
const namespace = baseName.split('.')[1];
if (!lang || !namespace) {
throw new Error("Unrecognized resource file " + fileName);
}
supportedNamespaces.add(namespace);
preload.push([namespace, lang, fullPath]);
supportedLngs.add(lang);
}
if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
throw new Error("Missing server English language file");
}
// Initialize localization language detector plugin that will read the language from the request.
instance.use(LanguageDetector);
let errorDuringLoad: Error | undefined;
instance.init({
defaultNS: 'server',
ns: [...supportedNamespaces],
fallbackLng: 'en',
detection: {
lookupCookie: 'grist_user_locale'
}
}, (err: any) => {
if (err) {
errorDuringLoad = err;
}
}).catch((err: any) => {
// This should not happen, the promise should be resolved synchronously, without
// any errors reported.
log.error("i18next failed unexpectedly", err);
});
if (errorDuringLoad) {
log.error('i18next failed to load', errorDuringLoad);
throw errorDuringLoad;
}
// Load all files synchronously.
// First sort by ns, which will put "client" first. That lets us check for a
// client key which, if absent, means the language should be ignored.
preload.sort((a, b) => a[0].localeCompare(b[0]));
const offerAll = appSettings.section('locale').flag('offerAllLanguages').readBool({
envVar: 'GRIST_OFFER_ALL_LANGUAGES',
});
const shouldIgnoreLng = new Set<string>();
for(const [ns, lng, fullPath] of preload) {
const data = JSON.parse(readFileSync(fullPath, 'utf8'));
// If the "Translators: please ..." key in "App" has not been translated,
// ignore this language for this and later namespaces.
if (!offerAll && ns === 'client' &&
!Object.keys(data.App || {}).some(key => key.includes('Translators: please'))) {
shouldIgnoreLng.add(lng);
log.debug(`skipping incomplete language ${lng} (set GRIST_OFFER_ALL_LANGUAGES if you want it)`);
}
if (!shouldIgnoreLng.has(lng)) {
instance.addResourceBundle(lng, ns, data);
}
}
return instance;
}
export function readLoadedLngs(instance?: i18n): readonly string[] {
if (!instance) { return []; }
return Object.keys(instance?.services.resourceStore.data);
}
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] : ['server'];
}