diff --git a/decls/i18n.js b/decls/i18n.js index e0cc7a9a4..e8be3b0b1 100644 --- a/decls/i18n.js +++ b/decls/i18n.js @@ -59,6 +59,8 @@ declare type FormattedNumberPart = { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/formatToParts#Return_value declare type NumberFormatToPartsResult = Array; +declare type WarnHtmlInMessageLevel = 'off' | 'warn' | 'error'; + declare type I18nOptions = { locale?: Locale, fallbackLocale?: Locale, @@ -74,6 +76,7 @@ declare type I18nOptions = { silentFallbackWarn?: boolean, pluralizationRules?: PluralizationRules, preserveDirectiveContent?: boolean, + warnHtmlInMessage?: WarnHtmlInMessageLevel, }; declare type IntlAvailability = { @@ -110,6 +113,8 @@ declare interface I18n { set pluralizationRules (rules: PluralizationRules): void, get preserveDirectiveContent (): boolean, set preserveDirectiveContent (preserve: boolean): void, + get warnHtmlInMessage (): WarnHtmlInMessageLevel, + set warnHtmlInMessage (level: WarnHtmlInMessageLevel): void, getLocaleMessage (locale: Locale): LocaleMessageObject, setLocaleMessage (locale: Locale, message: LocaleMessageObject): void, diff --git a/src/index.js b/src/index.js index 7cce203bc..d649d07cb 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import { install, Vue } from './install' import { warn, + error, isNull, parseArgs, isPlainObject, @@ -17,6 +18,7 @@ import I18nPath from './path' import type { PathValue } from './path' +const htmlTagMatcher = /<\/?[\w\s="/.':;#-\/]+>/ const linkKeyMatcher = /(?:@(?:\.[a-z]+)?:(?:[\w\-_|.]+|\([\w\-_|.]+\)))/g const linkKeyPrefixMatcher = /^@(?:\.([a-z]+))?:/ const bracketsMatcher = /[()]/g @@ -46,6 +48,7 @@ export default class VueI18n { _path: I18nPath _dataListeners: Array _preserveDirectiveContent: boolean + _warnHtmlInMessage: WarnHtmlInMessageLevel pluralizationRules: { [lang: string]: (choice: number, choicesLength: number) => number } @@ -87,6 +90,7 @@ export default class VueI18n { ? false : !!options.preserveDirectiveContent this.pluralizationRules = options.pluralizationRules || {} + this._warnHtmlInMessage = options.warnHtmlInMessage || 'off' this._exist = (message: Object, key: Path): boolean => { if (!message || !key) { return false } @@ -96,6 +100,12 @@ export default class VueI18n { return false } + if (this._warnHtmlInMessage === 'warn' || this._warnHtmlInMessage === 'error') { + Object.keys(messages).forEach(locale => { + this._checkLocaleMessage(locale, this._warnHtmlInMessage, messages[locale]) + }) + } + this._initVM({ locale, fallbackLocale, @@ -105,6 +115,55 @@ export default class VueI18n { }) } + _checkLocaleMessage (locale: Locale, level: WarnHtmlInMessageLevel, message: LocaleMessageObject): void { + const paths: Array = [] + + const fn = (level: WarnHtmlInMessageLevel, locale: Locale, message: any, paths: Array) => { + if (isPlainObject(message)) { + Object.keys(message).forEach(key => { + const val = message[key] + if (isPlainObject(val)) { + paths.push(key) + paths.push('.') + fn(level, locale, val, paths) + paths.pop() + paths.pop() + } else { + paths.push(key) + fn(level, locale, val, paths) + paths.pop() + } + }) + } else if (Array.isArray(message)) { + message.forEach((item, index) => { + if (isPlainObject(item)) { + paths.push(`[${index}]`) + paths.push('.') + fn(level, locale, item, paths) + paths.pop() + paths.pop() + } else { + paths.push(`[${index}]`) + fn(level, locale, item, paths) + paths.pop() + } + }) + } else if (typeof message === 'string') { + const ret = htmlTagMatcher.test(message) + if (ret) { + const msg = `Detected HTML in message '${message}' of keypath '${paths.join('')}' at '${locale}'. Consider component interpolation with '' to avoid XSS. See https://bit.ly/2ZqJzkp` + if (level === 'warn') { + warn(msg) + } else if (level === 'error') { + error(msg) + } + } + } + } + + fn(level, locale, message, paths) + } + _initVM (data: { locale: Locale, fallbackLocale: Locale, @@ -184,6 +243,18 @@ export default class VueI18n { get preserveDirectiveContent (): boolean { return this._preserveDirectiveContent } set preserveDirectiveContent (preserve: boolean): void { this._preserveDirectiveContent = preserve } + get warnHtmlInMessage (): WarnHtmlInMessageLevel { return this._warnHtmlInMessage } + set warnHtmlInMessage (level: WarnHtmlInMessageLevel): void { + const orgLevel = this._warnHtmlInMessage + this._warnHtmlInMessage = level + if (orgLevel !== level && (level === 'warn' || level === 'error')) { + const messages = this._getMessages() + Object.keys(messages).forEach(locale => { + this._checkLocaleMessage(locale, this._warnHtmlInMessage, messages[locale]) + }) + } + } + _getMessages (): LocaleMessages { return this._vm.messages } _getDateTimeFormats (): DateTimeFormats { return this._vm.dateTimeFormats } _getNumberFormats (): NumberFormats { return this._vm.numberFormats } @@ -499,10 +570,18 @@ export default class VueI18n { } setLocaleMessage (locale: Locale, message: LocaleMessageObject): void { + if (this._warnHtmlInMessage === 'warn' || this._warnHtmlInMessage === 'error') { + this._checkLocaleMessage(locale, this._warnHtmlInMessage, message) + if (this._warnHtmlInMessage === 'error') { return } + } this._vm.$set(this._vm.messages, locale, message) } mergeLocaleMessage (locale: Locale, message: LocaleMessageObject): void { + if (this._warnHtmlInMessage === 'warn' || this._warnHtmlInMessage === 'error') { + this._checkLocaleMessage(locale, this._warnHtmlInMessage, message) + if (this._warnHtmlInMessage === 'error') { return } + } this._vm.$set(this._vm.messages, locale, merge(this._vm.messages[locale] || {}, message)) } diff --git a/src/util.js b/src/util.js index e0c637994..834af4ce6 100644 --- a/src/util.js +++ b/src/util.js @@ -32,6 +32,16 @@ export function warn (msg: string, err: ?Error): void { } } +export function error (msg: string, err: ?Error): void { + if (typeof console !== 'undefined') { + console.error('[vue-i18n] ' + msg) + /* istanbul ignore if */ + if (err) { + console.error(err.stack) + } + } +} + export function isObject (obj: mixed): boolean %checks { return obj !== null && typeof obj === 'object' } diff --git a/test/unit/warn_html_in_message.test.js b/test/unit/warn_html_in_message.test.js new file mode 100644 index 000000000..382222fc3 --- /dev/null +++ b/test/unit/warn_html_in_message.test.js @@ -0,0 +1,149 @@ +describe('warnHtmlInMessage', () => { + let spyWarn + let spyError + beforeEach(() => { + spyWarn = sinon.spy(console, 'warn') + spyError = sinon.spy(console, 'error') + }) + afterEach(() => { + spyWarn.restore() + spyError.restore() + }) + + describe('constructor option', () => { + it('should be worked', () => { + const messages = { + en: { + message: { + foo: { + buz: '

buz

', + hello: 'hello' + }, + bar: [1, { buz: '

buz

' }], + buz: 22 + } + }, + ja: { message: '

こんにちは

' } + } + + // `off` + new VueI18n({ + warnHtmlInMessage: 'off', + messages + }) + assert(spyWarn.callCount === 0) + assert(spyError.callCount === 0) + + // `warn` + new VueI18n({ + warnHtmlInMessage: 'warn', + messages + }) + assert(spyWarn.callCount === 3) + assert(spyError.callCount === 0) + + // `error` + new VueI18n({ + warnHtmlInMessage: 'error', + messages + }) + assert(spyWarn.callCount === 3) + assert(spyError.callCount === 3) + }) + }) + + describe('property', () => { + it('should be worked', () => { + const messages = { + en: { + message: { + foo: { + buz: '

buz

' + }, + bar: [1, '

bar

'], + buz: 22 + } + }, + ja: { message: '

こんにちは

' } + } + + const i18n = new VueI18n({ + warnHtmlInMessage: 'off', + messages + }) + + // `warn` + i18n.warnHtmlInMessage = 'warn' + assert(spyWarn.callCount === 3) + assert(spyError.callCount === 0) + + // `error` + i18n.warnHtmlInMessage = 'error' + assert(spyWarn.callCount === 3) + assert(spyError.callCount === 3) + + // `off` + i18n.warnHtmlInMessage = 'off' + assert(spyWarn.callCount === 3) + assert(spyError.callCount === 3) + }) + }) + + describe('setLocaleMessage', () => { + it('should be worked', () => { + const i18n = new VueI18n({ + warnHtmlInMessage: 'warn', + messages: { + en: {}, + ja: {} + } + }) + + i18n.setLocaleMessage('en', { + hello: '

hello

' + }) + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 0) + + i18n.warnHtmlInMessage = 'error' + i18n.setLocaleMessage('ja', { + hello: '

こんにちは

' + }) + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 2) + + i18n.warnHtmlInMessage = 'off' + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 2) + }) + }) + + describe('mergeLocaleMessage', () => { + it('should be worked', () => { + const i18n = new VueI18n({ + warnHtmlInMessage: 'warn', + messages: { + en: {}, + ja: {} + } + }) + + i18n.mergeLocaleMessage('en', { + hello: '

hello

' + }) + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 0) + + i18n.warnHtmlInMessage = 'error' + i18n.mergeLocaleMessage('ja', { + hello: '

こんにちは

' + }) + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 2) + + i18n.warnHtmlInMessage = 'off' + assert(spyWarn.callCount === 1) + assert(spyError.callCount === 2) + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 6a31b4619..07438a427 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -71,6 +71,8 @@ declare namespace VueI18n { type FormattedNumberPartType = 'currency' | 'decimal' | 'fraction' | 'group' | 'infinity' | 'integer' | 'literal' | 'minusSign' | 'nan' | 'plusSign' | 'percentSign'; + type WarnHtmlInMessageLevel = 'off' | 'warn' | 'error'; + interface FormattedNumberPart { type: FormattedNumberPartType; value: string; @@ -103,6 +105,7 @@ declare namespace VueI18n { silentFallbackWarn?: boolean; preserveDirectiveContent?: boolean; pluralizationRules?: PluralizationRulesMap; + warnHtmlInMessage?: WarnHtmlInMessageLevel; } } @@ -124,6 +127,7 @@ export type NumberFormat = VueI18n.NumberFormat; export type NumberFormats = VueI18n.NumberFormats; export type NumberFormatResult = VueI18n.NumberFormatResult; export type NumberFormatToPartsResult = VueI18n.NumberFormatToPartsResult; +export type WarnHtmlInMessageLevel = VueI18n.WarnHtmlInMessageLevel; export type Formatter = VueI18n.Formatter; export type MissingHandler = VueI18n.MissingHandler; export type IntlAvailability = VueI18n.IntlAvailability; @@ -142,6 +146,7 @@ export declare interface IVueI18n { silentFallbackWarn: boolean; preserveDirectiveContent: boolean; pluralizationRules: VueI18n.PluralizationRulesMap; + warnHtmlInMessage: VueI18n.WarnHtmlInMessageLevel; } declare class VueI18n { @@ -160,6 +165,7 @@ declare class VueI18n { silentFallbackWarn: boolean; preserveDirectiveContent: boolean; pluralizationRules: VueI18n.PluralizationRulesMap; + warnHtmlInMessage: VueI18n.WarnHtmlInMessageLevel; t(key: VueI18n.Path, values?: VueI18n.Values): VueI18n.TranslateResult; t(key: VueI18n.Path, locale: VueI18n.Locale, values?: VueI18n.Values): VueI18n.TranslateResult; diff --git a/vuepress/api/README.md b/vuepress/api/README.md index 4f685e90a..bee8a34cf 100644 --- a/vuepress/api/README.md +++ b/vuepress/api/README.md @@ -292,6 +292,21 @@ If `true`, warnings will be generated only when no translation is available at a Whether `v-t` directive's element should preserve `textContent` after directive is unbinded. +#### warnHtmlInMessage + +> 8.11+ + + * **Type:** `WarnHtmlInMessageLevel` + + * **Default:** `off` + +Whether to allow the use locale messages of HTML formatting. See the `warnHtmlInMessage` property. + +:::danger Important!! +In next major version, `warnHtmlInMessage` option is `warn` as default. +::: + + ### Properties #### locale @@ -374,6 +389,24 @@ Whether suppress warnings outputted when localization fails. Whether `v-t` directive's element should preserve `textContent` after directive is unbinded. +#### warnHtmlInMessage + +> 8.11+ + + * **Type:** `WarnHtmlInMessageLevel` + + * **Read/Write** + +Whether to allow the use locale messages of HTML formatting. + +If you set `warn` or` error`, will check the locale messages on the VueI18n instance. + +If you are specified `warn`, a warning will be output at console. +If you are specified `error` will occured an Error. + +In VueI18n instance, set the `off` as default. + + ### Methods #### getChoiceIndex @@ -408,6 +441,12 @@ Get the locale message of locale. Set the locale message of locale. +:::tip NOTE +> 8.11+ + +If you set `warn` or` error` in the `warnHtmlInMessage` property, when this method is executed, it will check if HTML formatting is used for locale message. +::: + #### mergeLocaleMessage( locale, message ) > 6.1+ @@ -419,6 +458,12 @@ Set the locale message of locale. Merge the registered locale messages with the locale message of locale. +:::tip NOTE +> 8.11+ + +If you set `warn` or` error` in the `warnHtmlInMessage` property, when this method is executed, it will check if HTML formatting is used for locale message. +::: + #### t( key, [locale], [values] ) * **Arguments:** @@ -574,9 +619,9 @@ Update the element `textContent` that localized with locale messages. You can us * locale: optional, locale * args: optional, for list or named formatting -::::tip NOTE +:::tip NOTE The element `textContent` will be cleared by default when `v-t` directive is unbinded. This might be undesirable situation when used inside [transitions](https://vuejs.org/v2/guide/transitions.html). To preserve `textContent` data after directive unbind use `.preserve` modifier or global [`preserveDirectiveContent` option](#preservedirectivecontent). -:::: +::: * **Examples:** ```html diff --git a/vuepress/guide/formatting.md b/vuepress/guide/formatting.md index f502130d1..0275e2600 100644 --- a/vuepress/guide/formatting.md +++ b/vuepress/guide/formatting.md @@ -8,6 +8,12 @@ We recommended that use [component interpolation](interpolation.md) feature. ::: +:::warning Notice +> :new: 8.11+ + +You can control the use of HTML formatting. see the detail `allowHtmlFormatting` property API. +::: + In some cases you might want to rendered your translation as an HTML message and not a static string.