UNPKG

i18n-element

Version:

I18N Base Element for lit-html and Polymer

944 lines (903 loc) 33.4 kB
/** @license https://github.com/t2ym/i18n-element/blob/master/LICENSE.md Copyright (c) 2019, Tetsuya Mori <t2y3141592@gmail.com>. All rights reserved. */ import { html as litHtml } from 'lit-html/lit-html.js'; import { I18nControllerMixin, I18nControllerBehavior, bundles } from 'i18n-behavior/i18n-controller.js'; import { polyfill } from 'wc-putty/polyfill.js'; const isEdge = navigator.userAgent.indexOf(' Edge/') >= 0; const nameCache = new Map(); // for UncamelCase() const UncamelCase = function UncamelCase (name) { let tagName = nameCache.get(name); if (!tagName) { tagName = name // insert a hyphen between lower & upper .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // space before last upper in a sequence followed by lower .replace(/\b([A-Z]+)([A-Z])([a-z0-9])/, '$1 $2$3') // replace spaces with hyphens .replace(/ /g, '-') // lowercase .toLowerCase(); nameCache.set(name, tagName); } return tagName; } const mixinMethods = (mixin, base) => { // Mixin directly into base.prototype object to omit extra prototype chaining Object.assign(base.prototype, mixin); return base; } // Note: The bound (pseudo-)element-name in ${bind()} is used as the key to the cached strings and parts const templateCache = new Map(); const boundElements = new Map(); const suspendedBoundElements = new WeakMap(); /* default bundles */ const defaultBundles = bundles['']; /** * I18N mixin for lit-html * * Properties: * static get importMeta() { return import.meta; } // Extended class must have this property * text - {Object} the current locale resources object; read-only * is - {string} default value: this.constructor.is; Element name * templateDefaultLang - {string} default value: 'en'; locale for the template * effectiveLang - {string} set as this.lang value when locale resources are updated * observeHtmlLang - {boolean} default value: true; set to false in constructor if html.lang changes must not be reflected to the element * _fetchStatus - {Object} internally used object to store status of fetching locale resources; the object is shared among all the instances of a custom element * _i18nElementConnected - {boolean} set as true on connectedCallback and false on disconnectedCallback, undefined before connection * * Attributes: * * @polymer * @mixinFunction * @param {HTMLElement} base Base class to support I18N * @summary I18N mixin for lit-html */ export const i18n = (base) => mixinMethods(I18nControllerMixin, class I18nBaseElement extends polyfill(base) { /** * Fired when its locale resources are updated * * @event lang-updated * @param {lang} this.lang * @param {lastLang} Last this.lang value */ /** * is property by generating the custom element name from its own class name by un-camel-casing * @type {!string} element name */ static get is() { return UncamelCase(this.name || /* name is undefined in IE11 */ this.toString().replace(/^function ([^ \(]*)((.*|[\n]*)*)$/, '$1')); } /** * observedAttributes property for custom elements v1 API, adding lang property to that of the super class * @type {Array} list of observed attributes */ static get observedAttributes() { let attributes = new Set(super.observedAttributes); ['lang'].forEach(attr => attributes.add(attr)); return [...attributes]; } /** * isI18n property to return true if this mixin is provided * @type {boolean} true to show that this mixin is provided */ static get isI18n() { return true; } /** * static observeHtmlLang property to set the default value for this.observeHtmlLang * * Note: * - Override this static property to set observeHtmlLang to false */ static get observeHtmlLang() { return true; } /** * constructor */ constructor() { super(); this.is = this.constructor.is; this.importMeta = this.constructor.importMeta; this.templateDefaultLang = 'en'; this.observeHtmlLang = Boolean(this.constructor.observeHtmlLang); if (!this.constructor.prototype.hasOwnProperty('_fetchStatus')) { Object.defineProperty(this.constructor.prototype, '_fetchStatus', { configurable: true, enumerable: true, writable: true, value: { // per custom element fetchingInstance: null, ajax: null, ajaxLang: null, lastLang: null, fallbackLanguageList: null, targetLang: null, lastResponse: {}, rawResponses: {} } }); } if (!this.observeHtmlLang) { Object.defineProperty(this, '_fetchStatus', { configurable: true, enumerable: true, writable: true, value: { // per instance if observeHtmlLang === false fetchingInstance: null, ajax: null, ajaxLang: null, lastLang: null, fallbackLanguageList: null, targetLang: null, lastResponse: {}, rawResponses: {} } }); } this._updateEffectiveLangBindThis = this._updateEffectiveLang.bind(this); this.addEventListener('lang-updated', this._updateEffectiveLangBindThis); this._startMutationObserver(); } /** * custom elements connectedCallback() * * Tasks: * - Call super.connectedCallback() if exists * - Set this._i18nElementConnected as true */ connectedCallback() { if (super.connectedCallback) { super.connectedCallback(); } this._i18nElementConnected = true; if (!this._updateEffectiveLangBindThis) { this._updateEffectiveLangBindThis = this._updateEffectiveLang.bind(this); this.addEventListener('lang-updated', this._updateEffectiveLangBindThis); } this._reviveBoundLangUpdatedListeners(); // Noop if it has not been disconnected this._startMutationObserver(); // Noop if already started } /** * custom elements disconnectedCallback() * * Tasks: * - Call super.disconnectedCallback() if exists * - Set this._i18nElementConnected as false * - Remove lang-updated event listener * - Remove lang-updated event listeners for boundElement * - Stop MutationObserver */ disconnectedCallback() { if (super.disconnectedCallback) { super.disconnectedCallback(); } this._i18nElementConnected = false; this.removeEventListener('lang-updated', this._updateEffectiveLangBindThis); this._updateEffectiveLangBindThis = null; this._removeBoundLangUpdatedListeners(); this._stopMutationObserver(); } /** * Registers lang-updated event lister for boundElement to clean up on disconnection * * @param {I18nBaseElement} boundElement bound element to register * @param {function} listener lang-updated event listener to register */ _registerBoundLangUpdatedListener(boundElement, listener) { if (!this._boundLangUpdatedListeners) { this._boundLangUpdatedListeners = new Set(); } this._boundLangUpdatedListeners.add({ boundElement, listener }); } /** * Removes lang-updated listeners for boundElement */ _removeBoundLangUpdatedListeners() { if (this._boundLangUpdatedListeners) { let boundElementsForThis = new Map(); for (let { boundElement, listener } of this._boundLangUpdatedListeners) { boundElement.removeEventListener('lang-updated', listener); let _bound = boundElements.get(boundElement.constructor.is); if (_bound && _bound.elements) { if (_bound.elements.has(this)) { _bound.elements.delete(this); boundElementsForThis.set(boundElement.constructor.is, boundElement); //console.log(`${this.is}._removeBoundLangUpdatedListeners: removing from boundElements.get(${boundElement.constructor.is}).elements elements.size = ${_bound.elements.size}`); } } //console.log(`${this.is}._removeBoundLangUpdatedListeners: removing lang-updated listener`, listener, ' from ', boundElement.constructor.is); } suspendedBoundElements.set(this, boundElementsForThis); this._boundLangUpdatedListeners.clear(); this._boundLangUpdatedListeners = null; } } /** * Revives lang-updated listeners for boundElement */ _reviveBoundLangUpdatedListeners() { let suspendedBoundElementsForThis = suspendedBoundElements.get(this); if (suspendedBoundElementsForThis) { this._boundLangUpdatedListeners = new Set(); for (let [ name, boundElement ] of suspendedBoundElementsForThis.entries()) { let listener = boundElement.langUpdated.bind(this); boundElement.addEventListener('lang-updated', listener); let _bound = boundElements.get(name); if (_bound && _bound.elements) { if (!_bound.elements.has(this)) { _bound.elements.set(this, boundElement); this._boundLangUpdatedListeners.add({ boundElement, listener }); if (this.lang !== boundElement.lang) { this.lang = boundElement.lang; } //console.log(`${this.is}._reviveBoundLangUpdatedListeners: reviving boundElements.get(${boundElement.constructor.is}).elements elements.size = ${_bound.elements.size}`); } } //console.log(`${this.is}._reviveBoundLangUpdatedListeners: reviving lang-updated listener`, listener, ' from ', boundElement.constructor.is); } suspendedBoundElements.delete(this); } } /** * Resolves URL relative to the base URL of the element class, emulating resolveUrl() in Polymer library * @param {string} url URL to resolve * @param {string} base Base URL for resolution; default value: this.constructor.importMeta.url * @return {string} resolved URL */ resolveUrl(url, base = this.constructor.importMeta.url) { return new URL(url, base).href; } /** * Callback to notify updates on the locale resources; Does nothing as a dummy function for notifyPath() in Polymer library * Optionally overrides this method to catch the updates of the locale resources since it is called just before each 'lang-updated' event is dispatched * @param {string} path 'text'; this.text is updated * @param {Object} value this.text object */ notifyPath(path, value) { // this.invalidate() } /** * Emulates fire() of Polymer library * @param {string} type Event type * @param {Object} detail Event.detail object * @param {Object} options Event options */ fire(type, detail = {}, options = {}) { const { bubbles = true, cancelable = false, composed = true, node = this } = options; const event = new Event(type, { bubbles, cancelable, composed }); event.detail = detail === null ? {} : detail; node.dispatchEvent(event); return event; } /** * Updates this.effectiveLang * Event listener of 'lang-updated' event * @param {Event} event 'lang-updated' event */ _updateEffectiveLang(event) { this.effectiveLang = this.lang || this.templateDefaultLang || I18nControllerBehavior.properties.defaultLang.value || 'en'; if (this.lang !== this.effectiveLang) { this.lang = this.effectiveLang; } } /** * Read-only locale resources object of this element * Note: The object can be for the fallback value before the locale resourced are loaded from JSON * @type {Object} Locale resources object of the current locale */ get text() { return this._getBundle(this.lang); } /** * Gets the locale resources of the specified element name * @param {string} name (Pseudo-)Custom element name * @param {Object} meta import.meta object of the target element * @return {Object} Locale resources of the target element */ getText(name, meta) { this._preprocessed = true; if (name === this.is) { return this._getBundle(this.lang); } else { // bound element let boundElement = this.getBoundElement(name, meta); if (boundElement.lang !== this.lang) { if (!(boundElement.observeHtmlLang && boundElement._html.lang === boundElement.lang)) { boundElement.lang = this.lang; } } if (boundElement.templateDefaultLang !== this.templateDefaultLang) { boundElement.templateDefaultLang = this.templateDefaultLang; } return boundElement._getBundle(this.lang); } } /** * Internally sets the default locale resources for the specified (pseudo-)element name * @param {string} name (Pseudo-)element name * @param {Object} bundle Locale resources object * @param {string} templateLang Locale of the locale resources object */ _setText(name, bundle, templateLang) { defaultBundles[name] = bundle; if (templateLang) { if (!bundles.hasOwnProperty(templateLang)) { bundles[templateLang] = {}; } bundles[templateLang][name] = bundle; } } /** * Obtains bound (pseudo-)element of the specified name * @param {string} name (Pseudo-)element name * @param {Object} meta import.meta object for the (pseudo-)element * @return {HTMLElement} Bound element */ getBoundElement(name, meta) { let { boundElement, elements } = boundElements.get(name) || { boundElement: null, elements: new Map() }; let listener; /* Data structures of boundElements boundElements.get(name) -> { boundElement: boundElement, elements: elements } if this.observeHtmlLang === true elements -> { this: boundElement, ... } // all values are the same boundElement object if this.observeHtmlLang === false elements -> { this: boundElementForThis, ... } // dedicated bound element for each `this` if this.observeHtmlLang === undefined // NameBinding elements -> { } // empty */ if (!boundElement) { let elementClass = customElements.get(name); let observeHtmlLang = elementClass ? elementClass.observeHtmlLang : true; class BoundElementClass extends i18n(HTMLElement) { static get is() { return name; } static get importMeta() { return meta; } static get observeHtmlLang() { return observeHtmlLang; } constructor() { super(); this.importMeta = meta; } langUpdated(event) { // not called for this //console.log(`${name}.langUpdated.bind(${this.is}) connected=${this._i18nElementConnected}, ${event.target.is} ${event.target.lang}: ${JSON.stringify(event.detail)}`, this); if (this._i18nElementConnected) { this.notifyPath('text', this.text); if (observeHtmlLang || this.lang !== event.target.lang) { this.fire('lang-updated', event.detail); } } } } customElements.define('html-binding-namespace-' + name, BoundElementClass); boundElement = document.createElement('html-binding-namespace-' + name); boundElements.set(name, { boundElement, elements }); } if (this.observeHtmlLang === false) { boundElement = elements.get(this); if (!boundElement) { boundElement = document.createElement('html-binding-namespace-' + name); listener = boundElement.langUpdated.bind(this); boundElement.addEventListener('lang-updated', listener); this._registerBoundLangUpdatedListener(boundElement, listener); elements.set(this, boundElement); } } else if (this !== ObserverElement.prototype) { if (!elements.has(this) && !suspendedBoundElements.has(this)) { listener = boundElement.langUpdated.bind(this); boundElement.addEventListener('lang-updated', listener); this._registerBoundLangUpdatedListener(boundElement, listener); elements.set(this, boundElement); if (this.lang !== boundElement.lang) { this.lang = boundElement.lang; } //console.log(`boundElement(${boundElement.is}).addEventListener('lang-updated', boundElement.langUpdated.bind(${this.is})) connected=${this._i18nElementConnected} elements.size=${elements.size}`); } } return boundElement; } /** * Internally starts mutation observer for lang property of html tag of the document at constructor */ _startMutationObserver() { this._htmlLangObserver = this._htmlLangObserver || new MutationObserver(this._handleHtmlLangChange.bind(this)); this._htmlLangObserver.observe(this._html = I18nControllerBehavior.properties.html.value, { attributes: true }); if (this.observeHtmlLang && this.lang !== this._html.lang && this._html.lang) { let htmlLang = this._html.lang; let originalLang = this.lang; setTimeout(() => { this._updateEffectiveLang(); if (this.observeHtmlLang && (this.lang === originalLang || (this.lang === this.templateDefaultLang && originalLang === ''))) { this.lang = htmlLang; } }, 0); } else { setTimeout(() => { if (!this.lang) { this._updateEffectiveLang(); } }, 0); } } /** * Disconnects mutation observer */ _stopMutationObserver() { if (this._htmlLangObserver) { this._htmlLangObserver.disconnect(); this._htmlLangObserver = null; } } /** * Handle mutations of lang property of html tag via MutationObserver * @param {Array} mutations Array of mutations */ _handleHtmlLangChange(mutations) { mutations.forEach(function(mutation) { switch (mutation.type) { case 'attributes': if (this.observeHtmlLang && mutation.attributeName === 'lang') { let lang = this._html.lang; if (this.lang !== lang) { this.lang = lang; } } break; default: break; } }, this); } /** * attributeChangedCallback of custom elements v1 to catch lang attribute changes * It calls super.attributeChangedCallback() for attriutes other than lang * @param {string} name Name of attribute * @param {string} oldValue Old value of the attribute * @param {string} newValue New value of the attribute */ attributeChangedCallback(name, oldValue, newValue) { if (name === 'lang') { // super.attributeChangedCallback() is not called //console.log(`${this.is}#${this.number}.attributeChangedCallback("${name}", "${oldValue}"(${typeof oldValue}), "${newValue}"(${typeof newValue}))`); if (oldValue !== newValue) { if (defaultBundles[this.constructor.is]) { this._langChanged(newValue, oldValue); } else { this._tasks = this._tasks || []; this._tasks.push(['_langChanged', [newValue, oldValue]]); } } } else { if (super.attributeChangedCallback) { super.attributeChangedCallback(name, oldValue, newValue); } } } /** * Internally processes queued tasks in this._tasks, which contains _langChanged calls to self, queued at attributeChangedCallback */ _processTasks() { if (this._tasks) { let task; while (task = this._tasks.shift()) { this[task[0]].apply(this, task[1]); } this._tasks = null; } } }); /** * Preprocess a template literal and hand it to lit-html * Each template literal must have its unique (pseudo-)element name to identify itself * The preprocessed template for each template is cached with the (pseudo-)element name as its key * * Example: * * ```js * return html`${bind(this)}...`; * ``` * * Example: * * ```js * const meta = import.meta; * function getMessage() { * const name = 'get-message'; * return html`${bind(name,meta)}...`; * } * ``` * * @param {Array<string>} strings Array of strings in the template literal. [0] must be '' if I18N is required * @param {Array<any>} parts Array of parts in the tempalte literal. [0] must be an instance of ElementBinding or NameBinding if I18N is required * @return {TemplateResult} generated by lit-html */ export const html = (strings, ...parts) => { let name, meta, element; let preprocessedStrings = []; let preprocessedParts = []; let preprocessedPartsGenerator; if (strings.length !== parts.length + 1) { throw new Error(`html: strings.length (= ${strings.length}) !== parts.length (= ${parts.length}) + 1`); } let offset = 0; if (strings.length > 0 && strings[0] === '' && parts[0] instanceof BindingBase) { name = parts[0].name; meta = parts[0].meta; element = parts[0].element; offset++; } else if (strings.length > 0 && strings[0] === '<!-- localizable -->' && parts[0] instanceof BindingBase) { //name = parts[0].name; //meta = parts[0].meta; element = parts[0].element; if (element._tasks) { element._processTasks(); } //console.log('html: rendering preprocessed HTML template for ' + parts[0].name); strings.shift(); parts.shift(); return litHtml(strings, ...parts); // preprocessed HTML template } else { return litHtml(strings, ...parts); // no I18N } let cachedTemplate = templateCache.get(name); if (cachedTemplate) { preprocessedStrings = cachedTemplate.preprocessedStrings; preprocessedPartsGenerator = cachedTemplate.preprocessedPartsGenerator; if (element._tasks) { element._processTasks(); } } else { let originalHtml = ''; let preprocessedHtml; let preprocessedPartsExpressions = []; let i; for (i = 0; i + offset < parts.length; i++) { let string = strings[i + offset]; let match = string.match(/([.?@])[^ =]*=$/) || []; switch (match[1]) { case '.': originalHtml += string.replace(/[.]([^ =]*)=$/, '$1='); originalHtml += `{{parts.${i}:property}}`; break; case '?': originalHtml += string.replace(/[?]([^ =]*)=$/, '$1='); originalHtml += `{{parts.${i}:boolean}}`; break; case '@': originalHtml += string.replace(/[@]([^ =]*)=$/, '$1='); originalHtml += `{{parts.${i}:event}}`; break; default: originalHtml += string; originalHtml += `{{parts.${i}}}`; break; } } originalHtml += strings[i + offset]; //console.log('original html: ', originalHtml); let template = document.createElement('template'); let _originalHtml = originalHtml; if (isEdge) { // Note for Edge: transform attributes are temporarily substituted for x-transform-x since Edge unexpectedly modifies transform attributes in SVG originalHtml = originalHtml.replace(/([ \t\n])transform=/g, '$1x-transform-x='); } template.innerHTML = originalHtml; if (element.templateDefaultLang) { template.setAttribute('lang', element.templateDefaultLang); } element.constructor.prototype._constructDefaultBundle(template, name); preprocessedHtml = template.innerHTML; if (isEdge) { // Note for Edge: Substituted transform attributes are reverted to original transform attributes since Edge unexpectedly modifies transform attributes in SVG preprocessedHtml = preprocessedHtml.replace(/x-transform-x=/g, 'transform='); } //console.log('preprocessed html: ', preprocessedHtml); element._processTasks(); let index; let partIndex = 0; while ((index = preprocessedHtml.indexOf('{{')) >= 0) { let preprocessedString; if (index > 3 && preprocessedHtml.substring(index - 3, index) === '$="') { // convert Polymer template syntax preprocessedString = preprocessedHtml.substring(0, index - 3) + '="'; } else { preprocessedString = preprocessedHtml.substring(0, index); } preprocessedHtml = preprocessedHtml.substring(index); index = preprocessedHtml.indexOf('}}'); if (index < 0) { throw new Error('html: no matching }} for {{'); } let part = preprocessedHtml.substring(0, index + 2); preprocessedHtml = preprocessedHtml.substring(index + 2); let partMatch = part.match(/^{{parts[.]([0-9]*)(:[a-z]*)?}}$/); if (partMatch && partMatch[2]) { switch (partMatch[2]) { case ':property': preprocessedString = preprocessedString.replace(/([^ =]*)=(["]?)$/, '.$1=$2'); break; case ':boolean': preprocessedString = preprocessedString.replace(/([^ =]*)=(["]?)$/, '?$1=$2'); break; case ':event': preprocessedString = preprocessedString.replace(/([^ =]*)=(["]?)$/, '@$1=$2'); break; default: throw new Error(`html: invalid part ${part}`); } } preprocessedStrings.push(preprocessedString); if (partMatch) { // Note: IE 11 does not keep the order of attributes preprocessedPartsExpressions.push(`parts[${parseInt(partMatch[1]) + offset}]`); partIndex++; } else { let isJSON = false; let isI18nFormat = false; part = part.substring(2, part.length - 2); if (part.indexOf('serialize(') === 0) { isJSON = true; part = part.substring(10, part.length - 1); // serialize(text...) } else if (part.indexOf('i18nFormat(') === 0) { isI18nFormat = true; part = part.substring(11, part.length - 1); // i18nFormat(param.0,parts.X,parts.Y,...) } let params = isI18nFormat ? part.split(/,/) : [part]; let valueExpression; let valueExpressions = []; while (part = params.shift()) { let partPath = part.split(/[.]/); valueExpression = 'text'; let tmpPart = partPath.shift(); if (tmpPart === 'parts') { valueExpression = `parts[${parseInt(partPath[0]) + offset}]`; } else { if (tmpPart === 'model') { valueExpression = 'model'; } else if (tmpPart === 'effectiveLang') { valueExpression = 'effectiveLang'; } while (partPath.length) { tmpPart = partPath.shift(); valueExpression += `['${tmpPart}']`; } if (isJSON) { valueExpression = `JSON.stringify(${valueExpression}, null, 2)`; } } valueExpressions.push(valueExpression); } if (isI18nFormat) { valueExpression = `element.i18nFormat(${valueExpressions.join(',')})`; } preprocessedPartsExpressions.push(valueExpression); } } preprocessedStrings.push(preprocessedHtml); preprocessedPartsGenerator = new Function('element', 'parts', 'text', 'model', 'effectiveLang', `return [${preprocessedPartsExpressions.join(',')}]`); //console.log('preprocessedPartsGenerator', preprocessedPartsGenerator.toString()); templateCache.set(name, { preprocessedStrings: preprocessedStrings, preprocessedPartsGenerator: preprocessedPartsGenerator }); } let text = element.getText(name, meta); preprocessedParts = preprocessedPartsGenerator(element, parts, text, text.model, element.effectiveLang || element.lang); //console.log('preprocessed: strings ', preprocessedStrings, 'parts ', JSON.stringify(preprocessedParts, null, 2)); return litHtml(preprocessedStrings, ...preprocessedParts); } /** * A dummy `<observer-element>` class to call a method getBoundElement without instantiating any elements * @customElement * @polymer * @appliesMixin i18n */ class ObserverElement extends i18n(HTMLElement) {} /** * Base class to bind templates to (pseudo-)elements */ class BindingBase { /* * Returns an empty string * @return {string} '' An empty string to represent a binding object */ toString() { return ''; } } /** * Binding to an element */ class ElementBinding extends BindingBase { /** * Constructs ElementBinding object * Properties: * this.name: element.constructor.is * this.meta: element.constructor.importMeta * this.element: element * * @param {HTMLElement} element 'this' element to bind */ constructor(element) { super(); if (element instanceof HTMLElement && element.constructor.isI18n) { this.name = element.constructor.is; this.meta = element.constructor.importMeta; this.element = element; } else { this.name = null; this.meta = null; this.element = null; } } } /** * Binding to a (pseudo-)element name */ class NameBinding extends BindingBase { /** * Constructs NameBinding object * Properties: * this.name: name * this.meta: meta * this.element: (pseudo-)element instance, which is generated by getBoundElement() and cached * * @param {string} name (Pseudo-)element name * @param {Object} meta import.meta Object of the (pseudo-)element */ constructor(name, meta) { super(); this.name = name || null; this.meta = meta || null; this.element = ObserverElement.prototype.getBoundElement(name, meta); } } /** * Binding to a (pseudo-)element with a name */ class ElementNameBinding extends BindingBase { /** * Constructs NameBinding object * Properties: * this.name: name * this.meta: element.constructor.importMeta * this.element: element * * @param {HTMLElement} element 'this' element to bind * @param {string} name (Pseudo-)element name */ constructor(element, name) { super(); if (element instanceof HTMLElement && element.constructor.isI18n && name) { this.name = name; this.meta = element.constructor.importMeta; this.element = element; } else { this.name = null; this.meta = null; this.element = null; } } } /** * Bind a prefixed I18N template to a specified ID or an element * * Example: Bound to this (the element is not extendable) * * ```js * class MyElement extends i18n(HTMLElement) { * static get importMeta() { return import.meta; } * render() { * return html`${bind(this)}...`; * } * } * ``` * * Example: Bound to a name * * ```js * const binding = bind('get-message', import.meta) * function getMessage() { * return html`${binding}...`; * } * ``` * * Example: Bound to this with a name (the element is extendable) * * ```js * class MyElement extends i18n(HTMLElement) { * static get importMeta() { return import.meta; } * render() { * return html`${bind(this, 'my-element')}...`; * } * } * class MyExtendedElement extends MyElement { * static get importMeta() { return import.meta; } * render() { * return html`${bind(this, 'my-extended-element')}...${super.render()}`; * } * } * ``` * * @param {HTMLElement|string} target (Tag name of) target element instance * @param {Object} meta import.meta for the module. Optional if target is an element. Mandatory if target is a name */ export const bind = function (target, meta) { let partsGenerator; let localizableText; let binding; if (typeof arguments[1] === 'function' && typeof arguments[2] === 'object' && target instanceof BindingBase) { // bind(('name', binding), (_bind, text, model, effectiveLang) => [], {}) binding = target; partsGenerator = arguments[1]; localizableText = arguments[2]; } else if (typeof target === 'string' && typeof meta === 'object' && typeof arguments[2] === 'function' && typeof arguments[3] === 'object') { // bind('name', import.meta, (_bind, text, model, effectiveLang) => [], {}) binding = new NameBinding(target, meta); partsGenerator = arguments[2]; localizableText = arguments[3]; } else if (typeof arguments[1] === 'string' && typeof arguments[2] === 'function' && typeof arguments[3] === 'object' && target instanceof HTMLElement && target.constructor.isI18n) { // bind(this, 'name', (_bind, text, model, effectiveLang) => [], {}) binding = new ElementNameBinding(target, arguments[1]); partsGenerator = arguments[2]; localizableText = arguments[3]; } else if (typeof arguments[1] === 'function' && typeof arguments[2] === 'object' && target instanceof HTMLElement && target.constructor.isI18n) { // bind(this, (_bind, text, model, effectiveLang) => [], {}) binding = new ElementBinding(target); partsGenerator = arguments[1]; localizableText = arguments[2]; } if (binding) { // Preprocessed if (!defaultBundles.hasOwnProperty(binding.name)) { let templateLang = binding.element.templateDefaultLang || I18nControllerBehavior.properties.defaultLang.value || 'en'; binding.element._setText(binding.name, localizableText, templateLang); } let text = binding.element.getText(binding.name, binding.meta); if (!binding.element.effectiveLang) { text = localizableText; // fallback at the initial rendering } return partsGenerator(binding, text, text.model, binding.element.effectiveLang || binding.element.lang); } else { // Not preprocessed if (target instanceof HTMLElement && target.constructor.isI18n) { if (!meta) { return new ElementBinding(target); // meta is unused } else { return new ElementNameBinding(target, meta); } } if (typeof target === 'string' && meta && typeof meta === 'object') { return new NameBinding(target, meta); } return new BindingBase(); } }