Clean up language logic

- Creates a i18n library with tests
- Moves logic out of ui component (it shouldn't be there)
- Removes another entry point from index.html
This commit is contained in:
Vinicius Fortuna 2020-04-25 12:58:58 -04:00
parent 85a0d54823
commit ede3057adf
6 changed files with 205 additions and 71 deletions

View file

@ -23,7 +23,6 @@
"iron-collapse": "PolymerElements/iron-collapse#^2.2.1",
"iron-fit-behavior": "PolymerElements/iron-fit-behavior#^2.1.0",
"iron-pages": "PolymerElements/iron-pages#^2.0.1",
"outline-i18n": "Jigsaw-Code/outline-i18n#^0.0.7",
"paper-button": "PolymerElements/paper-button#^2.0.0",
"paper-checkbox": "PolymerElements/paper-checkbox#^2.0.4",
"paper-dialog": "PolymerElements/paper-dialog#^2.0.1",

View file

@ -25,7 +25,6 @@
<link rel="stylesheet" href="/ui_components/style.css" />
<script src="/bower_components/webcomponentsjs/webcomponents-loader.js"></script>
<script src="/bower_components/outline-i18n/index.js"></script>
<link rel="import" href="/ui_components/app-root.html" />
<script src="server_manager/web_app/main.js"></script>
@ -37,8 +36,5 @@
<title>Outline Manager</title>
</head>
<body>
<app-root id="appRoot"></app-root>
</body>
<body></body>
</html>

View file

@ -0,0 +1,49 @@
// Copyright 2020 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as i18n from './i18n';
describe('LanguageMatcher', () => {
it('returns supported language on match', () => {
const SUPPORTED_LANGUAGES = i18n.languageList(["es", "pt-BR", "ru"]);
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(["pt-PT"]));
expect(supportedLanguage.string()).toEqual('pt-BR');
});
it('returns the right variant', () => {
const SUPPORTED_LANGUAGES = i18n.languageList(['en-GB', 'en-IN', 'en-US']);
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(['en-IN']));
expect(supportedLanguage.string()).toEqual('en-IN');
});
it('prefers first matched user language', () => {
const SUPPORTED_LANGUAGES = i18n.languageList(['en-US', 'pt-BR']);
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, undefined);
const supportedLanguage =
matcher.getBestSupportedLanguage(i18n.languageList(['cn', 'en-GB', 'pt-BR']));
expect(supportedLanguage.string()).toEqual('en-US');
});
it('returns default on no match', () => {
const SUPPORTED_LANGUAGES = i18n.languageList(['es', 'pt-BR', 'ru']);
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES, new i18n.LanguageCode('fr'));
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(['cn']));
expect(supportedLanguage.string()).toEqual('fr');
});
it('returns undefined on no match and no default', () => {
const SUPPORTED_LANGUAGES = i18n.languageList(["es", "pt-BR", "ru"]);
const matcher = new i18n.LanguageMatcher(SUPPORTED_LANGUAGES);
const supportedLanguage = matcher.getBestSupportedLanguage(i18n.languageList(["cn"]));
expect(supportedLanguage).toBeUndefined();
});
});

View file

@ -0,0 +1,87 @@
// Copyright 2018 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export class LanguageCode {
private language: string;
private normalized: string;
constructor(languageCodeStr: string) {
this.language = languageCodeStr;
this.normalized = languageCodeStr.toLowerCase();
}
matches(other: LanguageCode): boolean {
return this.normalized === other.normalized;
}
string(): string {
return this.language;
}
split(): string[] {
return this.language.split('-');
}
}
export class LanguageMatcher {
constructor(
private supportedLanguages: LanguageCode[],
private defaultLanguage: LanguageCode = undefined) {}
// Goes over each user language, trying to find the supported language that matches
// the best. We'll trim variants of the user and supported languages in order to find
// a match, but the language base is guaranteed to match.
getBestSupportedLanguage(userLanguages: LanguageCode[]): LanguageCode | undefined {
for (const userLanguage of userLanguages) {
const parts = userLanguage.split();
while (parts.length > 0) {
const trimmedUserLanguage = new LanguageCode(parts.join('-'));
const supportedLanguage = this.getSupportedLanguage(trimmedUserLanguage);
if (supportedLanguage) {
return supportedLanguage;
}
parts.pop();
}
}
return this.defaultLanguage;
}
// Returns the closest supported language that matches the user language.
// We make sure the language matches, but the variant may differ.
private getSupportedLanguage(userLanguage: LanguageCode): LanguageCode | undefined {
for (const supportedLanguage of this.supportedLanguages) {
const parts = supportedLanguage.split();
while (parts.length > 0) {
const trimmedSupportedLanguage = new LanguageCode(parts.join('-'));
if (userLanguage.matches(trimmedSupportedLanguage)) {
return supportedLanguage;
}
parts.pop();
}
}
return undefined;
}
}
export function languageList(languagesAsStr: string[]): LanguageCode[] {
return languagesAsStr.map(l => new LanguageCode(l));
}
// Returns the languages supported by the browser.
export function getBrowserLanguages(): LanguageCode[] {
// Ensure that navigator.languages is defined and not empty, as can be the case with some browsers
// (i.e. Chrome 59 on Electron).
let languages = navigator.languages as string[];
if (!languages || languages.length === 0) {
languages = [navigator.language];
}
return languageList(languages);
}

View file

@ -540,70 +540,15 @@
static get properties() {
return {
LANGUAGES_AVAILABLE: {
type: Object,
readonly: true,
value: {
am: {id: "am", dir: "ltr"},
ar: {id: "ar", dir: "rtl"},
bg: {id: "bg", dir: "ltr"},
ca: {id: "ca", dir: "ltr"},
cs: {id: "cs", dir: "ltr"},
da: {id: "da", dir: "ltr"},
de: {id: "de", dir: "ltr"},
el: {id: "el", dir: "ltr"},
en: {id: "en", dir: "ltr"},
"es-419": {id: "es-419", dir: "ltr"},
fa: {id: "fa", dir: "rtl"},
fi: {id: "fi", dir: "ltr"},
fil: {id: "fil", dir: "ltr"},
fr: {id: "fr", dir: "ltr"},
he: {id: "he", dir: "rtl"},
hi: {id: "hi", dir: "ltr"},
hr: {id: "hr", dir: "ltr"},
hu: {id: "hu", dir: "ltr"},
id: {id: "id", dir: "ltr"},
it: {id: "it", dir: "ltr"},
ja: {id: "ja", dir: "ltr"},
ko: {id: "ko", dir: "ltr"},
km: {id: "km", dir: "ltr"},
lt: {id: "lt", dir: "ltr"},
lv: {id: "lv", dir: "ltr"},
nl: {id: "nl", dir: "ltr"},
no: {id: "no", dir: "ltr"},
pl: {id: "pl", dir: "ltr"},
"pt-BR": {id: "pt-BR", dir: "ltr"},
ro: {id: "ro", dir: "ltr"},
ru: {id: "ru", dir: "ltr"},
sk: {id: "sk", dir: "ltr"},
sl: {id: "sl", dir: "ltr"},
sr: {id: "sr", dir: "ltr"},
"sr-Latn": {id: "sr-Latn", dir: "ltr"},
sv: {id: "sv", dir: "ltr"},
th: {id: "th", dir: "ltr"},
tr: {id: "tr", dir: "ltr"},
uk: {id: "uk", dir: "ltr"},
ur: {id: "ur", dir: "rtl"},
vi: {id: "vi", dir: "ltr"},
zh: {id: "zh", dir: "ltr"},
"zh-CN": {id: "zh-CN", dir: "ltr"},
"zh-TW": {id: "zh-TW", dir: "ltr"},
},
},
DEFAULT_LANGUAGE: {
// Properties language and useKeyIfMissing are used by Polymer.AppLocalizeBehavior.
language: {
type: String,
readonly: true,
value: "en",
},
useKeyIfMissing: {
type: Boolean,
value: true,
},
language: {
type: String,
readonly: true,
computed: "_computeLanguage(LANGUAGES_AVAILABLE, DEFAULT_LANGUAGE)",
},
serverList: {
type: Array,
value: [],
@ -653,9 +598,8 @@
super.ready();
const messagesUrl = `/messages/${this.language}.json`;
this.loadResources(messagesUrl, this.language);
const languageProperties = this.LANGUAGES_AVAILABLE[this.language];
if (languageProperties && languageProperties.dir === "rtl") {
document.documentElement.setAttribute("dir", "rtl");
const langDir = this.getAttribute("dir");
if (langDir === "rtl") {
this.$.appDrawer.align = "right";
this.$.sideBar.align = "right";
}
@ -976,10 +920,6 @@
this.maybeCloseDrawer();
}
_computeLanguage(LANGUAGES_AVAILABLE, DEFAULT_LANGUAGE) {
return OutlineI18n.getBestMatchingLanguage(Object.keys(LANGUAGES_AVAILABLE)) || DEFAULT_LANGUAGE;
}
// Wrapper to encode a string in base64. This is necessary to set the server view IDs to
// the display server IDs, which are URLs, so they can be used with selector methods. The IDs
// are never decoded.

View file

@ -15,6 +15,7 @@
import * as url from 'url';
import * as digitalocean_api from '../cloud/digitalocean_api';
import * as i18n from '../infrastructure/i18n';
import {App, DATA_LIMITS_AVAILABILITY_DATE} from './app';
import {DigitalOceanTokenManager} from './digitalocean_oauth';
@ -23,6 +24,61 @@ import {DisplayServerRepository} from './display_server';
import {ManualServerRepository} from './manual_server';
import {DEFAULT_PROMPT_IMPRESSION_DELAY_MS, OutlineSurveys} from './survey';
const SUPPORTED_LANGUAGES: {[key: string]: {id: string, dir: string}} = {
'am': {id: 'am', dir: 'ltr'},
'ar': {id: 'ar', dir: 'rtl'},
'bg': {id: 'bg', dir: 'ltr'},
'ca': {id: 'ca', dir: 'ltr'},
'cs': {id: 'cs', dir: 'ltr'},
'da': {id: 'da', dir: 'ltr'},
'de': {id: 'de', dir: 'ltr'},
'el': {id: 'el', dir: 'ltr'},
'en': {id: 'en', dir: 'ltr'},
'es-419': {id: 'es-419', dir: 'ltr'},
'fa': {id: 'fa', dir: 'rtl'},
'fi': {id: 'fi', dir: 'ltr'},
'fil': {id: 'fil', dir: 'ltr'},
'fr': {id: 'fr', dir: 'ltr'},
'he': {id: 'he', dir: 'rtl'},
'hi': {id: 'hi', dir: 'ltr'},
'hr': {id: 'hr', dir: 'ltr'},
'hu': {id: 'hu', dir: 'ltr'},
'id': {id: 'id', dir: 'ltr'},
'it': {id: 'it', dir: 'ltr'},
'ja': {id: 'ja', dir: 'ltr'},
'ko': {id: 'ko', dir: 'ltr'},
'km': {id: 'km', dir: 'ltr'},
'lt': {id: 'lt', dir: 'ltr'},
'lv': {id: 'lv', dir: 'ltr'},
'nl': {id: 'nl', dir: 'ltr'},
'no': {id: 'no', dir: 'ltr'},
'pl': {id: 'pl', dir: 'ltr'},
'pt-BR': {id: 'pt-BR', dir: 'ltr'},
'ro': {id: 'ro', dir: 'ltr'},
'ru': {id: 'ru', dir: 'ltr'},
'sk': {id: 'sk', dir: 'ltr'},
'sl': {id: 'sl', dir: 'ltr'},
'sr': {id: 'sr', dir: 'ltr'},
'sr-Latn': {id: 'sr-Latn', dir: 'ltr'},
'sv': {id: 'sv', dir: 'ltr'},
'th': {id: 'th', dir: 'ltr'},
'tr': {id: 'tr', dir: 'ltr'},
'uk': {id: 'uk', dir: 'ltr'},
'ur': {id: 'ur', dir: 'rtl'},
'vi': {id: 'vi', dir: 'ltr'},
'zh': {id: 'zh', dir: 'ltr'},
'zh-CN': {id: 'zh-CN', dir: 'ltr'},
'zh-TW': {id: 'zh-TW', dir: 'ltr'},
};
function languageToUse(): i18n.LanguageCode {
const supportedLanguages = i18n.languageList(Object.keys(SUPPORTED_LANGUAGES));
const defaultLanguage = new i18n.LanguageCode('en');
const userLanguages = i18n.getBrowserLanguages();
return new i18n.LanguageMatcher(supportedLanguages, defaultLanguage)
.getBestSupportedLanguage(userLanguages);
}
function ensureString(queryParam: string|string[]): string {
if (Array.isArray(queryParam)) {
// We pick the last one if the parameter appears multiple times.
@ -48,10 +104,17 @@ document.addEventListener('WebComponentsReady', () => {
};
// Create and start the app.
const language = languageToUse();
const languageDirection = SUPPORTED_LANGUAGES[language.string()].dir;
document.documentElement.setAttribute('dir', languageDirection);
const appRootEl = document.createElement('app-root');
appRootEl.setAttribute('language', language.string());
appRootEl.setAttribute('dir', languageDirection);
document.body.appendChild(appRootEl);
// NOTE: this cast is safe and allows us to leverage Polymer typings since we haven't migrated to
// Polymer 3, which adds typescript support.
// tslint:disable-next-line:no-any
const appRoot: polymer.Base = (document.getElementById('appRoot') as any) as polymer.Base;
const appRoot = appRootEl as unknown as polymer.Base;
new App(
appRoot, version, digitalocean_api.createDigitalOceanSession,
digitalOceanServerRepositoryFactory, new ManualServerRepository('manualServers'),