/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import {DEFAULT_INTERPOLATION_CONFIG, HtmlParser} from '@angular/compiler'; import {MissingTranslationStrategy} from '@angular/core'; import {digest, serializeNodes as serializeI18nNodes} from '../../src/i18n/digest'; import {extractMessages, mergeTranslations} from '../../src/i18n/extractor_merger'; import * as i18n from '../../src/i18n/i18n_ast'; import {TranslationBundle} from '../../src/i18n/translation_bundle'; import * as html from '../../src/ml_parser/ast'; import {serializeNodes as serializeHtmlNodes} from '../ml_parser/util/util'; { describe('Extractor', () => { describe('elements', () => { it('should extract from elements', () => { expect(extract('
textnested
')).toEqual([ [ ['text', 'nested'], 'm', 'd|e', '' ], ]); }); it('should extract from attributes', () => { expect( extract( '
nested
')) .toEqual([ [['nested'], 'm1', 'd1', ''], [['single child'], 'm2', 'd2', ''], ]); }); it('should extract from attributes with id', () => { expect( extract( '
nested
')) .toEqual([ [ ['nested'], 'm1', 'd1', 'i1' ], [['single child'], 'm2', 'd2', 'i2'], ]); }); it('should trim whitespace from custom ids (but not meanings)', () => { expect(extract('
test
')).toEqual([ [['test'], '\n m1', 'd1', 'i1'], ]); }); it('should extract from attributes without meaning and with id', () => { expect( extract( '
nested
')) .toEqual([ [['nested'], '', 'd1', 'i1'], [['single child'], '', 'd2', 'i2'], ]); }); it('should extract from attributes with id only', () => { expect( extract( '
nested
')) .toEqual([ [['nested'], '', '', 'i1'], [['single child'], '', '', 'i2'], ]); }); it('should extract from ICU messages', () => { expect( extract( '
{count, plural, =0 {

}}
')) .toEqual([ [ [ '{count, plural, =0 {[]}}' ], 'm', 'd', '' ], [['title'], '', '', ''], [['desc'], '', '', ''], ]); }); it('should not create a message for empty elements', () => { expect(extract('
')).toEqual([]); }); it('should ignore implicit elements in translatable elements', () => { expect(extract('

', ['p'])).toEqual([ [[''], 'm', 'd', ''] ]); }); }); describe('blocks', () => { it('should extract from blocks', () => { expect(extract(`message1 message2 message3 message4 message5`)) .toEqual([ [['message1'], 'meaning1', 'desc1', ''], [['message2'], '', 'desc2', ''], [['message3'], '', '', ''], [['message4'], 'meaning4', 'desc4', 'id4'], [['message5'], '', '', 'id5'] ]); }); it('should ignore implicit elements in blocks', () => { expect(extract('

', ['p'])).toEqual([ [[''], 'm', 'd', ''] ]); }); it('should extract siblings', () => { expect( extract( `text

htmlnested

{count, plural, =0 {html}}{{interp}}`)) .toEqual([ [ [ '{count, plural, =0 {[html]}}' ], '', '', '' ], [ [ 'text', 'html, nested', '{count, plural, =0 {[html]}}', '[interp]' ], '', '', '' ], ]); }); it('should ignore other comments', () => { expect(extract(`message1`)) .toEqual([ [['message1'], 'meaning1', 'desc1', 'id1'], ]); }); it('should not create a message for empty blocks', () => { expect(extract(``)).toEqual([]); }); }); describe('ICU messages', () => { it('should extract ICU messages from translatable elements', () => { // single message when ICU is the only children expect(extract('
{count, plural, =0 {text}}
')).toEqual([ [['{count, plural, =0 {[text]}}'], 'm', 'd', ''], ]); // single message when ICU is the only (implicit) children expect(extract('
{count, plural, =0 {text}}
', ['div'])).toEqual([ [['{count, plural, =0 {[text]}}'], '', '', ''], ]); // one message for the element content and one message for the ICU expect(extract('
before{count, plural, =0 {text}}after
')).toEqual([ [ ['before', '{count, plural, =0 {[text]}}', 'after'], 'm', 'd', 'i' ], [['{count, plural, =0 {[text]}}'], '', '', ''], ]); }); it('should extract ICU messages from translatable block', () => { // single message when ICU is the only children expect(extract('{count, plural, =0 {text}}')).toEqual([ [['{count, plural, =0 {[text]}}'], 'm', 'd', ''], ]); // one message for the block content and one message for the ICU expect(extract('before{count, plural, =0 {text}}after')) .toEqual([ [['{count, plural, =0 {[text]}}'], '', '', ''], [ ['before', '{count, plural, =0 {[text]}}', 'after'], 'm', 'd', '' ], ]); }); it('should not extract ICU messages outside of i18n sections', () => { expect(extract('{count, plural, =0 {text}}')).toEqual([]); }); it('should ignore nested ICU messages', () => { expect(extract('
{count, plural, =0 { {sex, select, male {m}} }}
')) .toEqual([ [['{count, plural, =0 {[{sex, select, male {[m]}}, ]}}'], 'm', 'd', ''], ]); }); it('should ignore implicit elements in non translatable ICU messages', () => { expect(extract( '
{count, plural, =0 { {sex, select, male {

ignore

}}' + ' }}
', ['p'])) .toEqual([[ [ '{count, plural, =0 {[{sex, select, male {[ignore]}}, ]}}' ], 'm', 'd', 'i' ]]); }); it('should ignore implicit elements in non translatable ICU messages', () => { expect(extract('{count, plural, =0 { {sex, select, male {

ignore

}} }}', ['p'])) .toEqual([]); }); }); describe('attributes', () => { it('should extract from attributes outside of translatable sections', () => { expect(extract('
')).toEqual([ [['msg'], 'm', 'd', 'i'], ]); }); it('should extract from attributes in translatable elements', () => { expect(extract('

')).toEqual([ [ [''], '', '', '' ], [['msg'], 'm', 'd', 'i'], ]); }); it('should extract from attributes in translatable blocks', () => { expect(extract('

')) .toEqual([ [['msg'], 'm', 'd', ''], [ [''], '', '', '' ], ]); }); it('should extract from attributes in translatable ICUs', () => { expect(extract(`{count, plural, =0 {

}}`)) .toEqual([ [['msg'], 'm', 'd', 'i'], [ [ '{count, plural, =0 {[]}}' ], '', '', '' ], ]); }); it('should extract from attributes in non translatable ICUs', () => { expect(extract('{count, plural, =0 {

}}')) .toEqual([ [['msg'], 'm', 'd', ''], ]); }); it('should not create a message for empty attributes', () => { expect(extract('
')).toEqual([]); }); }); describe('implicit elements', () => { it('should extract from implicit elements', () => { expect(extract('bolditalic', ['b'])).toEqual([ [['bold'], '', '', ''], ]); }); it('should allow nested implicit elements', () => { let result: any[] = undefined!; expect(() => { result = extract('
outer
inner
', ['div']); }).not.toThrow(); expect(result).toEqual([ [['outer', 'inner'], '', '', ''], ]); }); }); describe('implicit attributes', () => { it('should extract implicit attributes', () => { expect(extract('bolditalic', [], {'b': ['title']})) .toEqual([ [['bb'], '', '', ''], ]); }); }); describe('errors', () => { describe('elements', () => { it('should report nested translatable elements', () => { expect(extractErrors(`

`)).toEqual([ [ 'Could not mark an element as translatable inside a translatable section', '' ], ]); }); it('should report translatable elements in implicit elements', () => { expect(extractErrors(`

`, ['p'])).toEqual([ [ 'Could not mark an element as translatable inside a translatable section', '' ], ]); }); it('should report translatable elements in translatable blocks', () => { expect(extractErrors(``)).toEqual([ [ 'Could not mark an element as translatable inside a translatable section', '' ], ]); }); }); describe('blocks', () => { it('should report nested blocks', () => { expect(extractErrors(``)).toEqual([ ['Could not start a block inside a translatable section', '`)).toEqual([ ['Unclosed block', '

`)).toEqual([ ['Could not start a block inside a translatable section', '

`, ['p'])).toEqual([ ['Could not start a block inside a translatable section', '

`)).toEqual([ ['I18N blocks should not cross element boundaries', '

'], ]); expect(extractErrors(`

`)).toEqual([ ['I18N blocks should not cross element boundaries', '`; expect(fakeTranslate(HTML)).toEqual('
before

**foo**

'); }); it('should merge empty messages', () => { const HTML = `
some element
`; const htmlNodes: html.Node[] = parseHtml(HTML); const messages: i18n.Message[] = extractMessages(htmlNodes, DEFAULT_INTERPOLATION_CONFIG, [], {}).messages; expect(messages.length).toEqual(1); const i18nMsgMap: {[id: string]: i18n.Node[]} = {}; i18nMsgMap[digest(messages[0])] = []; const translations = new TranslationBundle(i18nMsgMap, null, digest); const output = mergeTranslations(htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, [], {}); expect(output.errors).toEqual([]); expect(serializeHtmlNodes(output.rootNodes).join('')).toEqual(`
`); }); }); describe('blocks', () => { it('should console.warn if we use i18n comments', () => { // TODO(ocombe): expect a warning message when we have a proper log service extract('

'); }); it('should merge blocks', () => { const HTML = `before

foo

barafter`; expect(fakeTranslate(HTML)) .toEqual( 'before**[ph tag name="START_PARAGRAPH">foo[/ph name="CLOSE_PARAGRAPH">[ph tag' + ' name="START_TAG_SPAN">[ph tag name="START_ITALIC_TEXT">bar[/ph' + ' name="CLOSE_ITALIC_TEXT">[/ph name="CLOSE_TAG_SPAN">**after'); }); it('should merge nested blocks', () => { const HTML = `
before

foo

barafter
`; expect(fakeTranslate(HTML)) .toEqual( '
before**[ph tag name="START_PARAGRAPH">foo[/ph name="CLOSE_PARAGRAPH">[ph' + ' tag name="START_TAG_SPAN">[ph tag name="START_ITALIC_TEXT">bar[/ph' + ' name="CLOSE_ITALIC_TEXT">[/ph name="CLOSE_TAG_SPAN">**after
'); }); }); describe('attributes', () => { it('should merge attributes', () => { const HTML = `

`; expect(fakeTranslate(HTML)).toEqual('

'); }); it('should merge attributes with ids', () => { const HTML = `

`; expect(fakeTranslate(HTML)).toEqual('

'); }); it('should merge nested attributes', () => { const HTML = `
{count, plural, =0 {

}}
`; expect(fakeTranslate(HTML)) .toEqual('
{count, plural, =0 {

}}
'); }); it('should merge attributes without values', () => { const HTML = `

`; expect(fakeTranslate(HTML)).toEqual('

'); }); it('should merge empty attributes', () => { const HTML = `
some element
`; const htmlNodes: html.Node[] = parseHtml(HTML); const messages: i18n.Message[] = extractMessages(htmlNodes, DEFAULT_INTERPOLATION_CONFIG, [], {}).messages; expect(messages.length).toEqual(1); const i18nMsgMap: {[id: string]: i18n.Node[]} = {}; i18nMsgMap[digest(messages[0])] = []; const translations = new TranslationBundle(i18nMsgMap, null, digest); const output = mergeTranslations(htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, [], {}); expect(output.errors).toEqual([]); expect(serializeHtmlNodes(output.rootNodes).join('')) .toEqual(`
some element
`); }); }); describe('no translations', () => { it('should remove i18n attributes', () => { const HTML = `

foo

`; expect(fakeNoTranslate(HTML)).toEqual('

foo

'); }); it('should remove i18n- attributes', () => { const HTML = `

`; expect(fakeNoTranslate(HTML)).toEqual('

'); }); it('should remove i18n comment blocks', () => { const HTML = `before

foo

barafter`; expect(fakeNoTranslate(HTML)).toEqual('before

foo

barafter'); }); it('should remove nested i18n markup', () => { const HTML = `foo
{count, plural, =0 {

}}
`; expect(fakeNoTranslate(HTML)) .toEqual( 'foo
{count, plural, =0 {

}}
'); }); }); }); } function parseHtml(html: string): html.Node[] { const htmlParser = new HtmlParser(); const parseResult = htmlParser.parse(html, 'extractor spec', {tokenizeExpansionForms: true}); if (parseResult.errors.length > 1) { throw new Error(`unexpected parse errors: ${parseResult.errors.join('\n')}`); } return parseResult.rootNodes; } function fakeTranslate( content: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): string { const htmlNodes: html.Node[] = parseHtml(content); const messages: i18n.Message[] = extractMessages(htmlNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs) .messages; const i18nMsgMap: {[id: string]: i18n.Node[]} = {}; messages.forEach(message => { const id = digest(message); const text = serializeI18nNodes(message.nodes).join('').replace(/ 0) { throw new Error(`unexpected errors: ${result.errors.join('\n')}`); } // clang-format off // https://github.com/angular/clang-format/issues/35 return result.messages.map( message => [serializeI18nNodes(message.nodes), message.meaning, message.description, message.id]) as [string[], string, string, string][]; // clang-format on } function extractErrors( html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): any[] { const errors = extractMessages(parseHtml(html), DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs) .errors; return errors.map((e): [string, string] => [e.msg, e.span.toString()]); }