@my-ul/tod-angular-client
v14.0.0
Published
## Introduction
Downloads
446
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 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/[email protected] \
Change Log
1.2.0
- Support for
prefix
andsuffix
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...
- Pass no parameters to the hook. For example,
labels = useTranslation()
- 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 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, thevalue
of yourToken
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-inparseSprintfTokens
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
.
<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 callsetLocale()
early in your app's instantiation so that theTranslationService
is ready to translate as components initialize (on demand!). - Ensure you have set the
urlTemplate
correctly 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.
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 = '[email protected]';
t = {
label_ContactUs: "Contact Us"
}
}
In the template...
<!--
Result:
If you need assistance, please <a href="mailto:[email protected]">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>