@my-ul/tod-angular-client
TypeScript icon, indicating that this package has built-in type declarations

14.0.0 • Public • Published

Translation on Demand Angular Client

Introduction

This is an Angular library containing tools for working with Translation on Demand servers. Translation on Demand refers to a low-latency client-server technique for retrieving trimmed translation dictionaries that load quickly and can be switched easily and at runtime.

Expectations

What this library does:

  • Provides a management mechanism for setting locale.
  • Provides durable, lightweight mechanisms for changing translations at runtime.

This library does NOT:

  • Implement or provide a language switcher UI. Language/locale switching is limited to the setLocale(locale) function.
  • Enumerate or validate locales available on the ToD server. As a result, the library also does not preload locale dictionaries.
  • Persist locale selection in cookies, local storage or session storage.
  • Cache, as ToD is intended to obtain its performance from server-side optimizations.

Installation

npm install --save @my-ul/tod-angular-client@14.0.0 \

Change Log

1.2.0

  • Support for prefix and suffix for request truncation was removed, as the feature was not being used.
  • Added a one-time TranslationService.getLabels(labelKeys: string[]) function for ad-hoc label retrieval.
  • Added useTranslations() hook, which can be used to automatically add translation capability to a dictionary.

Hooks

useTranslation()

The useTranslation() hook uses a React-like interface to bring automatic translation to Angular Components.

Usage

Import the hook from the @my-ul/tod-angular-client library and pass your initial/fallback dictionary to the function as a first parameter.

import { Component } from "@angular/core";
import { useTranslation } from "@my-ul/tod-angular-client";

@Component({
	selector: "app-decorators-translate",
	templateUrl: "./translate.component.html",
	styleUrls: ["./translate.component.scss"],
})
export class TranslateComponent {
	// optional
	ngOnDestroy(): void {
		if (this.labels && this.labels.unsubscribe) {
			this.labels.unsubscribe();
		}
	}
	labels = useTranslation({
		label_Add: "Add",
		label_Remove: "Remove",
		label_Cancel: "Cancel",
		label_Other: "Other",
	});
}

Parameters

All parameters are optional. If no parameters are passed, the useTranslation will be set to debug mode.

fallbackDictionary: Record<string, string>

A dictionary of labels to provide to your views if your ToD remote doesn't have the labels you need.

debug: boolean

When set to true, debug output and behaviors will be enabled. If debug is false, the two following callbacks will not be used.

onFallbackMissing(fallbackLabels: Record<string, string>, missingFromFallback: string[]): void

Called when debug = true if a label is requested that does NOT exist in the fallback dictionary provided. A default function is provided, which will output an invocation of useTranslation() suitable for copy/paste. Any keys missing will be at the bottom, with the empty string "" provided as the default value. If there are no missing keys in the fallback label dictionary, this function will not be called.

useTranslation({
  "label_Add": "Add",
  "label_Remove": "Remove",
  "label_Cancel": "Cancel",
  "label_Other": ""
}) /* 1 missing keys */
onRemoteMissing(locale: string, remoteLabels: Record<string, string>, missingFromRemote: string[]): void

Called when debug = true if a label is requested that is missing from the latest dictionary update. A default function is provided, which will output a sample invocation of useTranslation(). Any keys missing from the latest remote response will be at the bottom with the empty string "" provided for default values. If there are no missing keys in the remote label dictionary, this function will not be called.

[en-US] useTranslation({
  "label_Add": "Add",
  "label_Remove": "Remove",
  "label_Cancel": "Cancel",
  "label_Other": ""
}) /* 1 missing keys */

Development

During development, the useTranslation() hook can provide useful output to simplify establishing the initial/fallback dictionary. To trigger this behavior, either...

  1. Pass no parameters to the hook. For example, labels = useTranslation()
  2. Pass the debug parameter to the hook after your fallback dictionary. For example, labels = useTranslation({ label_Add: "Add" }, true).

When the component is in debug mode, it will print scaffolded dictionaries wrapped in the useTranslation invocation, suitable for copy and paste during development.

Example: scaffolded dictionary output

useTranslation({
  "label_Add": "Add",
  "label_Remove": "Remove",
  "label_Cancel": "Cancel",
  "label_Other": ""
}) /* 1 missing keys */ Translate.decorator.ts:18:14
Subscription management

Internally, the useTranslation hook uses subscriptions to manage asyncronous data flow. Under normal circumstances, these should unsubscribe on their own. However, you can call unsubscribe() on the labels dictionary to ensure its internal subscriptions have been cleared.

If you notice errors our continued console.* output even after the component should be destroyed, consider adding a call to unsubscribe() in a lifecycle callback, such as ngOnDestroy().

import { Component } from '@angular/core';
import { useTranslation } from '@my-ul/tod-angular-client';

@Component({
	selector: 'app-decorators-translate',
	templateUrl: './translate.component.html',
	styleUrls: ['./translate.component.scss'],
})
export class TranslateComponent
{
	ngOnDestroy(): void {
		if (this.labels && this.labels.unsubscribe) {
			this.labels.unsubscribe();
		}
	}

	labels = useTranslation({
		label_Add: 'Add',
		label_Remove: 'Remove',
		label_Cancel: 'Cancel',
		label_Other: 'Other',
	});
}

## Components

### <tod-i18n-string>

The `tod-i18n-string` component provides robust translation interpolation support. The component leverages `ng-template` to allow rich content to be interpolated. This allows interpolated strings to contain links, buttons, text formatting (strong/em/etc), while allowing Angular event binding and directive binding to work as expected. This means `(click)` event binding or `[routerLink]` binding will work as expected within your translated strings.

Using this module also ensures that proper Angular sanitization of user data is occuring prior to binding.

#### Usage

##### Import the module

```typescript
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { I18nStringModule } from "@my-ul/tod-angular-client";

@NgModule({
  declarations: [
    /* your modules declarations */
  ],
  imports: [
    /* add next to your other imports */
    I18nStringModule,
  ],
  exports: [
    /* your module exports */
  ],
})
export class I18nStringModule {}
Use the Component

The component will parse your incoming string for placeholders, such as {0}. You can provide a custom parser if you need support for different placeholders, such as sprintf-style %1$s. Wherever possible, it will replace the placeholders with data pulled from ng-template. If a template doesn't exist (i.e. There are three placeholders in the string, but only two <ng-template> instances), the placeholder will be rendered verbatim.

@Component({
  /* ... */
})
export class MyComponent {
  myTranslatedString: string =
    "Visit our {0} pages, fill out the myUL® {1} form, or call the myUL® support team at {2}";

  labels = {
    label_Help: "Help",
    label_ContactUs: "Contact Us",
  };

  phoneNumber = "(888) 555-1212";

  constructor() {}

  launchHelp() {
    console.log("launching help pages");
  }

  launchContactUs() {
    console.log("launching Contact Us");
  }
}

For each placeholder in your string, use an ng-template. By using ng-template, you can use markup and bind to events in your own controller.

<tod-i18n-string [string]="myTranslatedString">
  <!-- {0} -->
  <ng-template>
    <a href="#" (click)="launchHelp()">{{ labels.label_Help }}</a>
  </ng-template>

  <!-- {1} -->
  <ng-template>
    <a href="#" (click)="launchContactUs()">{{ labels.label_ContactUs }}</a>
  </ng-template>

  <!-- {2} -->
  <ng-template>{{phoneNumber}}</ng-template>
</tod-i18n-string>
Alternate Syntax

You can bind to [string] without using square brackets. This might be useful for combining strings that use the same tokens. If your translations aren't appearing, ensure that you are using the appropriate binding syntax for your scenario. For most cases, where a label dictionary is being used, you will likely use [string] syntax.

<tod-i18n-string string="{{stringOne}} {{stringTwo}}">
  <!-- use ng-template -->
</tod-i18n-string>
Styling

This component has very little provided styling. In fact, the only behavior provided by (S)CSS is that the :host element is an inline element. This should allow you to easily style the contents of the translation if necessary.

Advanced Usage

If you are not using C#-style tokens, such as {0}, you can provide a different token-generating function as an input to the component. Please note that the refreshTokens input is NOT an event, but should be a direct reference to a function that takes a string and returns Token objects.

You can use the included parseStringToTokens function, preferring to change the RegExp for one of your own, as it handles the tokenization based on a regular expression. If using a custom regular expression with the parseStringToTokens function, you must use a global regular expression. In TypeScript, a regular expression literal with the global flag looks like /__(\d+)__/g ('g' flag at the end, after the forward slash). In a RegExp object, this would look like new RegExp('__(\d+)__', 'g'), where a string of flags is provided as a second argument.

import { parseStringToTokens } from "@my-ul/tod-angular-client";

export class MyComponent {
  myTranslatedString = "Good morning, __0__";
  myRegularExpression = /__(\d+)__/g;
  myTokenGenerator = (subject: string) =>
    parseStringToTokens(subject, this.myRegularExpression);
}
<tod-i18n-string
  [string]="myTranslatedString"
  [refreshTokens]="myTokenGenerator"
>
  <ng-template>Alice</ng-template>
</tod-i18n-string>

Managing change detection.

Triggering a digest

If the component is not updating when the content of your <ng-content> elements change, you may need to trigger a digest. This can be easily done by obtaining the reference to the <tod-i18n-string> element, and calling the digest() function.

In your template code, this is as simple as adding a #templateRef to the <tod-i18n-string> component and calling its digest() function. This example shows an <input> element calling the digest function on keyup. This approach does not need any Component code.

<input [(ngModel)]="firstName" (keyup)="i18nStringComponent.digest()" />

<tod-i18n-string string="Good Morning, {0}!" #i18nStringComponent>
  <ng-template>{{firstName}}</ng-template>
</tod-i18n-string>
Digest from your Component

You may need to call digest() from your Component class. Be careful, however! The reference to the component will not be defined until ngAfterViewInit is called, and attempts to use it sooner will cause errors in your application.

import { Component, ViewChild } from "@angular/core";
import { I18nStringComponent } from "@my-ul/tod-angular-client";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  firstName: string = "James";

  @ViewChild(
    /**
     * Name of the ID used in the template file
     */
    "i18nStringComponent",

    /**
     * Ensures that the reference returned is the I18nStringComponentInstance
     */
    { read: I18nStringComponent }
  )
  stringComponent: I18nStringComponent;

  ngAfterViewInit(): void {
    // Must be called after ViewInit event
    this.stringComponent.digest();
  }
}
Reattaching to automatic change detection

This component detaches itself from automatic change detection since it relies on values provided by nested <ng-template> elements. Typically, once your component fires ngAfterViewInit, you can safely reattach the component for automatic change detection, as long as the number of <ng-template> elements is not expected to change.

Again, this must be called during or after the ngAfterViewInit() event.

import { Component, ViewChild } from "@angular/core";
import { I18nStringComponent } from "@my-ul/tod-angular-client";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  firstName: string = "James";

  @ViewChild("i18nStringComponent", { read: I18nStringComponent })
  stringComponent: I18nStringComponent;

  ngAfterViewInit(): void {
    // Must be called after ViewInit event
    this.stringComponent.attach();
  }
}

Troubleshooting

Error/Exception: tod-i18n-string: @Input [string] was null, undefined.

The provided [string] input did not contain a string that can be used for translation. Either the provided value was null or undefined, which indicates that your translation dictionary might be missing a certain key.

Console Warning: tod-i18n-string: @Input [string] is empty! Translations may not appear.

The provided [string] @Input() was the empty string, ''. The component will not throw any errors, but nothing will appear in your app. The console will only omit a warning.

Translations are not being interpolated, or ng-template values are appearing in the wrong placeholders.
  • Ensure that you are using ng-template and not ng-container.
  • Ensure that your placeholders are zero-indexed. For example, {0}, {1}, {2}. If you attempt to use a placeholder that doesn't have a corresponding ng-template, the placeholder will be rendered in place, verbatim. Even if you're using a custom tokenizer, the value of your Token objects needs to be zero indexed. For example, if your tokenizer is for a sprintf-style string, you need to make sure it properly maps tokens like %1$s. The built-in parseSprintfTokens function shows how a optionally-indexed token system can still be used for robust tokenization.
export function parseSprintfTokens(template: string): Token[] {
  const tokens = [];

  if (isDevMode()) console.info(`parseSprintfTokens(template)`, { template });

  let regexp = /\%((\d+)\$)?[fdsu]/g;
  if (isDevMode()) console.log(`  TOKENIZING with ${regexp}`);
  
  // some matches don't have numbers in them
  // the common behavior is to keep an index of those
  // tokens separately, and then bind to their index
  let unnumberedTokenIdx = 0;
  let match: RegExpExecArray;
  let remainingTemplate = template.slice();
  while ((match = regexp.exec(template)) !== null) {
    const matchStr = match[0],
      matchVal = match[2] ? parseInt(match[2], 10) - 1 : unnumberedTokenIdx;

    // get the index of the match
    const matchPos = remainingTemplate.indexOf(matchStr);

    // gather the string up to the match
    // if pos is 0, segment will be the empty string
    const priorSegment = remainingTemplate.slice(0, matchPos);

    // copy any non-empty segments into the tokens array
    if (priorSegment.length > 0) {
      tokens.push(createTextToken(priorSegment));
    }

    // push the placeholder; the value is parsed to a number so that it can be used
    // to access templates by index
    tokens.push(createPlaceholderToken(matchStr, matchVal));

    // advance tempString past this match.
    remainingTemplate = template.slice(regexp.lastIndex);

    // if this was an unnumbered token, advance the internal counter.
    if (!match[2]) {
      unnumberedTokenIdx++;
    }
  }

  // there might be some text left over in tempString, which should be added as a token
  if (remainingTemplate.length > 0) {
    tokens.push(createTextToken(remainingTemplate));
  }

  if (isDevMode()) {
    console.debug('  TOKENS', {
      subject: template,
      tokens,
    });
  }

  return tokens;
}
  • Ensure that your placeholders are valid. If your placeholders in the translated string contain leading zeros, your placeholders may be parsed as {0}, leading to strange interpolation.
The whitespace in my translation is missing or looks odd.

If you are using the component next to other text elements, you may need to include wrap leading/trailing space in another inline element, such as a span or strong. You may also use an encoded HTML entities, such as &nbsp;.

<p>
  <strong>Trailing Space INSIDE the strong tag: </strong>
  <tod-i18n-string [string]="..."></tod-i18n-string>
</p>

Services

TranslationService

The Translation Service can be used to propagate locale and translations throughout your application. By using Translation Service, you can achieve high-performance, low-latency, on-demand app translation that can propagate translations throughout your app with very little configuration.

Getting Started

At a low level within your Angular application (AppComponent or your app's root component is best), inject and configure the service by setting the urlTemplate and locale with the setUrlTemplate(urlTemplate) and setLocale(locale) functions, respectively. The URL template is used to build URLs that can build your locales.

The urlTemplate within TranslationService is interpolated with two values: the current locale, {0} and the current time as milliseconds, {1}. Using the current time allows for implementations using cache busters, which can be useful during development and benchmarking.

The subscription associated with subscribeToLabels does not unsubscribe automatically, nor does it ever complete. Please keep a reference to the subscriptions you create and unsubscribe to them in the ngOnDestroy lifecycle hook. Consider using a library like subsink to make this a little easier. If you don't unsubscribe, you may notice your application making redundant calls to your ToD server, even after your component is destroyed.

import { TranslationService } from "@my-ul/tod-angular-client";

export class AppComponent implements OnDestroy {
	constructor(public translation: TranslationService) {
		/**
		 * The format of the locale codes is not terribly important...but adhering
		 * to the IETF BCP 47 standard makes working with translations from other
		 * teams easier.
		 *
		 *         good places to get user's locale...
		 *     - `navigator.language`
		 *     - HTML lang attribute: `<html lang="">`
		 */
		const defaultLocale = getDefaultLocaleFromSomewhere() || "en-US";

		/**
		 * If these values are not set, TranslationService will not emit. If
		 * urlTemplate doesn't get set, an error will be thrown. adding ?t={1} will
		 * set an appropriate cache-buster; it can be omitted.
		 */
		translation
			.setUrlTemplate(
				"https://my-tod-server.example.com/locales/RF_{0}.json?t={1}"
			)
			.setLocale(defaultLocale);
	}
}

Once the TranslationService is initialized, it can be used. If the urlTemplate is not set, calling subscribeToLabels(labels) will throw an error.

Each component should be aware of the labels it needs upon instantiation. Although not necessary, providing default, hard-coded labels is a good practice to ensure users don't see empty pages prior to the translations loading.

It is not required to provide an array of label keys to the subscribeToLabels function. Your TOD server will receive the query parameter labels=. It is up to you to determine how this is handled. For "fail-safe" behavior, most TOD implementations should return the entire dictionary.

Consuming Labels
import { OnDestroy } from "@angular/core";
import { TranslationService } from "@my-ul/tod-angular-client";

export class MyChildComponent implements OnDestroy {
	/**
	 * Using a short variable name like `t` or `labels` keeps your template files
	 * looking clean.
	 */
	t: Record<string, string> = {
		label_Welcome: "Welcome",
		label_YouMustAcceptTheTermsAndConditions:
			"You must accept the terms and conditions.",
		label_Accept: "Accept",
		label_Decline: "Decline",
	};

	// unsubscribe to the subscription when the component unloads
	translationSubscription: Subscription<any>;

	constructor(public translation: TranslationService) {
		this.translationSubscription = translation
			.subscribeToLabels(Object.keys(t))
			.subscribe((dictionary: Record<string, string>) => {
				/**
				 * By using Object.assign, this keeps old labels in place in the
				 * event that the new dictionary does not have them. This keeps
				 * a defined fallback in place, even if the new dictionary is
				 * missing a label.
				 */
				this.t = Object.assign(this.t, dictionary);
			});
	}

	ngOnDestroy() {
		if (this.translationSubscription) {
			this.translationSubscription.unsubscribe();
		}
	}

	accept() {
		console.log("User has ACCEPTED the Terms and Conditions.");
	}
	decline() {
		console.log("User has DECLINED the Terms and Conditions.");
	}
}
<!-- use the dictionary in your templates -->
<h2>{{ t.label_Welcome }}</h2>
<p>{{ t.label_YouMustAcceptTheTermsAndConditions }}</p>
<button (click)="accept()">{{ t.label_Accept }}</button>
<button (click)="decline()">{{ t.label_Decline }}</button>
Switching Locales

Switching languages is easy! Any component in your application can call setLocale(locale). Anywhere a component has used subscribeToLabels, it will update its labels automatically.

import { TranslationService } from "@my-ul/tod-angular-client";

export class MyChildComponent {
	// ... truncated ...

	constructor(private translation: TranslationService) {}

	setLocale(locale: string) {
		// this will trigger an application-wide update of translations
		this.translation.setLocale(locale);
	}

	// ... truncated ...
}

And in your templates...

<button (click)="setLocale('en-US')">English (US)</button>
<button (click)="setLocale('fr-CA')">Français (CA)</button>
<button (click)="setLocale('de')">Deutsch</button>

Troubleshooting

If labels are not loading...
  • Ensure you have called setLocale() at least once. setLocale can be called before any subscriptions are made. No values will be emitted to the subscriber until the locale is set. It is recommended to call setLocale() early in your app's instantiation so that the TranslationService is ready to translate as components initialize (on demand!).
  • Ensure you have set the urlTemplate correctly by calling setUrlTemplate(). Any attempts to use subscribeToLabels() will throw an error (check the console) if you don't have a URL template set. If you do not include the placeholder {0}, your generated URLs will not include the current locale. If you are struggling with cached data, include the {1} token somewhere in your URL as a cache buster.
  • Check the Network tab of your Developer Tools to make sure your URL is getting built properly.

Pipes

format Pipe

The format Pipe allows C#-style interpolation of strings. Using placeholders allows translators to reorder items in the interpolated string, which makes for robust translation. Please note that this example does NOT need to use the sanitize pipe, since it isn't directly binding to [innerHTML].

<!-- Good Morning, Alice! -->
<p>{{ "Good Morning, {0}!" | format : user.name }}</p>

safe Pipe

At times, you may want to interpolate HTML into strings to allow for translated hyperlinks, or bold/italicised info. You need to let Angular know the generated html is safe by using the safe Pipe.

All templates binding to [innerHTML] must use the safe pipe at a minimum. If user data is being interpolated into the string, sanitize should be used so that user data isn't rendered as HTML.

<!-- To finish, click <strong>Close</strong>. -->
<p innerHTML="{{
    'To finish, click {0}.'
    | format
        : ('<strong>{0}</strong>' | format : t.label_Close)
    | safe : 'html'
}}"></p>

An advanced example allowing the user to click a Contact Us link.

export class MyComponent {
    emailLinkTemplate = '<a href="mailto:{0}">{1}</a>';
    emailAddress = 'support@example.com';
    t = {
        label_ContactUs: "Contact Us"
    }
}

In the template...

<!-- 
  Result:
  If you need assistance, please <a href="mailto:support@example.com">Contact Us</a>.
-->
<p innerHTML="{{
    'If you need assistance, please {0}.'
    | format :
        (emailLinkTemplate | format : emailAddress : label_ContactUs)
    | safe : 'html'
}}"></p>

sanitize Pipe

If untrusted user data is going to be interpolated into the string, use the sanitize Pipe.

export class MyComponent
{
    user = {
        first_name: '<script>alert("hacked!");</script>'
    }
}

Multi-line use and indentation is not required, but recommended, as it makes the data flow through the pipe easier to follow.

<!--
  Approximate Result:
  `Good morning, &#x3C;script&#x3E;alert(&#x22;hacked!&#x22;);&#x3C;/script&#x3E;`
-->
<p [innerHTML]="{{
    'Good morning, {0}'
    | format 
        : ( user.first_name | sanitize : 'html' )
    | safe : 'html'
}}"></p>

Readme

Keywords

none

Package Sidebar

Install

npm i @my-ul/tod-angular-client

Weekly Downloads

55

Version

14.0.0

License

none

Unpacked Size

301 kB

Total Files

45

Last publish

Collaborators

  • bradkovach
  • matthew.gardner