1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Introduce the bit-i18n component and stories

This commit is contained in:
Shane Melton
2023-09-14 15:37:07 -07:00
parent 8dc11a6f12
commit 09b3f413ce
8 changed files with 296 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import {
ColorPasswordModule,
DialogModule,
FormFieldModule,
I18nModule,
IconButtonModule,
IconModule,
LinkModule,
@@ -65,6 +66,7 @@ import "./locales";
ColorPasswordModule,
DialogModule,
FormFieldModule,
I18nModule,
IconButtonModule,
IconModule,
LinkModule,
@@ -100,6 +102,7 @@ import "./locales";
ColorPasswordModule,
DialogModule,
FormFieldModule,
I18nModule,
IconButtonModule,
IconModule,
LinkModule,

View File

@@ -0,0 +1,14 @@
import { Directive, TemplateRef } from "@angular/core";
/**
* Structural directive that can be used to mark a template reference inside an I18nComponent.
* @example
* // The following would render a link to the policies page with translated text.
* <a routerLink="../policies" *bit-i18n-part="let text">{{text}}</a>
*/
@Directive({
selector: "[bit-i18n-tag]",
})
export class I18nTagDirective {
constructor(public templateRef: TemplateRef<any>) {}
}

View File

@@ -0,0 +1,99 @@
import {
AfterContentInit,
Component,
ContentChildren,
Input,
QueryList,
TemplateRef,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { I18nTagDirective } from "./i18n-tag.directive";
interface I18nStringPart {
text: string;
tagId?: number;
templateRef?: TemplateRef<any>;
}
/**
* Component that renders a translated string with optional templateRefs for each tag identifier in the translated string.
*
* The translated string must be in the following format:
*
* `"This will be a <0>translated link</0> and this will be another <1>translated link</1>."`
*
* The tag identifiers must be numbers surrounded by angle brackets and will be used to match the corresponding
* bit-i18n-tag. If there are not enough bit-i18n-tag directives, the text will be rendered as-is for the remaining
* tags.
*
* @example
* <div bit-i18n key="exampleI18nKey">
* <a *bit-i18n-tag="let text" routerLink="./first-link">{{ text }}</a>
* <a *bit-i18n-tag="let text" routerLink="./bold-link">
* <strong>{{ text }}</strong>
* </a>
* </div>
*/
@Component({
selector: "[bit-i18n],bit-i18n",
template: `
<ng-container *ngFor="let part of translationParts">
<ng-container *ngIf="part.templateRef != undefined; else text">
<ng-container
*ngTemplateOutlet="part.templateRef; context: { $implicit: part.text }"
></ng-container>
</ng-container>
<ng-template #text>{{ part.text }}</ng-template>
</ng-container>
`,
})
export class I18nComponent implements AfterContentInit {
@Input("key")
translationKey: string;
@ContentChildren(I18nTagDirective)
templateTags: QueryList<I18nTagDirective>;
protected translationParts: I18nStringPart[] = [];
constructor(private i18nService: I18nService) {}
ngAfterContentInit() {
this.translationParts = this.parseTranslatedString(this.i18nService.t(this.translationKey));
// Assign any templateRefs to the translation parts
this.templateTags.forEach((tag, index) => {
this.translationParts.forEach((part) => {
if (part.tagId === index) {
part.templateRef = tag.templateRef;
}
});
});
}
/**
* Parses a translated string into an array of parts separated by tag identifiers.
* Tag identifiers must be numbers surrounded by angle brackets.
* @example
* parseTranslatedString("Hello <0>World</0>!")
* // returns [{ text: "Hello " }, { text: "World", tagId: 0 }, { text: "!" }]
* @param inputString
* @private
*/
private parseTranslatedString(inputString: string): I18nStringPart[] {
const regex = /<(\d+)>(.*?)<\/\1>|([^<]+)/g;
const parts: I18nStringPart[] = [];
let match: RegExpMatchArray;
while ((match = regex.exec(inputString)) !== null) {
if (match[1]) {
parts.push({ text: match[2], tagId: parseInt(match[1]) });
} else {
parts.push({ text: match[3] });
}
}
return parts;
}
}

View File

@@ -0,0 +1,89 @@
import { Meta, Story, Canvas, Primary, Controls, Source } from "@storybook/addon-docs";
import * as stories from "./i18n.stories";
<Meta of={stories} />
# I18n with Templating Wrapping
The `<bit-i18n>` component is an alternative to the `i18n` pipe that supports template wrapping. It
supports wrapping developer defined tags around parts the translated text. It allows translators to
translate the original text more accurately and so that it still follows the grammar rules of the
target language.
The templating syntax uses numeric marker tags `<0></0>` around text that will be wrapped. The
marker tags should numbered sequentially starting from 0. The marker tags are then matched and
replaced by the `*bit-i18n-tag` directives in the order they appear in the template.
If a corresponding `*bit-i18n-tag` directive is not found for a marker tag, the marker tag's content
will be rendered as is.
## Basic Example
<Story of={stories.Basic} />
<br />
### Source
```html
<!-- basicExample in messages.json
"This is an example with <0>link</0> tags and <1>bold</1> tags.
The entire sentence can be <2>translated as a whole</2> and re-arranged according to each language's grammar rules."
-->
<bit-i18n key="basicExample">
<a *bit-i18n-tag="let text" routerLink="./">{{ text }}</a>
<!-- <0></0> -->
<strong *bit-i18n-tag="let text">{{ text }}</strong>
<!-- <1></1> -->
<a *bit-i18n-tag="let text" href="#">
<!-- <2></2> -->
<strong>{{ text }}</strong>
</a>
</bit-i18n>
```
## Attribute Selector Example
You can also use the `bit-i18n` as an attribute to avoid creating an extra element in the DOM.
<Story of={stories.AttributeSelector} />
<br />
### Source
```html
<!-- otherExample in messages.json
"This is another example with <1>bold</1> tags to show that tag order does not matter and the <0>link</0> tags are after."
-->
<p bit-i18n key="otherExample">
<a *bit-i18n-tag="let text" href="#">{{ text }}</a>
<strong *bit-i18n-tag="let text">{{ text }}</strong>
</p>
```
## Missing Template Example
If there are not enough `*bit-i18n-tag` directives to match the marker tags, the marker tags will be
rendered as is.
<Story of={stories.MissingTemplate} />
<br />
### Source
```html
<!-- basicExample in messages.json
"This is an example with <0>link</0> tags and <1>bold</1> tags.
The entire sentence can be <2>translated as a whole</2> and re-arranged according to each language's grammar rules."
-->
<bit-i18n key="basicExample">
<a *bit-i18n-tag="let text" routerLink="./">{{ text }}</a>
<!-- <0></0> -->
<strong *bit-i18n-tag="let text">{{ text }}</strong>
<!-- <1></1> -->
<!-- Missing template for <2></2> -->
</bit-i18n>
```

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../shared";
import { I18nTagDirective } from "./i18n-tag.directive";
import { I18nComponent } from "./i18n.component";
@NgModule({
imports: [SharedModule],
declarations: [I18nComponent, I18nTagDirective],
exports: [I18nComponent, I18nTagDirective],
})
export class I18nModule {}

View File

@@ -0,0 +1,76 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { I18nMockService } from "../utils/i18n-mock.service";
import { I18nComponent } from "./i18n.component";
import { I18nModule } from "./i18n.module";
export default {
title: "Component Library/I18n Templates",
component: I18nComponent,
decorators: [
moduleMetadata({
imports: [I18nModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
basicExample: `
This is an example with <0>link</0> tags and <1>bold</1> tags. The entire sentence
can be <2>translated as a whole</2> and re-arranged according to each language's grammar rules.`,
otherExample: `
This is another example with <1>bold</1> tags to show that tag order does not matter
and the <0>link</0> tags are after.`,
});
},
},
],
}),
],
args: {},
parameters: {},
} as Meta;
type Story = StoryObj<I18nComponent>;
export const Basic: Story = {
render: (args) => ({
props: args,
template: `
<bit-i18n key="basicExample">
<a *bit-i18n-tag="let text" href="javascript:;">{{ text }}</a>
<strong *bit-i18n-tag="let text">{{ text }}</strong>
<a *bit-i18n-tag="let text" href="https://localization.blog/2022/05/16/i18n-best-practices-keep-it-together/">
<strong>{{ text }}</strong>
</a>
</bit-i18n>
`,
}),
};
export const AttributeSelector: Story = {
render: (args) => ({
props: args,
template: `
<p bit-i18n key="otherExample">
<a *bit-i18n-tag="let text" href="javascript:;">{{ text }}</a>
<strong *bit-i18n-tag="let text">{{ text }}</strong>
</p>
`,
}),
};
export const MissingTemplate: Story = {
render: (args) => ({
props: args,
template: `
<p bit-i18n key="basicExample">
<a *bit-i18n-tag="let text" href="javascript:;">{{ text }}</a>
<strong *bit-i18n-tag="let text">{{ text }}</strong>
</p>
`,
}),
};

View File

@@ -0,0 +1 @@
export * from "./i18n.module";

View File

@@ -10,6 +10,7 @@ export * from "./checkbox";
export * from "./color-password";
export * from "./dialog";
export * from "./form-field";
export * from "./i18n";
export * from "./icon-button";
export * from "./icon";
export * from "./input";