Package Exports
- @my-ul/tod-angular-client
- @my-ul/tod-angular-client/bundles/my-ul-tod-angular-client.umd.js
- @my-ul/tod-angular-client/fesm5/my-ul-tod-angular-client.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@my-ul/tod-angular-client) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
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 a robust mechanism 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@1.2.0-1 \
tslib@^1.10.0Change Log
1.2.0
- Support for
prefixandsuffixfor 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
@Translate()decorator, which can automatically wire up translation functionality.
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.setLocalecan be called before any subscriptions are made. No values will be emitted to the subscriber until the locale is set. It is recommended to callsetLocale()early in your app's instantiation so that theTranslationServiceis ready to translate as components initialize (on demand!). - Ensure you have set the
urlTemplatecorrectly by callingsetUrlTemplate(). Any attempts to usesubscribeToLabels()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.
Decorators
`@Translate'
The @Translate() decorator can greatly simplify the work associated with translating your components. The decorator automatically enumerates and subscribes to labels using TranslationService. Observable Subscription instances are managed automatically.
How it works
The @Translate() decorator works by re-defining certain functions within the Angular lifecycle, and by replacing the translation dictionary with a Proxy object. Specifically, the ngOnInit(): void and ngOnDestroy(): void functions are augmented to ensure the component mounts and unmounts subscriptions properly.
Use of a Proxy object allows requests for translation labels to be intercepted on a per-label basis. This allows powerful introspection into the state of the current translation dictionary, making it possible to identify missing labels at runtime. This can be leveraged to rapidly enumerate the labels required by your component and build fallback dictionaries.
Getting started
The @Translate decorator may not work with Angular Ivy. You can determine if your project is using Angular Ivy by checking your angular.json file. The projects.yourProject.architect.build.options.aot parameter must be set to false.
Installation
Consider the following component.
import { Component } from "@angular/core";
import { Translate } from "@my-ul/tod-angular-client";
@Component({
selector: "app-demo-component",
templateUrl: "./translate.component.html",
styleUrls: ["./translate.component.scss"],
})
export class DemoComponent {
@Translate()
public labels: Record<string, string> = {
label_Add: "Add",
label_Remove: "Remove",
label_Cancel: "Cancel",
};
public missingCurrent: Record<string, string>;
public missingInitial: Record<string, string>;
}Debug Utility Behaviors
Components
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
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-templateand notng-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 correspondingng-template, the placeholder will be rendered in place, verbatim. Even if you're using a custom tokenizer, thevalueof yourTokenobjects 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-inparseSprintfTokensfunction 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 .
<p>
<strong>Trailing Space INSIDE the strong tag: </strong>
<tod-i18n-string [string]="..."></tod-i18n-string>
</p>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, <script>alert("hacked!");</script>`
-->
<p [innerHTML]="{{
'Good morning, {0}'
| format
: ( user.first_name | sanitize : 'html' )
| safe : 'html'
}}"></p>