From 2911f597a49e7867f0c99a8d81b661c8915ab693 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Fri, 26 Apr 2024 08:27:36 +0200 Subject: [PATCH] Add translation files to CDN assets (#181650) ## Summary Part of https://github.com/elastic/kibana/issues/72880 - Generate translation files for all locales (including all internal plugins) during the CDN asset generation task - Adapt the `rendering` service to use the translation files from the CDN if configured/enabled ### How to test Connect to the serverless project that was created for the PR, and confirm the translation file is being loaded from the CDN Screenshot 2024-04-25 at 15 55 23 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/static_assets/static_assets.test.ts | 13 +++++++ .../src/static_assets/static_assets.ts | 9 +++++ .../src/http_service.mock.ts | 1 + .../i18n/core-i18n-server-internal/index.ts | 2 ++ .../src/constants.ts | 12 +++++++ .../src/rendering_service.test.ts | 36 +++++++++++++++++++ .../src/rendering_service.tsx | 14 +++++--- src/dev/build/tasks/create_cdn_assets_task.ts | 29 +++++++++++++-- src/dev/tsconfig.json | 1 + 9 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 packages/core/i18n/core-i18n-server-internal/src/constants.ts diff --git a/packages/core/http/core-http-server-internal/src/static_assets/static_assets.test.ts b/packages/core/http/core-http-server-internal/src/static_assets/static_assets.test.ts index 438a87765d85e..9d2c58e85b8ae 100644 --- a/packages/core/http/core-http-server-internal/src/static_assets/static_assets.test.ts +++ b/packages/core/http/core-http-server-internal/src/static_assets/static_assets.test.ts @@ -35,6 +35,19 @@ describe('StaticAssets', () => { }); }); + describe('#isUsingCdn()', () => { + it('returns false when the CDN is not configured', () => { + staticAssets = new StaticAssets(args); + expect(staticAssets.isUsingCdn()).toBe(false); + }); + + it('returns true when the CDN is configured', () => { + args.cdnConfig = CdnConfig.from({ url: 'https://cdn.example.com/test' }); + staticAssets = new StaticAssets(args); + expect(staticAssets.isUsingCdn()).toBe(true); + }); + }); + describe('#getPluginAssetHref()', () => { it('returns the expected value when CDN is not configured', () => { staticAssets = new StaticAssets(args); diff --git a/packages/core/http/core-http-server-internal/src/static_assets/static_assets.ts b/packages/core/http/core-http-server-internal/src/static_assets/static_assets.ts index f5f7d7ac80430..f67dfbc46d9b7 100644 --- a/packages/core/http/core-http-server-internal/src/static_assets/static_assets.ts +++ b/packages/core/http/core-http-server-internal/src/static_assets/static_assets.ts @@ -16,6 +16,11 @@ import { export interface InternalStaticAssets { getHrefBase(): string; + /** + * Returns true if a CDN has been configured and should be used to serve static assets. + * Should only be used in scenarios where different behavior has to be used when CDN is enabled or not. + */ + isUsingCdn(): boolean; /** * Intended for use by server code rendering UI or generating links to static assets * that will ultimately be called from the browser and must respect settings like @@ -67,6 +72,10 @@ export class StaticAssets implements InternalStaticAssets { this.assetsServerPathBase = `/${shaDigest}`; } + public isUsingCdn() { + return this.hasCdnHost; + } + /** * Returns a href (hypertext reference) intended to be used as the base for constructing * other hrefs to static assets. diff --git a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts index 7172accf98a9f..01f8c99bf2332 100644 --- a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts +++ b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts @@ -87,6 +87,7 @@ const createInternalStaticAssetsMock = ( basePath: BasePathMocked, cdnUrl: undefined | string = undefined ): InternalStaticAssetsMocked => ({ + isUsingCdn: jest.fn().mockReturnValue(!!cdnUrl), getHrefBase: jest.fn().mockReturnValue(cdnUrl ?? basePath.serverBasePath), getPluginAssetHref: jest.fn().mockReturnValue(cdnUrl ?? basePath.serverBasePath), getPluginServerPath: jest.fn((v, _) => v), diff --git a/packages/core/i18n/core-i18n-server-internal/index.ts b/packages/core/i18n/core-i18n-server-internal/index.ts index e4709a8cd7b83..e7a7daa801609 100644 --- a/packages/core/i18n/core-i18n-server-internal/index.ts +++ b/packages/core/i18n/core-i18n-server-internal/index.ts @@ -8,3 +8,5 @@ export type { I18nConfigType, InternalI18nServicePreboot } from './src'; export { config, I18nService } from './src'; +export { getKibanaTranslationFiles } from './src/get_kibana_translation_files'; +export { supportedLocale } from './src/constants'; diff --git a/packages/core/i18n/core-i18n-server-internal/src/constants.ts b/packages/core/i18n/core-i18n-server-internal/src/constants.ts new file mode 100644 index 0000000000000..c662f77b6adbf --- /dev/null +++ b/packages/core/i18n/core-i18n-server-internal/src/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * List of all locales that are officially supported. + */ +export const supportedLocale = ['en', 'fr-FR', 'ja-JP', 'zh-CN']; diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts index 7ebb97fb0779b..b07b8a1cd6fa1 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.test.ts @@ -258,6 +258,42 @@ function renderTestCases( const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); expect(data.logging).toEqual(loggingConfig); }); + + it('use the correct translation url when CDN is enabled', async () => { + const userSettings = { 'theme:darkMode': { userValue: true } }; + uiSettings.client.getUserProvided.mockResolvedValue(userSettings); + + const [render, deps] = await getRender(); + + (deps.http.staticAssets.getHrefBase as jest.Mock).mockReturnValueOnce('http://foo.bar:1773'); + (deps.http.staticAssets.isUsingCdn as jest.Mock).mockReturnValueOnce(true); + + const content = await render(createKibanaRequest(), uiSettings, { + isAnonymousPage: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + expect(data.i18n.translationsUrl).toEqual('http://foo.bar:1773/translations/en.json'); + }); + + it('use the correct translation url when CDN is disabled', async () => { + const userSettings = { 'theme:darkMode': { userValue: true } }; + uiSettings.client.getUserProvided.mockResolvedValue(userSettings); + + const [render, deps] = await getRender(); + + (deps.http.staticAssets.getHrefBase as jest.Mock).mockReturnValueOnce('http://foo.bar:1773'); + (deps.http.staticAssets.isUsingCdn as jest.Mock).mockReturnValueOnce(false); + + const content = await render(createKibanaRequest(), uiSettings, { + isAnonymousPage: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + expect(data.i18n.translationsUrl).toEqual( + '/mock-server-basepath/translations/MOCK_HASH/en.json' + ); + }); }); } diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index cf97bad34fc60..498cc60b49376 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -130,6 +130,7 @@ export class RenderingService { packageInfo: this.coreContext.env.packageInfo, }; const staticAssetsHrefBase = http.staticAssets.getHrefBase(); + const usingCdn = http.staticAssets.isUsingCdn(); const basePath = http.basePath.get(request); const { serverBasePath, publicBaseUrl } = http.basePath; @@ -205,8 +206,14 @@ export class RenderingService { const loggingConfig = await getBrowserLoggingConfig(this.coreContext.configService); - const translationHash = i18n.getTranslationHash(); - const translationsUrl = `${serverBasePath}/translations/${translationHash}/${i18nLib.getLocale()}.json`; + const locale = i18nLib.getLocale(); + let translationsUrl: string; + if (usingCdn) { + translationsUrl = `${staticAssetsHrefBase}/translations/${locale}.json`; + } else { + const translationHash = i18n.getTranslationHash(); + translationsUrl = `${serverBasePath}/translations/${translationHash}/${locale}.json`; + } const filteredPlugins = filterUiPlugins({ uiPlugins, isAnonymousPage }); const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; @@ -215,7 +222,7 @@ export class RenderingService { uiPublicUrl: `${staticAssetsHrefBase}/ui`, bootstrapScriptUrl: `${basePath}/${bootstrapScript}`, i18n: i18nLib.translate, - locale: i18nLib.getLocale(), + locale, themeVersion, darkMode, stylesheetPaths: commonStylesheetPaths, @@ -239,7 +246,6 @@ export class RenderingService { clusterInfo, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, i18n: { - // TODO: Make this load as part of static assets! translationsUrl, }, theme: { diff --git a/src/dev/build/tasks/create_cdn_assets_task.ts b/src/dev/build/tasks/create_cdn_assets_task.ts index 870fd05c4ae39..79e79fba548a0 100644 --- a/src/dev/build/tasks/create_cdn_assets_task.ts +++ b/src/dev/build/tasks/create_cdn_assets_task.ts @@ -12,11 +12,13 @@ import { access } from 'fs/promises'; import { resolve, dirname } from 'path'; import { asyncForEach } from '@kbn/std'; import { Jsonc } from '@kbn/repo-packages'; +import { getKibanaTranslationFiles, supportedLocale } from '@kbn/core-i18n-server-internal'; +import { i18n, i18nLoader } from '@kbn/i18n'; import del from 'del'; import globby from 'globby'; -import { mkdirp, compressTar, Task, copyAll } from '../lib'; +import { mkdirp, compressTar, Task, copyAll, write } from '../lib'; export const CreateCdnAssets: Task = { description: 'Creating CDN assets', @@ -31,9 +33,19 @@ export const CreateCdnAssets: Task = { await del(assets); await mkdirp(assets); - // Plugins - const plugins = globby.sync([`${buildSource}/node_modules/@kbn/**/*/kibana.jsonc`]); + + // translation files + const pluginPaths = plugins.map((plugin) => resolve(dirname(plugin))); + for (const locale of supportedLocale) { + const translationFileContent = await generateTranslationFile(locale, pluginPaths); + await write( + resolve(assets, buildSha, `translations`, `${locale}.json`), + translationFileContent + ); + } + + // Plugins static assets await asyncForEach(plugins, async (path) => { const manifest = Jsonc.parse(readFileSync(path, 'utf8')) as any; if (manifest?.plugin?.id) { @@ -101,3 +113,14 @@ export const CreateCdnAssets: Task = { }); }, }; + +async function generateTranslationFile(locale: string, pluginPaths: string[]) { + const translationFiles = await getKibanaTranslationFiles(locale, pluginPaths); + i18nLoader.registerTranslationFiles(translationFiles); + const translations = await i18nLoader.getTranslationsByLocale(locale); + i18n.init({ + locale, + ...translations, + }); + return JSON.stringify(i18n.getTranslation()); +} diff --git a/src/dev/tsconfig.json b/src/dev/tsconfig.json index ca2253b2ee055..e028b31a931f7 100644 --- a/src/dev/tsconfig.json +++ b/src/dev/tsconfig.json @@ -42,5 +42,6 @@ "@kbn/core-test-helpers-so-type-serializer", "@kbn/core-test-helpers-kbn-server", "@kbn/dev-proc-runner", + "@kbn/core-i18n-server-internal", ] }