Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Translating strings with links #223

Closed
DethAriel opened this issue Sep 2, 2016 · 29 comments
Closed

Translating strings with links #223

DethAriel opened this issue Sep 2, 2016 · 29 comments

Comments

@DethAriel
Copy link
Contributor

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:

  • ng2-translate version: 2.4.3
  • Angular version: 2.0.0-rc.6
  • Browser: all
  • Language: all
@bbarry
Copy link

bbarry commented Oct 7, 2016

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
Copy link
Contributor

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
Copy link
Contributor

@DethAriel did you find any solution to this?

@DethAriel
Copy link
Contributor Author

@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
Copy link
Collaborator

ocombe commented Dec 21, 2016

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

@ocombe ocombe closed this as completed Dec 21, 2016
@yuristsepaniuk
Copy link

yuristsepaniuk commented Sep 30, 2017

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
Copy link

kasperlauge commented Aug 7, 2018

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
Copy link

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
Copy link

denu5 commented Jan 29, 2019

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

@DanielSchaffer
Copy link

@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
Copy link

@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
Copy link

DanielSchaffer commented Apr 11, 2019

@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
Copy link

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

@alexander-myltsev
Copy link

@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
Copy link

DanielSchaffer commented Apr 26, 2019

@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
Copy link

@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
Copy link

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
Copy link

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
Copy link

Bat-Orshikh commented Aug 22, 2019

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
Copy link

kmrsfrnc commented Sep 5, 2019

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

@alex-che
Copy link

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
Copy link

yksht commented Jul 16, 2020

@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
Copy link

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
Copy link

duncte123 commented May 16, 2023

@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
Copy link

alexgipi commented Aug 15, 2023

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
Copy link

Does anyone have a working solution for Angular 16?

@duncte123
Copy link

duncte123 commented Apr 28, 2024

@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 #1417

https://gist.github.com/duncte123/e80f5cadbe08f24c31a83893353391fd

@RikudouSage
Copy link

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

@duncte123
Copy link

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests