Expose */translations/{locale}.json endpoint for the translations instead of embedding them into every app HTML index template. (#29075)

This commit is contained in:
Aleh Zasypkin 2019-01-28 14:21:22 +01:00 committed by GitHub
parent 0556ab7b66
commit b450e1bab5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 121 additions and 23 deletions

View file

@ -32,5 +32,6 @@ export class I18nProvider implements angular.IServiceProvider {
public getFormats = i18n.getFormats;
public getRegisteredLocales = i18n.getRegisteredLocales;
public init = i18n.init;
public load = i18n.load;
public $get = () => i18n.translate;
}

View file

@ -29,6 +29,7 @@ describe('I18n engine', () => {
afterEach(() => {
// isolate modules for every test so that local module state doesn't conflict between tests
jest.resetModules();
jest.clearAllMocks();
});
describe('addMessages', () => {
@ -882,4 +883,47 @@ describe('I18n engine', () => {
expect(message).toMatchSnapshot();
});
});
describe('load', () => {
let mockFetch: jest.Mock<unknown>;
beforeEach(() => {
mockFetch = jest.spyOn(global as any, 'fetch').mockImplementation();
});
test('fails if server returns >= 300 status code', async () => {
mockFetch.mockResolvedValue({ status: 301 });
await expect(i18n.load('some-url')).rejects.toMatchInlineSnapshot(
`[Error: Translations request failed with status code: 301]`
);
mockFetch.mockResolvedValue({ status: 404 });
await expect(i18n.load('some-url')).rejects.toMatchInlineSnapshot(
`[Error: Translations request failed with status code: 404]`
);
});
test('initializes engine with received translations', async () => {
const translations = {
locale: 'en-XA',
formats: {
number: { currency: { style: 'currency' } },
},
messages: { 'common.ui.someLabel': 'some label' },
};
mockFetch.mockResolvedValue({
status: 200,
json: jest.fn().mockResolvedValue(translations),
});
await expect(i18n.load('some-url')).resolves.toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('some-url');
expect(i18n.getTranslation()).toEqual(translations);
});
});
});

View file

@ -234,3 +234,17 @@ export function init(newTranslation?: Translation) {
setFormats(newTranslation.formats);
}
}
/**
* Loads JSON with translations from the specified URL and initializes i18n engine with them.
* @param translationsUrl URL pointing to the JSON bundle with translations.
*/
export async function load(translationsUrl: string) {
const response = await fetch(translationsUrl);
if (response.status >= 300) {
throw new Error(`Translations request failed with status code: ${response.status}`);
}
init(await response.json());
}

View file

@ -286,7 +286,7 @@ describe('#start()', () => {
rootDomElement,
});
core.start();
return core.start();
}
it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', () => {
@ -353,6 +353,10 @@ describe('#start()', () => {
expect(mockInstance.start).toHaveBeenCalledTimes(1);
expect(mockInstance.start).toHaveBeenCalledWith();
});
it('returns start contract', () => {
expect(startCore()).toEqual({ fatalErrors: mockFatalErrorsStartContract });
});
});
describe('LegacyPlatform targetDomElement', () => {

View file

@ -120,6 +120,8 @@ export class CoreSystem {
uiSettings,
chrome,
});
return { fatalErrors };
} catch (error) {
this.fatalErrors.add(error);
}

View file

@ -55,16 +55,11 @@ export async function i18nMixin(kbnServer, server, config) {
]);
const translationPaths = [].concat(...groupedEntries);
i18nLoader.registerTranslationFiles(translationPaths);
const pureTranslations = await i18nLoader.getTranslationsByLocale(locale);
const translations = Object.freeze({
const translations = await i18nLoader.getTranslationsByLocale(locale);
i18n.init(Object.freeze({
locale,
...pureTranslations,
});
i18n.init(translations);
server.decorate('server', 'getUiTranslations', () => translations);
...translations,
}));
}

View file

@ -75,7 +75,7 @@ export default class KbnServer {
// writes pid file
pidMixin,
// scan translations dirs, register locale files, initialize i18n engine and define `server.getUiTranslations`
// scan translations dirs, register locale files and initialize i18n engine.
i18nMixin,
// find plugins and set this.plugins and this.pluginSpecs

View file

@ -37,13 +37,22 @@ import { i18n } from '@kbn/i18n';
import { CoreSystem } from '__kibanaCore__'
const injectedMetadata = JSON.parse(document.querySelector('kbn-injected-metadata').getAttribute('data'));
i18n.init(injectedMetadata.legacyMetadata.translations);
new CoreSystem({
injectedMetadata,
rootDomElement: document.body,
requireLegacyFiles: () => {
${bundle.getRequires().join('\n ')}
}
}).start()
i18n.load(injectedMetadata.i18n.translationsUrl)
.catch(e => e)
.then((i18nError) => {
const coreSystem = new CoreSystem({
injectedMetadata,
rootDomElement: document.body,
requireLegacyFiles: () => {
${bundle.getRequires().join('\n ')}
}
});
const coreStartContract = coreSystem.start();
if (i18nError) {
coreStartContract.fatalErrors.add(i18nError);
}
});
`;

View file

@ -35,8 +35,6 @@ export const UI_EXPORT_DEFAULTS = {
'moment-timezone$': resolve(ROOT, 'webpackShims/moment-timezone')
},
translationPaths: [],
styleSheetPaths: [],
appExtensions: {

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { createHash } from 'crypto';
import { props, reduce as reduceAsync } from 'bluebird';
import Boom from 'boom';
import { resolve } from 'path';
@ -57,6 +58,35 @@ export function uiRenderMixin(kbnServer, server, config) {
server.exposeStaticDir('/node_modules/@elastic/eui/dist/{path*}', fromRoot('node_modules/@elastic/eui/dist'));
server.exposeStaticDir('/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist'));
const translationsCache = { translations: null, hash: null };
server.route({
path: '/translations/{locale}.json',
method: 'GET',
config: { auth: false },
handler(request, h) {
// Kibana server loads translations only for a single locale
// that is specified in `i18n.locale` config value.
const { locale } = request.params;
if (i18n.getLocale() !== locale.toLowerCase()) {
throw Boom.notFound(`Unknown locale: ${locale}`);
}
// Stringifying thousands of labels and calculating hash on the resulting
// string can be expensive so it makes sense to do it once and cache.
if (translationsCache.translations == null) {
translationsCache.translations = JSON.stringify(i18n.getTranslation());
translationsCache.hash = createHash('sha1')
.update(translationsCache.translations)
.digest('hex');
}
return h.response(translationsCache.translations)
.header('cache-control', 'must-revalidate')
.header('content-type', 'application/json')
.etag(translationsCache.hash);
}
});
// register the bootstrap.js route after plugins are initialized so that we can
// detect if any default auth strategies were registered
kbnServer.afterPluginsInit(() => {
@ -175,12 +205,10 @@ export function uiRenderMixin(kbnServer, server, config) {
async function renderApp({ app, h, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) {
const request = h.request;
const translations = await server.getUiTranslations();
const basePath = request.getBasePath();
const legacyMetadata = await getLegacyKibanaPayload({
app,
translations,
request,
includeUserProvidedConfig,
injectedVarsOverrides
@ -197,6 +225,9 @@ export function uiRenderMixin(kbnServer, server, config) {
version: kbnServer.version,
buildNumber: config.get('pkg.buildNum'),
basePath,
i18n: {
translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`,
},
vars: await replaceInjectedVars(
request,
mergeVariables(