gilsdav / ngx-translate-router

Translate routes using ngx-translate
131 stars 43 forks source link

ngx-translate-router

An implementation of routes localization for Angular.

Fork of localize-router.

Based on and extension of ngx-translate.

Version to choose :

angular version translate-router http-loader type remarks
6 - 7 1.0.2 1.0.1 legacy
7 1.7.3 1.1.0 legacy
8 2.2.3 1.1.0 legacy
8 - 12 3.1.9 1.1.2 active
13 4.0.1 2.0.0 active
14 5.1.1 2.0.0 active need rxjs 7 or higher
15 6.0.0 2.0.0 active minimum angular 15.0.3
15.1 6.1.0 2.0.0 active minimum angular 15.1.0
16 7.0.0 2.0.0 active minimum angular 16
17 7.1.0 2.0.0 active optional standalone API
18 7.2.1 2.0.0 active

Demo project can be found under sub folder src.

This documentation is for version 1.x.x which requires Angular 6+. If you are migrating from the older version follow migration guide to upgrade to latest version.

Table of contents:

Installation

npm install --save @gilsdav/ngx-translate-router

Usage

In order to use @gilsdav/ngx-translate-router you must initialize it with following information:

Initialize "module"

Module mode

import {LocalizeRouterModule} from '@gilsdav/ngx-translate-router'; Module can be initialized either using static file or manually by passing necessary values.

Be careful to import this module after the standard RouterModule and the TranslateModule. This should be done for the main router as well as for lazy loaded ones.

imports: [
  TranslateModule.forRoot(),
  RouterModule.forRoot(routes),
  LocalizeRouterModule.forRoot(routes) // <--
]

Standalone mode

Standalone mode is the new Angular API that allow you to manage your application without ng-modules.

This library provide an additional "RouterConfigurationFeature" called withLocalizeRouter you can provide to provideRouter Angular built-in function. Parameters for this function are exactly the same as for LocalizeRouterModule.forRoot().

Here is an example to configure it within an SSR app:

providers: [
  provideHttpClient(withFetch()),
  importProvidersFrom(TranslateModule.forRoot()),
  provideRouter(
    routes,
    withDisabledInitialNavigation(),
    withLocalizeRouter(routes, { // <--
      parser: {
        provide: LocalizeParser,
        useFactory: (createTranslateRouteLoader),
        deps: [TranslateService, Location, LocalizeRouterSettings]
      },
      initialNavigation: true
    })
  ),
  provideClientHydration()
]

You are also able to import LocalizeRouterPipe into your standalone components.

Http loader

In order to use Http loader for config files, you must include @gilsdav/ngx-translate-router-http-loader package and use its LocalizeRouterHttpLoader.

import {BrowserModule} from "@angular/platform-browser";
import {NgModule} from '@angular/core';
import {Location} from '@angular/common';
import {HttpClientModule, HttpClient} from '@angular/common/http';
import {TranslateModule} from '@ngx-translate/core';
import {LocalizeRouterModule} from '@gilsdav/ngx-translate-router';
import {LocalizeRouterHttpLoader} from '@gilsdav/ngx-translate-router-http-loader';
import {RouterModule} from '@angular/router';

import {routes} from './app.routes';

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    TranslateModule.forRoot(),
    RouterModule.forRoot(routes),
    LocalizeRouterModule.forRoot(routes, {
      parser: {
        provide: LocalizeParser,
        useFactory: (translate, location, settings, http) =>
            new LocalizeRouterHttpLoader(translate, location, settings, http),
        deps: [TranslateService, Location, LocalizeRouterSettings, HttpClient]
      }
    })
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

More details are available on localize-router-http-loader.

If you are using child modules or routes you need to initialize them with forChild command:

@NgModule({
  imports: [
    TranslateModule,
    RouterModule.forChild(routes),
    LocalizeRouterModule.forChild(routes)
  ],
  declarations: [ChildComponent]
})
export class ChildModule { }

Manual initialization

With manual initialization you need to provide information directly:

   LocalizeRouterModule.forRoot(routes, {
       parser: {
           provide: LocalizeParser,
           useFactory: (translate, location, settings) =>
               new ManualParserLoader(translate, location, settings, ['en','de',...], 'YOUR_PREFIX'),
           deps: [TranslateService, Location, LocalizeRouterSettings]
       }
   })

Initialization config

Apart from providing routes which are mandatory, and parser loader you can provide additional configuration for more granular setting of @gilsdav/ngx-translate-router. More information at LocalizeRouterConfig.

Server side

In order to use @gilsdav/ngx-translate-router in Angular universal application (SSR) you need to:

  1. Initialize the module
  2. In case you opted for initializing with Http loader, you need to take care of static file location. @gilsdav/ngx-translate-router-http-loader by default will try loading the config file from assets/locales.json. This is a relative path which won't work with SSR. You could use one of the following approaches,

    1. Creating a factory function to override the default location with an absolute URL

      export function localizeLoaderFactory(translate: TranslateService, location: Location, settings: LocalizeRouterSettings, http: HttpClient) {
        return new LocalizeRouterHttpLoader(translate, location, settings, http, 'http://example.com/assets/locales.json');
      }
      
      LocalizeRouterModule.forRoot(routes, {
        parser: {
          provide: LocalizeParser,
          useFactory: localizeLoaderFactory,
          deps: [TranslateService, Location, LocalizeRouterSettings, HttpClient]
        }
      })
    2. Using an HTTP interceptor in your server.module to convert relative paths to absolute ons, ex:

      intercept(request: HttpRequest<any>, next: HttpHandler) {
        if (request.url.startsWith('assets') && isPlatformServer(this.platformId)) {
          const req = this.injector.get(REQUEST);
          const url = req.protocol + '://' + req.get('host') + '/' + request.url;
          request = request.clone({
            url: url
          });
        }
        return next.handle(request);
      }
      
  3. Let node server knows about the new routes:

    let fs = require('fs');
    let data: any = JSON.parse(fs.readFileSync(`src/assets/locales.json`, 'utf8'));
    
    app.get('/', ngApp);
    data.locales.forEach(route => {
      app.get(`/${route}`, ngApp);
      app.get(`/${route}/*`, ngApp);
    });
  4. In case you want to use cacheMechanism = CacheMechanism.Cookie you will need to handle the cookie in your node server. Something like,

    app.use(cookieParser());
    
    app.get('/', (req, res) => {
      const defaultLang = 'de';
      const lang = req.acceptsLanguages('de', 'en');
      const cookieLang = req.cookies.LOCALIZE_DEFAULT_LANGUAGE; // This is the default name of cookie
    
      const definedLang = cookieLang || lang || defaultLang;
    
      res.redirect(301, `/${definedLang}/`);
    });

Gotchas

Deal with initialNavigation

When you add Universal into your app you will have initialNavigation set to "enabled". This is to avoid the flickering of the lazy-load.

Unfortunatly it doesn't help with this library and can cause issues. So you need to set it to "disabled" and add the ngx-translate-router option initialNavigation: true to have this desired behavior.

imports: [
  RouterModule.forRoot(routes, { initialNavigation: 'disabled' }),
  LocalizeRouterModule.forRoot(routes, {
    ...
    initialNavigation: true
  })
]

How it works

@gilsdav/ngx-translate-router intercepts Router initialization and translates each path and redirectTo path of Routes. The translation process is done with ngx-translate. In order to separate router translations from normal application translations we use prefix. Default value for prefix is ROUTES.. Finally, in order to avoid accidentally translating a URL segment that should not be translated, you can optionally use escapePrefix so the prefix gets stripped and the segment doesn't get translated. Default escapePrefix is unset.

'home' -> 'ROUTES.home'

Example to escape the translation of the segment with escapePrefix: '!'

'!segment' -> 'segment'
{ path: '!home/first' ... } -> '/fr/home/premier'

Upon every route change @gilsdav/ngx-translate-router kicks in to check if there was a change to language. Translated routes are prepended with two letter language code:

http://yourpath/home -> http://yourpath/en/home

If no language is provided in the url path, application uses:

Make sure you therefore place most common language (e.g. 'en') as a first string in the array of locales.

Note that ngx-translate-router does not redirect routes like my/route to translated ones e.g. en/my/route. All routes are prepended by currently selected language so route without language is unknown to Router.

Excluding routes

Sometimes you might have a need to have certain routes excluded from the localization process e.g. login page, registration page etc. This is possible by setting flag skipRouteLocalization on route's data object.

In case you want to redirect to an url when skipRouteLocalization is activated, you can also provide config option localizeRedirectTo to skip route localization but localize redirect to. Otherwise, route and redirectTo will not be translated.

let routes = [
  // this route gets localized
  { path: 'home', component: HomeComponent },
  // this route will not be localized
  { path: 'login', component: LoginComponent, data: { skipRouteLocalization: true } }
    // this route will not be localized, but redirect to will do
  { path: 'logout', redirectTo: 'login', data: { skipRouteLocalization: { localizeRedirectTo: true } } }
];

Note that this flag should only be set on root routes. By excluding root route, all its sub routes are automatically excluded. Setting this flag on sub route has no effect as parent route would already have or have not language prefix.

ngx-translate integration

LocalizeRouter depends on ngx-translate core service and automatically initializes it with selected locales. Following code is run on LocalizeParser init:

this.translate.setDefaultLang(cachedLanguage || languageOfBrowser || firstLanguageFromConfig);
// ...
this.translate.use(languageFromUrl || cachedLanguage || languageOfBrowser || firstLanguageFromConfig);

Both languageOfBrowser and languageFromUrl are cross-checked with locales from config.

Path discrimination

Do you use same path to load multiple lazy-loaded modules and you have wrong component tree ? discriminantPathKey will help ngx-translate-router to generate good component tree.

  {
    path: '',
    loadChildren: () => import('app/home/home.module').then(m => m.HomeModule),
    data: {
        discriminantPathKey: 'HOMEPATH'
    }
  },
  {
    path: '',
    loadChildren: () => import('app/information/information.module').then(m => m.InformationModule),
    data: {
        discriminantPathKey: 'INFOPATH'
    }
  }

WildCard Path

Favored way

The favored way to use WildCard ( '**' path ) is to use the redirectTo. It will let the user to translate the "not found" page message.

{
  path: '404',
  component: NotFoundComponent
},
{
  path: '**',
  redirectTo: '/404'
}
Alternative

If you need to keep the wrong url you will face to a limitation: You can not translate current page. This limitation is because we can not determine the language from a wrong url.

{
  path: '**',
  component: NotFoundComponent
}

Matcher params translation

Configure routes

In case you want to translate some params of matcher, localizeMatcher provides you the way to do it through a function per each param. Make sure that the key is the same as the one used in the navigate path (example: if the function returns "map", it must be contained in the not localized path: [routerLink]="['/matcher', 'aaa', 'map'] | localize") otherwise you will not be able to use routerLinkActiveOptions.

Example:

{
  path: 'matcher',
  children: [
    {
      matcher: detailMatcher,
      loadChildren: () => import('./matcher/matcher-detail/matcher-detail.module').then(mod => mod.MatcherDetailModule)
    },
    {
      matcher: baseMatcher,
      loadChildren: () => import('./matcher/matcher.module').then(mod => mod.MatcherModule),
      data: {
        localizeMatcher: {
          params: {
            mapPage: shouldTranslateMap
          }
        }
      }
    }
  ]
}

...

export function shouldTranslateMap(param: string): string {
  if (isNaN(+param)) {
    return 'map';
  }
  return null;
}

The output of the function should be falsy if the param must not be translated or should return the key (without prefix) you want to use when translating if you want to translate the param.

Notice that any function that you use in localizeMatcher must be exported to be compatible with AOT.

Small changes to your matcher

We work with UrlSegment to split URL into "params" in basic UrlMatchResult but there is not enough information to apply the translations.

You must use the LocalizedMatcherUrlSegment type to more strongly associate a segment with a parameter. It contains only the localizedParamName attribute in addition to basic UrlSegment. Set this attribute before adding the segment into consumed andposParams.

const result: UrlMatchResult = {
  consumed: [],
  posParams: { }
};

...

(segment as LocalizedMatcherUrlSegment).localizedParamName = name;
result.consumed.push(segment);
result.posParams[name] = segment;
Matcher params translated without localizeMatcher issue

If the URL is accidentally translated from a language to another which creates an inconsistent state you have to enable escapePrefix mechanism. (example: escapePrefix: '!')

RedirectTo with function

Starting in Angular 18 introduced redirectTo as a function in addition to a string. If you use this feature, you can use the redirectTo function to translate the path. However, the library translates the entire router at the start of the application statically, which means that the translate function has no yet the navigation context of the user in that moment as it should be evaluated dynamically once the user navigates to such route. If you want to use this feature, you can use the LocalizeRouterService to translate the path injecting the service as in this example.

{
  path: 'conditionalRedirectTo', redirectTo: ({ queryParams }) => {
    const localizeRouterService = inject(LocalizeRouterService);
    if (queryParams['redirect']) {
      return localizeRouterService.translateRoute('/test') as string;
    }
    return localizeRouterService.translateRoute('/home') as string;
  }
}

Pipe

LocalizeRouterPipe is used to translate routerLink directive's content. Pipe can be appended to partial strings in the routerLink's definition or to entire array element:

<a [routerLink]="['user', userId, 'profile'] | localize">{{'USER_PROFILE' | translate}}</a>
<a [routerLink]="['about' | localize]">{{'ABOUT' | translate}}</a>

Root routes work the same way with addition that in case of root links, link is prepended by language. Example for german language and link to 'about':

'/about' | localize -> '/de/über'

Service

Routes can be manually translated using LocalizeRouterService. This is important if you want to use router.navigate for dynamical routes.

class MyComponent {
    constructor(private localize: LocalizeRouterService) { }

    myMethod() {
        let translatedPath: any = this.localize.translateRoute('about/me');

        // do something with translated path
        // e.g. this.router.navigate([translatedPath]);
    }
}

AOT

In order to use Ahead-Of-Time compilation any custom loaders must be exported as functions. This is the implementation currently in the solution:

export function localizeLoaderFactory(translate: TranslateService, location: Location, http: Http) {
  return new StaticParserLoader(translate, location, http);
}

API

LocalizeRouterModule

Methods:

Usage example:

export const appInitializerFactory = (injector: Injector) => {
  return () => {
    const localize = injector.get(LocalizeRouterService);
    return firstValueFrom(
      localize.hooks.initialized
        .pipe(
          tap(() => {
            const router = injector.get(Router);
            router.events.pipe(
              filter(url => url instanceof NavigationEnd),
              first()
            ).subscribe((route: NavigationEnd) => {
              console.log(router.url, route.url);
              router.navigate(['/fr/accueil']);
            });
          })
        )
      )
  }
};

LocalizeParser

Properties:

Methods:

License

Licensed under MIT

Thanks

Thanks to all our contributors

As well as to all the contributors of the initial project

Made with contributors-img.