feat(localize): support google translate
This commit is contained in:
parent
8d9225c040
commit
271520d55c
3 changed files with 284 additions and 49 deletions
43
packages/localize/docs/40-google-translate-integration.md
Normal file
43
packages/localize/docs/40-google-translate-integration.md
Normal file
|
|
@ -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
|
||||||
|
<html lang="en-US"></html>
|
||||||
|
```
|
||||||
|
|
||||||
|
If Google Translate is enabled and set to French, it will change html[lang]:
|
||||||
|
to `<html lang="fr">`
|
||||||
|
|
||||||
|
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 lang="en-US" data-localize-lang="en-US"></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: `<html lang="fr" data-localize-lang="en-US">`
|
||||||
|
|
||||||
|
The page is accessible and `localize` will fetch the right resources
|
||||||
|
|
@ -11,10 +11,6 @@ export class LocalizeManager extends LionSingleton {
|
||||||
super(params);
|
super(params);
|
||||||
this._fakeExtendsEventTarget();
|
this._fakeExtendsEventTarget();
|
||||||
|
|
||||||
if (!document.documentElement.lang) {
|
|
||||||
document.documentElement.lang = 'en-GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange;
|
this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange;
|
||||||
this._fallbackLocale = params.fallbackLocale;
|
this._fallbackLocale = params.fallbackLocale;
|
||||||
this.__storage = {};
|
this.__storage = {};
|
||||||
|
|
@ -23,24 +19,80 @@ export class LocalizeManager extends LionSingleton {
|
||||||
this.__namespaceLoaderPromisesCache = {};
|
this.__namespaceLoaderPromisesCache = {};
|
||||||
this.formatNumberOptions = { returnIfNaN: '' };
|
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();
|
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() {
|
teardown() {
|
||||||
this._teardownHtmlLangAttributeObserver();
|
this._teardownHtmlLangAttributeObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
get locale() {
|
get locale() {
|
||||||
|
if (this._supportExternalTranslationTools) {
|
||||||
|
return this.__locale;
|
||||||
|
}
|
||||||
return document.documentElement.lang;
|
return document.documentElement.lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
set locale(value) {
|
set locale(value) {
|
||||||
const oldLocale = document.documentElement.lang;
|
let oldLocale;
|
||||||
|
if (this._supportExternalTranslationTools) {
|
||||||
this._teardownHtmlLangAttributeObserver();
|
oldLocale = this.__locale;
|
||||||
document.documentElement.lang = value;
|
this.__locale = value;
|
||||||
this._setupHtmlLangAttributeObserver();
|
if (this._langAttrSetByTranslationTool === null) {
|
||||||
|
this._setHtmlLangAttribute(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldLocale = document.documentElement.lang;
|
||||||
|
this._setHtmlLangAttribute(value);
|
||||||
|
}
|
||||||
|
|
||||||
if (!value.includes('-')) {
|
if (!value.includes('-')) {
|
||||||
this.__handleLanguageOnly(value);
|
this.__handleLanguageOnly(value);
|
||||||
|
|
@ -49,6 +101,12 @@ export class LocalizeManager extends LionSingleton {
|
||||||
this._onLocaleChanged(value, oldLocale);
|
this._onLocaleChanged(value, oldLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_setHtmlLangAttribute(locale) {
|
||||||
|
this._teardownHtmlLangAttributeObserver();
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
this._setupHtmlLangAttributeObserver();
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
__handleLanguageOnly(value) {
|
__handleLanguageOnly(value) {
|
||||||
throw new Error(`
|
throw new Error(`
|
||||||
|
|
@ -119,7 +177,17 @@ export class LocalizeManager extends LionSingleton {
|
||||||
if (!this._htmlLangAttributeObserver) {
|
if (!this._htmlLangAttributeObserver) {
|
||||||
this._htmlLangAttributeObserver = new MutationObserver(mutations => {
|
this._htmlLangAttributeObserver = new MutationObserver(mutations => {
|
||||||
mutations.forEach(mutation => {
|
mutations.forEach(mutation => {
|
||||||
|
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);
|
this._onLocaleChanged(document.documentElement.lang, mutation.oldValue);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, oneEvent, aTimeout } from '@open-wc/testing';
|
import { expect, oneEvent, aTimeout } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { fetchMock } from '@bundled-es-modules/fetch-mock';
|
import { fetchMock } from '@bundled-es-modules/fetch-mock';
|
||||||
import { setupFakeImport, resetFakeImport, fakeImport } from '../test-helpers.js';
|
import { setupFakeImport, resetFakeImport, fakeImport } from '../test-helpers.js';
|
||||||
|
|
||||||
|
|
@ -27,11 +28,6 @@ describe('LocalizeManager', () => {
|
||||||
resetFakeImport();
|
resetFakeImport();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initializes locale from <html> by default', () => {
|
|
||||||
manager = new LocalizeManager();
|
|
||||||
expect(manager.locale).to.equal('en-GB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncs locale back to <html> if changed', () => {
|
it('syncs locale back to <html> if changed', () => {
|
||||||
manager = new LocalizeManager();
|
manager = new LocalizeManager();
|
||||||
manager.locale = 'nl-NL';
|
manager.locale = 'nl-NL';
|
||||||
|
|
@ -63,16 +59,6 @@ describe('LocalizeManager', () => {
|
||||||
expect(event.detail.oldLocale).to.equal('en-GB');
|
expect(event.detail.oldLocale).to.equal('en-GB');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fires "localeChanged" event if locale was changed via <html lang> 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', () => {
|
it('does not fire "localeChanged" event if it was set to the same locale', () => {
|
||||||
manager = new LocalizeManager();
|
manager = new LocalizeManager();
|
||||||
const eventSpy = sinon.spy();
|
const eventSpy = sinon.spy();
|
||||||
|
|
@ -432,30 +418,6 @@ describe('LocalizeManager', () => {
|
||||||
'nl-NL': { 'my-component': { greeting: 'Hallo!' } },
|
'nl-NL': { 'my-component': { greeting: 'Hallo!' } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads namespaces automatically when locale is changed via <html lang> 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', () => {
|
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 <html> by default', () => {
|
||||||
|
manager = new LocalizeManager({ supportExternalTranslationTools: false });
|
||||||
|
expect(manager.locale).to.equal('en-GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires "localeChanged" event if locale was changed via <html lang> 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 <html lang> 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!' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue