Closed DethAriel closed 7 years ago
I think something like this would be better:
{
"ALREADY_SIGNED_UP": "Already signed up? <a>Log in</a>!"
}
<span [merge]="{{ 'ALREADY_SIGNED_UP' | translate }}">
<a routerLink="/login"></a>
</span>
A merge
mixin somewhere would simultaneously walk the span template and the dom of the merging value and add nodes from the value that are not in the template as it is rendering (the point: I don't think this is specifically an ng2-translate issue, more of an advanced templating issue for core somewhere).
This is a must feature as often in real world apps we would have to embed a routerLink or a click handle to the strings being translated
@DethAriel did you find any solution to this?
@deepu105 for now I went with a workaround solution:
<span>
{{ 'ALREADY_SIGNED_UP_PREFIX' | translate }}
<a routerLink="/login">{{ 'ALREADY_SIGNED_UP_LINK' | translate }}</a>
{{ 'ALREADY_SIGNED_UP_SUFFIX' | translate }}
</span>
And made it really obvious for localizators that these are part of one sentence via the supporting docs.
Sorry guys, there's no way to create html content with angular components like this based on a string. Angular was written in a way that you could abstract all the dynamic logic from the templates because the idea is that everyone should use precompilation (AoT) and remove the compiler from the code. This means that there is no "compile" function like there was in Angular 1... You can still create components by importing them and appending them to the dom, but it's something that you'll have to do on your own, it would be way too difficult to make a generic version for ng2 translate :(
Hi folks! I wish to propose our workaround too:
SOLUTION: we use single variable with text piped | --> example "Hello, please click |here| to register".
We implemented custom angular pipe
@Pipe({ name: 'translateCut' })
export class TranslateCut implements PipeTransform {
transform(value: string, index: string): string {
const cutIndex = Number(index);
return value.split('|')[cutIndex];
}
}
Then we use it just like that:
<p>
{{ 'page.registration' | translate | translateCut:0 }}
<a (click)="go()">{{ 'page.registration' | translate | translateCut:1 }}</a>
{{ 'page.registration' | translate | translateCut:2 }}
</p>
We are good, no xss, we can use angular components in the middle, we provide single variable to translators.
Thx -Yura
I know this issue is closed, but as it was the hit I got searching for the same problem I would like to present my solution to the problem, implementing a generic solution for ngx-translate. It consist of two directives and a service shared between them.
template-translate.directive.ts
import { Directive, TemplateRef, ViewContainerRef, Input, Host, OnInit, Renderer2, OnDestroy } from "@angular/core";
import { TemplateTranlateService } from "./template-translate.service";
import { TranslateService, TranslateDefaultParser } from "@ngx-translate/core";
import { Subscription } from "rxjs/Subscription";
import { getValue, TEMPLATE_MATCHER } from "./util/translation.util";
@Directive({
selector: "[templateTranslate]",
providers: [TemplateTranlateService],
})
export class TemplateTranslateDirective implements OnInit, OnDestroy {
@Input() templateTranslate: string;
numberOfDirectChildElements: number;
private rawResourceString: string;
private refsSubscription: Subscription;
private translateSubscription: Subscription;
constructor(
private viewRef: ViewContainerRef,
private renderer: Renderer2,
private translateService: TranslateService,
private templateTranlateService: TemplateTranlateService,
) {}
ngOnInit(): void {
// Atm all the params are HTML insertions using this directive
this.rawResourceString = getValue(this.translateService.translations[this.translateService.currentLang], this.templateTranslate);
if (!this.rawResourceString) {
throw new Error(`[Template translate directive] No resource matching the key '${this.templateTranslate}'`);
}
this.templateTranlateService.rawResourceString.next(this.rawResourceString);
// This makes this directive all or nothing with the HTML insertions
this.numberOfDirectChildElements = this.rawResourceString.match(TEMPLATE_MATCHER).length;
this.refsSubscription = this.templateTranlateService.$refs.subscribe(resources => {
// The first resource value is null from the behaviour subject
if (resources.length) {
// Clear the view and save every HTML insertion needed in the translation string
this.viewRef.clear();
// Only do anything when all the HTML insertions is received
if (resources.length < this.numberOfDirectChildElements) {
return;
}
// Sort them so the leftmost HTML insertion is first and so forth
resources.sort((a, b) => a.firstIndex - b.firstIndex);
// Find the substrings and replace them with the correct HTML insertions
for (let i = 0; i < resources.length; i++) {
let firstString;
if (i > 0) {
firstString = "";
} else {
firstString = this.rawResourceString.substring(0, resources[i].firstIndex);
}
let nextString;
if (i < resources.length - 1) {
nextString = this.rawResourceString.substring(resources[i].lastIndex, resources[i + 1].firstIndex);
} else {
nextString = this.rawResourceString.substring(resources[i].lastIndex);
}
const firstStringElement = this.renderer.createText(firstString);
const nextStringElement = this.renderer.createText(nextString);
const embeddedViewRef = resources[i].viewRef.createEmbeddedView(resources[i].templateRef);
this.renderer.appendChild(this.viewRef.element.nativeElement, firstStringElement);
this.renderer.appendChild(this.viewRef.element.nativeElement, embeddedViewRef.rootNodes[0]);
this.renderer.appendChild(this.viewRef.element.nativeElement, nextStringElement);
}
}
});
}
ngOnDestroy(): void {
if (this.refsSubscription) {
this.refsSubscription.unsubscribe();
}
if (this.translateSubscription) {
this.translateSubscription.unsubscribe();
}
}
}
template-translation.directive.ts
@Directive({
selector: "[templateTranslation]",
})
export class TemplateTranslationDirective implements OnInit, OnDestroy {
@Input() templateTranslation: string;
private rawResourceSubscription: Subscription;
constructor(
private viewRef: ViewContainerRef,
private templateRef: TemplateRef<any>,
@Host() private templateTranlateService: TemplateTranlateService,
) {}
ngOnInit(): void {
this.rawResourceSubscription = this.templateTranlateService.rawResourceString.subscribe(rawResourceString => {
if (rawResourceString) {
// Could be replaced with regex
const matchString = `{{${this.templateTranslation}}}`;
const firstIndex = rawResourceString.indexOf(matchString);
this.templateTranlateService.templateRefs.push({
viewRef: this.viewRef,
templateRef: this.templateRef,
firstIndex: firstIndex,
lastIndex: firstIndex + matchString.length,
});
this.templateTranlateService.refs.next(null);
}
});
}
ngOnDestroy(): void {
if (this.rawResourceSubscription) {
this.rawResourceSubscription.unsubscribe();
}
}
}
template-translate.service.ts
import { Injectable } from "@angular/core";
import { BehaviorSubject } from "rxjs/BehaviorSubject";
import { TemplateRefs } from "./shared/templateRefs.model";
import { map } from "rxjs/operators";
@Injectable()
export class TemplateTranlateService {
templateRefs = new Array<TemplateRefs>();
public refs = new BehaviorSubject<null>(null);
public $refs = this.refs.pipe(map(() => this.templateRefs));
public rawResourceString = new BehaviorSubject<string>(null);
constructor() {}
}
templateRefs.model.ts
import { ViewContainerRef, TemplateRef } from "@angular/core";
export class TemplateRefs {
viewRef: ViewContainerRef;
templateRef: TemplateRef<any>;
firstIndex: number;
lastIndex: number;
}
The solution works by using the Renderer two moving the right HTML/Angular element in place of the replacement string.
The interface for this is quite simple, given resource file: "SomeScreen", and resource string:"someResourceString", looking like this: "Replace {{this}} in this sentence":
<p [templateTranslate]="'someScreen.someResourceString'">
<a *templateTranslation="'this'"
[routerLink]="['/someLink', data.id]">
{{data.name}}
</a>
</p>
The string will then be replaced, where the {{this}} will be replaced with the given HTML/Angular element with the *templateTranslation directive. I know that the directive only handles double curly braces atm. And if it is used, every replacement in the resource string should be replaced with an HTML/Angular element. So the solution could probably be fine tuned a bit. But I would say that this is a start for a generic solution for ngx-translate library. Currently the solution uses three private properties/functions from the ngx-translate library which is:
// Some of these functions are taken directly from https://github.com/ngx-translate/core/blob/master/projects/ngx-translate/
// As they dont expose every function in the public API
// Taken from https://github.com/ngx-translate/core/blob/master/projects/ngx-translate/core/src/lib/util.ts
export function isDefined(value: any): boolean {
return typeof value !== "undefined" && value !== null;
}
// Taken from https://github.com/ngx-translate/core/blob/master/projects/ngx-translate/core/src/lib/translate.parser.ts
export const TEMPLATE_MATCHER = /{{\s?([^{}\s]*)\s?}}/g;
export function getValue(target: Object, key: string) {
const keys = key.split(".");
key = "";
do {
key += keys.shift();
if (isDefined(target) && isDefined(target[key]) && (typeof target[key] === "object" || !keys.length)) {
target = target[key];
key = "";
} else if (!keys.length) {
target = undefined;
} else {
key += ".";
}
} while (keys.length);
return target as string;
}
This makes the solution incomplete. It would probably be better if it was implemented natively in ngx-translate, or if the methods was made public in the ngx-translate library.
For the developers, would you consider implementing this native in the ngx-translate library? Or maybe open for a potential PR me or another dev could make?
And feel free to address if this solution contains any problems which isn't just fine tuning :)
Thanks @yuristsepaniuk for your pipe solution! For those who are using Angular 1.x with filters instead of pipes, here is the matching solution:
"POLICY_FOOTER": "We use |Cookies|. Here is our |Privacy Policy|"
{{ "POLICY_FOOTER" | translate | translateCut : 0 }}
<a href="https://www.example.com/cookies.html" target="_blank">{{ "Cookies" | translate }}</a>
{{ "POLICY_FOOTER" | translate | translateCut : 2 }}
<a href="https://www.example.com/privacy.html" target="_blank">{{ "Privacy Policy" | translate }}</a>
.filter('translateCut', function() {
return function(input, cutIndex) {
return input.split('|')[cutIndex];
}
})
@kasperlauge it looks like rocketscience but from the usage its perfect and super flexible! thx for your solution kasper.
@kasperlauge thanks so much for this - I was planning on writing something with the same approach, and then I found that you'd already done it!
Once small issue is that it doesn't take into account waiting for remote translation files to load - this.translateService.translations
is an empty object at the point you call getValue
in that case. Fortunately, it's a pretty easy fix, as all it needs a little adapting to make rawResourceString
an Observable
instead of a string
.
@DanielSchaffer thank you for the feedback! I actually also discovered that, but solved it by having a global behavior subject translationReady (which is triggered when the translations have been loaded) which is being subscribed to in ngOnInit in the directive before doing anything else :) I hope your solution or this one can solve the problems for others :)
@kasperlauge - upon further fiddling, you can simplify it a bit further by using @ContentChildren
in TemplateTranslateDirective
. Essentially, the only reason TemplateTranslationDirective
is needed is to get the ViewContainerRef
and TemplateRef
from the to-be-embedded element templates. So, you can simplify that directive down to being just a dumb container for just those (and the translation token key). Then, in TemplateTranslateDirective
, you can get a QueryList
of all the instances of TemplateTranslationDirective
and go from there. This eliminates the need for the service, since you can now leave all the logic in the main directive.
Here's my adapted solution (also, please forgive the liberties I took with the name changes):
// translated-content.directive.ts
import {
AfterContentInit,
ChangeDetectorRef,
ContentChildren,
Directive,
Input,
OnInit,
OnDestroy,
QueryList,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, merge, Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { TranslatedElementDirective } from './translated-element.directive';
interface TranslationData {
elements: TranslatedElementDirective[];
rawTranslation: string;
}
const TOKEN_START_DEMARC = '{{';
const TOKEN_END_DEMARC = '}}';
// adapted from @kasperlauge's solution in https://github.com/ngx-translate/core/issues/223
@Directive({
selector: '[translatedContent]',
})
export class TranslatedContentDirective implements OnInit, OnDestroy, AfterContentInit {
@Input('translatedContent') translationKey: string;
@ContentChildren(TranslatedElementDirective)
private elements: QueryList<TranslatedElementDirective>;
private subs: Subscription[] = [];
private rawTranslation: Observable<string>;
private translationData: Observable<TranslationData>;
constructor(
private viewRef: ViewContainerRef,
private renderer: Renderer2,
private translateService: TranslateService,
private changeDetectorRef: ChangeDetectorRef,
) {}
public ngOnInit(): void {
this.rawTranslation = this.translateService.get(this.translationKey);
}
public ngAfterContentInit(): void {
// QueryList.changes doesn't re-emit after its initial value, which we have by now
// BehaviorSubjects re-emit their initial value on subscription, so we get what we need by merging
// the BehaviorSubject and the QueryList.changes observable
const elementsSubject = new BehaviorSubject(this.elements.toArray());
const elementsChanges = merge(elementsSubject, this.elements.changes);
this.translationData = combineLatest(this.rawTranslation, elementsChanges)
.pipe(map(([rawTranslation]) => ({
elements: this.elements.toArray(),
rawTranslation,
})));
this.subs.push(this.translationData.subscribe(this.render.bind(this)));
}
private render(translationData: TranslationData): void {
if (!translationData.rawTranslation || translationData.rawTranslation === this.translationKey) {
throw new Error(`No resource matching the key '${this.translationKey}'`);
}
this.viewRef.clear();
let lastTokenEnd = 0;
while (lastTokenEnd < translationData.rawTranslation.length) {
const tokenStartDemarc = translationData.rawTranslation.indexOf(TOKEN_START_DEMARC, lastTokenEnd);
if (tokenStartDemarc < 0) {
break;
}
const tokenStart = tokenStartDemarc + TOKEN_START_DEMARC.length;
const tokenEnd = translationData.rawTranslation.indexOf(TOKEN_END_DEMARC, tokenStart);
if (tokenEnd < 0) {
throw new Error(`Encountered unterminated token in translation string '${this.translationKey}'`);
}
const tokenEndDemarc = tokenEnd + TOKEN_END_DEMARC.length;
const precedingText = translationData.rawTranslation.substring(lastTokenEnd, tokenStartDemarc);
const precedingTextElement = this.renderer.createText(precedingText);
this.renderer.appendChild(this.viewRef.element.nativeElement, precedingTextElement);
const elementKey = translationData.rawTranslation.substring(tokenStart, tokenEnd);
const embeddedElementTemplate = translationData.elements.find(element => element.elementKey === elementKey);
if (embeddedElementTemplate) {
const embeddedElementView = embeddedElementTemplate.viewRef.createEmbeddedView(embeddedElementTemplate.templateRef);
this.renderer.appendChild(this.viewRef.element.nativeElement, embeddedElementView.rootNodes[0]);
} else {
const missingTokenText = translationData.rawTranslation.substring(tokenStartDemarc, tokenEndDemarc);
const missingTokenElement = this.renderer.createText(missingTokenText);
this.renderer.appendChild(this.viewRef.element.nativeElement, missingTokenElement);
}
lastTokenEnd = tokenEndDemarc;
}
const trailingText = translationData.rawTranslation.substring(lastTokenEnd);
const trailingTextElement = this.renderer.createText(trailingText);
this.renderer.appendChild(this.viewRef.element.nativeElement, trailingTextElement);
// in case the rendering happens outside of a change detection event, this ensures that any translations in the
// embedded elements are rendered
this.changeDetectorRef.detectChanges();
}
public ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}
// translated-element.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[translatedElement]',
})
export class TranslatedElementDirective {
@Input('translatedElement')
public elementKey: string;
constructor(
public readonly viewRef: ViewContainerRef,
public readonly templateRef: TemplateRef<any>,
) {}
}
And it's used like so:
// TRANSLATION_KEY = "Click {{here}} to do something awesome"
<label appTranslatedContent="TRANSLATION_KEY_HERE">
<a [routerLink]="['stuff', someId]" *translatedElement="'here'">{{ 'MORE_TRANSLATION_HERE' | translate }}</a>
</label>
@DanielSchaffer Awesome solution! I might look into it and adapt mine to use that in the future :)
@DanielSchaffer I tried your solution. When I use translationService.use(language)
, translatedElement
is switched between languages. But not the TRANSLATION_KEY
. How to fix that?
@alexander-myltsev so the clarify - you're seeing the content that replaces {{here}}
swapped out, but not the rest of it? (e.g. Click {{here}} to do something awesome
to haga clic {{here}} para hacer algo impresionante
)
@DanielSchaffer exactly the opposite. In your example, I see Click **here** to do something awesome
and Click **aquí** to do something awesome
. The host message isn't translated. Should it?
Hi, @alexander-myltsev, I also tried @DanielSchaffer solution and I got same problem as you had.
How can I fix that?
The problem is translatedContent
value is not translated when switching two different languages.
Hi @Bat-Orshikh , I didn't use the code. I need 3 places to do it, and did it manually. When I need it regularly, I'm going to figure out how it works.
Hi again @alexander-myltsev , I resolved the problem that translatedContent
value is not translated when switching two different languages, using onLangChange
event of ngx-translate
.
@Bat-Orshikh can you please post your solution. Not sure what to do onLangChange
Thanks @kasperlauge and @DanielSchaffer for your solutions. I've tried it and it worked great! I liked it for its usage simplicity and mostly for its versatility.
As opposed to this, the solution of @yuristsepaniuk (upon which the whole ngx-translate-cut plugin is built) should be used with caution, since it relies on the order of translatable parts, which may not always be the same between different languages.
@kasperlauge @DanielSchaffer thank you for the great solution @Bat-Orshikh @kmrsfrnc I have modificated code a little bit to support onLangChange. Checked with angular v8
// translated-content.directive.ts
import {
AfterContentInit,
ChangeDetectorRef,
ContentChildren,
Directive,
Input,
OnInit,
OnDestroy,
QueryList,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import {BehaviorSubject, combineLatest, concat, merge, Observable, Subscription} from 'rxjs';
import {map, switchMap, tap} from 'rxjs/operators';
import { TranslatedElementDirective } from './translated-element.directive';
interface TranslationData {
elements: TranslatedElementDirective[];
rawTranslation: string;
}
const TOKEN_START_DEMARC = '{{';
const TOKEN_END_DEMARC = '}}';
// adapted from @kasperlauge's solution in https://github.com/ngx-translate/core/issues/223
@Directive({
selector: '[appTranslatedContent]',
})
export class TranslatedContentDirective implements OnInit, OnDestroy, AfterContentInit {
@Input('appTranslatedContent') translationKey: string;
@ContentChildren(TranslatedElementDirective)
private elements: QueryList<TranslatedElementDirective>;
private subs: Subscription[] = [];
private rawTranslation: Observable<string>;
private translationData: Observable<TranslationData>;
constructor(
private viewRef: ViewContainerRef,
private renderer: Renderer2,
private translateService: TranslateService,
private changeDetectorRef: ChangeDetectorRef,
) {}
public ngOnInit(): void {
this.rawTranslation = merge(
this.translateService.get(this.translationKey),
this.translateService.onLangChange.asObservable().pipe(switchMap(() => this.translateService.get(this.translationKey)))
);
}
public ngAfterContentInit(): void {
// QueryList.changes doesn't re-emit after its initial value, which we have by now
// BehaviorSubjects re-emit their initial value on subscription, so we get what we need by merging
// the BehaviorSubject and the QueryList.changes observable
const elementsSubject = new BehaviorSubject(this.elements.toArray());
const elementsChanges = merge(elementsSubject, this.elements.changes);
this.translationData = combineLatest(this.rawTranslation, elementsChanges)
.pipe(
map(([rawTranslation]) => {
return {
elements: this.elements.toArray(),
rawTranslation,
};
})
);
this.subs.push(this.translationData.subscribe(this.render.bind(this)));
}
private render(translationData: TranslationData): void {
if (!translationData.rawTranslation || translationData.rawTranslation === this.translationKey) {
throw new Error(`No resource matching the key '${this.translationKey}'`);
}
while (this.viewRef.element.nativeElement.firstChild) {
this.renderer.removeChild(this.viewRef.element.nativeElement, this.viewRef.element.nativeElement.firstChild);
}
let lastTokenEnd = 0;
while (lastTokenEnd < translationData.rawTranslation.length) {
const tokenStartDemarc = translationData.rawTranslation.indexOf(TOKEN_START_DEMARC, lastTokenEnd);
if (tokenStartDemarc < 0) {
break;
}
const tokenStart = tokenStartDemarc + TOKEN_START_DEMARC.length;
const tokenEnd = translationData.rawTranslation.indexOf(TOKEN_END_DEMARC, tokenStart);
if (tokenEnd < 0) {
throw new Error(`Encountered unterminated token in translation string '${this.translationKey}'`);
}
const tokenEndDemarc = tokenEnd + TOKEN_END_DEMARC.length;
const precedingText = translationData.rawTranslation.substring(lastTokenEnd, tokenStartDemarc);
const precedingTextElement = this.renderer.createText(precedingText);
this.renderer.appendChild(this.viewRef.element.nativeElement, precedingTextElement);
const elementKey = translationData.rawTranslation.substring(tokenStart, tokenEnd);
const embeddedElementTemplate = translationData.elements.find(element => element.elementKey === elementKey);
if (embeddedElementTemplate) {
const embeddedElementView = embeddedElementTemplate.viewRef.createEmbeddedView(embeddedElementTemplate.templateRef);
this.renderer.appendChild(this.viewRef.element.nativeElement, embeddedElementView.rootNodes[0]);
} else {
const missingTokenText = translationData.rawTranslation.substring(tokenStartDemarc, tokenEndDemarc);
const missingTokenElement = this.renderer.createText(missingTokenText);
this.renderer.appendChild(this.viewRef.element.nativeElement, missingTokenElement);
}
lastTokenEnd = tokenEndDemarc;
}
const trailingText = translationData.rawTranslation.substring(lastTokenEnd);
const trailingTextElement = this.renderer.createText(trailingText);
this.renderer.appendChild(this.viewRef.element.nativeElement, trailingTextElement);
// in case the rendering happens outside of a change detection event, this ensures that any translations in the
// embedded elements are rendered
this.changeDetectorRef.detectChanges();
}
public ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}
We use arrays, I think it is a pretty generalized and straight-forward approach, hope it helps someone :)
JSON:
{
"ALREADY_SIGNED_UP": ["Already signed up?", "Log in", "!"]
}
HTML:
<p>
{{ 'ALREADY_SIGNED_UP.0 | translate }}
<a routerLink="/login">{{ 'ALREADY_SIGNED_UP.1 | translate }}</a>
{{ 'ALREADY_SIGNED_UP.2 | translate }}
</p>
@kasperlauge @DanielSchaffer thank you for the great solution @Bat-Orshikh @kmrsfrnc I have modificated code a little bit to support onLangChange. Checked with angular v8
// translated-content.directive.ts import { AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, Input, OnInit, OnDestroy, QueryList, Renderer2, ViewContainerRef, } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import {BehaviorSubject, combineLatest, concat, merge, Observable, Subscription} from 'rxjs'; import {map, switchMap, tap} from 'rxjs/operators'; import { TranslatedElementDirective } from './translated-element.directive'; interface TranslationData { elements: TranslatedElementDirective[]; rawTranslation: string; } const TOKEN_START_DEMARC = '{{'; const TOKEN_END_DEMARC = '}}'; // adapted from @kasperlauge's solution in https://github.com/ngx-translate/core/issues/223 @Directive({ selector: '[appTranslatedContent]', }) export class TranslatedContentDirective implements OnInit, OnDestroy, AfterContentInit { @Input('appTranslatedContent') translationKey: string; @ContentChildren(TranslatedElementDirective) private elements: QueryList<TranslatedElementDirective>; private subs: Subscription[] = []; private rawTranslation: Observable<string>; private translationData: Observable<TranslationData>; constructor( private viewRef: ViewContainerRef, private renderer: Renderer2, private translateService: TranslateService, private changeDetectorRef: ChangeDetectorRef, ) {} public ngOnInit(): void { this.rawTranslation = merge( this.translateService.get(this.translationKey), this.translateService.onLangChange.asObservable().pipe(switchMap(() => this.translateService.get(this.translationKey))) ); } public ngAfterContentInit(): void { // QueryList.changes doesn't re-emit after its initial value, which we have by now // BehaviorSubjects re-emit their initial value on subscription, so we get what we need by merging // the BehaviorSubject and the QueryList.changes observable const elementsSubject = new BehaviorSubject(this.elements.toArray()); const elementsChanges = merge(elementsSubject, this.elements.changes); this.translationData = combineLatest(this.rawTranslation, elementsChanges) .pipe( map(([rawTranslation]) => { return { elements: this.elements.toArray(), rawTranslation, }; }) ); this.subs.push(this.translationData.subscribe(this.render.bind(this))); } private render(translationData: TranslationData): void { if (!translationData.rawTranslation || translationData.rawTranslation === this.translationKey) { throw new Error(`No resource matching the key '${this.translationKey}'`); } while (this.viewRef.element.nativeElement.firstChild) { this.renderer.removeChild(this.viewRef.element.nativeElement, this.viewRef.element.nativeElement.firstChild); } let lastTokenEnd = 0; while (lastTokenEnd < translationData.rawTranslation.length) { const tokenStartDemarc = translationData.rawTranslation.indexOf(TOKEN_START_DEMARC, lastTokenEnd); if (tokenStartDemarc < 0) { break; } const tokenStart = tokenStartDemarc + TOKEN_START_DEMARC.length; const tokenEnd = translationData.rawTranslation.indexOf(TOKEN_END_DEMARC, tokenStart); if (tokenEnd < 0) { throw new Error(`Encountered unterminated token in translation string '${this.translationKey}'`); } const tokenEndDemarc = tokenEnd + TOKEN_END_DEMARC.length; const precedingText = translationData.rawTranslation.substring(lastTokenEnd, tokenStartDemarc); const precedingTextElement = this.renderer.createText(precedingText); this.renderer.appendChild(this.viewRef.element.nativeElement, precedingTextElement); const elementKey = translationData.rawTranslation.substring(tokenStart, tokenEnd); const embeddedElementTemplate = translationData.elements.find(element => element.elementKey === elementKey); if (embeddedElementTemplate) { const embeddedElementView = embeddedElementTemplate.viewRef.createEmbeddedView(embeddedElementTemplate.templateRef); this.renderer.appendChild(this.viewRef.element.nativeElement, embeddedElementView.rootNodes[0]); } else { const missingTokenText = translationData.rawTranslation.substring(tokenStartDemarc, tokenEndDemarc); const missingTokenElement = this.renderer.createText(missingTokenText); this.renderer.appendChild(this.viewRef.element.nativeElement, missingTokenElement); } lastTokenEnd = tokenEndDemarc; } const trailingText = translationData.rawTranslation.substring(lastTokenEnd); const trailingTextElement = this.renderer.createText(trailingText); this.renderer.appendChild(this.viewRef.element.nativeElement, trailingTextElement); // in case the rendering happens outside of a change detection event, this ensures that any translations in the // embedded elements are rendered this.changeDetectorRef.detectChanges(); } public ngOnDestroy(): void { this.subs.forEach(sub => sub.unsubscribe()); } }
This seems to be broken in angular 13. The snippet below causes the application to hang, trying to find a solution
while (this.viewRef.element.nativeElement.firstChild) {
this.renderer.removeChild(this.viewRef.element.nativeElement, this.viewRef.element.nativeElement.firstChild);
}
Edit: working in angular 13 https://gist.github.com/duncte123/e80f5cadbe08f24c31a83893353391fd
We use arrays, I think it is a pretty generalized and straight-forward approach, hope it helps someone :)
JSON:
{ "ALREADY_SIGNED_UP": ["Already signed up?", "Log in", "!"] }
HTML:
<p> {{ 'ALREADY_SIGNED_UP.0 | translate }} <a routerLink="/login">{{ 'ALREADY_SIGNED_UP.1 | translate }}</a> {{ 'ALREADY_SIGNED_UP.2 | translate }} </p>
The problem with this approach is that not all languages maintain the same word order, so it only works in specific cases.
Does anyone have a working solution for Angular 16?
@RikudouSage This is what I use on angular 13, have not net had the time to upgrade to 16. Really wishing this feature will be included in the lib in the future. I made this issue a while back in the hope that they will implement it https://github.com/ngx-translate/core/issues/1417
https://gist.github.com/duncte123/e80f5cadbe08f24c31a83893353391fd
@duncte123 Thanks! Though I've moved to transloco with the ngx-transloco-markup plugin which offers such functionality.
@duncte123 Thanks! Though I've moved to transloco with the ngx-transloco-markup plugin which offers such functionality.
Thanks for the tip, I'll check it out
I know it's an old topic, but this approach works for me.
` import { TranslateService, _ } from '@codeandweb/ngx-translate';
translate = inject(TranslateService);
const message: string = await lastValueFrom(this.translate.get(_('app.auth.sign-up.alert.sign-up.message'), { link: '<a role=\"button\" routerLink=\"/sign-in\" class=\"alert-link\" title=\"login\">logge ind</a>' }));
"message": "Brugerkontoen er nu oprettet. Du kan {{link}} med det samme."
`
this approach works for me in the template:
` @if (form.controls.email.hasError('emailAlreadyRegistered')) { @let message$ = this.translate.get(_('app.auth.sign-up.form.email.errors.emailAlreadyRegistered.invalid-feedback'), { link: '<a role=\"link\" routerLink=\"/sign-in\" class=\"alert-link\" title=\"Log ind\">Log ind' }); <div (click)='onClick(messageDiv)' #messageDiv class='invalid-feedback' [innerHTML]='(message$ | async) | safeHtml'>
onClick(element: HTMLDivElement) { const path = element.children[0].getAttribute('routerLink'); this.router.navigate([path]).then(); }
"emailAlreadyRegistered": { "invalid-feedback": "En konto er allerede tilknyttet den angivne e-mailadresse. Gå til {{link}}, hvis du ønsker at logge ind." } `
I'm submitting a ... (check one with "x")
This is somewhere in between "how to" and "what if".
Use case
Embedding angular links into translation resources, something like:
The strings are self-explanatory.
Work-around
One could do something along those lines:
which would be much harder to track for the localization team.
Difficulties
Security. Such template layout would require to call
DomSanitizer.bypassSecurityTrustHtml
at the very least, which in turn requires extracting the localizable string into a variable (see this plunker):If this is skipped, the following string will be output to browser console:
"WARNING: sanitizing HTML stripped some content (see http://g.co/ng/security#xss)."
. The resulting HTML will not contain therouterLink
attribute on an<a>
element.Now what?
That's the thing - I don't know, and I'm looking for suggestions on how to solve this problem in a non-workaroundish way. AFAIK it's not possible to do something like
bypassSecurityTrustHtml
from within a custom pipe (though it would probably be a nice, but heavily misused feature).On the other hand, if we could make the plunker work in expected way, this could potentially be extracted into a reusable
TranslateInsecureContentService
utility.Please tell us about your environment: