Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I dynamically import locales in Angular 9?

I'm attempting to dynamically import locales in an Angular 9 (monorepo-based) app. I'm doing something like the following:

import { Injectable } from '@angular/core';
import { registerLocaleData } from '@angular/common';

@Injectable()
export class LocaleService {
    ...

    private capitalize(str: string): string {
        return str.charAt[0].toUpperCase() + str.substring(1).toLowerCase();
    }

    registerLocales() {
        for (const lang of ['de', 'fr', 'es']) {
            const basePkg = `locale${this.capitalize(lang)}`;
            const extraPkg = basePkg + 'Extra';

            const base = import(`@angular/common/locales/${lang}`).then(m => m[basePkg]);
            const extra = import(`@angular/common/locales/extra/${lang}`).then(m => m[extraPkg]);

            registerLocaleData(base, extra);
        }
    }
}

instead of:

import { Injectable } from '@angular/core';
import { registerLocaleData } from '@angular/common';

import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import localeEs from '@angular/common/locales/es';
import localeEsExtra from '@angular/common/locales/extra/es';
import localeFr from '@angular/common/locales/fr';
import localeFrExtra from '@angular/common/locales/extra/fr';

@Injectable()
export class LocaleService {
    ...

    registerLocales() {
        registerLocaleData(localeDe, localeDeExtra);
        registerLocaleData(localeEs, localeEsExtra);
        registerLocaleData(localeFr, localeFrExtra);
    }
}

Within this code even executing, I'm getting a boatload of errors caused by the imports of the form:

WARNING in /home/me/somerepo/node_modules/@angular/common/locales/zu.d.ts Module build failed (from /home/me/somerepo/node_modules/@ngtools/webpack/src/index.js): Error: /home/me/somerepo/node_modules/@angular/common/locales/zu.d.ts is missing from the TypeScript compilation. Please make sure it is in your tsconfig via the 'files' or 'include' property.

Commenting out the imports and call to registerLocaleData eliminates the error. What on earth am I doing wrong here?

like image 716
Scott Deerwester Avatar asked Apr 04 '20 22:04

Scott Deerwester


2 Answers

The excellent article mentioned by Eliseo's comment has the answer. Typescript's import function is not an ordinary function call. In short, what's happening here is that import tells Webpack to create chunks for everything that matches the pattern in the argument. This is a problem, because the pattern matches all of the .d.ts files in the locales directory, while we actually only want the .js files. The solution is to use Webpack's "magic comments". The following is enough to get everything loading properly:

const base = import(
  /* webpackExclude: /\.d\.ts$/ */
  `@angular/common/locales/${key}`).then(m => m[basePkg]);

const extra = import(
  /* webpackExclude: /\.d\.ts$/ */
  `@angular/common/locales/extra/${key}`).then(m => m[extraPkg]);

But... there are a couple of problems.

  1. Every locale is turned into a chunk. That creates over 1,000 chunks. Ouch.

  2. The chunks are just given numbers as names.

Magic comments to the rescue again:

const base = import(
  /* webpackExclude: /\.d\.ts$/ */
  /* webpackMode: "lazy-once" */
  /* webpackChunkName: "i18n-base" */
  `@angular/common/locales/${key}`).then(m => m[basePkg]);

const extra = import(
  /* webpackExclude: /\.d\.ts$/ */
  /* webpackMode: "lazy-once" */
  /* webpackChunkName: "i18n-extra" */
  `@angular/common/locales/extra/${key}`).then(m => m[extraPkg]);

This gets closer, creating two chunks instead of thousands, but they're big. If we know what locales we're interested in, we can do a lot better. Here's the final version:

const base = import(
  /* webpackInclude: /(de|en|es|fr|it|nl|no|pl|pt-BR|pt|fi|sv|ko|ru|zh|zh-Hans|zh-Hant|ja)\.js/ */
  /* webpackMode: "lazy-once" */
  /* webpackChunkName: "i18n-base" */
  `@angular/common/locales/${key}`).then(m => m[basePkg]);

const extra = import(
  /* webpackInclude: /(de|en|es|fr|it|nl|no|pl|pt-BR|pt|fi|sv|ko|ru|zh|zh-Hans|zh-Hant|ja)\.js/ */
  /* webpackMode: "lazy-once" */
  /* webpackChunkName: "i18n-extra" */
  `@angular/common/locales/extra/${key}`).then(m => m[extraPkg]);

This changes the logic from specifying which files to ignore to specifying which files to load. It results in chunks of around 100Kb instead of 6Mb.

like image 78
Scott Deerwester Avatar answered Sep 21 '22 01:09

Scott Deerwester


I took a different approach and decided to use @ngx-translate to manage the translations and the @ngx-translate/http-loader to dynamically load the translations from a JSON file on app load. This will keep the build size smaller because it doesn't build/bundle the translations. It just copies over the translated json files as assets

The folder structure looks like this:

src
├── app
│   ├── ...
│   ├── app.component.html
│   ├── app.component.ts
│   └── app.modules.ts
├── environments
│   └── environment.ts
└── i18l
    ├── en-us.json
    ├── es.json
    └── ...

Add the src/i18l/ directory to assets in angular.json:

{
  "projects": {
    "your-app": {
      ...
      "architect": {
        ...
        "build": {
          ...
          "options": {
            ...
            "assets": [
              "src/favicon.ico",
              "src/assets",
              "src/i18l" // <-- add this
            ]
          },
        }
      }
    }
  }
}

Setup the translation module in app.module.ts

// other imports 
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

// AoT requires an exported function for factories
export function HttpLoaderFactory (http: HttpClient) {
  // this tells the translation service what path to fetch the translation from
  return new TranslateHttpLoader(http, 'i18l/', '.json');
}

@NgModule({
  declarations: [...],
  imports: [
    // other imports
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      }
    }),
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

I like to keep a list of available translations in my environments/enviornment.ts file:

export const environment = {
  production: false,
  availableTranslations: [
    'en-us',
    'es'
  ]
};

Then you need to select and load the translation at some point on app load. For simplicity, this is an example in app.component.ts

// other imports
import { TranslateService } from '@ngx-translate/core';
import { environment } from '../environments/environment';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  localeForm: FormGroup;
  locales: string[];
  translationLoaded: boolean = false;

  constructor (
    private translateService: TranslateService
  ) { }

  ngOnInit () {
    this.locales = environment.availableTranslations.slice(); //slice to create a copy
    // just a basic form for the user to select a translation
    this.localeForm = new FormGroup({
      locale: new FormControl('en-us', {
        updateOn: 'blur',
        validators: [Validators.required]
      })
    });
  }

  async submitForm (): void {
    // await the http request for the translation file
    await this.translateService.use(this.localeForm.value.locale).toPromise();
    this.translationLoaded = true;
  }
}

Create a basic form for the user to select the translation in app.component.html

<!-- if we have loaded a translation, display the app -->
<ng-container *ngIf="translationLoaded">
  <router-outlet></router-outlet>
</ng-container>

<!-- if we haven't loaded the translation, show the translation picker -->
<ng-container *ngIf="!translationLoaded">
  You need to select a language

  <form [formGroup]="localeForm" (ngSubmit)="submitForm()">
    <label for="locale">Select a Language</label>
    <select name="locale" id="locale" formControlName="locale">
      <option *ngFor="let loc of locales" id="{{loc}}" value="{{loc}}">
        {{loc}}
      </option>
    </select>

    <label for="useLocale">Use Language</label>
    <button name="useLocale" type="submit" [disabled]="!localeForm.valid">Select</button>
  </form>

</ng-container>

Set up the translation form and app initialization however you would like. That is just a simple example. Then you can follow the documentation on how to use the translation service throughout your app.

I've never worked with @angular/common/locale. I realize this may not be an exact fix for the webpack issue. Hopefully it could help others if they are looking for translation solutions.

like image 44
DJ House Avatar answered Sep 20 '22 01:09

DJ House