In this article we'll take a look at existing solutions to inject HTML into translations and their cons. Then we'll build our own solution to create translations with HTML and Angular Components.
We'll use the translation library Transloco, but our final solution can be used with any other translation library like ngx-translate. In fact, it even works in scenarios without a translation library like with (translated) values received from the backend.
Existing solutions
There are a few existing solutions to inject HTML into translations.
Include HTML in translations:
You can use HTML in your translations and render it via [innerHTML]
. This allows you to include formatting, links and more. However, you cannot add event listeners or Angular Components.
{ "terms": "I accept the <a href='/terms'>terms</a>." }
<p [innerHTML]="{{ 'terms' | translate }}"></p>
Angular Elements:
This is an extension of the first solution mentioned above. With Angular Elements you can export any Angular Component as Custom Element (aka WebComponent). This gives you an HTML element that you can use in the translation.
{
"terms": "I accept the <terms-router-link>terms</terms-router-link>."
}
Split translations:
You can split your translation into multiple parts: the text before the link, the text of the link and the text after the link.
{
"terms-before": "I accept the ",
"terms-link": "terms",
"terms-after": "."
}
<p>
{{ 'terms-before' | translate }}
<a routerLink="/terms">{{ 'terms-link' | translate }}</a>
{{ 'terms-after' | translate }}
</p>
These are the three most common solutions to inject HTML into translations. But let's be honest: these are not very good solutions. Splitting translations into multiple keys is not very handy. Creating WebComponents just for including them in a translation seems to be over-engineered and putting HTML in translations is not a very clean approach.
Now let's implement our own solution.
Solution Overview
Here is how our solution works:
Before we are going to implement it, let's take a closer look at each part of our solution.
Slot Placeholder
For each interactive element that we want to add, we create a placeholder in our translation, I call them slots (or slot placeholders). Each slot should have a name and some text. We choose a custom syntax for the slot that looks like this:
{ "terms": "I accept the [[termsLink:terms]]." }
Each slot is wrapped in double square brackets [[ ]]
. Its name and its text are separated with a colon. The slot above has the name "termsLink" and the text "terms". We'll later use RegEx to find all slots and split the translation into multiple parts.
Component
We need a component that takes the translated value as input, splits it into multiple parts and renders them. Every slot will be replaced with the corresponding element or component.
For this we create a component DynamicTranslationComponent
with an attribute selector that accepts the translated value as input:
<p [appDynamicTranslation]="'terms' | transloco">
<!-- .... -->
</p>
Slot Element
Then we need a way to add an element for each slot. As a slot can have some text ("terms" in our example) we need a way to receive and render that text as part of our element.
We use a structural directive for this. It accepts the name of the slot (here "termsLink") and provides the text of the slot as context ("terms" in our example). Don't worry if you don't understand it yet. We'll go into detail later.
<a *appDynamicTranslationSlot="'termsLink'; let text">{{ text }}</a>
Implementation
Now let's implement our solution.
Slot Directive
We start with the slot element. In our Angular template we'll add a hyperlink for our terms link. It could be a link with a static href
attribute, an Angular Router link or even a button that opens a modal dialog with the terms. You can do whatever you want here.
We need a way to define what slot is associated with what element. This mapping is done by the name of the slot placeholder, in our example "termsLink". And we also need a way to provide the text of the slot placeholder (in our example "terms") to the element so that we can use that text as the content of our link.
What about using ng-content
? Content projection with ng-content
is not flexible enough for that use case. The select
attribute of ng-content
is static and does not support dynamic values. Passing the slot text to the element would not be possible and we couldn't use the same slot multiple times in a translation (which could be useful for an icon slot, when you want to replace each icon with an icon component).
We use a structural directive instead which is pretty flexible. As this generates a template for each element we can pass the slot text as template context and render a slot multiple times with different slot texts. In addition, the element will only be rendered when the slot placeholder exists.
So let's create a new directive:
@Directive({ selector: "[appDynamicTranslationSlot]" })
export class DynamicTranslationSlotDirective {
@Input("appDynamicTranslationSlot")
slotName?: string;
constructor(private templateRef: TemplateRef) {}
getSlotName() {
if (!this.slotName) throw new Error("Missing slot name");
return this.slotName;
}
getTemplate() {
return this.templateRef;
}
}
The directive accepts the slot name as input. We set the binding property name to appDynamicTranslationSlot
to match the directive attribute selector. This allows us to use the directive like this:
<a *appDynamicTranslationSlot="'termsLink'">...</a>
That's it for the slot element directive. The directive itself does not render the element (our a
tag). This will be done by the main component as we'll see in the next chapter. That's why we implemented the two methods getSlotName()
and getTemplate()
in the directive. The main component will use these methods.
Main component
Now let's build the main component. We create a new component and accept the translated value as input.
@Component({
selector: "[appDynamicTranslation]",
template: ` ...`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DynamicTranslationComponent {
private text$ = new ReplaySubject<string>(1);
@Input("appDynamicTranslation")
set text(text: string) {
this.text$.next(text);
}
}
We create a new RxJS ReplaySubject and implement the input as a setter which gives us more flexibility and allows us to use OnPush
change detection. That is not a requirement for this solution but it's a good practice.
You will notice that I changed the selector
of the component to an attribute selector (line 2). The advantage of this type of selector is that you can use the component directly on an HTML element like a p
tag and the content will be the translated value without having an additional component element tag. That's not necessary, you can keep the default component tag selector if you like.
Finding the slots
The component needs to get the list of slots that are defined in the template. For that we use the ContentChildren
decorator to find all slot directives.
We'll again use RxJS and implement the property as a setter. The setter creates an object that maps each slot name (in our example "termsLink") to the corresponding template.
export class DynamicTranslationSlotComponent {
// ... (see above)
private slotTemplates$ = new ReplaySubject(1);
@ContentChildren(DynamicTranslationSlotDirective)
set slots(slots: QueryList<DynamicTranslationSlotDirective>) {
// Loop through all slot directives...
const slotTemplates = slots.reduce((mapping, slot) => {
// ... and add the slot name and template to the mapping
const slotName = slot.getSlotName();
mapping[slotName] = slot.getTemplate();
return mapping;
}, {});
this.slotTemplates$.next(slotTemplates);
}
}
Splitting the translation
Next we need to split the translation into multiple parts to render the slots.
Since we have an RxJS subject for both the text and the slot template mapping, we can use combineLatest
to combine them together and then use map
to split the translation into multiple parts.
export class DynamicTranslationComponent {
// ... (see above)
parts$ = combineLatest([this.text$, this.slotTemplates$]).pipe(
map(([text, slotTemplates]) => {
const parts = [];
// TODO (we'll implement that next)
return parts;
})
);
}
What do we need to do to split the translation value? First, we need to get all slot names by using Object.keys
on the slot template mapping. Then we use RegEx to find all slot placeholders in the translation.
const slotNames = Object.keys(slotTemplates).join("|");
const slotSelector =
new RegExp("\\[\\[(" + slotNames + ")(?::(.+?))?\\]\\]", "g");
That RegEx pattern looks a bit complicated so let's break it down:
\\[\\[
The beginning of the slot placeholder. We need to escape the brackets.slotNames
The slot names. We use|
to separate the possible slot names which matches any of the used slot names.(?::(.+?))?
The optional slot text. We break it down further:?:
We mark this as a non-capturing group because this group contains both the slot name and the slot text (like "termsLink:terms") and we don't need this combined value.:
The character is used to separate the slot name from the slot text (like "termsLink:terms").(.+?)
The actual slot text. This can be any character and as many characters as you want (but at least one). The?
makes the quantifier non-greedy because otherwise it would match the rest of the translation value.?
This marks the slot text as optional. You can define a slot without a text like[[termsLink]]
or with a text like[[termsLink:terms]]
.
\\]\\]
The end of the slot placeholder.
Now that we have our RegEx pattern we can split the translation value into parts. A part can either be some text or a slot. We use the RegEx pattern defined above to find all slots and then split the value into parts.
let match;
let lastIndex = 0;
// We loop through all matches until exec
// returns null (which means there are no more matches)
while ((match = slotSelector.exec(text)) !== null) {
// wholeMatch: the whole match (like "[[termsLink:terms]]")
// slotName: the name of the slot (like "termsLink")
// slotText: the optional slot text (like "terms")
const [wholeMatch, slotName, slotText] = match;
// Add the text before the slot
const textBeforeSlot = text.substring(lastIndex, match.index);
if (textBeforeSlot) {
parts.push({ type: "text", text: textBeforeSlot });
}
// Get the template from the mapping
const slotTemplate = slotTemplates[slotName];
// Provide the text as template context
const slotContext = { $implicit: slotText };
parts.push({
type: "slot",
template: slotTemplate,
context: slotContext,
});
lastIndex = match.index + wholeMatch.length;
}
// There may be some text after the last slot
const textAfterLastSlot = text.substring(lastIndex);
if (textAfterLastSlot) {
parts.push({ type: "text", text: textAfterLastSlot });
}
That's it. We now have a list of parts that we can render in our component template. We use the async
pipe to subscribe to the parts$
observable.
When we receive the list of parts we loop through it and render each part which is either some static text or a slot.
<!-- subscribe to the parts$ observable -->
<ng-container *ngIf="parts$ | async as parts">
<!-- loop through all parts -->
<ng-container *ngFor="let part of parts">
<!-- if the part is a static text -->
<ng-container *ngIf="part.type === 'text'">
<span>{{ part.text }}</span>
</ng-container>
<!-- if the part is a slot -->
<ng-container *ngIf="part.type === 'slot'">
<ng-container
*ngTemplateOutlet="part.template; context: part.context"
></ng-container>
</ng-container>
</ng-container>
</ng-container>
That's it. We're done. Now let's use the component.
Usage
We use the main component on our p
tag. We use the Transloco pipe to translate the text.
Here you could use any translation library or provide a text received from the backend. For the terms link we use the structural directive and provide the name of the slot as input (here "termsLink"). As we created a template context { $implicit: slotText }
we now receive the slot text as the implicit value of the template so we can use let text
to receive that link text and render it.
<p [appDynamicTranslation]="'terms' | transloco">
<a *appDynamicTranslationSlot="'termsLink'; let text">{{ text }}</a>
</p>
We are done 🎉. We can now render translations and include HTML, Angular Components, Directives, Pipes and much more. All this without relying on innerHTML
.
Demo and code
Take a look at the demo: Dynamic Translation Demo
The code snippets in this post are shortened and don't include TypeScript types. If you would like to use this approach I recommend that you take a look at the full source code. The code is available on GitHub: @rothsandro/blog-angular-dynamic-translations
Further improvements
There are a few things that could be improved.
Slot Name Validation
Our RegEx pattern to find all slots is composed by all slot names. If you use a slot name with special characters like termsLink.+
it will break the RegEx pattern. While you could escape the slot name it's probably easier to just forbid the use of special characters in the slot name. You can extend the directive to validate the name:
export class DynamicTranslationSlotDirective {
readonly NAME_PATTERN = /^[a-z0-9]+$/i;
// ...
getSlotName() {
if (!this.slotName) throw new Error("Missing slot name");
if (!this.NAME_PATTERN.test(this.slotName)) {
throw new Error("Invalid slot name " + this.slotName);
}
return this.slotName;
}
}
Dynamic slot names
In our main component we read each slot name (using the getSlotName()
method of the directive) and create a mapping to the slot template. ContentChildren
will update whenever a directive is added or removed but it will not update when the name of a slot changes. If you change the name of a slot dynamically at runtime it will not rerender.
You can support that by extending the directive with an observable that emits whenever the slot name changes and extend the main component to subscribe to those name changes. But it makes the code more complex and I don't believe that this is actually a use case. But feel free to implement it if you need it.
Final thoughts
We took a look at different approaches to handle dynamic translations. We saw that none of them are flexible enough to support HTML and Angular Components while keeping the translations clean and simple.
The approach we implemented provides an easy and clean way to include not just HTML but any Angular Component in the translation. It works with any translation library, does not rely on innerHTML
and keeps your translations and your code separated.