From f1f5f1c9b3e7a938e16d19c726a24c56612c39bc Mon Sep 17 00:00:00 2001 From: Leanid Shutau Date: Mon, 3 Dec 2018 10:11:49 +0300 Subject: [PATCH] [I18n] Support interpreting individual i18n-values as html or text-only (#26274) (#26469) * [I18n] Add attribute for interpreting i18n-values as html or text-only * Switch over to html_ prefixed values solution * Update readme --- packages/kbn-i18n/README.md | 18 ++- .../__snapshots__/directive.test.ts.snap | 30 ++++- .../kbn-i18n/src/angular/directive.test.ts | 36 +++++- packages/kbn-i18n/src/angular/directive.ts | 64 +++++++++-- .../public/src/directives/welcome.html | 2 +- .../public/controls/gauge_options.html | 4 +- .../public/controls/heatmap_options.html | 4 +- .../public/dashboard/dashboard_app.html | 4 +- .../edit_index_pattern.html | 2 +- .../metric_vis/public/metric_vis_params.html | 2 +- .../tile_map/public/editors/wms_options.html | 2 +- .../timelion_help/timelion_help.html | 104 +++++++++--------- src/dev/i18n/utils.js | 11 +- .../plugins/graph/public/templates/index.html | 2 +- .../public/templates/index.html | 11 +- .../public/views/management/roles.html | 4 +- 16 files changed, 209 insertions(+), 91 deletions(-) diff --git a/packages/kbn-i18n/README.md b/packages/kbn-i18n/README.md index cff7fbe98ad4..2c39df9096e2 100644 --- a/packages/kbn-i18n/README.md +++ b/packages/kbn-i18n/README.md @@ -378,19 +378,33 @@ The translation `directive` has the following syntax: ```html ``` Where: - `i18n-id` - translation id to be translated -- `i18n-values` - values to pass into translation - `i18n-default-message` - will be used unless translation was successful +- `i18n-values` - values to pass into translation - `i18n-description` - optional context comment that will be extracted by i18n tools and added as a comment next to translation message at `defaultMessages.json` +If HTML rendering in `i18n-values` is required then value key in `i18n-values` object +should have `html_` prefix. Otherwise the value will be inserted to the message without +HTML rendering.\ +Example: +```html +

+``` + Angular `I18n` module is placed into `autoload` module, so it will be loaded automatically. After that we can use i18n directive in Angular templates: ```html diff --git a/packages/kbn-i18n/src/angular/__snapshots__/directive.test.ts.snap b/packages/kbn-i18n/src/angular/__snapshots__/directive.test.ts.snap index 9e783305a5a3..44bb96399432 100644 --- a/packages/kbn-i18n/src/angular/__snapshots__/directive.test.ts.snap +++ b/packages/kbn-i18n/src/angular/__snapshots__/directive.test.ts.snap @@ -1,5 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`i18nDirective doesn't render html in result message with text-only values 1`] = ` +
+ Default <span onclick=alert(1) >Press</span> message +
+`; + +exports[`i18nDirective doesn't render html in text-only value 1`] = ` +
+ Default <strong>message</strong> +
+`; + exports[`i18nDirective inserts correct translation html content with values 1`] = `"default-message word"`; exports[`i18nDirective inserts correct translation html content with values 2`] = `"default-message anotherWord"`; @@ -9,7 +31,7 @@ exports[`i18nDirective sanitizes message before inserting it to DOM 1`] = ` class="ng-scope ng-isolate-scope" i18n-default-message="Default message, {value}" i18n-id="id" - i18n-values="{ value: '
' }" + i18n-values="{ html_value: '
' }" > Default message,
@@ -19,9 +41,9 @@ exports[`i18nDirective sanitizes message before inserting it to DOM 1`] = ` exports[`i18nDirective sanitizes onclick attribute 1`] = `
Default @@ -36,7 +58,7 @@ exports[`i18nDirective sanitizes onmouseover attribute 1`] = ` class="ng-scope ng-isolate-scope" i18n-default-message="Default {value} message" i18n-id="id" - i18n-values="{ value: 'Press' }" + i18n-values="{ html_value: 'Press' }" > Default diff --git a/packages/kbn-i18n/src/angular/directive.test.ts b/packages/kbn-i18n/src/angular/directive.test.ts index bd454192a9b2..e26dc72802fa 100644 --- a/packages/kbn-i18n/src/angular/directive.test.ts +++ b/packages/kbn-i18n/src/angular/directive.test.ts @@ -89,7 +89,7 @@ describe('i18nDirective', () => { `
` ); @@ -99,7 +99,7 @@ describe('i18nDirective', () => { expect(element[0]).toMatchSnapshot(); }); - test('sanitizes onclick attribute', () => { + test(`doesn't render html in result message with text-only values`, () => { const element = angular.element( `
{ expect(element[0]).toMatchSnapshot(); }); + test('sanitizes onclick attribute', () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + test('sanitizes onmouseover attribute', () => { const element = angular.element( `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + + test(`doesn't render html in text-only value`, () => { + const element = angular.element( + `
` ); diff --git a/packages/kbn-i18n/src/angular/directive.ts b/packages/kbn-i18n/src/angular/directive.ts index 0b111c3daad6..8a4b724ec062 100644 --- a/packages/kbn-i18n/src/angular/directive.ts +++ b/packages/kbn-i18n/src/angular/directive.ts @@ -27,6 +27,9 @@ interface I18nScope extends IScope { id: string; } +const HTML_KEY_PREFIX = 'html_'; +const PLACEHOLDER_SEPARATOR = '@I18N@'; + export function i18nDirective( i18n: I18nServiceType, $sanitize: (html: string) => string @@ -41,27 +44,66 @@ export function i18nDirective( link($scope, $element) { if ($scope.values) { $scope.$watchCollection('values', () => { - setHtmlContent($element, $scope, $sanitize, i18n); + setContent($element, $scope, $sanitize, i18n); }); } else { - setHtmlContent($element, $scope, $sanitize, i18n); + setContent($element, $scope, $sanitize, i18n); } }, }; } -function setHtmlContent( +function setContent( $element: IRootElementService, $scope: I18nScope, $sanitize: (html: string) => string, i18n: I18nServiceType ) { - $element.html( - $sanitize( - i18n($scope.id, { - values: $scope.values, - defaultMessage: $scope.defaultMessage, - }) - ) - ); + const originalValues = $scope.values; + const valuesWithPlaceholders = {} as Record; + let hasValuesWithPlaceholders = false; + + // If we have values with the keys that start with HTML_KEY_PREFIX we should replace + // them with special placeholders that later on will be inserted as HTML + // into the DOM, the rest of the content will be treated as text. We don't + // sanitize values at this stage as some of the values can be excluded from + // the translated string (e.g. not used by ICU conditional statements). + if (originalValues) { + for (const [key, value] of Object.entries(originalValues)) { + if (key.startsWith(HTML_KEY_PREFIX)) { + valuesWithPlaceholders[ + key.slice(HTML_KEY_PREFIX.length) + ] = `${PLACEHOLDER_SEPARATOR}${key}${PLACEHOLDER_SEPARATOR}`; + + hasValuesWithPlaceholders = true; + } else { + valuesWithPlaceholders[key] = value; + } + } + } + + const label = i18n($scope.id, { + values: valuesWithPlaceholders, + defaultMessage: $scope.defaultMessage, + }); + + // If there are no placeholders to replace treat everything as text, otherwise + // insert label piece by piece replacing every placeholder with corresponding + // sanitized HTML content. + if (!hasValuesWithPlaceholders) { + $element.text(label); + } else { + $element.empty(); + for (const contentOrPlaceholder of label.split(PLACEHOLDER_SEPARATOR)) { + if (!contentOrPlaceholder) { + continue; + } + + $element.append( + originalValues!.hasOwnProperty(contentOrPlaceholder) + ? $sanitize(originalValues![contentOrPlaceholder]) + : document.createTextNode(contentOrPlaceholder) + ); + } + } } diff --git a/src/core_plugins/console/public/src/directives/welcome.html b/src/core_plugins/console/public/src/directives/welcome.html index 7fba7d21a916..29fac7592f2f 100644 --- a/src/core_plugins/console/public/src/directives/welcome.html +++ b/src/core_plugins/console/public/src/directives/welcome.html @@ -29,7 +29,7 @@ i18n-id="console.welcomePage.supportedRequestFormatDescription" i18n-default-message="While typing a request, Console will make suggestions which you can then accept by hitting Enter/Tab. These suggestions are made based on the request structure {asWellAs} your indices and types." - i18n-values="{ asWellAs: '' + asWellAsFragmentText + '' }" + i18n-values="{ html_asWellAs: '' + asWellAsFragmentText + '' }" >

diff --git a/src/core_plugins/kbn_vislib_vis_types/public/controls/gauge_options.html b/src/core_plugins/kbn_vislib_vis_types/public/controls/gauge_options.html index 0d9c1334ef69..2d6ceaa462f1 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/controls/gauge_options.html +++ b/src/core_plugins/kbn_vislib_vis_types/public/controls/gauge_options.html @@ -205,10 +205,10 @@

-

diff --git a/src/core_plugins/kbn_vislib_vis_types/public/controls/heatmap_options.html b/src/core_plugins/kbn_vislib_vis_types/public/controls/heatmap_options.html index 2f3e7ca882a2..0598cb05c416 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/controls/heatmap_options.html +++ b/src/core_plugins/kbn_vislib_vis_types/public/controls/heatmap_options.html @@ -195,8 +195,8 @@ i18n-id="kbnVislibVisTypes.controls.heatmapOptions.specifiedRangeNumberWarningMessage" i18n-default-message="{icon} {required} You must specify at least one range." i18n-values="{ - icon: '', - required: '' + requiredText + '' + html_icon: '', + html_required: '' + requiredText + '' }" >

diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/core_plugins/kibana/public/dashboard/dashboard_app.html index 18c40bda026f..0aa3c6eb930c 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -77,8 +77,8 @@ i18n-id="kbn.dashboard.addVisualizationDescription2" i18n-default-message=" button in the menu bar above to add a visualization to the dashboard. {br}If you haven't set up any visualizations yet, {visitVisualizeAppLink} to create your first visualization." i18n-values="{ - br: '
', - visitVisualizeAppLink: '' + visitVisualizeAppLinkText + '' + html_br: '
', + html_visitVisualizeAppLink: '' + visitVisualizeAppLinkText + '' }" >

diff --git a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.html b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.html index 426a0600663e..9c4fb65dc734 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.html +++ b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.html @@ -33,7 +33,7 @@

+ i18n-values="{ html_indexPatternTitle: '' + indexPattern.title + '' }"> diff --git a/src/core_plugins/metric_vis/public/metric_vis_params.html b/src/core_plugins/metric_vis/public/metric_vis_params.html index ae421468e40c..f530f1af7765 100644 --- a/src/core_plugins/metric_vis/public/metric_vis_params.html +++ b/src/core_plugins/metric_vis/public/metric_vis_params.html @@ -113,7 +113,7 @@

diff --git a/src/core_plugins/tile_map/public/editors/wms_options.html b/src/core_plugins/tile_map/public/editors/wms_options.html index a0d9b008e3b9..f2b625f866e9 100644 --- a/src/core_plugins/tile_map/public/editors/wms_options.html +++ b/src/core_plugins/tile_map/public/editors/wms_options.html @@ -57,7 +57,7 @@ i18n-id="tileMap.wmsOptions.wmsDescription" i18n-default-message="WMS is an OGC standard for map image services. For more information, go {wmsLink}." i18n-values="{ - wmsLink: '' + wmsLinkText + '' + html_wmsLink: '' + wmsLinkText + '' }" >


diff --git a/src/core_plugins/timelion/public/directives/timelion_help/timelion_help.html b/src/core_plugins/timelion/public/directives/timelion_help/timelion_help.html index 7cfaa39c706c..fa9480fc7db4 100644 --- a/src/core_plugins/timelion/public/directives/timelion_help/timelion_help.html +++ b/src/core_plugins/timelion/public/directives/timelion_help/timelion_help.html @@ -4,7 +4,7 @@

@@ -120,8 +120,8 @@ looks ok. We found data from {statsMin} to {statsMax}. You're probably all set. If this doesn't look right, see" i18n-values="{ - statsMin: '' + es.stats.min + '', - statsMax: '' + es.stats.max + '', + html_statsMin: '' + es.stats.min + '', + html_statsMax: '' + es.stats.max + '', }" i18n-description="Part of composite text timelion.help.configuration.valid.paragraph1Part1 + timelion.help.configuration.firstTimeConfigurationLinkText + @@ -159,7 +159,7 @@ i18n-id="timelion.help.configuration.valid.intervalsTextPart1" i18n-default-message="The interval selector at the right of the input bar lets you control the sampling frequency. It's currently set to {interval}." - i18n-values="{ interval: '' + state.interval + '' }" + i18n-values="{ html_interval: '' + state.interval + '' }" i18n-description="Part of composite text timelion.help.configuration.valid.intervalsTextPart1 + (timelion.help.configuration.valid.intervalIsAutoText || @@ -186,7 +186,7 @@ (timelion.help.configuration.valid.intervalIsAutoText || timelion.help.configuration.valid.intervals.content.intervalIsNotAutoText) + timelion.help.configuration.valid.intervalsTextPart2" - i18n-values="{ auto: 'auto ' }" + i18n-values="{ html_auto: 'auto' }" >

@@ -250,7 +250,7 @@ datasource, you can start submitting queries. For starters, enter {esPattern} in the input bar and hit enter." i18n-values="{ - esPattern: '.es(*)', + html_esPattern: '.es(*)', }" >

@@ -262,13 +262,13 @@ field that is greater than 100. Note that this query is enclosed in single quotes—that's because it contains spaces. You can enter any" i18n-values="{ - esAsteriskQueryDescription: '' + translations.esAsteriskQueryDescription + '', - html: 'html', - htmlQuery: '.es(html)', - bobQuery: '.es(\'user:bob AND bytes:>100\')', - bob: 'bob', - user: 'user', - bytes: 'bytes', + html_esAsteriskQueryDescription: '' + translations.esAsteriskQueryDescription + '', + html_html: 'html', + html_htmlQuery: '.es(html)', + html_bobQuery: '.es(\'user:bob AND bytes:>100\')', + html_bob: 'bob', + html_user: 'user', + html_bytes: 'bytes', }" i18n-description="Part of composite text timelion.help.querying.paragraph2Part1 + @@ -290,7 +290,7 @@ i18n-id="timelion.help.querying.paragraph2Part2" i18n-default-message="as the first argument to the {esQuery} function." i18n-values="{ - esQuery: '.es()', + html_esQuery: '.es()', }" i18n-description="Part of composite text timelion.help.querying.paragraph2Part1 + @@ -312,10 +312,10 @@ For example, you can enter {esLogstashQuery} to tell the Elasticsearch datasource {esIndexQueryDescription}." i18n-values="{ - esEmptyQuery: '.es()', - esStarQuery: '.es(*)', - esLogstashQuery: '.es(index=\'logstash-*\', q=\'*\')', - esIndexQueryDescription: '' + translations.esIndexQueryDescription + '', + html_esEmptyQuery: '.es()', + html_esStarQuery: '.es(*)', + html_esLogstashQuery: '.es(index=\'logstash-*\', q=\'*\')', + html_esIndexQueryDescription: '' + translations.esIndexQueryDescription + '', }" >

@@ -419,7 +419,7 @@ i18n-id="timelion.help.expressions.examples.twoExpressionsDescription" i18n-default-message="{descriptionTitle} Two expressions on the same chart." i18n-values="{ - descriptionTitle: '' + translations.twoExpressionsDescriptionTitle + '', + html_descriptionTitle: '' + translations.twoExpressionsDescriptionTitle + '', }" > @@ -430,7 +430,7 @@ i18n-default-message="{descriptionTitle} Colorizes the first series red and uses 1 pixel wide bars for the second series." i18n-values="{ - descriptionTitle: '' + translations.customStylingDescriptionTitle + '', + html_descriptionTitle: '' + translations.customStylingDescriptionTitle + '', }" > @@ -445,7 +445,7 @@ to specify arguments in, use named arguments to make the expressions easier to read and write." i18n-values="{ - descriptionTitle: '' + translations.namedArgumentsDescriptionTitle + '', + html_descriptionTitle: '' + translations.namedArgumentsDescriptionTitle + '', }" > @@ -456,7 +456,7 @@ i18n-default-message="{descriptionTitle} You can also chain groups of expressions to functions. Here, both series are shown as points instead of lines." i18n-values="{ - descriptionTitle: '' + translations.groupedExpressionsDescriptionTitle + '', + html_descriptionTitle: '' + translations.groupedExpressionsDescriptionTitle + '', }" > @@ -515,7 +515,7 @@

diff --git a/src/dev/i18n/utils.js b/src/dev/i18n/utils.js index 7357eca53825..f1d7a8622122 100644 --- a/src/dev/i18n/utils.js +++ b/src/dev/i18n/utils.js @@ -40,6 +40,7 @@ const ESCAPE_LINE_BREAK_REGEX = /(? (key.startsWith(HTML_KEY_PREFIX) ? key.slice(HTML_KEY_PREFIX.length) : key) + ); + let defaultMessageAst; try { diff --git a/x-pack/plugins/graph/public/templates/index.html b/x-pack/plugins/graph/public/templates/index.html index 20e54d4f569e..cbbaf0b149b7 100644 --- a/x-pack/plugins/graph/public/templates/index.html +++ b/x-pack/plugins/graph/public/templates/index.html @@ -625,7 +625,7 @@ class="col-sm-10" i18n-id="xpack.graph.sidebar.similarLabels.keyTermsText" i18n-default-message="Key terms: {inferredEdgeLabel}" - i18n-values="{ inferredEdgeLabel: '' + detail.inferredEdge.label + '' }" + i18n-values="{ html_inferredEdgeLabel: '' + detail.inferredEdge.label + '' }" > diff --git a/x-pack/plugins/searchprofiler/public/templates/index.html b/x-pack/plugins/searchprofiler/public/templates/index.html index a2d5e837b078..3b9346a00829 100644 --- a/x-pack/plugins/searchprofiler/public/templates/index.html +++ b/x-pack/plugins/searchprofiler/public/templates/index.html @@ -23,21 +23,26 @@ class="kuiTitle kuiVerticalRhythmSmall" i18n-id="xpack.searchProfiler.licenseErrorMessageTitle" i18n-default-message="{warningIcon} License error" - i18n-values="{ warningIcon: '' }" + i18n-values="{ html_warningIcon: '' }" >

diff --git a/x-pack/plugins/security/public/views/management/roles.html b/x-pack/plugins/security/public/views/management/roles.html index a7b77228c037..7c5933a6489f 100644 --- a/x-pack/plugins/security/public/views/management/roles.html +++ b/x-pack/plugins/security/public/views/management/roles.html @@ -142,7 +142,7 @@ i18n-id="xpack.security.management.roles.reversedTitle" i18n-default-message="Reserved {icon}" i18n-values="{ - icon: '