Refactor background page to use Typescript

Also, this CL adds clean-terminal-webpack-plugin to make it easier to
debug Typescript errors while developing the extension.

Bug: translateselectedtext:15
Change-Id: If9b97cb7508859e2e05f5dc82940808fd935bf1a
diff --git a/package-lock.json b/package-lock.json
index 6caff14..59a8ef6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,8 @@
       "version": "0.0.0",
       "license": "MIT",
       "dependencies": {
+        "chrome-types": "^0.1.113",
+        "clean-terminal-webpack-plugin": "^3.0.0",
         "clean-webpack-plugin": "^4.0.0",
         "copy-webpack-plugin": "^11.0.0",
         "json5": "^2.2.1",
@@ -516,6 +518,19 @@
         "node": ">=6.0"
       }
     },
+    "node_modules/chrome-types": {
+      "version": "0.1.113",
+      "resolved": "https://registry.npmjs.org/chrome-types/-/chrome-types-0.1.113.tgz",
+      "integrity": "sha512-vzon6Gcdtbzd7UJBa3Mwa2CFjtu7SV9jnejj3KMgYPbzHr+i1LAMAUA/uYi2n5z3mclEkdaXRlMRQHsE0C7c7g=="
+    },
+    "node_modules/clean-terminal-webpack-plugin": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/clean-terminal-webpack-plugin/-/clean-terminal-webpack-plugin-3.0.0.tgz",
+      "integrity": "sha512-wcgkQZmwEWYYjHblXc0+UGFDtx37S+1qgUQl4EOhhinzSHbZpixWBiasQ91RoCMf5lAm67j1XOt9z+HN+sWkWA==",
+      "peerDependencies": {
+        "webpack": "^4.0.0 || ^5.0.0"
+      }
+    },
     "node_modules/clean-webpack-plugin": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz",
@@ -2577,6 +2592,17 @@
       "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
       "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg=="
     },
+    "chrome-types": {
+      "version": "0.1.113",
+      "resolved": "https://registry.npmjs.org/chrome-types/-/chrome-types-0.1.113.tgz",
+      "integrity": "sha512-vzon6Gcdtbzd7UJBa3Mwa2CFjtu7SV9jnejj3KMgYPbzHr+i1LAMAUA/uYi2n5z3mclEkdaXRlMRQHsE0C7c7g=="
+    },
+    "clean-terminal-webpack-plugin": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/clean-terminal-webpack-plugin/-/clean-terminal-webpack-plugin-3.0.0.tgz",
+      "integrity": "sha512-wcgkQZmwEWYYjHblXc0+UGFDtx37S+1qgUQl4EOhhinzSHbZpixWBiasQ91RoCMf5lAm67j1XOt9z+HN+sWkWA==",
+      "requires": {}
+    },
     "clean-webpack-plugin": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz",
diff --git a/package.json b/package.json
index 4d38e49..2bda602 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,8 @@
   "license": "MIT",
   "private": true,
   "dependencies": {
+    "chrome-types": "^0.1.113",
+    "clean-terminal-webpack-plugin": "^3.0.0",
     "clean-webpack-plugin": "^4.0.0",
     "copy-webpack-plugin": "^11.0.0",
     "json5": "^2.2.1",
diff --git a/src/background.js b/src/background.ts
similarity index 88%
rename from src/background.js
rename to src/background.ts
index d3aa1ed..699109d 100644
--- a/src/background.js
+++ b/src/background.ts
@@ -1,10 +1,14 @@
-import actionApi from './common/actionApi.js';
-import {isoLangs} from './common/consts.js';
-import Options from './common/options.js';
-import ExtSessionStorage from './common/sessionStorage.js';
+import actionApi from './common/actionApi';
+import {isoLangs} from './common/consts';
+import Options from './common/options';
+import ExtSessionStorage from './common/sessionStorage';
 
-function getTranslationUrl(lang, text) {
-  var params = new URLSearchParams({
+interface ContextMenuLangs {
+  [id: string]: string;
+}
+
+function getTranslationUrl(lang: string, text: string): string {
+  let params = new URLSearchParams({
     sl: 'auto',
     tl: lang,
     text: text,
@@ -13,7 +17,8 @@
   return 'https://translate.google.com/?' + params.toString();
 }
 
-function translationClick(info, tab) {
+function translationClick(
+    info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab): void {
   let optionsPromise = Options.getOptions();
   let ssPromise = ExtSessionStorage.get(['contextMenuLangs', 'translatorTab']);
   Promise.all([optionsPromise, ssPromise])
@@ -55,10 +60,10 @@
       });
 }
 
-function createMenus(options) {
+function createMenus(options: Options): Promise<void> {
   chrome.contextMenus.removeAll();
 
-  let contextMenuLangs = {};
+  let contextMenuLangs: ContextMenuLangs = {};
   let langs = options.targetLangs;
   let isSingleEntry = Object.values(langs).length == 1;
 
@@ -182,6 +187,8 @@
       break;
 
     default:
-      console.error(`Unknown action "${action}" received as a message.`);
+      console.error(`Unknown action "${request.action}" received as a message.`);
   }
+
+  return undefined;
 });
diff --git a/src/common/actionApi.js b/src/common/actionApi.ts
similarity index 100%
rename from src/common/actionApi.js
rename to src/common/actionApi.ts
diff --git a/src/common/consts.js b/src/common/consts.ts
similarity index 96%
rename from src/common/consts.js
rename to src/common/consts.ts
index 7b1ef78..44e6ae8 100644
--- a/src/common/consts.js
+++ b/src/common/consts.ts
@@ -1,4 +1,12 @@
-export const isoLangs = {
+interface IsoLang {
+  name: string;
+  nativeName: string;
+};
+interface IsoLangs {
+  [key: string]: IsoLang;
+};
+
+export const isoLangs: IsoLangs = {
   'af': {'name': 'Afrikaans', 'nativeName': 'Afrikaans'},
   'ak': {'name': 'Twi', 'nativeName': 'Akan'},
   'am': {'name': 'Amharic', 'nativeName': 'አማርኛ'},
@@ -134,9 +142,13 @@
   'zu': {'name': 'Zulu', 'nativeName': 'isiZulu'},
 };
 
+interface LanguageDictionary {
+  [key: string]: string;
+};
+
 // Some languages were incorrectly set. This map serves as a conversion between
 // the previous wrong languages and the correct code.
-export const convertLanguages = {
+export const convertLanguages: LanguageDictionary = {
   'jv': 'jw',
   'zh': 'zh-CN',
 };
diff --git a/src/common/i18n.js b/src/common/i18n.ts
similarity index 63%
rename from src/common/i18n.js
rename to src/common/i18n.ts
index 053ea9b..38bb0ca 100644
--- a/src/common/i18n.js
+++ b/src/common/i18n.ts
@@ -1,5 +1,3 @@
 // Helper function which serves as a shorter alias to the chrome.i18n.getMessage
 // method, specially useful inside lit templates.
-export function msg(...args) {
-  return chrome.i18n.getMessage(...args);
-}
+export const msg = chrome.i18n.getMessage;
diff --git a/src/common/options.js b/src/common/options.ts
similarity index 70%
rename from src/common/options.js
rename to src/common/options.ts
index 8861881..6268f22 100644
--- a/src/common/options.js
+++ b/src/common/options.ts
@@ -1,6 +1,43 @@
-import {convertLanguages, isoLangs} from './consts.js';
+import {convertLanguages, isoLangs} from './consts';
 
-export const TAB_OPTIONS = [
+type TabOptionValue = ''|'yep'|'popup';
+type DeprecatedTabOptionValue = 'panel';
+type TabOptionValueIncludingDeprecated =
+    TabOptionValue|DeprecatedTabOptionValue;
+
+interface TabOption {
+  value: TabOptionValue;
+  labelMsg: string;
+  deprecatedValues: TabOptionValueIncludingDeprecated[];
+}
+
+interface TargetLangs {
+  [key: string]: string;  // Here the key is a string with a number.
+}
+interface OptionsV0 {
+  translateinto: TargetLangs;
+  uniquetab: TabOptionValue;
+}
+
+interface LegacyLanguages {
+  [key: string]: string;  // Here the key is a string with the language code.
+}
+/**
+ * Backwards-compatible interface for the information available in the sync
+ * storage area.
+ */
+interface LegacyOptions {
+  translateinto: TargetLangs;
+  languages: LegacyLanguages;
+  uniquetab: TabOptionValueIncludingDeprecated;
+}
+
+interface OptionsWrapper {
+  options: OptionsV0;
+  isFirstRun: boolean;
+}
+
+export const TAB_OPTIONS: TabOption[] = [
   // Open in new tab for each translation
   {
     value: '',
@@ -24,22 +61,25 @@
 // Class which can be used to retrieve the user options in order to act
 // accordingly.
 export default class Options {
-  constructor(options, isFirstRun) {
+  _options: OptionsV0;
+  isFirstRun: boolean;
+
+  constructor(options: OptionsV0, isFirstRun: boolean) {
     this._options = options;
     this.isFirstRun = isFirstRun;
   }
 
-  get uniqueTab() {
+  get uniqueTab(): TabOptionValue {
     return this._options.uniquetab;
   }
 
-  get targetLangs() {
+  get targetLangs(): TargetLangs {
     return this._options.translateinto;
   }
 
   // Returns a promise that resolves in an instance of the Object class with the
   // current options.
-  static getOptions(readOnly = true) {
+  static getOptions(readOnly: boolean = true): Promise<Options> {
     return Options.getOptionsRaw(readOnly).then(res => {
       return new Options(res.options, res.isFirstRun);
     });
@@ -53,20 +93,20 @@
   //
   // If the options needed to be normalized/created, they are also saved in the
   // sync storage area.
-  static getOptionsRaw(readOnly) {
+  static getOptionsRaw(readOnly: boolean): Promise<OptionsWrapper> {
     return new Promise((res, rej) => {
-      chrome.storage.sync.get(null, items => {
+      chrome.storage.sync.get(null, itemsAny => {
         if (chrome.runtime.lastError) {
           return rej(chrome.runtime.lastError);
         }
 
+        let items = <LegacyOptions>itemsAny;
         let didTranslateintoChange = false;
         let didUniquetabChange = false;
-        let returnObject = {};
 
         // If the extension sync storage area is blank, set this as being the
         // first run.
-        returnObject.isFirstRun = Object.keys(items).length === 0;
+        let isFirstRun = Object.keys(items).length === 0;
 
         // Create |translateinto| property if it doesn't exist.
         if (items.translateinto === undefined) {
@@ -75,8 +115,9 @@
           // Upgrade from a version previous to v0.7 if applicable, otherwise
           // create the property with the default values.
           if (items.languages !== undefined) {
+            let newTranslateinto: TargetLangs;
             items.translateinto =
-                Object.assign({}, Object.values(items.languages));
+                Object.assign(newTranslateinto, Object.values(items.languages));
           } else {
             let uiLocale = chrome.i18n.getMessage('@@ui_locale');
             let defaultLang1 = uiLocale.replace('_', '-');
@@ -122,20 +163,22 @@
         // value we use now.
         // - If it is set to an incorrect value or it isn't set, change it to
         // the default value.
+        let uniquetabNewValue: TabOptionValue;
         let foundValue = false;
         for (let opt of TAB_OPTIONS) {
           if (opt.value == items?.uniquetab) {
+            uniquetabNewValue = opt.value;
             foundValue = true;
             break;
           }
           if (opt.deprecatedValues.includes(items?.uniquetab)) {
             foundValue = true;
-            items.uniquetab = opt.value;
+            uniquetabNewValue = opt.value;
             break;
           }
         }
         if (!foundValue) {
-          items.uniquetab = 'popup';
+          uniquetabNewValue = 'popup';
           didUniquetabChange = true;
         }
 
@@ -145,17 +188,21 @@
           chrome.storage.sync.remove('languages');
         }
 
+        let returnObject: OptionsWrapper = {
+          isFirstRun,
+          options: {
+            translateinto: items.translateinto,
+            uniquetab: uniquetabNewValue,
+          }
+        };
+
         // Save properties that have changed if we're not in read-only mode
         if (!readOnly) {
           if (didTranslateintoChange || didUniquetabChange) {
-            chrome.storage.sync.set({
-              translateinto: items.translateinto,
-              uniquetab: items.uniquetab,
-            });
+            chrome.storage.sync.set(returnObject.options);
           }
         }
 
-        returnObject.options = items;
         res(returnObject);
       });
     });
diff --git a/src/common/sessionStorage.js b/src/common/sessionStorage.js
deleted file mode 100644
index db46c84..0000000
--- a/src/common/sessionStorage.js
+++ /dev/null
@@ -1,7 +0,0 @@
-// #!if ['chromium_mv3', 'edge_mv3'].includes(browser_target)
-import ExtSessionStorage from './sessionStorage_mv3.js'
-// #!else
-import ExtSessionStorage from './sessionStorage_mv2.js'
-// #!endif
-
-export default ExtSessionStorage;
diff --git a/src/common/sessionStorage.ts b/src/common/sessionStorage.ts
new file mode 100644
index 0000000..450e466
--- /dev/null
+++ b/src/common/sessionStorage.ts
@@ -0,0 +1,7 @@
+// #!if ['chromium_mv3', 'edge_mv3'].includes(browser_target)
+import ExtSessionStorage from './sessionStorage_mv3'
+// #!else
+import ExtSessionStorage from './sessionStorage_mv2'
+// #!endif
+
+export default ExtSessionStorage;
diff --git a/src/common/sessionStorage_mv2.js b/src/common/sessionStorage_mv2.ts
similarity index 80%
rename from src/common/sessionStorage_mv2.js
rename to src/common/sessionStorage_mv2.ts
index 4ad7daf..dda0ae1 100644
--- a/src/common/sessionStorage_mv2.js
+++ b/src/common/sessionStorage_mv2.ts
@@ -1,5 +1,11 @@
+declare global {
+  interface Window {
+    extCustomStorage: any;
+  }
+}
+
 export default class ExtSessionStorage {
-  static set(items) {
+  static set(items: any): Promise<void> {
     return new Promise((res, rej) => {
       if (window.extCustomStorage === undefined) window.extCustomStorage = {};
 
@@ -10,7 +16,7 @@
     });
   }
 
-  static get(keys) {
+  static get(keys: string|Array<string>|undefined): Promise<any> {
     return new Promise((res, rej) => {
       if (window.extCustomStorage === undefined) window.extCustomStorage = {};
 
@@ -25,7 +31,7 @@
       }
 
       if (Array.isArray(keys)) {
-        let returnObject = {};
+        let returnObject: any = {};
         for (const key of keys) {
           returnObject[key] = window.extCustomStorage[key];
         }
diff --git a/src/common/sessionStorage_mv3.js b/src/common/sessionStorage_mv3.js
deleted file mode 100644
index 9def901..0000000
--- a/src/common/sessionStorage_mv3.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export default class ExtSessionStorage {
-  static set(items) {
-    return chrome.storage.session.set(items);
-  }
-
-  static get(keys) {
-    return chrome.storage.session.get(keys);
-  }
-}
diff --git a/src/common/sessionStorage_mv3.ts b/src/common/sessionStorage_mv3.ts
new file mode 100644
index 0000000..befd2a8
--- /dev/null
+++ b/src/common/sessionStorage_mv3.ts
@@ -0,0 +1,9 @@
+export default class ExtSessionStorage {
+  static set(items: any): Promise<void> {
+    return chrome.storage.session.set(items);
+  }
+
+  static get(keys?: string|string[]|undefined): Promise<any> {
+    return chrome.storage.session.get(keys);
+  }
+}
diff --git a/src/options/credits.json5 b/src/options/credits.json5
index 6572988..56220e2 100644
--- a/src/options/credits.json5
+++ b/src/options/credits.json5
@@ -17,6 +17,18 @@
     "license": "MIT License"
   },
   {
+    "name": "chrome-types",
+    "url": "https://github.com/GoogleChrome/chrome-types",
+    "author": "Google LLC",
+    "license": "Apache-2.0 License"
+  },
+  {
+    "name": "clean-terminal-webpack-plugin",
+    "url": "https://github.com/danillouz/clean-terminal-webpack-plugin",
+    "author": "Daniël Illouz",
+    "license": "MIT License"
+  },
+  {
     "name": "clean-webpack-plugin",
     "url": "https://github.com/johnagan/clean-webpack-plugin",
     "author": "John Agan",
diff --git a/src/options/elements/credits-dialog/credits-dialog.js b/src/options/elements/credits-dialog/credits-dialog.js
index abec000..e7c3b10 100644
--- a/src/options/elements/credits-dialog/credits-dialog.js
+++ b/src/options/elements/credits-dialog/credits-dialog.js
@@ -2,11 +2,11 @@
 import {map} from 'lit/directives/map.js';
 import {unsafeHTML} from 'lit/directives/unsafe-html.js';
 
-import {msg} from '../../../common/i18n.js';
+import {msg} from '../../../common/i18n';
 import credits from '../../credits.json5';
 import i18nCredits from '../../i18n-credits.json5';
-import {DIALOG_STYLES} from '../../shared/dialog-styles.js';
-import {SHARED_STYLES} from '../../shared/shared-styles.js';
+import {DIALOG_STYLES} from '../../shared/dialog-styles';
+import {SHARED_STYLES} from '../../shared/shared-styles';
 
 export class CreditsDialog extends LitElement {
   static get styles() {
diff --git a/src/options/elements/options-editor/add-language-dialog.js b/src/options/elements/options-editor/add-language-dialog.js
index 7b85618..4cfd711 100644
--- a/src/options/elements/options-editor/add-language-dialog.js
+++ b/src/options/elements/options-editor/add-language-dialog.js
@@ -1,9 +1,9 @@
 import {css, html, LitElement} from 'lit';
 
-import {isoLangs} from '../../../common/consts.js';
-import {msg} from '../../../common/i18n.js';
-import {DIALOG_STYLES} from '../../shared/dialog-styles.js';
-import {SHARED_STYLES} from '../../shared/shared-styles.js';
+import {isoLangs} from '../../../common/consts';
+import {msg} from '../../../common/i18n';
+import {DIALOG_STYLES} from '../../shared/dialog-styles';
+import {SHARED_STYLES} from '../../shared/shared-styles';
 
 const ALL_LANGUAGES =
     Object.entries(isoLangs)
diff --git a/src/options/elements/options-editor/languages-editor.js b/src/options/elements/options-editor/languages-editor.js
index 4265d50..76d9967 100644
--- a/src/options/elements/options-editor/languages-editor.js
+++ b/src/options/elements/options-editor/languages-editor.js
@@ -1,11 +1,11 @@
 import {css, html, LitElement} from 'lit';
 import {map} from 'lit/directives/map.js';
 
-import {isoLangs} from '../../../common/consts.js';
-import {msg} from '../../../common/i18n.js';
-import {SHARED_STYLES} from '../../shared/shared-styles.js';
+import {isoLangs} from '../../../common/consts';
+import {msg} from '../../../common/i18n';
+import {SHARED_STYLES} from '../../shared/shared-styles';
 
-import AddLanguageDialog from './add-language-dialog.js';
+import AddLanguageDialog from './add-language-dialog';
 
 export class LanguagesEditor extends LitElement {
   static properties = {
diff --git a/src/options/elements/options-editor/options-editor.js b/src/options/elements/options-editor/options-editor.js
index 9592d27..a26072e 100644
--- a/src/options/elements/options-editor/options-editor.js
+++ b/src/options/elements/options-editor/options-editor.js
@@ -1,11 +1,11 @@
 import {css, html, LitElement} from 'lit';
 import {map} from 'lit/directives/map.js';
 
-import {msg} from '../../../common/i18n.js';
-import {TAB_OPTIONS} from '../../../common/options.js';
-import {SHARED_STYLES} from '../../shared/shared-styles.js';
+import {msg} from '../../../common/i18n';
+import {TAB_OPTIONS} from '../../../common/options';
+import {SHARED_STYLES} from '../../shared/shared-styles';
 
-import LanguagesEditor from './languages-editor.js';
+import LanguagesEditor from './languages-editor';
 
 export class OptionsEditor extends LitElement {
   static properties = {
diff --git a/src/options/options.js b/src/options/options.js
index 7a16ef9..2dd9dca 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -1,11 +1,11 @@
 import {css, html, LitElement} from 'lit';
 
-import {msg} from '../common/i18n.js';
-import Options from '../common/options.js';
+import {msg} from '../common/i18n';
+import Options from '../common/options';
 
-import CreditsDialog from './elements/credits-dialog/credits-dialog.js';
-import OptionsEditor from './elements/options-editor/options-editor.js';
-import {SHARED_STYLES} from './shared/shared-styles.js';
+import CreditsDialog from './elements/credits-dialog/credits-dialog';
+import OptionsEditor from './elements/options-editor/options-editor';
+import {SHARED_STYLES} from './shared/shared-styles';
 
 let bodyStyles = document.createElement('style');
 // #!if browser_target == 'chromium'
diff --git a/src/types/chrome.d.ts b/src/types/chrome.d.ts
new file mode 100644
index 0000000..3e15a3d
--- /dev/null
+++ b/src/types/chrome.d.ts
@@ -0,0 +1,5 @@
+/// <reference types="chrome-types" />
+
+interface Window {
+  chrome: chrome;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d22d747
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "sourceMap": true,
+    "noImplicitAny": true,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "module": "es6",
+    "target": "es5",
+    "allowJs": true
+  },
+  "include": [
+    "src/**/*.ts",
+    "node_modules/chrome-types/*"
+  ]
+}
diff --git a/webpack.config.js b/webpack.config.js
index 0e620ad..239fc73 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
 const path = require('path');
 const json5 = require('json5');
+const CleanTerminalPlugin = require('clean-terminal-webpack-plugin');
 const CopyWebpackPlugin = require('copy-webpack-plugin');
 const WebpackShellPluginNext = require('webpack-shell-plugin-next');
 
@@ -7,7 +8,7 @@
   // NOTE: When adding an entry, add the corresponding source map file to
   // web_accessible_resources in //templates/manifest.gjson.
   let entry = {
-    background: './src/background.js',
+    background: './src/background.ts',
     options: './src/options/options.js',
   };
 
@@ -58,6 +59,7 @@
           },
         ]
       }),
+      new CleanTerminalPlugin(),
     ],
     devtool: (args.mode == 'production' ? 'source-map' : 'inline-source-map'),
     resolve: {