Add frontend localization support (#18)

closes #17
This commit is contained in:
sua yoo 2021-11-20 07:44:21 -08:00 committed by GitHub
parent 76e5ceb864
commit 14f2d13a73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 348 additions and 22 deletions

View File

@ -27,14 +27,16 @@ follow instructions for deploying to a local Docker instance. Update `API_BASE_U
## Scripts
| `yarn <name>` | |
| ------------- | ------------------------------------------------------------------- |
| `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 <name>` | |
| ------------------ | ------------------------------------------------------------------- |
| `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`
<button>
${msg("Click me")}
</button>
`
}
```
Entire templates can be wrapped as well:
```js
render() {
return msg(html`
<p>Click the button</p>
<button>Click me</button>
`)
}
```
See: <https://lit.dev/docs/localization/overview/#message-types>
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: <https://lit.dev/docs/localization/overview/#extracting-messages>

View File

@ -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"
}
}

View File

@ -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",

View File

@ -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;

17
frontend/src/__generated__/locales/ko.ts generated Normal file
View File

@ -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': `내 계정`,
};

View File

@ -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`
<sl-select value=${selectedLocale} @sl-change=${this.localeChanged}>
${allLocales.map(
(locale) =>
html`<sl-menu-item
value=${locale}
?selected=${locale === selectedLocale}
>
${this.localeNames![locale]}
</sl-menu-item>`
)}
</sl-select>
`;
}
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();
}
}
}

View File

@ -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()}
<div class="w-full h-full px-12 py-12">${this.renderPage()}</div>
<footer class="flex justify-center p-4">
<locale-picker></locale-picker>
</footer>
`;
}
@ -87,10 +99,12 @@ export class App extends LiteElement {
${theme}
</style>
<div class="flex p-3 shadow-lg bg-white text-neutral-content">
<div
class="flex p-2 items-center shadow-lg bg-white text-neutral-content"
>
<div class="flex-1 px-2 mx-2">
<a href="/" class="text-lg font-bold" @click="${this.navLink}"
>Browsertrix Cloud</a
>${msg("Browsertrix Cloud")}</a
>
</div>
<div class="flex-none">
@ -99,20 +113,15 @@ export class App extends LiteElement {
class="font-bold px-4"
href="/my-account"
@click="${this.navLink}"
>My Account</a
>${msg("My Account")}</a
>
<button class="btn btn-error" @click="${this.onLogOut}">
Log Out
${msg("Log Out")}
</button>`
: html`
<button
class="btn ${this.viewState._route !== "login"
? "btn-primary"
: "btn-ghost"}"
@click="${this.onNeedLogin}"
>
Log In
</button>
<sl-button type="primary" @click="${this.onNeedLogin}">
${msg("Log In")}
</sl-button>
`}
</div>
</div>
@ -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);

View File

@ -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";

View File

@ -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);
};

23
frontend/xliff/ko.xlf Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="ko" source-language="en" original="lit-localize-inputs" datatype="plaintext">
<body>
<trans-unit id="s47d31e4dbe55f7d9">
<source>Browsertrix Cloud</source>
<target>Browsertrix Cloud</target>
</trans-unit>
<trans-unit id="sd03ac20f93055ed8">
<source>My Account</source>
<target>내 계정</target>
</trans-unit>
<trans-unit id="sa03807e44737a915">
<source>Log Out</source>
<target>로그아웃</target>
</trans-unit>
<trans-unit id="sca974356724f8230">
<source>Log In</source>
<target>로그인</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@ -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==