diff --git a/packages/localize/docs/40-google-translate-integration.md b/packages/localize/docs/40-google-translate-integration.md new file mode 100644 index 000000000..71e7f0f18 --- /dev/null +++ b/packages/localize/docs/40-google-translate-integration.md @@ -0,0 +1,43 @@ +# Google Translate integration + +```js script +export default { + title: 'Localize/Google Translate Integration', +}; +``` + +When Google Translate is enabled, it takes control of the html[lang] attribute. +Below, we find a simplified example that illustrates this. + +## The problem + +A developer initializes a page like this (and instructs localize to fetch data for `en-US` locale) + +```html + +``` + +If Google Translate is enabled and set to French, it will change html[lang]: +to `` + +Now `localize` will fetch data for locale `fr`. There are two problems here: + +- There might be no available data for locale `fr` +- Let's imagine data were loaded for `fr`. If Google Translate is turned off again, + the page content will consist of a combination of different locales. + +## How to solve this + +To trigger support for Google Translate, we need to configure two attributes + +```html + +``` + +- html[data-localize-lang] will be read by `localize` and used for fetching data +- html[lang] will be configured for accessibility purposes (it will makes sure the + page is accessible if localize would be lazy loaded). + +When Google Translate is set to French, we get: `` + +The page is accessible and `localize` will fetch the right resources diff --git a/packages/localize/src/LocalizeManager.js b/packages/localize/src/LocalizeManager.js index 4f7aac3f0..f1cbca4c8 100644 --- a/packages/localize/src/LocalizeManager.js +++ b/packages/localize/src/LocalizeManager.js @@ -11,10 +11,6 @@ export class LocalizeManager extends LionSingleton { super(params); this._fakeExtendsEventTarget(); - if (!document.documentElement.lang) { - document.documentElement.lang = 'en-GB'; - } - this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange; this._fallbackLocale = params.fallbackLocale; this.__storage = {}; @@ -23,24 +19,80 @@ export class LocalizeManager extends LionSingleton { this.__namespaceLoaderPromisesCache = {}; this.formatNumberOptions = { returnIfNaN: '' }; + /** + * Via html[data-localize-lang], developers are allowed to set the initial locale, without + * having to worry about whether locale is initialized before 3rd parties like Google Translate. + * When this value differs from html[lang], we assume the 3rd party took + * control over the page language and we set this._langAttrSetByTranslationTool to html[lang] + */ + const initialLocale = document.documentElement.getAttribute('data-localize-lang'); + + this._supportExternalTranslationTools = Boolean(initialLocale); + + if (this._supportExternalTranslationTools) { + this.locale = initialLocale || 'en-GB'; + this._setupTranslationToolSupport(); + } + + if (!document.documentElement.lang) { + document.documentElement.lang = this.locale || 'en-GB'; + } + this._setupHtmlLangAttributeObserver(); } + _setupTranslationToolSupport() { + /** + * This value allows for support for Google Translate (or other 3rd parties taking control + * of the html[lang] attribute). + * + * Have the following scenario in mind: + * 1. locale is initialized by developer via html[data-localize-lang="en-US"] and + * html[lang="en-US"]. When localize is loaded (note that this also can be after step 2 below), + * it will sync its initial state from html[data-localize-lang] + * 2. Google Translate kicks in for the French language. It will set html[lang="fr"]. + * This new language is not one known by us, so we most likely don't have translations for + * this file. Therefore, we do NOT sync this value to LocalizeManager. The manager should + * still ask for known resources (in this case for locale 'en-US') + * 3. locale is changed (think of a language dropdown) + * It's a bit of a weird case, because we would not expect an end user to do this. If he/she + * does, make sure that we do not go against Google Translate, so we maintain accessibility + * (by not altering html[lang]). We detect this by reading _langAttrSetByTranslationTool: + * when its value is null, we consider Google translate 'not active'. + * + * When Google Translate is turned off by the user (html[lang=auto]), + * `localize.locale` will be synced to html[lang] again + * + * Keep in mind that all of the above also works with other tools than Google Translate, + * but this is the most widely used tool and therefore used as an example. + */ + this._langAttrSetByTranslationTool = document.documentElement.lang || null; + } + teardown() { this._teardownHtmlLangAttributeObserver(); } // eslint-disable-next-line class-methods-use-this get locale() { + if (this._supportExternalTranslationTools) { + return this.__locale; + } return document.documentElement.lang; } set locale(value) { - const oldLocale = document.documentElement.lang; - - this._teardownHtmlLangAttributeObserver(); - document.documentElement.lang = value; - this._setupHtmlLangAttributeObserver(); + let oldLocale; + if (this._supportExternalTranslationTools) { + oldLocale = this.__locale; + this.__locale = value; + if (this._langAttrSetByTranslationTool === null) { + this._setHtmlLangAttribute(value); + } + } else { + oldLocale = document.documentElement.lang; + this._setHtmlLangAttribute(value); + } if (!value.includes('-')) { this.__handleLanguageOnly(value); @@ -49,6 +101,12 @@ export class LocalizeManager extends LionSingleton { this._onLocaleChanged(value, oldLocale); } + _setHtmlLangAttribute(locale) { + this._teardownHtmlLangAttributeObserver(); + document.documentElement.lang = locale; + this._setupHtmlLangAttributeObserver(); + } + // eslint-disable-next-line class-methods-use-this __handleLanguageOnly(value) { throw new Error(` @@ -119,7 +177,17 @@ export class LocalizeManager extends LionSingleton { if (!this._htmlLangAttributeObserver) { this._htmlLangAttributeObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { - this._onLocaleChanged(document.documentElement.lang, mutation.oldValue); + if (this._supportExternalTranslationTools) { + if (document.documentElement.lang === 'auto') { + // Google Translate is switched off + this._langAttrSetByTranslationTool = null; + this._setHtmlLangAttribute(this.locale); + } else { + this._langAttrSetByTranslationTool = document.documentElement.lang; + } + } else { + this._onLocaleChanged(document.documentElement.lang, mutation.oldValue); + } }); }); } diff --git a/packages/localize/test/LocalizeManager.test.js b/packages/localize/test/LocalizeManager.test.js index fe2048baa..bb9c978b5 100644 --- a/packages/localize/test/LocalizeManager.test.js +++ b/packages/localize/test/LocalizeManager.test.js @@ -1,5 +1,6 @@ import { expect, oneEvent, aTimeout } from '@open-wc/testing'; import sinon from 'sinon'; +// eslint-disable-next-line import/no-unresolved import { fetchMock } from '@bundled-es-modules/fetch-mock'; import { setupFakeImport, resetFakeImport, fakeImport } from '../test-helpers.js'; @@ -27,11 +28,6 @@ describe('LocalizeManager', () => { resetFakeImport(); }); - it('initializes locale from by default', () => { - manager = new LocalizeManager(); - expect(manager.locale).to.equal('en-GB'); - }); - it('syncs locale back to if changed', () => { manager = new LocalizeManager(); manager.locale = 'nl-NL'; @@ -63,16 +59,6 @@ describe('LocalizeManager', () => { expect(event.detail.oldLocale).to.equal('en-GB'); }); - it('fires "localeChanged" event if locale was changed via attribute', async () => { - manager = new LocalizeManager(); - setTimeout(() => { - document.documentElement.lang = 'en-US'; - }); - const event = await oneEvent(manager, 'localeChanged'); - expect(event.detail.newLocale).to.equal('en-US'); - expect(event.detail.oldLocale).to.equal('en-GB'); - }); - it('does not fire "localeChanged" event if it was set to the same locale', () => { manager = new LocalizeManager(); const eventSpy = sinon.spy(); @@ -432,30 +418,6 @@ describe('LocalizeManager', () => { 'nl-NL': { 'my-component': { greeting: 'Hallo!' } }, }); }); - - it('loads namespaces automatically when locale is changed via attribute', async () => { - setupFakeImport('./my-component/en-GB.js', { default: { greeting: 'Hello!' } }); - setupFakeImport('./my-component/nl-NL.js', { default: { greeting: 'Hallo!' } }); - - manager = new LocalizeManager({ autoLoadOnLocaleChange: true }); - - await manager.loadNamespace({ - 'my-component': locale => fakeImport(`./my-component/${locale}.js`, 25), - }); - - expect(manager.__storage).to.deep.equal({ - 'en-GB': { 'my-component': { greeting: 'Hello!' } }, - }); - - document.documentElement.lang = 'nl-NL'; - await aTimeout(); // wait for mutation observer to be called - await manager.loadingComplete; - - expect(manager.__storage).to.deep.equal({ - 'en-GB': { 'my-component': { greeting: 'Hello!' } }, - 'nl-NL': { 'my-component': { greeting: 'Hallo!' } }, - }); - }); }); describe('loading extra features', () => { @@ -594,3 +556,165 @@ describe('LocalizeManager', () => { }); }); }); + +describe('When supporting external translation tools like Google Translate', () => { + let manager; + const originalLang = document.documentElement.lang; + + async function simulateGoogleTranslateOn(lang) { + document.documentElement.lang = lang; + } + + async function simulateGoogleTranslateOff() { + document.documentElement.lang = 'auto'; + } + + function getInstance(cfg) { + LocalizeManager.resetInstance(); + return LocalizeManager.getInstance(cfg || {}); + } + + afterEach(() => { + document.documentElement.removeAttribute('lang'); + document.documentElement.removeAttribute('data-localize-lang'); + }); + + after(() => { + document.documentElement.lang = originalLang; + }); + + describe('On initialization', () => { + /** A default scenario */ + it('synchronizes from html[data-localize-lang] attribute to LocalizeManager', async () => { + document.documentElement.setAttribute('data-localize-lang', 'nl-NL'); + document.documentElement.lang = 'nl-NL'; + manager = getInstance(); + expect(manager.locale).to.equal('nl-NL'); + }); + + /** A scenario where Google Translate kicked in before initialization */ + it(`synchronizes from html[data-localize-lang] attribute to LocalizeManager when html[lang] + has a different value`, async () => { + document.documentElement.setAttribute('data-localize-lang', 'en-US'); + document.documentElement.lang = 'fr'; + manager = getInstance(); + expect(manager.locale).to.equal('en-US'); + }); + + it("doesn't synchronize from html[lang] attribute to LocalizeManager", async () => { + document.documentElement.setAttribute('data-localize-lang', 'en-US'); + manager = getInstance(); + document.documentElement.lang = 'nl-NL'; + expect(manager.locale).to.not.equal('nl-NL'); + }); + + it('triggers support for external translation tools via data-localize-lang', async () => { + document.documentElement.removeAttribute('data-localize-lang'); + manager = getInstance(); + expect(manager._supportExternalTranslationTools).to.be.false; + + document.documentElement.setAttribute('data-localize-lang', 'nl-NL'); + manager = getInstance(); + expect(manager._supportExternalTranslationTools).to.be.true; + }); + }); + + describe('After initialization', () => { + it(`synchronizes from LocalizeManager to html[lang] when + 3rd party translation tool is NOT in control`, async () => { + document.documentElement.removeAttribute('lang'); + manager = getInstance(); + expect(document.documentElement.lang).to.equal('en-GB'); + manager.locale = 'nl-NL'; + expect(document.documentElement.lang).to.equal('nl-NL'); + }); + + it(`doesn't synchronize from LocalizeManager to html[lang] when + 3rd party translation tool is in control`, async () => { + document.documentElement.setAttribute('data-localize-lang', 'en-US'); + manager = getInstance(); + await simulateGoogleTranslateOn('fr'); + manager.locale = 'nl-NL'; + expect(document.documentElement.lang).to.equal('fr'); + }); + + it(`doesn't synchronize from html[lang] attribute to LocalizeManager`, async () => { + document.documentElement.setAttribute('data-localize-lang', 'en-US'); + manager = getInstance(); + manager.locale = 'nl-NL'; + // When a 3rd party like Google Translate alters lang attr of the page, we want to + // keep this for accessibility, but it should NOT be synchronized to our manager. + await simulateGoogleTranslateOn('fr'); + expect(manager.locale).to.equal('nl-NL'); + }); + + it(`restores html[lang] when 3rd party translation tool is turned off again`, async () => { + manager = getInstance(); + manager.locale = 'nl-NL'; + await simulateGoogleTranslateOn('fr'); + expect(document.documentElement.lang).to.equal('fr'); + await simulateGoogleTranslateOff(); + expect(document.documentElement.lang).to.equal('nl-NL'); + }); + }); +}); + +describe('[deprecated] When not supporting external translation tools like Google Translate', () => { + let manager; + + beforeEach(() => { + // makes sure that between tests the localization is reset to default state + document.documentElement.lang = 'en-GB'; + }); + + afterEach(() => { + manager.teardown(); + }); + + afterEach(() => { + fetchMock.restore(); + resetFakeImport(); + }); + + it('initializes locale from by default', () => { + manager = new LocalizeManager({ supportExternalTranslationTools: false }); + expect(manager.locale).to.equal('en-GB'); + }); + + it('fires "localeChanged" event if locale was changed via attribute', async () => { + manager = new LocalizeManager({ supportExternalTranslationTools: false }); + setTimeout(() => { + document.documentElement.lang = 'en-US'; + }); + const event = await oneEvent(manager, 'localeChanged'); + expect(event.detail.newLocale).to.equal('en-US'); + expect(event.detail.oldLocale).to.equal('en-GB'); + }); + + it('loads namespaces automatically when locale is changed via attribute', async () => { + setupFakeImport('./my-component/en-GB.js', { default: { greeting: 'Hello!' } }); + setupFakeImport('./my-component/nl-NL.js', { default: { greeting: 'Hallo!' } }); + + manager = new LocalizeManager({ + supportExternalTranslationTools: false, + autoLoadOnLocaleChange: true, + }); + + await manager.loadNamespace({ + 'my-component': locale => fakeImport(`./my-component/${locale}.js`, 25), + }); + + expect(manager.__storage).to.deep.equal({ + 'en-GB': { 'my-component': { greeting: 'Hello!' } }, + }); + + document.documentElement.lang = 'nl-NL'; + await aTimeout(); // wait for mutation observer to be called + await manager.loadingComplete; + + expect(manager.__storage).to.deep.equal({ + 'en-GB': { 'my-component': { greeting: 'Hello!' } }, + 'nl-NL': { 'my-component': { greeting: 'Hallo!' } }, + }); + }); +});