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:
@@ -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,
|
||||
|
||||
14
libs/components/src/i18n/i18n-tag.directive.ts
Normal file
14
libs/components/src/i18n/i18n-tag.directive.ts
Normal 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>) {}
|
||||
}
|
||||
99
libs/components/src/i18n/i18n.component.ts
Normal file
99
libs/components/src/i18n/i18n.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
libs/components/src/i18n/i18n.mdx
Normal file
89
libs/components/src/i18n/i18n.mdx
Normal 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>
|
||||
```
|
||||
13
libs/components/src/i18n/i18n.module.ts
Normal file
13
libs/components/src/i18n/i18n.module.ts
Normal 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 {}
|
||||
76
libs/components/src/i18n/i18n.stories.ts
Normal file
76
libs/components/src/i18n/i18n.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
1
libs/components/src/i18n/index.ts
Normal file
1
libs/components/src/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./i18n.module";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user