From 09b3f413ce5fc35c2735b85badd3a96ef18ce532 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 14 Sep 2023 15:37:07 -0700 Subject: [PATCH] Introduce the bit-i18n component and stories --- apps/web/src/app/shared/shared.module.ts | 3 + .../components/src/i18n/i18n-tag.directive.ts | 14 +++ libs/components/src/i18n/i18n.component.ts | 99 +++++++++++++++++++ libs/components/src/i18n/i18n.mdx | 89 +++++++++++++++++ libs/components/src/i18n/i18n.module.ts | 13 +++ libs/components/src/i18n/i18n.stories.ts | 76 ++++++++++++++ libs/components/src/i18n/index.ts | 1 + libs/components/src/index.ts | 1 + 8 files changed, 296 insertions(+) create mode 100644 libs/components/src/i18n/i18n-tag.directive.ts create mode 100644 libs/components/src/i18n/i18n.component.ts create mode 100644 libs/components/src/i18n/i18n.mdx create mode 100644 libs/components/src/i18n/i18n.module.ts create mode 100644 libs/components/src/i18n/i18n.stories.ts create mode 100644 libs/components/src/i18n/index.ts diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 350ecce8dad..d17fdfaa486 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -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, diff --git a/libs/components/src/i18n/i18n-tag.directive.ts b/libs/components/src/i18n/i18n-tag.directive.ts new file mode 100644 index 00000000000..6421ea92c81 --- /dev/null +++ b/libs/components/src/i18n/i18n-tag.directive.ts @@ -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. + * {{text}} + */ +@Directive({ + selector: "[bit-i18n-tag]", +}) +export class I18nTagDirective { + constructor(public templateRef: TemplateRef) {} +} diff --git a/libs/components/src/i18n/i18n.component.ts b/libs/components/src/i18n/i18n.component.ts new file mode 100644 index 00000000000..170f36ca133 --- /dev/null +++ b/libs/components/src/i18n/i18n.component.ts @@ -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; +} + +/** + * 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 and this will be another <1>translated link."` + * + * 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 + *
+ * {{ text }} + * + * {{ text }} + * + *
+ */ +@Component({ + selector: "[bit-i18n],bit-i18n", + template: ` + + + + + {{ part.text }} + + `, +}) +export class I18nComponent implements AfterContentInit { + @Input("key") + translationKey: string; + + @ContentChildren(I18nTagDirective) + templateTags: QueryList; + + 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!") + * // 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; + } +} diff --git a/libs/components/src/i18n/i18n.mdx b/libs/components/src/i18n/i18n.mdx new file mode 100644 index 00000000000..ed85da1d6fe --- /dev/null +++ b/libs/components/src/i18n/i18n.mdx @@ -0,0 +1,89 @@ +import { Meta, Story, Canvas, Primary, Controls, Source } from "@storybook/addon-docs"; + +import * as stories from "./i18n.stories"; + + + +# I18n with Templating Wrapping + +The `` 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>` 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 + + + +
+ +### Source + +```html + + + {{ text }} + + {{ text }} + + + + {{ text }} + + +``` + +## Attribute Selector Example + +You can also use the `bit-i18n` as an attribute to avoid creating an extra element in the DOM. + + + +
+ +### Source + +```html + +

+ {{ text }} + {{ text }} +

+``` + +## 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. + + + +
+ +### Source + +```html + + + {{ text }} + + {{ text }} + + + +``` diff --git a/libs/components/src/i18n/i18n.module.ts b/libs/components/src/i18n/i18n.module.ts new file mode 100644 index 00000000000..795475c4fa7 --- /dev/null +++ b/libs/components/src/i18n/i18n.module.ts @@ -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 {} diff --git a/libs/components/src/i18n/i18n.stories.ts b/libs/components/src/i18n/i18n.stories.ts new file mode 100644 index 00000000000..f98abcd8da9 --- /dev/null +++ b/libs/components/src/i18n/i18n.stories.ts @@ -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 tags and <1>bold tags. The entire sentence + can be <2>translated as a whole and re-arranged according to each language's grammar rules.`, + otherExample: ` + This is another example with <1>bold tags to show that tag order does not matter + and the <0>link tags are after.`, + }); + }, + }, + ], + }), + ], + args: {}, + parameters: {}, +} as Meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: (args) => ({ + props: args, + template: ` + + {{ text }} + {{ text }} + + {{ text }} + + + `, + }), +}; + +export const AttributeSelector: Story = { + render: (args) => ({ + props: args, + template: ` +

+ {{ text }} + {{ text }} +

+ `, + }), +}; + +export const MissingTemplate: Story = { + render: (args) => ({ + props: args, + template: ` +

+ {{ text }} + {{ text }} +

+ `, + }), +}; diff --git a/libs/components/src/i18n/index.ts b/libs/components/src/i18n/index.ts new file mode 100644 index 00000000000..5bf65f88154 --- /dev/null +++ b/libs/components/src/i18n/index.ts @@ -0,0 +1 @@ +export * from "./i18n.module"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d4fdda08a2a..337959849bd 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -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";