From cb3f239ec6d6a9afbb6ac22d2a151d7b8b2e7985 Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Tue, 14 Nov 2023 15:12:03 -0500 Subject: [PATCH] Use Spanish translations in the UI ## Description Finally we're here: you can just set `language="es"` on the calculator element and it'll be in Spanish. Getting the strings out of the XLIFF file and into code requires running `lit-localize build`. Rather than making people do that manually, I wrote a Parcel resolver that runs the command behind the scenes. Overengineered? Maybe! But I learned a bunch about Parcel! Note about switching `language` dynamically. Apart from the problem I noted in the comment on the `language` attribute (text that came back from the API will not change language until the next API fetch), there's another problem, with the Shoelace select elements: the text shown in them won't change until you make a new selection in the element. I _think_ it may be related to [this](https://github.com/shoelace-style/shoelace/issues/1570); in any case, once all the immediate i18n work is wrapped up I may try to isolate the issue and file an issue with them if it's not the same bug. With this bug, dynamically setting the attribute _on page load_ will leave a couple of untranslated strings, and I think that's a use case we do want to support. ## Test Plan Add a `language="es"` attribute to the main element in `rhode-island.html`, and make sure the UI shows up in Spanish. Query for incentives; make sure the program names of federal incentives show up in Spanish. (Those are the only thing localized on the backend right now.) --- .gitignore | 3 ++ .parcelrc | 3 +- scripts/parcel-resolver-locales.mjs | 44 +++++++++++++++++++++++++++++ src/parcel.d.ts | 14 +++++++++ src/state-calculator.ts | 35 ++++++++++++++++++++++- 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 scripts/parcel-resolver-locales.mjs diff --git a/.gitignore b/.gitignore index 8db0cde..c407b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ yarn-error.log* # cypress cypress/videos/*.mp4 cypress/screenshots/* + +# lit-localize generated +generated diff --git a/.parcelrc b/.parcelrc index 55a9ca4..c03b736 100644 --- a/.parcelrc +++ b/.parcelrc @@ -1,4 +1,5 @@ { "extends": ["@parcel/config-default"], - "reporters": ["...", "parcel-reporter-static-files-copy"] + "resolvers": ["./scripts/parcel-resolver-locales.mjs", "..."], + "reporters": ["...", "parcel-reporter-static-files-copy"] } \ No newline at end of file diff --git a/scripts/parcel-resolver-locales.mjs b/scripts/parcel-resolver-locales.mjs new file mode 100644 index 0000000..155127d --- /dev/null +++ b/scripts/parcel-resolver-locales.mjs @@ -0,0 +1,44 @@ +import { Resolver } from '@parcel/plugin'; +import { exec } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +async function allXlfFiles(projectRoot) { + const entries = await promisify(fs.readdir)( + path.join(projectRoot, 'translations'), + ); + return entries.map(entry => path.join(projectRoot, 'translations', entry)); +} + +/** + * Resolves import specifiers starting with `locales:` by running lit-localize + * to generate strings files, and pointing Parcel at the generated files. + * + * NB: this is not a Typescript file! (Parcel doesn't support plugins written + * in TS.) No type checking! + */ +export default new Resolver({ + async resolve({ specifier, options: { projectRoot } }) { + if (specifier.startsWith('locales:')) { + await promisify(exec)('npx lit-localize build'); + + const locale = specifier.substring('locales:'.length); + const filePath = + locale === 'config' + ? path.join(projectRoot, 'generated/locales.ts') + : path.join(projectRoot, `generated/strings/${locale}.ts`); + + return { + // Rebuild if an XLIFF file changes, or the lit-localize config. + invalidateOnFileChange: [ + ...(await allXlfFiles(projectRoot)), + path.join(projectRoot, 'lit-localize.json'), + ], + filePath, + }; + } else { + return null; + } + }, +}); diff --git a/src/parcel.d.ts b/src/parcel.d.ts index 65725b7..a0a788e 100644 --- a/src/parcel.d.ts +++ b/src/parcel.d.ts @@ -2,3 +2,17 @@ declare module 'bundle-text:*' { const value: string; export default value; } + +/** + * We use the magic "locales" scheme to import files that are generated by + * lit-localize. Running lit-localize and resolving these magic specifiers + * happen in scripts/parcel-resolver-locale.mjs. + */ +declare module 'locales:config' { + export const sourceLocale: string; + export const targetLocales: string[]; +} +declare module 'locales:*' { + import { TemplateMap } from '@lit/localize'; + export const templates: TemplateMap; +} diff --git a/src/state-calculator.ts b/src/state-calculator.ts index 6851e38..efea9db 100644 --- a/src/state-calculator.ts +++ b/src/state-calculator.ts @@ -27,7 +27,20 @@ import { submitEmailSignup, wasEmailSubmitted } from './email-signup'; import SlSelect from '@shoelace-style/shoelace/dist/components/select/select'; import { safeLocalStorage } from './safe-local-storage'; import scrollIntoView from 'scroll-into-view-if-needed'; -import { localized, msg, str } from '@lit/localize'; +import { configureLocalization, localized, msg, str } from '@lit/localize'; +import { sourceLocale, targetLocales } from 'locales:config'; + +// See scripts/parcel-resolver-locale.mjs for how this import is resolved. +const { setLocale } = configureLocalization({ + sourceLocale, + targetLocales, + loadLocale: locale => + locale === 'es' + ? import('locales:es') + : (() => { + throw new Error(`unknown locale ${locale}`); + })(), +}); const loadingTemplate = () => html`
@@ -147,6 +160,15 @@ export class RewiringAmericaStateCalculator extends LitElement { formTitleStyles, ]; + /** + * Property to control display language. Changing this dynamically is not + * supported: UI labels and such will change immediately, but user-visible + * text that came from API responses will not change until the next API + * fetch completes. + */ + @property({ type: String, attribute: 'language' }) + language: string = 'en'; + /* supported properties to control showing/hiding of each card in the widget */ @property({ type: Boolean, attribute: 'hide-form' }) @@ -308,6 +330,15 @@ export class RewiringAmericaStateCalculator extends LitElement { this.initFormProperties(); } + /** + * Make sure the locale is set before rendering begins. setLocale() is async + * and this is the only async part of the component lifecycle we can hook. + */ + protected override async scheduleUpdate(): Promise { + await setLocale(this.language); + super.scheduleUpdate(); + } + override async updated() { await new Promise(r => setTimeout(r, 0)); if (!this.renderRoot) { @@ -357,6 +388,7 @@ export class RewiringAmericaStateCalculator extends LitElement { autoRun: false, task: async () => { const query = new URLSearchParams({ + language: this.language, 'location[zip]': this.zip, }); @@ -403,6 +435,7 @@ export class RewiringAmericaStateCalculator extends LitElement { } const query = new URLSearchParams({ + language: this.language, 'location[zip]': this.zip, owner_status: this.ownerStatus, household_income: this.householdIncome,