Skip to content

Commit

Permalink
Use Spanish translations in the UI (#66)
Browse files Browse the repository at this point in the history
## Description

Finally we're here: you can just set `lang="es"` on the calculator
element (or any ancestor 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 `lang` dynamically. Apart from the problem I
noted in the comment on the `lang` 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](shoelace-style/shoelace#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 `lang="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.)
  • Loading branch information
oyamauchi committed Nov 16, 2023
1 parent 1e43d35 commit 9a8f240
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 4 deletions.
3 changes: 2 additions & 1 deletion .parcelrc
Original file line number Diff line number Diff line change
@@ -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"]
}
4 changes: 2 additions & 2 deletions lit-localize.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"tsConfig": "./tsconfig.json",
"output": {
"mode": "runtime",
"localeCodesModule": "generated/locales.ts",
"outputDir": "generated/strings"
"localeCodesModule": ".parcel-cache/locales.ts",
"outputDir": ".parcel-cache/strings"
},
"interchange": {
"format": "xliff",
Expand Down
57 changes: 57 additions & 0 deletions scripts/parcel-resolver-locales.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.
*
* The generated files are in Parcel's cache directory (configured in
* lit-localize.json) so that they don't trigger Parcel's watcher, which could
* result in an infinite loop of rebuilding. Yes this is silly, but Parcel does
* not have an official way to make the watcher ignore some 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:')) {
const locale = specifier.substring('locales:'.length);

const litConfig = JSON.parse(
await promisify(fs.readFile)(
path.join(projectRoot, 'lit-localize.json'),
'utf-8',
),
);

const filePath =
locale === 'config'
? path.join(projectRoot, litConfig.output.localeCodesModule)
: path.join(projectRoot, litConfig.output.outputDir, `${locale}.ts`);

await promisify(exec)('npx lit-localize build\n');

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;
}
},
});
15 changes: 15 additions & 0 deletions src/parcel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,18 @@ 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[];
export const allLocales: string[];
}
declare module 'locales:*' {
import { TemplateMap } from '@lit/localize';
export const templates: TemplateMap;
}
42 changes: 41 additions & 1 deletion src/state-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, allLocales } 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`
<div class="card card-content">
Expand Down Expand Up @@ -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: 'lang' })
override lang: string = this.getDefaultLanguage();

/* supported properties to control showing/hiding of each card in the widget */

@property({ type: Boolean, attribute: 'hide-form' })
Expand Down Expand Up @@ -216,6 +238,13 @@ export class RewiringAmericaStateCalculator extends LitElement {
*/
lastLoadFrom: 'calculate' | 'utility-selector' = 'calculate';

private getDefaultLanguage() {
const closestLang =
(this.closest('[lang]') as HTMLElement | null)?.lang?.split('-')?.[0] ??
'';
return allLocales.includes(closestLang) ? closestLang : 'en';
}

/**
* Called when the component is added to the DOM. At this point the values of
* the HTML attributes are available, so we can initialize the properties
Expand Down Expand Up @@ -308,6 +337,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<void> {
await setLocale(this.lang);
super.scheduleUpdate();
}

override async updated() {
await new Promise(r => setTimeout(r, 0));
if (!this.renderRoot) {
Expand Down Expand Up @@ -357,6 +395,7 @@ export class RewiringAmericaStateCalculator extends LitElement {
autoRun: false,
task: async () => {
const query = new URLSearchParams({
language: this.lang,
'location[zip]': this.zip,
});

Expand Down Expand Up @@ -403,6 +442,7 @@ export class RewiringAmericaStateCalculator extends LitElement {
}

const query = new URLSearchParams({
language: this.lang,
'location[zip]': this.zip,
owner_status: this.ownerStatus,
household_income: this.householdIncome,
Expand Down

1 comment on commit 9a8f240

@vercel
Copy link

@vercel vercel bot commented on 9a8f240 Nov 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.