UNPKG

i18n-behavior

Version:

Instant and Modular I18N engine for lit-html and Polymer

1,262 lines (1,242 loc) 51.4 kB
/** @license https://github.com/t2ym/i18n-behavior/blob/master/LICENSE.md Copyright (c) 2019, Tetsuya Mori <t2y3141592@gmail.com>. All rights reserved. */ import { I18nControllerCoreMixin, I18nControllerBehavior, html, defaultLang, attributesRepository, bundles, paramAttribute, debuglog } from './i18n-controller-core.js'; import deepcopy from 'deepcopy/dist/deepcopy.js'; export { I18nControllerBehavior, html, defaultLang, attributesRepository, bundles }; /** * I18nControllerMixin: Polymer-independent parts of `BehaviorsStore.I18nBehavior` * * <dom-module id="custom-element"> * <template> * <span>Hard-coded UI texts are automatically made localizable</span> * </template> * <script> * Polymer({ * is: 'custom-element', * behaviors: [ * BehaviorsStore.I18nBehavior // Add this behavior * ] * }); * </script> * </dom-module> * * `I18nBehavior` automatically extracts UI texts from `template` and * binds them to localizable variables in `this.text` object. * * According to the `lang` attribute value, `this.text`, and thus the bound UI texts, * dynamically mutates by loading localized values from a JSON file in the `locales` directory. * By default, `lang` attribute values of all the localizable elements with `I18nBehavior` are * automatically updated according to `<html lang>` attribute value. * * The UI text externalization can be processed at build time as well by `gulp-*` task * so that `I18nBehavior` can immediately recognize the extracted texts in JSON and * skip run-time externalization. * * Run-time externalization is suitable for development and debugging * since the code changes are immediately reflected at reloading without build-time preprocesses. * In contrast, build-time externalization is suitable for production builds * since it eliminates run-time externalization overheads. * * ### Steps to localize a custom element * * 1. [JavaScript] Add `BehaviorsStore.I18nBehavior` to `behaviors` * 1. [gulp] Add `gulp-*` filter for `custom-element.html` and generate `custom-element.json` * 1. [locales] Put `custom-element.lang.json` in `locales` directory * 1. [translation] Translate `locales/custom-element.lang.json` * * - - - * * ### Directory structure of bundle files * * Normal bundles (`/element-root/locales/element-name.*.json`) for elements * are stored under their root directories. * * Shared bundles (`/locales/bundle.*.json`) are generated at build time * by merging all the targeted bundles of the localizable elements. * * Once the shared bundles are loaded, there should be no need to search for * normal bundles per element unless the element is intentionally excluded * from the shared bundles. * * ``` * /bundle.json * /locales/bundle.ja.json * /bundle.fr.json * /bundle.zh-Hans.json * * /elements/my-list/my-list.json * /locales/my-list.ja.json * /my-list.zh-Hans.json * * /google-chart-demo/google-chart-demo.json * /locales/google-chart-demo.ja.json * /google-chart-demo.fr.json * ``` * * - - - * * ### Localizable `<template is="i18n-dom-bind" id="app">` element * * `<template is="i18n-dom-bind">` template element extends * `<template is="dom-bind">` template element with all the capabilities of * `I18nBehavior`. * * The `id` attribute value is used for naming bundle files instead of the element name. * * The bundle files are stored at the locales directory under the application root. * * ``` * /app.json * /locales/app.ja.json * /app.fr.json * /app.zh-Hans.json * ``` */ export const I18nControllerMixin = Object.assign({ /* bundles = { "": {}, "en": { "my-list": { "p_2": "You now have:", "model": { "list": { "items": [ "item 1", "item 2" ] } } }, "google-chart-demo": { "simple-chart-desc": [ "template {1} string", "param 1" ] "model": { "simple-chart": { "options": { "title": "Simple Chart" }, "rows": [] } } } }, "ja": { "my-list": {}, "google-chart-demo": {} } } bundles[lang] /bundle.json - fallback /locales/bundle.en.json /bundle.ja.json /bundle.fr.json /bundle.zh-Hans.json bundles[lang][is] /elements/my-list/my-list.json - fallback /locales/my-list.en.json /my-list.ja.json /my-list.zh-Hans.json /google-chart-demo/google-chart-demo.json - fallback /locales/google-chart-demo.en.json /google-chart-demo.ja.json /google-chart-demo.zh-Hans.json app/elements/my-list/my-list.json /locales/my-list.fr.json /my-list.ja.json /my-list.zh-Hans.json dist/elements/my-list/my-list.json /locales/my-list.fr.json /my-list.ja.json /my-list.zh-Hans.json */ /** * Construct the default bundle from the element's template. * * Extract the default bundle from the template if it is embedded as JSON at build time. * * ### Hard-coded UI text example: * ``` * <dom-module id="custom-element"> * <template> * <span id="label">UI Text Label:</span> * <input id="input-el" * value="{{keyword}}" * placeholder="Keyword"> * </template> * </dom-module> * ``` * * ### Constructed default bundle for the above example: * ``` * { * "model" : { * "input-el": { * "placeholder": "Keyword" * } * }, * "label": "UI Text Label:" * } * ``` * * ### Processed template with bound annotations for the above example: * ``` * <dom-module id="custom-element"> * <template localizable-text="embedded"> * <span id="label">{{text.label}}</span> * <input id="input-el" * value="{{keyword}}" * placeholder="{{model.input-el.placeholder}}"> * </template> * </dom-module> * ``` * * ### Embedded JSON bundle format for the above example: * ``` * <dom-module id="custom-element"> * <template localizable-text="embedded"> * <span id="label">{{text.label}}</span> * <input id="input-el" * value="{{keyword}}" * placeholder="{{model.input-el.placeholder}}"> * <template id="localizable-text"> * <json-data>{ * "model" : { * "input-el": { * "placeholder": "Keyword" * } * }, * "label": "UI Text Label:" * }</json-data> * </template> * </template> * </dom-module> * ``` */ _constructDefaultBundle: function (_template, _id) { var template; var id = _id || this.is; if (this.is === 'i18n-dom-bind') { template = _template || this; id = this.id; /* istanbul ignore if */ if (template.content && template.content.childNodes.length === 0) { // Find the real template in Internet Explorer 11 when i18n-dom-bind is concealed in a parent template // This does not happen on Polymer 1.3.1 or later. So ignore this 'if' statement in code coverage. template = Array.prototype.map.call(document.querySelectorAll('template'), function (parentTemplate) { return parentTemplate.content.querySelector('template#' + id + '[is="i18n-dom-bind"]'); }).reduce(function (prev, current) { return prev || current; }); // Patch this.content with the real one if (template) { this.content = template.content; } } } else { template = _template; } if (template) { this.templateDefaultLang = template.hasAttribute('lang') ? template.lang : 'en'; } else { this.templateDefaultLang = 'en'; } var bundle = { model: {} }; var path = []; var templateDefaultLang = this.templateDefaultLang; var localizableText, jsonData; if (template) { // register localizable attributes of the element itself if (attributesRepository.registerLocalizableAttributes) { attributesRepository.registerLocalizableAttributes(id, template); } else { attributesRepository._created(); attributesRepository.registerLocalizableAttributes(id, template); } if (template.getAttribute('localizable-text') === 'embedded') { // pick up embedded JSON from the template localizableText = template.content.querySelector('#localizable-text'); if (localizableText) { jsonData = localizableText.content.querySelector('json-data'); if (jsonData) { bundle = JSON.parse(jsonData.textContent); } else { console.error('<json-data> not found in <template id=\"localizable-text\">'); } } else { console.error('<template id=\"localizable-text\"> not found'); } } else { // traverse template to generate bundle this._traverseTemplateTree(template.content, path, bundle, 0); } } bundles[''][id] = bundle; bundles[templateDefaultLang] = bundles[templateDefaultLang] || {}; bundles[templateDefaultLang][id] = bundle; //console.log('text = '); //console.log(JSON.stringify(bundle, null, 2)); return true; }, /** * Traverse localizable attributes of the target element node and * add them to the target bundle under the `model` object. * * The `<i18n-attr-repo>` object is used * to judge if the target attributes are localizable. * * @param {Object} node Target element node. * @param {string} path Path to the target node. * @param {Object} bundle Default bundle. */ _traverseAttributes: function (node, path, bundle) { var name = node.nodeName.toLowerCase(); var id = node.getAttribute ? (node.getAttribute('text-id') || node.getAttribute('id')) : null; var text; var messageId; var attrId; var isLocalizable; var dummy; var renamedAttributes = []; // pick up element attributes Array.prototype.forEach.call(node.attributes, function (attribute) { text = attribute.value; switch (attribute.name) { case 'id': case 'text-id': case 'is': case 'lang': case 'class': // verification required before removing these attributes case 'href': case 'src': case 'style': case 'url': case 'selected': break; default: if (!(isLocalizable = attributesRepository.isLocalizableAttribute(node, attribute.name))) { break; } if (text.length === 0) { // skip empty value attribute } else if (text.match(/^{{[^{}]*}}$/) || text.match(/^\[\[[^\[\]]*\]\]$/)) { // skip annotation attribute } else if (text.replace(/\n/g, ' ').match(/^{.*}|\[.*\]$/g) && !text.match(/^{{[^{}]*}}|\[\[[^\[\]]*\]\]/) && !text.match(/{{[^{}]*}}|\[\[[^\[\]]*\]\]$/)) { // generate message id messageId = this._generateMessageId(path, id); try { //console.log(messageId + ' parsing attribute ' + attribute.name + ' = ' + text); var value = JSON.parse(text.replace(/\n/g, ' ')); //console.log('parsed JSON object = '); //console.log(value); switch (typeof value) { case 'string': case 'number': case 'object': // put into model attrId = ['model', messageId, attribute.name].join('.'); debuglog(attrId + ' = ' + text); this._setBundleValue(bundle, attrId, value); attribute.value = '{{' + attrId + '}}'; break; default: // skip other types break; } } catch (e) { // invalid JSON console.warn(e, 'Invalid JSON at <' + name + ' ' + attribute.name + '> with value = ' + text); } } else if (text.match(/{{[^{}]{1,}}}|\[\[[^\[\]]{1,}\]\]/)) { // compound binding attribute // Parameterized: // e.g., attr="Compound binding attribute has [[bound.value]] {{parameters}} in the value string" // replaced as "{{i18nFormat(attrId.0,bound.value,parameters)}}" // extracted as [ "Compound binding attribute has {1} {2} in the value string", "[[bound.value]]", "{{parameters}}" ] // Concatenated: (Parameters with functions cannot be reordered in translation) // e.g., attr2="Compound binding attribute has [[f1(bound.value)]] {{f2(parameters)}} in the value string" // replaced as "{{attrId.0}}[[f1(bound.value)]]{{attrId.2}}{{f2(parameters)}}{{attrId.4}}" // extracted as [ "Compound binding attribute has ", "[[f1(bound.value)]]", " ", "{{f2(parameters)}}", " in the value string" ] var parsed = text.match(/([^{}\[\]]{1,})|({{[^{}]{1,}}})|(\[\[[^\[\]]{1,}\]\])/g); var parameterized; var processed; var n; messageId = this._generateMessageId(path, id); attrId = ['model', messageId, attribute.name.replace(/\$$/, '')].join('.'); if (text.match(/\)}}|\)\]\]/)) { // check for function parameter // Concatenate debuglog(attrId + ' = ' + JSON.stringify(parsed)); this._setBundleValue(bundle, attrId, parsed); processed = ''; for (n = 0; n < parsed.length; n++) { if (parsed[n].match(/^{{[^{}]{1,}}}|\[\[[^\[\]]{1,}\]\]$/)) { processed += parsed[n]; } else { processed += '{{' + attrId + '.' + n + '}}'; } } if (isLocalizable === '$' && !attribute.name.match(/\$$/)) { dummy = document.createElement('span'); dummy.innerHTML = '<span ' + attribute.name + '$="' + processed + '"></span>'; node.setAttributeNode(dummy.childNodes[0].attributes[0].cloneNode()); renamedAttributes.push(attribute.name); } else { attribute.value = processed; } } else { // Parameterize parameterized = [ '' ]; while (parsed.length) { if (parsed[0].match(/^{{[^{}]{1,}}}|\[\[[^\[\]]{1,}\]\]$/)) { parameterized.push(parsed[0]); parameterized[0] += '{' + (parameterized.length - 1) + '}'; } else { parameterized[0] += parsed[0]; } parsed.shift(); } debuglog(attrId + ' = ' + JSON.stringify(parameterized)); this._setBundleValue(bundle, attrId, parameterized); processed = '{{i18nFormat(' + attrId + '.0'; for (n = 1; n < parameterized.length; n++) { processed += ',' + parameterized[n].replace(/^[{\[][{\[](.*)[}\]][}\]]$/, '$1'); } processed += ')}}'; if (isLocalizable === '$' && !attribute.name.match(/\$$/)) { dummy = document.createElement('span'); dummy.innerHTML = '<span ' + attribute.name + '$="' + processed + '"></span>'; node.setAttributeNode(dummy.childNodes[0].attributes[0].cloneNode()); renamedAttributes.push(attribute.name); } else { attribute.value = processed; } } } else { // string attribute messageId = this._generateMessageId(path, id); attrId = ['model', messageId, attribute.name].join('.'); debuglog(attrId + ' = ' + text); this._setBundleValue(bundle, attrId, text); if (isLocalizable === '$' && !attribute.name.match(/\$$/)) { dummy = document.createElement('span'); dummy.innerHTML = '<span ' + attribute.name + '$=' + '"{{' + attrId + '}}"' + '></span>'; node.setAttributeNode(dummy.childNodes[0].attributes[0].cloneNode()); renamedAttributes.push(attribute.name); } else { attribute.value = '{{' + attrId + '}}'; } } break; } }, this); renamedAttributes.forEach(name => node.removeAttribute(name)); }, /** * Recursively traverse text contents of the target element node * and add them to the target bundle object. * * Traversed text contents are replaced with annotations * for the corresponding text in the bundle. * * - - - * * There are some special treatments in the following cases. * * ### Sentence with parameterized child parameter-like elements: * * As the order of the parameters can change in different languages, * `<i18n-format>` element is used to make the sentence localizable. * * ### Before processing * * ``` * <p id="p"><code>i18n-format</code> is * used for * <a href="https://www.google.com">parameterized</a> * sentences.</p> * ``` * * ### After processing * * ``` * <p id="p"> * <i18n-format lang="{{effectiveLang}}"> * <span>{{text.p.0}}</span> * <code>{{text.p.1}}</code> * <a href="https://www.google.com">{{text.p.2}}</a> * </i18n-format> * </p> * ``` * * ### Constructed bundle data: Texts are stored in an Array of strings. * * ``` * { * "p" : [ * "{1} is\n used for\n {2}\n sentences.", * "i18n-format", * "parameterized" * ] * } * ``` * * - - - * * ### `<i18n-format>` element with compound templates: * * Texts with compound templates for `<i18n-format>` element are * processed as below. * * `lang="{{effectiveLang}}"` attribute is added to `<i18n-format>` node * as well as `<i18n-number>` node. * * ### Before processing * * ``` * <i18n-format id="sentence"> * <json-data>{ * "0": "You ({3}) gave no gifts.", * "1": { * "male": "You ({3}) gave him ({4}) {5}.", * "female": "You ({3}) gave her ({4}) {5}.", * "other": "You ({3}) gave them ({4}) {5}." * }, * "one": { * "male": * "You ({3}) gave him ({4}) and one other {5}.", * "female": * "You ({3}) gave her ({4}) and one other {5}.", * "other": * "You ({3}) gave them ({4}) and one other {5}." * }, * "other": * "You ({3}) gave them ({4}) and {1} others gifts." * }</json-data> * <i18n-number * offset="1" * >{{recipients.length}}</i18n-number> * <span>{{recipients.0.gender}}</span> * <span>{{sender.name}}</span> * <span>{{recipients.0.name}}</span> * <span>a gift</span> * </i18n-format> * ``` * * ### After processing * * ``` * <i18n-format id="sentence" * lang="{{effectiveLang}}"> * <json-data>{{serialize(text.sentence.0)}}</json-data> * <i18n-number * offset="1" * lang="{{effectiveLang}}" * >{{recipients.length}}</i18n-number> * <span>{{recipients.0.gender}}</span> * <span>{{sender.name}}</span> * <span>{{recipients.0.name}}</span> * <span>{{text.sentence.5}}</span> * </i18n-format> * ``` * * ### Constructed bundle data: * * ``` * { * "sentence" : [ * { * "0": "You ({3}) gave no gifts.", * "1": { * "male": "You ({3}) gave him ({4}) {5}.", * "female": "You ({3}) gave her ({4}) {5}.", * "other": "You ({3}) gave them ({4}) {5}." * }, * "one": { * "male": * "You ({3}) gave him ({4}) and one other {5}.", * "female": * "You ({3}) gave her ({4}) and one other {5}.", * "other": * "You ({3}) gave them ({4}) and one other {5}." * }, * "other": * "You ({3}) gave them ({4}) and {1} others gifts." * }, * "{{recipients.length - 1}}", * "{{recipients.0.gender}}", * "{{sender.name}}", * "{{recipients.0.name}}", * "a gift" * ] * } * ``` * * @param {Object} node Target element node. * @param {string} path Path to the target node. * @param {Object} bundle Default bundle. * @param {Number} index Index in the siblings of the target node excluding whitespace nodes. */ _traverseTemplateTree: function (node, path, bundle, index) { var i; var whiteSpaceElements = 0; var isWhiteSpace = false; var isCompoundAnnotatedNode = false; var text; var span; var name = node.nodeName.toLowerCase(); var id = node.getAttribute ? (node.getAttribute('text-id') || node.getAttribute('id')) : null; var messageId; var n; var templateText; var templateTextParams; path.push(id ? '#' + id : name + (index > 0 ? '_' + index : '')); //console.log(path.join(':')); switch (node.nodeType) { case node.ELEMENT_NODE: switch (name) { case 'style': case 'script': case 'meta': // skip break; case 'i18n-format': // pick up element attributes this._traverseAttributes(node, path, bundle); // generate message id messageId = this._generateMessageId(path, id); if (!node.hasAttribute('lang')) { node.setAttribute('lang', '{{effectiveLang}}'); } text = Array.prototype.filter.call(node.childNodes, function (child) { return child.nodeType === child.ELEMENT_NODE; }).map(function (param, n) { var value = param.textContent; var parsedValue = value.match(/^({{)(.*)(}})$/) || value.match(/^(\[\[)(.*)(\]\])$/); if (n === 0) { // template element if (param.tagName.toLowerCase() === 'json-data') { if (parsedValue) { var parsedValue2 = value.match(/^({{)(serialize\(.*\))(}})$/) || value.match(/^(\[\[)(serialize\(.*\))(\]\])$/); if (!parsedValue2) { // convert to {{serialize(id)}} parsedValue.shift(); parsedValue.splice(1, 0, 'serialize('); parsedValue.splice(3, 0, ')'); param.textContent = parsedValue.join(''); } } else { value = JSON.parse(value); param.textContent = '{{serialize(text.' + messageId + '.' + n + ')}}'; } } else { if (!parsedValue) { param.textContent = '{{text.' + messageId + '.' + n + '}}'; } } } else { // param element // TODO: handle localization of param nodes and attributes if (!param.hasAttribute(paramAttribute)) { param.setAttribute(paramAttribute, n); } if (param.tagName.toLowerCase() === 'i18n-number') { if (!param.hasAttribute('lang')) { param.setAttribute('lang', '{{effectiveLang}}'); } var offset = param.getAttribute('offset'); if (offset) { offset = ' - ' + offset; } else { offset = ''; } if (parsedValue) { // convert to {{path - offset}} parsedValue.shift(); parsedValue.splice(2, 0, offset); value = parsedValue.join(''); } else { param.textContent = '{{text.' + messageId + '.' + n + '}}'; } } else { if (!parsedValue) { param.textContent = '{{text.' + messageId + '.' + n + '}}'; } } } return value; }, this); debuglog(messageId + ' = ' + text); this._setBundleValue(bundle, messageId, text); break; case 'template': // traverse into its content //console.log(path.join(':') + ':' + node.content.nodeName + ':' + 0); this._traverseTemplateTree(node.content, path, bundle, 0); break; default: // element node if (name === 'i18n-number' || name === 'i18n-datetime') { if (!node.hasAttribute('lang')) { node.setAttribute('lang', '{{effectiveLang}}'); } } // pick up element attributes this._traverseAttributes(node, path, bundle); // check annonated node isCompoundAnnotatedNode = false; if (node.childElementCount === 0) { if (node.textContent) { isCompoundAnnotatedNode = this._isCompoundAnnotatedText(node.textContent); } } if (node.childElementCount === 0 && !isCompoundAnnotatedNode) { if (node.textContent) { // use textContent for Firefox compatibility text = node.textContent; if (text.length === 0 || text.match(/^\s*$/g)) { // skip empty or whitespace node } else if (text.trim().match(/^({{[^{}]*}}|\[\[[^\[\]]*\]\])$/)) { // skip annotation node // TODO: compound bindings support } else { // a text message found // generate message id messageId = this._generateMessageId(path, id); // store the text message text = text.replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' '); if (name === 'json-data') { this._setBundleValue(bundle, messageId, JSON.parse(text)); } else { this._setBundleValue(bundle, messageId, text); } // replace textContent with annotation node.textContent = '{{text.' + messageId + '}}'; if (!id) { //node.id = messageId; //console.warn('add missing node id as ' + messageId + ' for ' + text); } debuglog(messageId + ' = ' + text); } } else { // skip } } else { // has children or compound annotation // check if i18n-format is applicable var childStatus = Array.prototype.map.call( node.childNodes, function (child) { var result; if (child.nodeType === child.ELEMENT_NODE && child.tagName === 'TEMPLATE') { var templateNonCommentChildNodes = Array.prototype.filter.call(child.content.childNodes, function (templateChild) { switch (templateChild.nodeType) { case templateChild.COMMENT_NODE: return false; case templateChild.TEXT_NODE: return !templateChild.textContent.match(/^\s*$/g); default: case templateChild.ELEMENT_NODE: return true; } }); var firstChild = templateNonCommentChildNodes.shift(); // Examples: // hasText: <template>text</template> // hasCompoundAnnotatedText: <template>{{item.name}} text</template> // hasTextChild: <template><b>text</b></template> or <template><br></template> // hasCompoundAnnotatedChildNode: <template><b>{{item.name}} text</b></template> // hasGrandChildren: <template><span><b>text</b></span></template> or // <template><b>A</b><i>B</i></template> or // hasCompoundAnnotatedText result = { hasText: templateNonCommentChildNodes.length === 0 && firstChild && firstChild.nodeType === firstChild.TEXT_NODE && firstChild.textContent.length > 0 && !firstChild.textContent.match(/^\s*$/g), hasCompoundAnnotatedText: firstChild && firstChild.nodeType === firstChild.TEXT_NODE && this._isCompoundAnnotatedText(firstChild.textContent), hasTextChild: templateNonCommentChildNodes.length === 0 && firstChild && firstChild.nodeType === child.ELEMENT_NODE && firstChild.childElementCount === 0, // including <br> hasCompoundAnnotatedChildNode: firstChild && firstChild.nodeType === firstChild.ELEMENT_NODE && firstChild.childElementCount === 0 && this._isCompoundAnnotatedText(firstChild.textContent), hasGrandChildren: templateNonCommentChildNodes.length > 0 || (firstChild && firstChild.nodeType === firstChild.ELEMENT_NODE && Array.prototype.map.call( firstChild.childNodes, function (grandChild) { return grandChild.nodeType !== grandChild.TEXT_NODE; } ).reduce(function (prev, current) { return prev || current; }, false)) || (firstChild && firstChild.nodeType === firstChild.TEXT_NODE && this._isCompoundAnnotatedText(firstChild.textContent)) }; } else { result = { hasText: child.nodeType === child.TEXT_NODE && child.textContent.length > 0 && !child.textContent.match(/^\s*$/g), hasCompoundAnnotatedText: child.nodeType === child.TEXT_NODE && this._isCompoundAnnotatedText(child.textContent), hasTextChild: child.nodeType === child.ELEMENT_NODE && child.childElementCount === 0, // including <br> hasCompoundAnnotatedChildNode: child.nodeType === child.ELEMENT_NODE && child.childElementCount === 0 && this._isCompoundAnnotatedText(child.textContent), hasGrandChildren: child.nodeType === child.ELEMENT_NODE && Array.prototype.map.call( child.childNodes, function (grandChild) { return grandChild.nodeType !== grandChild.TEXT_NODE; } ).reduce(function (prev, current) { return prev || current; }, false) }; } return result; }.bind(this)).reduce(function (prev, current) { return { hasText: prev.hasText || current.hasText, hasCompoundAnnotatedText: prev.hasCompoundAnnotatedText || current.hasCompoundAnnotatedText, hasTextChild: prev.hasTextChild || current.hasTextChild, hasCompoundAnnotatedChildNode: prev.hasCompoundAnnotatedChildNode || current.hasCompoundAnnotatedChildNode, hasGrandChildren: prev.hasGrandChildren || current.hasGrandChildren }; }, { hasText: false, hasCompoundAnnotatedText: false, hasTextChild: false, hasCompoundAnnotatedChildNode: false, hasGrandChildren: false }); if ((childStatus.hasText || node.hasAttribute('text-id')) && (childStatus.hasTextChild || childStatus.hasCompoundAnnotatedText) && !childStatus.hasGrandChildren && !childStatus.hasCompoundAnnotatedChildNode) { // apply i18n-format /* <i18n-format> <span>{{text.simpleChartDesc.0}}</span> <code param="1">{{text.simpleChartDesc.1}}</code> <a param="2" href="link">{{text.simpleChartDesc.2}}</a> <a param="3" href="link2">{{text.simpleChartDesc.3}}</a> </i18n-format> */ n = 0; messageId = this._generateMessageId(path, id); templateTextParams = Array.prototype.map.call( node.childNodes, function (child) { var firstChild; if (child.nodeType === child.TEXT_NODE && this._hasAnnotatedText(child.textContent)) { return this._compoundAnnotationToSpan(child) .map(function (_child) { return { node: _child, templateNode: null, type: _child.nodeType, text: _child.nodeType === _child.TEXT_NODE ? _child.textContent : null, childTextNode: _child.nodeType === _child.ELEMENT_NODE && _child.childNodes.length > 0 }; }); } else if (child.nodeType === child.ELEMENT_NODE && child.tagName === 'TEMPLATE') { firstChild = Array.prototype.filter.call(child.content.childNodes, function (templateChild) { switch (templateChild.nodeType) { case templateChild.COMMENT_NODE: return false; case templateChild.TEXT_NODE: return !templateChild.textContent.match(/^\s*$/g); default: case templateChild.ELEMENT_NODE: return true; } }).shift(); if (!firstChild) { firstChild = Array.prototype.filter.call(child.content.childNodes, function (templateChild) { switch (templateChild.nodeType) { case templateChild.COMMENT_NODE: return false; default: return true; } }).shift(); } if (firstChild) { return [{ node: firstChild, templateNode: child, type: firstChild.nodeType, text: null, childTextNode: true }]; } else { return []; } } else { return [{ node: child, templateNode: null, type: child.nodeType, text: child.nodeType === child.TEXT_NODE ? child.textContent : null, childTextNode: child.nodeType === child.ELEMENT_NODE && child.childNodes.length > 0 }]; } }.bind(this)).reduce(function (prev, currentList) { var current; var textContent; for (var i = 0; i < currentList.length; i++) { current = currentList[i]; if (current.text) { prev.text[0] += current.text; } if (current.type === current.node.ELEMENT_NODE) { n++; prev.text[0] += '{' + n + '}'; path.push(n); this._traverseAttributes(current.node, path, bundle); path.pop(); if (current.childTextNode) { textContent = current.node.textContent; if (textContent.length === 0) { // tag without textContent prev.text.push('<' + current.node.nodeName.toLowerCase() + '>'); current.node.textContent = ''; } else if (textContent.match(/^\s*$/g)) { // tag with whitespace textContent prev.text.push('<' + current.node.nodeName.toLowerCase() + '>'); current.node.textContent = ' '; } else if (textContent.match(/^[\s]*({{.*}}|\[\[.*\]\])[\s]*$/)) { // tag with annotation prev.text.push(textContent); // textContent is untouched } else { prev.text.push(current.node.textContent.replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' ')); current.node.textContent = '{{text.' + messageId + '.' + n + '}}'; } } else { prev.text.push('<' + current.node.nodeName.toLowerCase() + '>'); } current.node.setAttribute(paramAttribute, n.toString()); prev.params.push(current.templateNode || current.node); } else if (current.type === current.node.TEXT_NODE && current.childTextNode) { // in template node n++; prev.text[0] += '{' + n + '}'; textContent = current.node.textContent; if (textContent.length === 0) { // template without textContent prev.text.push('<template>'); current.node.textContent = ''; } else if (textContent.match(/^\s*$/g)) { // template with whitespace textContent prev.text.push('<template>'); current.node.textContent = ' '; } else if (textContent.match(/^[\s]*({{.*}}|\[\[.*\]\])[\s]*$/)) { // tag with annotation prev.text.push(textContent); // textContent is untouched } else { prev.text.push(textContent.replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' ')); current.node.textContent = '{{text.' + messageId + '.' + n + '}}'; } span = document.createElement('span'); span.setAttribute(paramAttribute, n.toString()); current.templateNode.content.removeChild(current.node); span.appendChild(current.node); current.templateNode.content.appendChild(span); prev.params.push(current.templateNode); } } return prev; }.bind(this), { text: [ '' ], params: [ '{{text.' + messageId + '.0}}' ] }); // clear original childNodes before implicit removals by appendChild to i18n-format for ShadyDOM compatibility node.innerHTML = ''; templateText = document.createElement('i18n-format'); templateText.setAttribute('lang', '{{effectiveLang}}'); // insert i18n-format node.appendChild(templateText); span = document.createElement('span'); // span.innerText does not set an effective value in Firefox span.textContent = templateTextParams.params.shift(); templateText.appendChild(span); Array.prototype.forEach.call(templateTextParams.params, function (param) { templateText.appendChild(param); } ); // store the text message templateTextParams.text[0] = templateTextParams.text[0].replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' '); this._setBundleValue(bundle, messageId, templateTextParams.text); if (!id) { //node.id = messageId; //console.warn('add missing node id as ' + messageId + ' for ' + templateTextParams.text[0]); } debuglog(messageId + ' = ' + templateTextParams.text); } else { // traverse childNodes for (i = 0; i < node.childNodes.length; i++) { //console.log(path.join(':') + ':' + node.childNodes[i].nodeName + ':' + (i - whiteSpaceElements) + ' i = ' + i + ' whiteSpaceElements = ' + whiteSpaceElements); if (this._traverseTemplateTree(node.childNodes[i], path, bundle, i - whiteSpaceElements)) { whiteSpaceElements++; } } } } break; } break; case node.TEXT_NODE: // text node text = node.textContent; if (text.length === 0 || text.match(/^\s*$/g)) { // skip empty or whitespace node isWhiteSpace = true; } else if (text.trim().match(/^({{[^{}]*}}|\[\[[^\[\]]*\]\])$/)) { // skip annotation node } else { var parent = node.parentNode; if (this._isCompoundAnnotatedText(text)) { // apply i18n-format n = 0; messageId = this._generateMessageId(path, id); templateTextParams = Array.prototype.map.call( [ node ], function (child) { return this._compoundAnnotationToSpan(child) .map(function (_child) { return { node: _child, type: _child.nodeType, text: _child.nodeType === _child.TEXT_NODE ? _child.textContent : null, childTextNode: _child.nodeType === _child.ELEMENT_NODE && _child.childNodes.length > 0 }; }); }.bind(this)).reduce(function (prev, currentList) { var current; for (var i = 0; i < currentList.length; i++) { current = currentList[i]; if (current.text) { prev.text[0] += current.text; } if (current.type === current.node.ELEMENT_NODE) { n++; prev.text[0] += '{' + n + '}'; path.push(n); this._traverseAttributes(current.node, path, bundle); path.pop(); /* current.childTextNode is always true since current.node is <span>{{annotation}}</span> */ prev.text.push(current.node.textContent); current.node.setAttribute(paramAttribute, n.toString()); prev.params.push(current.node); } } return prev; }.bind(this), { text: [ '' ], params: [ '{{text.' + messageId + '.0}}' ] }); templateText = document.createElement('i18n-format'); templateText.setAttribute('lang', '{{effectiveLang}}'); // insert i18n-format parent.insertBefore(templateText, node); parent.removeChild(node); span = document.createElement('span'); // span.innerText does not set an effective value in Firefox span.textContent = templateTextParams.params.shift(); templateText.appendChild(span); Array.prototype.forEach.call(templateTextParams.params, function (param) { templateText.appendChild(param); } ); // store the text message templateTextParams.text[0] = templateTextParams.text[0].replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' '); this._setBundleValue(bundle, messageId, templateTextParams.text); debuglog(messageId + ' = ' + templateTextParams.text); } else { // generate message id messageId = this._generateMessageId(path, id); // store the text message text = text.replace(/^[\s]*[\s]/, ' ').replace(/[\s][\s]*$/, ' '); this._setBundleValue(bundle, messageId, text); // replace textContent with annotation node.textContent = '{{text.' + messageId + '}}'; if (!id) { //span.id = messageId; //console.warn('add missing span with id as ' + messageId + ' for ' + text); } debuglog(messageId + ' = ' + text); } } break; case node.DOCUMENT_NODE: case node.DOCUMENT_FRAGMENT_NODE: // traverse childNodes for (i = 0; i < node.childNodes.length; i++) { //console.log(path.join(':') + ':' + node.childNodes[i].nodeName + ':' + (i - whiteSpaceElements) + ' i = ' + i + ' whiteSpaceElements = ' + whiteSpaceElements); if (this._traverseTemplateTree(node.childNodes[i], path, bundle, i - whiteSpaceElements)) { whiteSpaceElements++; } } break; default: isWhiteSpace = true; // comment node, etc. break; } path.pop(); return isWhiteSpace; }, /** * Check if the text has compound annotation * * @param {string} text target text to check compound annotation * @return {Boolean} true if the text contains compound annotation */ _isCompoundAnnotatedText: function (text) { return !text.trim().match(/^({{[^{}]*}}|\[\[[^\[\]]*\]\])$/) && !!text.match(/({{[^{}]*}}|\[\[[^\[\]]*\]\])/); }, /** * Check if the text has annotation * * @param {string} text target text to check annotation * @return {Boolean} true if the text contains annotation */ _hasAnnotatedText: function (text) { return !!text.match(/({{[^{}]*}}|\[\[[^\[\]]*\]\])/); }, /** * Convert compound annotations to span elements * * @param {Text} node target text node to convert compound annotations * @return {Object[]} Array of Text or span elements */ _compoundAnnotationToSpan: function (node) { var result; /* istanbul ignore else: node is prechecked to contain annotation(s) */ if (node.textContent.match(/({{[^{}]*}}|\[\[[^\[\]]*\]\])/)) { result = node.textContent .match(/({{[^{}]*}}|\[\[[^\[\]]*\]\]|[^{}\[\]]{1,}|[{}\[\]]{1,})/g) .reduce(function (prev, current) { if (current.match(/^({{[^{}]*}}|\[\[[^\[\]]*\]\])$/)) { prev.push(current); prev.push(''); } else { if (prev.length === 0) { prev.push(current); } else { prev[prev.length - 1] += current; } } return prev; }.bind(this), []) .map(function (item) { var childNode; if (item.match(/^({{[^{}]*}}|\[\[[^\[\]]*\]\])$/)) { childNode = document.createElement('span'); childNode.textContent = item; } else if (item) { childNode = document.createTextNode(item); } else { childNode = null; } return childNode; }); if (result.length > 0) { if (!result[result.length - 1]) { result.pop(); // pop null node for '' } } } else { // no compound annotation result = [ node ]; } return result; }, /** * Add the value to the target default bundle with the specified message Id * * @param {Object} bundle Default bundle. * @param {string} messageId ID string of the value. * @param {Object} value Value of the text message. Normally a string. */ _setBundleValue: function (bundle, messageId, value) { var messageIdPath = messageId.split('.'); bundle.model = bundle.model || {}; if (messageIdPath.length === 1) { bundle[messageId] = value; } else { var cursor = bundle;