From 14f2d13a73dbc2e0490327f41b81670f165b5a70 Mon Sep 17 00:00:00 2001 From: sua yoo Date: Sat, 20 Nov 2021 07:44:21 -0800 Subject: [PATCH] Add frontend localization support (#18) closes #17 --- frontend/README.md | 59 ++++++++++++-- frontend/lit-localize.json | 15 ++++ frontend/package.json | 10 ++- frontend/src/__generated__/locale-codes.ts | 23 ++++++ frontend/src/__generated__/locales/ko.ts | 17 ++++ frontend/src/components/locale-picker.ts | 90 ++++++++++++++++++++++ frontend/src/index.ts | 34 +++++--- frontend/src/shoelace.ts | 3 + frontend/src/utils/localization.ts | 16 ++++ frontend/xliff/ko.xlf | 23 ++++++ frontend/yarn.lock | 80 ++++++++++++++++++- 11 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 frontend/lit-localize.json create mode 100644 frontend/src/__generated__/locale-codes.ts create mode 100644 frontend/src/__generated__/locales/ko.ts create mode 100644 frontend/src/components/locale-picker.ts create mode 100644 frontend/src/utils/localization.ts create mode 100644 frontend/xliff/ko.xlf diff --git a/frontend/README.md b/frontend/README.md index 30ec4011..f7f9bf65 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -27,14 +27,16 @@ follow instructions for deploying to a local Docker instance. Update `API_BASE_U ## Scripts -| `yarn ` | | -| ------------- | ------------------------------------------------------------------- | -| `start-dev` | runs app in development server, reloading on file changes | -| `test` | runs tests in chromium with playwright | -| `build-dev` | bundles app and outputs it in `dist` directory | -| `build` | bundles app app, optimized for production, and outputs it to `dist` | -| `lint` | find and fix auto-fixable javascript errors | -| `format` | formats js, html and css files | +| `yarn ` | | +| ------------------ | ------------------------------------------------------------------- | +| `start-dev` | runs app in development server, reloading on file changes | +| `test` | runs tests in chromium with playwright | +| `build-dev` | bundles app and outputs it in `dist` directory | +| `build` | bundles app app, optimized for production, and outputs it to `dist` | +| `lint` | find and fix auto-fixable javascript errors | +| `format` | formats js, html and css files | +| `localize:extract` | generate XLIFF file to be translated | +| `localize:build` | output a localized version of strings/templates | ## Testing @@ -51,3 +53,44 @@ To run tests in multiple browsers: ```sh yarn test --browsers chromium firefox webkit ``` + +## Localization + +Wrap text or templates in the `msg` helper to make them localizable: + +```js +// import from @lit/localize: +import { msg } from "@lit/localize"; + +// later, in the render function: +render() { + return html` + + ` +} +``` + +Entire templates can be wrapped as well: + +```js +render() { + return msg(html` +

Click the button

+ + `) +} +``` + +See: + +To add new languages: + +1. Add [BCP 47 language tag](https://www.w3.org/International/articles/language-tags/index.en) to `targetLocales` in `lit-localize.json` +2. Run `yarn localize:extract` to generate new .xlf file in `/xliff` +3. Provide .xlf file to translation team +4. Replace .xlf file once translated +5. Run `yarn localize:build` bring translation into `src` + +See: diff --git a/frontend/lit-localize.json b/frontend/lit-localize.json new file mode 100644 index 00000000..1cabf80c --- /dev/null +++ b/frontend/lit-localize.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://raw.githubusercontent.com/lit/lit/main/packages/localize-tools/config.schema.json", + "sourceLocale": "en", + "targetLocales": ["ko"], + "tsConfig": "tsconfig.json", + "output": { + "mode": "runtime", + "outputDir": "src/__generated__/locales", + "localeCodesModule": "src/__generated__/locale-codes.ts" + }, + "interchange": { + "format": "xliff", + "xliffDir": "xliff" + } +} diff --git a/frontend/package.json b/frontend/package.json index 3890e7e4..1b09f9c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,9 @@ "license": "MIT", "private": true, "dependencies": { + "@formatjs/intl-displaynames": "^5.2.5", + "@formatjs/intl-getcanonicallocales": "^1.8.0", + "@lit/localize": "^0.11.1", "@shoelace-style/shoelace": "^2.0.0-beta.61", "axios": "^0.22.0", "lit": "^2.0.0", @@ -14,14 +17,19 @@ }, "scripts": { "test": "web-test-runner \"src/**/*.test.{ts,js}\" --node-resolve --playwright --browsers chromium", + "prebuild": "npm run localize:build", + "prebuild-dev": "npm run localize:build", "build": "webpack --mode production", "build-dev": "webpack --mode development", "start-dev": "webpack serve --mode=development", "lint": "eslint --fix \"src/**/*.{ts,js}\"", - "format": "prettier --write \"**/*.{ts,js,html,css}\"" + "format": "prettier --write \"**/*.{ts,js,html,css}\"", + "localize:extract": "lit-localize extract", + "localize:build": "lit-localize build" }, "devDependencies": { "@esm-bundle/chai": "^4.3.4-fix.0", + "@lit/localize-tools": "^0.5.0", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", "@web/dev-server-esbuild": "^0.2.16", diff --git a/frontend/src/__generated__/locale-codes.ts b/frontend/src/__generated__/locale-codes.ts new file mode 100644 index 00000000..c4896f3e --- /dev/null +++ b/frontend/src/__generated__/locale-codes.ts @@ -0,0 +1,23 @@ +// Do not modify this file by hand! +// Re-generate this file by running lit-localize. + +/** + * The locale code that templates in this source code are written in. + */ +export const sourceLocale = `en`; + +/** + * The other locale codes that this application is localized into. Sorted + * lexicographically. + */ +export const targetLocales = [ + `ko`, +] as const; + +/** + * All valid project locale codes. Sorted lexicographically. + */ +export const allLocales = [ + `en`, + `ko`, +] as const; diff --git a/frontend/src/__generated__/locales/ko.ts b/frontend/src/__generated__/locales/ko.ts new file mode 100644 index 00000000..7f91ea49 --- /dev/null +++ b/frontend/src/__generated__/locales/ko.ts @@ -0,0 +1,17 @@ + + // Do not modify this file by hand! + // Re-generate this file by running lit-localize + + + + + /* eslint-disable no-irregular-whitespace */ + /* eslint-disable @typescript-eslint/no-explicit-any */ + + export const templates = { + 's47d31e4dbe55f7d9': `Browsertrix Cloud`, +'sa03807e44737a915': `로그아웃`, +'sca974356724f8230': `로그인`, +'sd03ac20f93055ed8': `내 계정`, + }; + \ No newline at end of file diff --git a/frontend/src/components/locale-picker.ts b/frontend/src/components/locale-picker.ts new file mode 100644 index 00000000..3cc63e7d --- /dev/null +++ b/frontend/src/components/locale-picker.ts @@ -0,0 +1,90 @@ +import { LitElement, html } from "lit"; +import { shouldPolyfill } from "@formatjs/intl-displaynames/should-polyfill"; + +import { allLocales } from "../__generated__/locale-codes"; +import { getLocale, setLocaleFromUrl } from "../utils/localization"; +import { localized } from "@lit/localize"; + +type LocaleCode = typeof allLocales[number]; +type LocaleNames = { + [L in LocaleCode]: string; +}; + +@localized() +export class LocalePicker extends LitElement { + localeNames?: LocaleNames; + + private setLocaleName = (locale: LocaleCode) => { + this.localeNames![locale] = new Intl.DisplayNames([locale], { + type: "language", + }).of(locale); + }; + + async firstUpdated() { + let isFirstPolyfill = true; + + // Polyfill if needed + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames#browser_compatibility + // TODO actually test if polyfill works in older browser + const polyfill = async (locale: LocaleCode) => { + if (!shouldPolyfill(locale)) { + return; + } + + if (isFirstPolyfill) { + await import("@formatjs/intl-getcanonicallocales/polyfill"); + await import("@formatjs/intl-displaynames/polyfill"); + + isFirstPolyfill = false; + } + + try { + await import("@formatjs/intl-displaynames/locale-data/" + locale); + } catch (e) { + console.debug(e); + } + }; + + await Promise.all( + allLocales.map((locale) => polyfill(locale as LocaleCode)) + ); + + this.localeNames = {} as LocaleNames; + allLocales.forEach(this.setLocaleName); + + this.requestUpdate(); + } + + render() { + if (!this.localeNames) { + return; + } + + const selectedLocale = getLocale(); + + return html` + + ${allLocales.map( + (locale) => + html` + ${this.localeNames![locale]} + ` + )} + + `; + } + + async localeChanged(event: Event) { + const newLocale = (event.target as HTMLSelectElement).value as LocaleCode; + + if (newLocale !== getLocale()) { + const url = new URL(window.location.href); + url.searchParams.set("locale", newLocale); + window.history.pushState(null, "", url.toString()); + setLocaleFromUrl(); + } + } +} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 64268069..352987a1 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,4 +1,7 @@ +import { msg, updateWhenLocaleChanges } from "@lit/localize"; + import "./shoelace"; +import { LocalePicker } from "./components/locale-picker"; import { LogInPage } from "./pages/log-in"; import { MyAccountPage } from "./pages/my-account"; import { ArchivePage } from "./pages/archive-info"; @@ -21,6 +24,12 @@ export class App extends LiteElement { constructor() { super(); + + // Note we use updateWhenLocaleChanges here so that we're always up to date with + // the active locale (the result of getLocale()) when the locale changes via a + // history navigation. + updateWhenLocaleChanges(this); + this.authState = null; const authState = window.localStorage.getItem("authState"); @@ -78,6 +87,9 @@ export class App extends LiteElement { return html` ${this.renderNavBar()}
${this.renderPage()}
+
+ +
`; } @@ -87,10 +99,12 @@ export class App extends LiteElement { ${theme} -
+
Browsertrix Cloud${msg("Browsertrix Cloud")}
@@ -99,20 +113,15 @@ export class App extends LiteElement { class="font-bold px-4" href="/my-account" @click="${this.navLink}" - >My Account${msg("My Account")} ` : html` - + + ${msg("Log In")} + `}
@@ -178,6 +187,7 @@ export class App extends LiteElement { } } +customElements.define("locale-picker", LocalePicker); customElements.define("browsertrix-app", App); customElements.define("log-in", LogInPage); customElements.define("my-account", MyAccountPage); diff --git a/frontend/src/shoelace.ts b/frontend/src/shoelace.ts index 4b9cbfd3..14f2eb19 100644 --- a/frontend/src/shoelace.ts +++ b/frontend/src/shoelace.ts @@ -6,3 +6,6 @@ import "@shoelace-style/shoelace/dist/themes/light.css"; import "@shoelace-style/shoelace/dist/components/button/button"; import "@shoelace-style/shoelace/dist/components/form/form"; import "@shoelace-style/shoelace/dist/components/input/input"; +import "@shoelace-style/shoelace/dist/components/menu/menu"; +import "@shoelace-style/shoelace/dist/components/menu-item/menu-item"; +import "@shoelace-style/shoelace/dist/components/select/select"; diff --git a/frontend/src/utils/localization.ts b/frontend/src/utils/localization.ts new file mode 100644 index 00000000..6cbc07bf --- /dev/null +++ b/frontend/src/utils/localization.ts @@ -0,0 +1,16 @@ +import { configureLocalization } from "@lit/localize"; + +import { sourceLocale, targetLocales } from "../__generated__/locale-codes"; + +export const { getLocale, setLocale } = configureLocalization({ + sourceLocale, + targetLocales, + loadLocale: (locale: string) => + import(`/src/__generated__/locales/${locale}.ts`), +}); + +export const setLocaleFromUrl = async () => { + const url = new URL(window.location.href); + const locale = url.searchParams.get("locale") || sourceLocale; + await setLocale(locale); +}; diff --git a/frontend/xliff/ko.xlf b/frontend/xliff/ko.xlf new file mode 100644 index 00000000..afd91d53 --- /dev/null +++ b/frontend/xliff/ko.xlf @@ -0,0 +1,23 @@ + + + + + + Browsertrix Cloud + Browsertrix Cloud + + + My Account + 내 계정 + + + Log Out + 로그아웃 + + + Log In + 로그인 + + + + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index aaddac0d..bff82423 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -66,6 +66,37 @@ dependencies: "@types/chai" "^4.2.12" +"@formatjs/ecma402-abstract@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.10.0.tgz#f51b9167535c9463113c24644de90262aa5d31a7" + integrity sha512-WNkcUHC6xw12rWY87TUw6KXzb1LnOooYBLLqtyn1kW2j197rcwpqmUOJMBED56YcLzaJPfVw1L2ShiDhL5pVnQ== + dependencies: + "@formatjs/intl-localematcher" "0.2.21" + tslib "^2.1.0" + +"@formatjs/intl-displaynames@^5.2.5": + version "5.2.5" + resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-5.2.5.tgz#c8cb4983a3ce3bdc18d11e22cffc7dad9ceb3050" + integrity sha512-iYlce/hG31ohJOwpv3yhOiEIwEBMqOt2kzA2BQTx1ra8ferBn4WlTxkouoDNiAKEBD1LFYZBIC25jsSJUJOEbg== + dependencies: + "@formatjs/ecma402-abstract" "1.10.0" + "@formatjs/intl-localematcher" "0.2.21" + tslib "^2.1.0" + +"@formatjs/intl-getcanonicallocales@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.8.0.tgz#2987a879f399b2fdf2812e76431f1db8a5b02a64" + integrity sha512-nBwLvOaClSPt4UrvNKHuOf3vgQ8ofZ8jS5TB54bKBw1VKe3Rt/omvze/UhiboWFxs3VCWVHswqikHS5UfUq3SA== + dependencies: + tslib "^2.1.0" + +"@formatjs/intl-localematcher@0.2.21": + version "0.2.21" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.21.tgz#39ef33d701fe8084f3d693cd3ff7cbe03cdd3a49" + integrity sha512-JTJeLiNwexN4Gy0cMxoUPvJbKhXdnSuo5jPrDafEZpnDWlJ5VDYta8zUVVozO/pwzEmFVHEUpgiEDj+39L4oMg== + dependencies: + tslib "^2.1.0" + "@humanwhocodes/config-array@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.6.0.tgz#b5621fdb3b32309d2d16575456cbc277fa8f021a" @@ -90,6 +121,30 @@ resolved "https://registry.yarnpkg.com/@lit-labs/react/-/react-1.0.1.tgz#35f4a8fe12501f79e3973b408e67aa75dcd45ff4" integrity sha512-ShvoOB34Oj0ZkSnlWdGIWzSiEBP1MUY81nC3nAsNoWqbYMS2F/EskGzwSQj7mCKNznUCbmpB272AvSMwejm3Nw== +"@lit/localize-tools@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@lit/localize-tools/-/localize-tools-0.5.0.tgz#766d6fe738d4c8f15995cc379ae93205d21fbe00" + integrity sha512-dO8txYfCGgbIx8zLrsPMRNtMDvgS9T5HzNTBo/BnoO/o8bJvQsjv1oYxkpoFOpl/KjrhPmdrTpfAFoe+JlEyoA== + dependencies: + "@lit/localize" "^0.11.0" + "@xmldom/xmldom" "^0.7.0" + fast-glob "^3.2.7" + fs-extra "^10.0.0" + jsonschema "^1.4.0" + lit "^2.0.0" + minimist "^1.2.5" + parse5 "^6.0.1" + source-map-support "^0.5.19" + typescript "^4.3.5" + +"@lit/localize@^0.11.0", "@lit/localize@^0.11.1": + version "0.11.1" + resolved "https://registry.yarnpkg.com/@lit/localize/-/localize-0.11.1.tgz#eda104ee88ee1f2e820729918b04c560cf3ebf90" + integrity sha512-8LVAZy38QxPAEaz2iKAaaUWuH9Tv2YiQcAcQIC9Cx+L9MG9fBk6yFFQ0QTtZwDE4A8pL4A2w99jRHl3VKUtCaA== + dependencies: + "@lit/reactive-element" "^1.0.0" + lit "^2.0.0" + "@lit/reactive-element@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.0.tgz#7b6e6a85709cda0370c47e425ac2f3b553696a4b" @@ -836,6 +891,11 @@ resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.5.2.tgz#ea584b637ff63c5a477f6f21604b5a205b72c9ec" integrity sha512-vgJ5OLWadI8aKjDlOH3rb+dYyPd2GTZuQC/Tihjct6F9GpXGZINo3Y/IVuZVTM1eDQB+/AOsjPUWH/WySDaXvw== +"@xmldom/xmldom@^0.7.0": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d" + integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -2849,6 +2909,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonschema@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2" + integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw== + keygrip@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" @@ -4176,6 +4241,14 @@ source-map-js@^0.6.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== +source-map-support@^0.5.19: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map-support@~0.5.20: version "0.5.20" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9" @@ -4463,6 +4536,11 @@ tslib@^1.10.0, tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + tsscmp@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" @@ -4500,7 +4578,7 @@ type-is@^1.6.16, type-is@~1.6.17, type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typescript@^4.5.2: +typescript@^4.3.5, typescript@^4.5.2: version "4.5.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==