ngx-translate / core

The internationalization (i18n) library for Angular
MIT License
4.53k stars 579 forks source link

Translating strings with links #223

Closed DethAriel closed 7 years ago

DethAriel commented 8 years ago

I'm submitting a ... (check one with "x")

[ ] bug report => check the FAQ and search github for a similar issue or PR before submitting
[x] support request => check the FAQ and search github for a similar issue before submitting
[x] feature request

This is somewhere in between "how to" and "what if".

Use case

Embedding angular links into translation resources, something like:

{
  "ALREADY_SIGNED_UP": "Already signed up? <a routerLink=\"/login\">Log in</a>!",
  "ACCEPT_TERMS": "I have read and accept <a routerLink=\"/terms\">Terms and Conditions</a>"
}
<span [innerHTML]="'ALREADY_SIGNED_UP' | translate"></span>

The strings are self-explanatory.

Work-around

One could do something along those lines:

{
  "ALREADY_SIGNED_UP_PREFIX": "Already signed up? ",
  "ALREADY_SIGNED_UP_LINK": "Log in",
  "ALREADY_SIGNED_UP_SUFFIX": "!"
}
<span>
  {{ 'ALREADY_SIGNED_UP_PREFIX' | translate }}
  <a routerLink="/login">{{ 'ALREADY_SIGNED_UP_LINK' | translate }}</a>
  {{ 'ALREADY_SIGNED_UP_SUFFIX' | translate }}
</span>

which would be much harder to track for the localization team.

Difficulties

  1. 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):

    import {DomSanitizer} from '@angular/platform-browser';
    
    @Component({
     selector: 'my-signup',
     template: `<span [innerHTML]="label"></span>`,
    })
    export class SignUpComponentLocalized {
     private label;
     constructor(private translate: TranslateService, sanitizer: DomSanitizer) {
       translate.get('ALREADY_SIGNED_UP').subscribe(s =>
         this.label = sanitizer.bypassSecurityTrustHtml(s);
       );
     }
    }

    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 the routerLink attribute on an <a> element.

  2. Router hooks. Even if we bypass the HTML sanitization, it's still unclear how Angular is supposed to tie such a link into the routing stuff. In the above plunker the thing is not hooked, and I have yet to figure this out (any help?)

    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:

bbarry commented 8 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).

deepu105 commented 8 years ago

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

deepu105 commented 8 years ago

@DethAriel did you find any solution to this?

DethAriel commented 8 years ago

@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.

ocombe commented 7 years ago

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 :(

yuristsepaniuk commented 7 years ago

Hi folks! I wish to propose our workaround too:

  1. Html is good, if you have xss filter, but still possible to miss smth, also you can't include angular components in such html, because of reason brought by @ocombe , thx btw!
  2. We also don't want html, because we pass text to non technical translators.
  3. We want them to read full text, but not 3 or 4 cuts.

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

kasperlauge commented 6 years ago

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 :)

pyroflies commented 6 years ago

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];
    }
  })
denu5 commented 5 years ago

@kasperlauge it looks like rocketscience but from the usage its perfect and super flexible! thx for your solution kasper.

DanielSchaffer commented 5 years ago

@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.

kasperlauge commented 5 years ago

@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 :)

DanielSchaffer commented 5 years ago

@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>
kasperlauge commented 5 years ago

@DanielSchaffer Awesome solution! I might look into it and adapt mine to use that in the future :)

alexander-myltsev commented 5 years ago

@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?

DanielSchaffer commented 5 years ago

@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)

alexander-myltsev commented 5 years ago

@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?

Bat-Orshikh commented 5 years ago

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.

alexander-myltsev commented 5 years ago

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.

Bat-Orshikh commented 5 years ago

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.

kmrsfrnc commented 5 years ago

@Bat-Orshikh can you please post your solution. Not sure what to do onLangChange

alex-che commented 4 years ago

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.

yksht commented 4 years ago

@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());
  }
}
dormeiri commented 4 years ago

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>
duncte123 commented 1 year ago

@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

alexgipi commented 1 year ago

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.

RikudouSage commented 1 year ago

Does anyone have a working solution for Angular 16?

duncte123 commented 7 months ago

@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

RikudouSage commented 7 months ago

@duncte123 Thanks! Though I've moved to transloco with the ngx-transloco-markup plugin which offers such functionality.

duncte123 commented 7 months ago

@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

xbisa commented 1 month ago

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."

`

xbisa commented 1 month ago

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." } `